diff --git a/client/command-dispatch.mjs b/client/command-dispatch.mjs index 66fa66a..65c8dfc 100644 --- a/client/command-dispatch.mjs +++ b/client/command-dispatch.mjs @@ -620,13 +620,16 @@ function lifecycleOptions(flags) { function projectInitOptions(paths, flags) { const repoRoot = flagValue(flags, "repo-root"); const resolvedRepoRoot = repoRoot ? path.resolve(repoRoot) : null; + const projectFilePath = path.resolve( + paths.projectFilePath ?? path.join(resolvedRepoRoot ?? process.cwd(), DEFAULT_PROJECT_FILE), + ); return { iosVersion: flagValue(flags, "ios-version"), overwrite: parseBooleanFlag(flags, "overwrite"), - projectFilePath: paths.projectFilePath ?? path.join(resolvedRepoRoot ?? process.cwd(), DEFAULT_PROJECT_FILE), + projectFilePath, projectId: flagValue(flags, "project-id"), projectName: flagValue(flags, "project-name"), - repoRoot: resolvedRepoRoot, + repoRoot: resolvedRepoRoot ?? path.resolve(path.dirname(projectFilePath), ".."), }; } diff --git a/client/test/simbroker.test.mjs b/client/test/simbroker.test.mjs index f45c64e..a71b10a 100644 --- a/client/test/simbroker.test.mjs +++ b/client/test/simbroker.test.mjs @@ -191,6 +191,7 @@ function spawnCli(fixture, ...args) { "--state-root", fixture.stateRoot, ...args, ], { + cwd: fixture.cwd, encoding: "utf8", env: { ...process.env, @@ -2441,6 +2442,75 @@ test("project init scaffolds a starter repo config that can be validated immedia assert.equal(validateResult.json.projectConfig.purposes.some((purpose) => purpose.id === "agent-ui-session"), true); }); +test("project init keeps caller repo identity when a resident service was launched elsewhere", (t) => { + const fixture = makeFixture(); + const serviceCwd = path.join(fixture.root, "service-launch"); + const callerRepoRoot = path.join(fixture.root, "fresh-consumer"); + fs.mkdirSync(serviceCwd, { recursive: true }); + fs.mkdirSync(callerRepoRoot, { recursive: true }); + const canonicalCallerRepoRoot = fs.realpathSync(callerRepoRoot); + const serviceFixture = { ...fixture, cwd: serviceCwd }; + const callerFixture = { ...fixture, cwd: callerRepoRoot }; + t.after(() => { + runCli(serviceFixture, "service", "stop"); + }); + + assert.equal(runCli(serviceFixture, "host", "init").status, 0); + assert.equal(runCli(serviceFixture, "service", "start").status, 0); + + const initialized = runCli(callerFixture, "project", "init"); + assert.equal(initialized.status, 0, initialized.stderr); + assert.equal(initialized.json.transport, "service"); + assert.equal(initialized.json.repoRoot, canonicalCallerRepoRoot); + assert.equal(initialized.json.projectFilePath, path.join(canonicalCallerRepoRoot, ".simulator-broker/project.json")); + assert.equal(initialized.json.projectConfig.projectName, "Fresh Consumer"); + + const knownProject = readJson(path.join(fixture.stateRoot, "known-projects.json")) + .projects[initialized.json.projectConfig.projectId]; + assert.equal(knownProject.projectFilePath, initialized.json.projectFilePath); + assert.equal(knownProject.repoRoot, canonicalCallerRepoRoot); + + const repeated = runCli(callerFixture, "project", "init"); + assert.equal(repeated.status, 2); + assert.equal(repeated.json.reasonCode, "project-config-exists"); + assert.equal(repeated.json.projectFilePath, initialized.json.projectFilePath); + assert.ok(repeated.json.suggestedCommand.includes(canonicalCallerRepoRoot)); + assert.equal(repeated.json.suggestedCommand.includes(serviceCwd), false); + + const capacity = runCli( + callerFixture, + "capacity", + "check", + "--purpose", + "agent-ui-session", + "--json", + ); + assert.equal(capacity.status, 0, capacity.stderr); + assert.equal(capacity.json.transport, "service"); + assert.equal(capacity.json.repo.configured, true); + assert.equal(capacity.json.repo.projectId, initialized.json.projectConfig.projectId); + assert.equal(capacity.json.repo.repoRoot, null); + + const explicitRepoRoot = path.join(fixture.root, "explicit-consumer"); + const explicitProjectFilePath = path.join(explicitRepoRoot, ".simulator-broker/project.json"); + const explicit = runCli( + callerFixture, + "project", + "init", + "--repo-root", + explicitRepoRoot, + "--project-file", + explicitProjectFilePath, + "--project-id", + "explicit-consumer", + ); + assert.equal(explicit.status, 0, explicit.stderr); + assert.equal(explicit.json.transport, "service"); + assert.equal(explicit.json.repoRoot, path.resolve(explicitRepoRoot)); + assert.equal(explicit.json.projectFilePath, path.resolve(explicitProjectFilePath)); + assert.equal(explicit.json.projectConfig.projectId, "explicit-consumer"); +}); + test("project validate can discover the project file from --repo-root", () => { const fixture = makeFixture(); const result = runCli(fixture, "project", "validate", "--repo-root", fixture.repoRoot); diff --git a/spec/build-and-test.md b/spec/build-and-test.md index 6e5d544..9742ca0 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -36,6 +36,11 @@ A first extracted implementation slice now exists: confirmed six-device apply, service/snapshot/doctor readiness, version-agnostic project scaffold, lease acquire/release, zero-create rerun, and baseline-preserving cleanup +- resident-service project-init coverage launches `brokerd` from a directory + unrelated to the fresh consumer repo, runs the documented no-flag init from + that repo, and verifies the returned file/root, generated identity, + known-project registration, repeat-init recovery, next capacity lookup, and + aligned explicit repo-root/project-file pair - installer coverage for stopping a running service before replacing the installed runtime, restarting it after metadata is written, shell-safe env helper serialization, and default-location install metadata for custom prefixes without leaving smoke-run paths in a developer install - daemon runtime-health coverage for missing worker modules, replaced runtime files, stale client/daemon versions, fail-closed CLI and app probes, explicit @@ -539,6 +544,10 @@ Add stronger profiles next for: mutation-lock serialization, shutdown failure repair state, and confirmed count-only cleanup - `npm run test:client` proves service lifecycle, concurrent clients, startup readiness before service metadata publication, restart safety, missing-worker degradation, on-disk runtime replacement detection, exact client/daemon runtime compatibility, state-preserving explicit upgrade restart, malformed service response handling, required exact runtime identity before command worker dispatch, path-compatible stop dispatch, NDJSON event streaming including stop with an active follower, service-backed lifecycle-control flows and boot readiness budgets against the fixture-backed `simctl` boundary, stable direct plus service-backed exit-code behavior, useful command help, direct/service capacity and idle parity, lazy daemon start, local-only scheduler limitation, immediate startup reconciliation, 30-second timer wiring, and snapshot refresh +- `npm run test:client` also proves `project init` resolves its repository context + in the caller before service dispatch, so a long-lived daemon's launch + directory cannot alter the new project's identity; repeat init and the next + public-safe capacity lookup remain deterministic - `npm run test:app` proves the XcodeGen project builds, embeds the root package runtime version, rejects missing or mismatched service runtime versions before exposing command authority, recognizes only exact `409` `service-runtime-incompatible` status with exit code `3`, `running: true`, and matching service identity as verified restart-required recovery, keeps other conflict/non-success/malformed/missing/mismatched/timeout results unverified, decodes snapshots, filters pin candidates, bounds local CLI subprocesses, preserves refresh diagnostics after successful mutations whose snapshot reload fails, revokes cached service authority after a current-generation refresh failure until a later exact-status success, keeps confirmed service absence and verified restart-required status recovery-capable across snapshot-only decode failures while healthy service plus the same failure stays unverified, represents unverified service status with or without a cached snapshot including first load with an existing host configuration while preserving true and cached missing-host onboarding and clearing stale cached service authority after a missing-host refresh failure without leaving onboarding, dismisses pending service mutations and delayed idle previews across every service-authority or runtime-health transition, including confirmed absence to live service, without letting in-flight lifecycle responses republish them, cancels guided setup still previewing or awaiting confirmation while preserving setup already applying, captures lifecycle ownership before queuing operator, polling-loop, and setup-owned refreshes, verifies setup, lifecycle, and service-authority eligibility before queued setup work begins, and requires lifecycle ownership before publishing setup completion/failure state, refreshes intentional setup cancellation outside the cancelled task and discards late setup-generation results so replacement setup is not poisoned, does not start that recovery refresh after dashboard-store lifecycle shutdown, treats refresh cancellation—including a loader that reports a different failure after cancellation—as discarded without revoking cached authority or publishing an error, propagates mutation/setup cancellation without retry and resumes superseded refresh waiters exactly once, blocks confirm/mutate paths while authority is revoked, prevents superseded failures from downgrading newer state, routes broker-command errors correctly, preserves selected host-config, state-root, and service-socket paths in the unverified-status CLI fallback and keeps that command visible with a cached snapshot, and drives Automatic shutdown apply, disable, preview, confirmation, cleanup, and refresh flows - the generated app test scheme receives a per-run temporary state root and host-config path from `scripts/test_app.sh`; the XCTest host never launches diff --git a/spec/global-simulator-broker.md b/spec/global-simulator-broker.md index 84bcdd4..cb76d99 100644 --- a/spec/global-simulator-broker.md +++ b/spec/global-simulator-broker.md @@ -2,8 +2,8 @@ Related: `spec/README.md`, `spec/architecture.md`, `spec/implementation-plan.md`, `spec/build-and-test.md`, `spec/project-structure.md`, `spec/tasks/public-safe-on-demand-simulator-lifecycle.md`, `references/README.md` > **Document ID:** `GSB-001` -> **Version:** `0.16.7` -> **Last Updated:** `2026-08-31` +> **Version:** `0.16.8` +> **Last Updated:** `2026-09-01` > **Status:** `Draft` > **Owner:** `spec-steward` > **Implementation owners:** `spec-steward`, `ios-dev` @@ -252,6 +252,22 @@ Current implementation slice: - local install, package, and smoke scripts under `scripts/`, including CLI-only install through `install_local.sh --cli-only` and PATH persistence through Homebrew prefix bin or one guarded login-profile line - `host init --bootstrap-config` warns that it creates real iOS Simulator devices before those devices are created +#### Project initialization caller-context contract + +`project init` resolves repository identity at the CLI boundary before direct or +service-backed dispatch. This prevents a resident `brokerd` process from making +project identity depend on the directory where the daemon happened to start. + +| ID | Requirement | Verifier | +| --- | --- | --- | +| SB-PROJECT-INIT-001 | The CLI sends both an absolute `projectFilePath` and an absolute `repoRoot` with every `project init` request. With neither path flag, the project file is `/.simulator-broker/project.json` and the repository root is the caller working directory. With a caller-resolved project file and no explicit repo root, the root is one directory above the file's containing directory, matching the canonical `.simulator-broker/project.json` layout. | `client/test/simbroker.test.mjs` `project init keeps caller repo identity when a resident service was launched elsewhere` | +| SB-PROJECT-INIT-002 | An explicit `--repo-root` remains authoritative for project identity. An explicit `--project-file` remains authoritative for the file location. The canonical paired form points that file at `/.simulator-broker/project.json`, and direct and service-backed dispatch use the same absolute values. | The same resident-service regression plus `project init scaffolds a starter repo config that can be validated immediately` | +| SB-PROJECT-INIT-003 | A resident service launched from another working directory must return and register the caller repository identity. Repeating the same init without overwrite fails with `project-config-exists` and a caller-root recovery command. A following capacity check discovers the created project and reports its project ID; `repo.repoRoot` remains `null` by the public-safe capacity redaction contract. | `client/test/simbroker.test.mjs` `project init keeps caller repo identity when a resident service was launched elsewhere` | + +These rules add no retry or service-side path discovery. The caller resolves the +context once and the daemon treats it as request data. This keeps the recovery +boundary explicit while avoiding a second working-directory-dependent path. + ### `app` Must show in the current phase: @@ -599,6 +615,7 @@ This repo is ready for public-source collaboration only if: | Version | Date | Summary | | --- | --- | --- | +| 0.16.8 | 2026-09-01 | Required `project init` to carry caller-resolved repository identity across direct and resident-service dispatch, including repeat-init recovery and the intentionally redacted capacity response. | | 0.16.7 | 2026-08-31 | App restart-required status requires canonical exit code `3`, queued refreshes retain lifecycle ownership, and missing-host probe failures clear stale live-service authority while remaining onboarding. | | 0.16.6 | 2026-08-30 | Lifecycle cancellation preserves cached authority, service transitions invalidate stale setup, setup recovery cannot restart after stop, and path-qualified unverified guidance remains visible. | | 0.16.5 | 2026-08-30 | App distinguishes exact verified live runtime incompatibility from generic unverified status and offers cooperative restart without command authority. |