Repository navigation
Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709
Description
Part of #700.
Scope
After this PR, a machine with one daemon never needs --gateway. Without the flag, the CLI and simlock mcp reach the gateway when a gateway is present (#708's gatewayPresent: <root>/gateway/instance.json or <root>/gateway/config.json exists) and <root>/instance.json does not; otherwise they reach the worker, as after #708. When a worker refuses a gateway-only command and a gateway is present, the CLI adds a hint to use --gateway. A global --local flag (this machine's worker; named so it cannot clash with component install --worker <id>), the mirror of --gateway, always reaches (and auto-starts) the worker in <root>, so a machine that got its gateway first can still start a worker: simlock --local daemon start. After that first start <root>/instance.json exists and plain commands reach the worker again.
In short
On a machine with only a gateway, simlock status, simlock daemon start and every other command reach it without a flag, and never start an empty worker. On a machine with both, simlock worker drain <id> without the flag prints the worker's refusal followed by A gateway also runs on this machine: run the command again with --gateway.
flowchart TD
A["simlock <command>"] --> B{--gateway?}
B -- yes --> G["<root>/gateway"]
B -- no --> C{"(changed) a gateway is present<br/>and <root>/instance.json does not exist?"}
C -- yes --> G
C -- no --> W["<root> worker"]
W --> D{"(changed) UNSUPPORTED_IN_WORKER_MODE<br/>and a gateway is present?"}
D -- yes --> E["message + hint"]
# a machine with only a gateway, before (after #708)
simlock status --json | jq -r .daemon.mode
worker # an empty worker was auto-started in ~/.simlock
# after
simlock status --json | jq -r .daemon.mode
gateway
# a machine with both, after
simlock worker drain 3f2a…
{"error":{"code":"UNSUPPORTED_IN_WORKER_MODE","message":"worker.drain acts on a gateway's workers, and this daemon is not a gateway. A gateway also runs on this machine: run the command again with --gateway."}}
No --gateway flag:
<root>/instance.json |
gateway present | After #708 | After this task |
|---|---|---|---|
| no | no | worker | worker |
| yes | no | worker | worker |
| no | yes | worker | gateway |
| yes | yes | worker | worker, with the hint on a gateway-only refusal |
| no | no (<root>/gateway/ exists, empty) |
worker | worker |
| no | <root>/gateway/instance.json only (started with defaults) |
worker | gateway |
| no | stat fails with EACCES |
error, no command runs (#708) | error, no command runs |
With --gateway, every row reaches the gateway, as after #708. With --local, every row reaches the worker in <root> and auto-starts it; on a gateway-only machine that is how a worker is added:
simlock --local daemon start
Daemon running
simlock status --json | jq -r .daemon.mode # <root>/instance.json now exists
worker
``` The hint is never added when `--gateway` was given or when no gateway is present.
If this goes wrong, a user on a gateway-only machine sees `"mode": "worker"` and an empty catalog, or a user with both gets the gateway's answer to a plain command.
## Technical spec
### Facts
Commit `e27dc327`. #708 adds `resolveDaemonHome`, `--gateway` and `context.gateway` (feature Shared values).
| # | Fact | Source |
| -- | ---- | ------ |
| F1 | A worker writes `<data dir>/instance.json` on its first start, before driver discovery, with an exclusive create; it is never regenerated. | `src/daemon/main.ts:244-249`; `src/core/instance-identity.ts:38-53` |
| F2 | `config set` writes `<data dir>/config.json` (and creates the folder) without any daemon; it never writes `instance.json`. | `src/cli/environment.ts:191-197`; `src/cli/commands/config.ts:41-53` |
| F3 | The launcher creates `daemon.log` before spawning; so `daemon.log` can exist with no daemon ever having run. | `src/ports/daemon-launcher.ts:38-43` |
| F4 | `Filesystem.exists` returns `false` only for a missing path; any other `stat` error is thrown. | `src/ports/filesystem.ts:232-242` |
| F5 | A worker answers `worker.drain`, `worker.undrain`, `worker.remove`, `worker.install-component` with `UNSUPPORTED_IN_WORKER_MODE`, message `<operation> acts on a gateway's workers, and this daemon is not a gateway` (`worker.install-component` has its own message, also with no final period). | `src/daemon/dispatcher.ts:301-306`, `:1066-1072` |
| F6 | `UNSUPPORTED_IN_WORKER_MODE` has `cliExitCode: 2`, `httpStatus: 501`. | `src/contract/errors.ts:357-362` |
| F7 | `runCli` catches every error and prints one JSON line `{"error":{"code","message"}}` through `writeError`; the exit code comes from `errorExitCode`. | `src/cli/index.ts:126-129`; `src/cli/errors.ts:22-42` |
| F8 | Commands that never connect read files from the data dir: `events --since` (`events.jsonl`), `daemon logs` (`daemon.log`), `config set` (`config.json`). | `src/cli/environment.ts:182-185`, `:217`, `:228-230`; `src/cli/commands/events.ts:50-55` |
| F9 | MCP's tool-call path auto-launches; its renew path never does. Both use the same socket path. | `src/mcp/main.ts:170-199`; `src/mcp/connect.ts:48-56`, `:87-95` |
| F10 | `e2e/single-host-fleet.test.ts:82` asserts `worker drain` against a worker exits 2 with `UNSUPPORTED_IN_WORKER_MODE`. | `e2e/single-host-fleet.test.ts:82-91` |
| F11 | `e2e/daemon-lifecycle.test.ts:8` asserts the first CLI call auto-starts a worker (`daemon.mode` `worker`). | `e2e/daemon-lifecycle.test.ts:8-31` |
| F12 | `e2e/helpers/cli.ts` `cli(args, env)` runs the real CLI with a given env and no added flags. | `e2e/helpers/cli.ts:34-40` |
### Modules touched
Searches: `rg "resolveDaemonHome"` (callers after #708: `src/cli/index.ts`, `src/mcp/main.ts`; the daemon uses `daemonDataDirectory`), `rg "UNSUPPORTED_IN_WORKER_MODE" src/cli` (none today).
```mermaid
flowchart LR
CLI["cli/index: folder choice, hint (changed)"] --> PORTS["ports/paths: resolveDaemonHome (changed)"]
MCP["mcp/main (changed)"] --> PORTS
PORTS --> FS["ports/filesystem (unchanged)"]
DAEMON["daemon/main (unchanged: role from flag only)"]
sequenceDiagram
participant U as simlock worker drain x
participant C as CLI
participant W as worker (<root>)
U->>C: argv, no --gateway
C->>C: resolveDaemonHome: instance.json yes → worker; gatewayPresent true
C->>W: worker.drain
W-->>C: UNSUPPORTED_IN_WORKER_MODE "<msg>"
C-->>U: {"error":{"code":"UNSUPPORTED_IN_WORKER_MODE","message":"<msg>. A gateway also runs on this machine: run the command again with --gateway."}} exit 2
Changes:
src/ports/paths.ts:resolveDaemonHome({ root, flag, filesystem }),flag: "gateway" | "local" | undefined."gateway": as The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708."local": roleworker. No flag: role"gateway"whengatewayPresentand notexists(<root>/instance.json), else"worker".instance.jsonis read only when no flag is given and a gateway is present.ENOENT/ENOTDIRmean absent, any other error propagates (as The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708).dataDirectoryisdaemonDataDirectory(root, role). The daemon does not call it (The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 step 3).src/cli/index.ts: the global-flag pass also strips--local(bare, anywhere;--local=<x>→UsageError("--local takes no value"); both--gatewayand--local→UsageError("--gateway and --local cannot be used together")).CommandContext(src/cli/tree.ts:10) carries the flag in place of The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's boolean. The USAGE text gains:Pass --local anywhere on the command line to reach this machine's worker even when only a gateway has run here.component install's own--worker <id>is untouched. The launcher gets--gatewaywhenever the chosen role isgateway, flag or not. The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708'sGATEWAY_NOT_PRESENTcheck applies only when--gatewaywas given.runCli's catch: when the error's code isUNSUPPORTED_IN_WORKER_MODE, the chosen role isworker,--gatewaywas not given andgatewayPresentis true, the printed message is the original message,., then the hint (Shared values). Code and exit code unchanged. An error thrown before the environment exists gets no hint.src/cli/types.ts,src/cli/commands/mcp.ts,src/mcp/main.ts: the MCP launcher takes the flag ("gateway" | "local" | undefined) in place of The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's boolean and passes it toresolveDaemonHome. MCP adds no hint (no MCP tool is gateway-only).- Docs and manuals:
src/ports/AGENTS.md(theresolveDaemonHomechoice), anddocs/CLI.mdgains a short "Which daemon a command reaches" section with the table above,--local, the order rule (a worker added after the gateway starts withsimlock --local daemon start) and the hint; the--gatewayentry from The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 links to it.src/cli/AGENTS.mdandsrc/mcp/AGENTS.mddescribe the choice.
Contract and event changes
None. Searched src/contract for UNSUPPORTED_IN_WORKER_MODE: the code and its table entry are unchanged; the hint is CLI output only.
Other code on the same state
State: <root>/instance.json, <root>/gateway/instance.json, <root>/gateway/config.json. Searches: rg "instance.json" src (writers: src/daemon/main.ts:249, :707, both through loadInstanceId), rg '"config.json"' src (writers: src/cli/environment.ts:191-197, read by src/daemon/main.ts:156).
- A worker's first start beside a present gateway: it starts only through
--local(plain commands go to the gateway until<root>/instance.jsonexists, F1). A plain command run while that first start is in flight may still reach the gateway. Accepted: the window is the worker's first start only. Stated indocs/CLI.md. simlock --gateway config set …orsimlock --gateway daemon startmakes a gateway present (The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708): from then on, a machine with no worker reads as gateway-only. That is the intended way to set one up.- A worker never writes into
<root>/gateway(The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708), and the gateway's owninstance.jsonis in<root>/gateway, so neither marker can be written by the other role. daemon.logis not a marker (F3).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
exists(<root>/instance.json) |
true / false | as the table | none |
exists |
throws (e.g. EACCES) |
the command does not run; the CLI prints the error (INTERNAL, exit 1); MCP fails its first tool call |
none |
| worker reply | UNSUPPORTED_IN_WORKER_MODE, gateway present, no flag |
message + hint, exit 2 | none |
| worker reply | UNSUPPORTED_IN_WORKER_MODE, no gateway present or --gateway given |
message unchanged, exit 2 | none |
| worker reply | any other error | unchanged | none |
| auto-start of the chosen daemon | as after #708 | 20 s from the launch | DAEMON_STARTUP_TIMEOUT_MS |
This task writes no state. Two commands at once read the same two files and choose the same folder.
Rules in play
- Architecture: "one place enforces a rule": the folder choice stays in
resolveDaemonHome; CLI and MCP call it, neither repeats it. - Architecture: "bounded cross-process waits": the two
existscalls are localstats; the auto-start keeps its 20 s bound. - Safety: "wire input is a claim, not a fact": the hint is added only for the one error code, the reply is not otherwise reinterpreted.
- ADR 0034 §3: the marker, the choice and the hint on the worker's reply.
Tests
Seam: the real CLI against real daemons on the fake driver through the e2e harness (model: e2e/single-host-fleet.test.ts, e2e/daemon-lifecycle.test.ts); cli(args, env) from e2e/helpers/cli.ts sends a command without --gateway. src/ports/paths.test.ts covers the choice itself.
src/ports/paths.test.ts: "resolveDaemonHome without a flag picks the gateway only when a gateway is present and instance.json does not exist" (cases: neither; worker instance only; gateway config only → gateway; gateway instance only → gateway; worker instance and gateway config → worker; rootconfig.jsonand gateway config, no rootinstance.json→ gateway; empty gateway folder → worker). Extends The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's test.src/ports/paths.test.ts: "resolveDaemonHome with --local picks the worker and with --gateway the gateway, whatever the folders hold" (cases: each flag × gateway-only folders).src/ports/paths.test.ts: "resolveDaemonHome rethrows an error reading instance.json and chooses nothing" (gateway config present, MemoryFilesystem failingstatof<root>/instance.jsonwithEACCES).src/cli/index.test.ts: "--local is taken from anywhere in the argv; --local= and --local with --gateway are usage errors".- e2e
e2e/daemon-folders.test.ts: "on a machine that got its gateway first, simlock --local daemon start starts a worker in , and plain status reaches it afterwards" (a worker env that has started nothing yet; the test creates the gateway with a freehttp.port, and stops the gateway and the worker infinally). src/cli/index.test.ts: "component install ios 26.4 --worker a --worker b still passes both worker ids to the install" (extend the test atsrc/cli/index.test.ts:2912).e2e/daemon-folders.test.ts: "simlock --local mcp on a machine with only a present gateway lists the worker's catalog".e2e/daemon-folders.test.ts: "with a worker and a present gateway, a worker error of another code carries no hint" (releaseof an unknown lease id →UNKNOWN_LEASE, message unchanged).- e2e
e2e/daemon-folders.test.ts(The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's file): "on a machine with only a present gateway, plain daemon start and plain status reach the gateway and start no worker" (both commands;daemon.modegateway;<root>/instance.jsonstill absent). - same file: "on a machine with only a gateway, plain daemon logs and events --since read the gateway's files".
- same file: "with a worker and a present gateway, a plain status reaches the worker".
e2e/single-host-fleet.test.ts: extend F10's test: "without a gateway on the machine, worker drain exits 2 with UNSUPPORTED_IN_WORKER_MODE and no hint".e2e/daemon-folders.test.ts: "with a worker and a present gateway, a plain worker drain exits 2 with the worker's message and the --gateway hint; with --gateway the gateway answers UNKNOWN_WORKER (exit 12) for the worker's id" (the gateway knows no worker yet). Every test that starts a gateway in a worker env first sets it a freehttp.portwith--gateway config setand stops it with--gateway daemon stopin its ownfinally: the env's teardown stops only the worker.e2e/daemon-lifecycle.test.ts: extend F11's test to assert<root>/gatewayis not created by a plain auto-start.e2e/daemon-folders.test.ts: "plain simlock mcp on a machine with only a present gateway lists the gateway's catalog".
Done when
pnpm testand the e2e fast lane pass.docs/CLI.mdhas the "Which daemon a command reaches" section with the table and the exact hint text.src/cli/AGENTS.md,src/mcp/AGENTS.md,src/ports/AGENTS.mddescribe the folder choice.
Out of scope
-
Starting a worker on a gateway machine without a flag.
-
A hint in MCP replies.
-
A static list of gateway-only commands in the CLI.
-
Locks across OS users.
Depends on
Written by an agent.
Activity
feat(cli): commands reach a present gateway without --gateway when no…
test: with a worker and a present gateway, a plain worker drain exits…
feat(cli): a worker's gateway-only refusal gets a --gateway hint when…
test: resolveDaemonHome chooses by flag and by the instance.json and …
test: --local is taken from anywhere in the argv; --local=<x> and --l…
feat(cli): --local reaches this machine's worker (#709)
test: on a machine that got its gateway first, simlock --local daemon…
test: simlock --local mcp on a machine with only a present gateway li…
test: with a worker and a present gateway, a worker error of another …
test: plain daemon logs and events --since, status with a worker, wor…
docs(cli): which daemon a command reaches, --local and the --gateway …
refactor(cli): read the daemon flags in one helper (#709)
fix: assert the gateway's events, drop a repeated test, correct which…
docs: fix the mcp entry signature and the proxyAuth config set wording (
feat(cli): commands find the daemon by folder, with a --gateway hint …