Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions client/command-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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), ".."),
};
}

Expand Down
70 changes: 70 additions & 0 deletions client/test/simbroker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ function spawnCli(fixture, ...args) {
"--state-root", fixture.stateRoot,
...args,
], {
cwd: fixture.cwd,
encoding: "utf8",
env: {
...process.env,
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions spec/build-and-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions spec/global-simulator-broker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 `<caller cwd>/.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 `<repo-root>/.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:
Expand Down Expand Up @@ -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. |
Expand Down