@awebai/oats 0.38.1 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ # OATS 0.39.0
2
+
3
+ ## Changed (breaking)
4
+
5
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.40.0`**, so it runs against
6
+ this release's kernel. Install the CLI and the Desktop 0.39.0 together: the
7
+ Desktop 0.38.x refuses a 0.39 CLI.
8
+
9
+ ## Added
10
+
11
+ - **`oats server connect`: put this workspace on another machine and register
12
+ it, in one run** (awebai/oats#517, feature `server-connect`). From a
13
+ deployment, `oats server connect <id> --ssh <host>` takes a machine you reach
14
+ over ssh, even one with no OATS and no deployment, to a registered, synced
15
+ deployment of the same workspace. Six steps (`ssh`, `oats`, `git`,
16
+ `deployment`, `register`, `readiness`) are re-checked on every run, and each
17
+ does only what is missing. `--install-oats` installs exactly this kernel's
18
+ version with npm on the host. The deployment goes to `~/Agents/<this
19
+ deployment's directory name>` there unless `--dir` says otherwise; a
20
+ non-empty directory is never written into. What only a person can do (Git
21
+ credentials on the host, a provider's configuration) comes back as
22
+ `needs-human` with the exact remedy; run the same command again afterwards.
23
+ See docs/servers.md, "Connect a machine".
24
+
25
+ - **A registration knows which workspace it serves** (feature
26
+ `servers-per-workspace`). `~/.oats/servers.json` registrations record
27
+ `workspaceKey`, the canonical workspace key the host's deployment reports.
28
+ It is learned from the host, never typed: `server add` records it when the
29
+ host answers, `server check` fills it in when it is missing, and a host that
30
+ reports a different key is refused with `E_SERVER_WORKSPACE_MISMATCH`
31
+ without rewriting the registration. `oats server list --workspace-ref <ref>`
32
+ lists one workspace's servers and reports `unknownWorkspace`.
33
+
34
+ - **Capability commands run on a server with `--server <id>`** (feature
35
+ `capability-route`). For example, `oats aweb setup --join aweb
36
+ --invite-stdin --soul dev --server altair-aweb` runs `oats aweb setup …` in
37
+ the registered deployment on that machine, with stdin forwarded untouched
38
+ and the host's output, exit status and `--json` envelope relayed. Invite
39
+ values are redacted wherever the command line is printed.
40
+
41
+ - **macOS hosts say why a private remote is unreadable over ssh.** On macOS,
42
+ in a session without a terminal (every routed command runs in one), Git
43
+ cannot open the login keychain where its credential helper keeps the forge
44
+ token. An `auth` failure there now carries `details.hint:
45
+ "keychain-non-interactive"` and names the remedies: store Git credentials
46
+ outside the keychain (`gh auth login --insecure-storage`, then `gh auth
47
+ setup-git`), or use an SSH key the session can reach (one without a
48
+ passphrase, or the desktop login's ssh-agent with `SSH_AUTH_SOCK` pointed at
49
+ it in the shell's startup file). `oats server check` reports whether the
50
+ host's Git reads the workspace remote (`workspaceReadable`).
51
+
52
+ - **A capability command from a deployment no longer needs `--soul`**
53
+ (feature `operator-default-soul`). Without it, `oats <namespace> …` runs as
54
+ the first soul of the deployment, by name, not disabled there, that provides
55
+ the namespace, and says which on stderr. An explicit `--soul` still wins.
56
+ When no soul provides the namespace the command is refused with `E_BAD_ARGS`
57
+ naming it. This is what lets `oats aweb setup … --server <id>` and the
58
+ Desktop's `oats aweb connect` run without naming a soul.
59
+
60
+ - `oats onboard <dir> [--workspace <ref>] --check --json` answers, without
61
+ writing anything, where `<dir>` is, what is there, and whether this
62
+ machine's Git reads the workspace remote. `server connect` asks it of the
63
+ host.
64
+
65
+ - **The Desktop shows each workspace's own machines** (awebai/oats#517). With
66
+ a CLI that has `servers-per-workspace` and `server-connect`, a window's
67
+ spawn dialog (**Where to run**) and Workspace › Setup (**Machines**) list
68
+ only the machines whose registration reports that window's workspace key,
69
+ and **Add a machine to this workspace…** runs `oats server connect` (then
70
+ `oats aweb connect` for oats.aweb messaging), showing each step and what to
71
+ do for any that needs you. Without those features the Desktop behaves as
72
+ before. See docs/desktop.md.
73
+
74
+ - **oats.aweb 1.21.0: `oats aweb connect <server-id>` gives a deployment on
75
+ another machine its messaging** (awebai/oats#517). After `oats server
76
+ connect` has created the workspace's deployment on a registered server, one
77
+ command from the local deployment gives that deployment membership in its
78
+ default team. It runs through the kernel's capability route
79
+ (`oats aweb … --server <id>`) and reports its steps (`aw`, `invite`, `join`,
80
+ `readiness`) in the server-connect status vocabulary, with every runnable
81
+ remedy in backticks. It checks aw on the host and installs it with
82
+ `--install-aw`. It mints a hosted invite from this deployment's root for the
83
+ host's default team; nothing is minted when the host is already a member.
84
+ It joins on the host with `oats aweb setup --join <label> --invite-stdin`,
85
+ then checks again. On this machine and in transit the invite token exists
86
+ only in memory and on the routed command's stdin; on the host,
87
+ `aw id team accept-invite` takes it as an argument, so it is visible in the
88
+ host's process list while that call runs. The join half works on its own: `setup --invite-stdin`,
89
+ `setup --install-aw [--aw-version <v>]` and `setup --check-only --json`.
90
+ `setup --join` also recovers a per-team root left connected but unrecorded
91
+ by an interrupted join.
92
+
93
+ ## Upgrading
94
+
95
+ - Install the CLI and the Desktop 0.39 together. An older kernel on the same
96
+ machine refuses a `servers.json` holding `workspaceKey` (an unknown field),
97
+ so don't downgrade once 0.39 has written registrations; removing the
98
+ `workspaceKey` lines by hand makes the file readable to an older kernel
99
+ again.
package/docs/servers.md CHANGED
@@ -10,11 +10,14 @@ keeps a saved route per remote instance.
10
10
 
11
11
  ```bash
12
12
  oats server add build --ssh build-host --workspace /srv/team --oats /usr/local/bin/oats
13
- oats server check build # ssh reachability, remote version, workspace roster; changes nothing
14
- oats server list
13
+ oats server check build # ssh reachability, remote version, workspace roster and Git readability
14
+ oats server list [--workspace-ref <ref>]
15
15
  oats server remove build
16
16
  ```
17
17
 
18
+ To put a workspace on a machine that has no deployment yet, use
19
+ [`oats server connect`](#connect-a-machine) instead of `server add`.
20
+
18
21
  - `--ssh` is an OpenSSH host alias or host name, never `user@host` or an
19
22
  option. Users, keys and host verification belong in `~/.ssh/config`.
20
23
  Connections are non-interactive (`BatchMode=yes`): a prompt fails fast.
@@ -31,7 +34,117 @@ oats server remove build
31
34
  is ignored, never written or printed.
32
35
  - `--label` sets a display name. `--replace` overwrites an existing id.
33
36
  - Registrations live in `~/.oats/servers.json` on this machine, never in a
34
- repository.
37
+ repository. Every change to the file (add, remove, a learned workspace key,
38
+ connect) is a short read-modify-write of the file as it is then, under the
39
+ lock directory `~/.oats/servers.lock`, so concurrent commands never lose each
40
+ other's registrations. A lock still held after 5 s, or left by a process
41
+ that died, is refused with `E_SERVERS_BUSY`, naming the directory to remove
42
+ once no oats process is changing the registry.
43
+
44
+ **Which workspace a server serves.** A registration records `workspaceKey`:
45
+ the canonical key of the workspace its host deployment realizes, as the
46
+ host's own `oats status --json` reports it (`workspace.key`). It is learned,
47
+ never typed:
48
+
49
+ - `server add` asks the host after writing the registration. A host that does
50
+ not answer one (unreachable, no deployment at `--workspace`, a kernel before
51
+ 0.38) is registered without it, with a warning.
52
+ - `server check` records it when the registration has none, and `server
53
+ connect` writes it.
54
+ - A key is recorded only on the registration that was asked: if the
55
+ registration is removed or pointed elsewhere while the host is being asked,
56
+ the answer is dropped (`server add` warns), never written onto the new
57
+ entry or used to bring a removed one back.
58
+ - A host reporting a different key than the recorded one is
59
+ `E_SERVER_WORKSPACE_MISMATCH` (`details: {recorded, reported}`) and the
60
+ registration is not rewritten. If the host now serves another workspace,
61
+ register it again with `server add <id> --replace`.
62
+ - `server list --workspace-ref <ref>` lists the servers whose key is the
63
+ canonical key of `<ref>` (any spelling of the same repository matches), and
64
+ `unknownWorkspace` names the ids whose key is not known yet. One host can
65
+ carry several registrations, one per deployment.
66
+
67
+ `server check` also asks the host whether its Git reads the workspace remote
68
+ (`workspaceReadable`; the host's [`onboard --check`](#what-connect-runs-on-the-host)),
69
+ which is where a [macOS keychain](#git-on-a-macos-host) problem shows up.
70
+
71
+ ## Connect a machine
72
+
73
+ ```bash
74
+ oats server connect altair-aweb --ssh altair --path /opt/homebrew/bin --install-oats
75
+ ```
76
+
77
+ Run from a deployment, `server connect` puts that deployment's workspace on
78
+ the machine behind `--ssh` and registers it, from a host with nothing but
79
+ ssh access and Node.js. Every run re-checks every step and does only what is
80
+ missing, so after a human step you run the same command again.
81
+
82
+ | Option | Default |
83
+ |---|---|
84
+ | `--workspace-ref <ref>` | this deployment's `oats-local.yaml` `workspace:` |
85
+ | `--dir <path>` | `~/Agents/<this deployment's directory name>`; absolute or `~/…`, resolved by the host |
86
+ | `--oats <path>`, `--path <dirs>`, `--label <text>` | as for `server add`; `--path` is where the host finds `npm`, `oats` and the harnesses |
87
+ | `--install-oats` | off: a missing or older OATS is a human step |
88
+ | `--replace` | off: an id registered for another host or directory is refused |
89
+
90
+ The steps, in order:
91
+
92
+ | Step | Checks | When something is missing |
93
+ |---|---|---|
94
+ | `ssh` | the host answers a non-interactive ssh | `failed` `E_SSH` |
95
+ | `oats` | `oats version --json` there is this kernel's version or newer and advertises `server-connect` (a build of the same version without it is not enough) | with `--install-oats`: runs `npm install -g @awebai/oats@<this version>` there (`done`); without it, or with no `npm` on the host's PATH: `needs-human` with the command to run |
96
+ | `git` | the host's Git reads the workspace remote | `needs-human` with the remedy (and the [keychain hint](#git-on-a-macos-host)) |
97
+ | `deployment` | `--dir` holds a deployment of this workspace | an absent or empty directory is onboarded there (`done`); a deployment of another workspace is `failed` `E_SERVER_WORKSPACE_MISMATCH`; a non-empty directory without `oats-local.yaml` is `failed` `E_DIR_NOT_EMPTY` and nothing is written into it |
98
+ | `register` | the registration exists, with its `workspaceKey` | written (`done`); an id registered for another target is `failed` `E_SERVER_EXISTS` unless `--replace` (checked before anything else, so a requested id never reports ready while it routes elsewhere); a new id whose host and directory are already registered under another id is reported (`ok`, naming that id; the text result then names that id to spawn with) and not registered twice |
99
+ | `readiness` | each soul of the host deployment (disabled souls skipped) passes `oats readiness` there | each failing or unknown required item becomes a `needs-human` line, once for all the souls that share it; the listing and the checks share one 60 s budget, and souls it does not reach (a check it cuts off included) become one line naming the command to check them; a broken link still fails the run (`E_SSH`) |
100
+
101
+ A step is `ok` (already so), `done` (this run did it), `needs-human` (its
102
+ `remedy` says what to run where; later steps are `skipped`, waiting for it)
103
+ or `failed` (the run ends). The result is `ready` when no step needs a
104
+ human. Connect never handles credentials: what Git on the host cannot read
105
+ is always a human step there. The `--json` shape is in
106
+ [desktop-cli-api.md](desktop-cli-api.md#oats-server-connect).
107
+
108
+ ### What connect runs on the host
109
+
110
+ Besides `version --json`, `status --json`, `souls --json` and `readiness
111
+ --json`, connect runs two commands there:
112
+
113
+ - `oats onboard <dir> --workspace <ref> --check --json`, read-only: where
114
+ `<dir>` is (a leading `~` is the host's home), whether it is absent, empty,
115
+ not empty, not a directory or a deployment, and whether the host's Git
116
+ reads the workspace remote. An unreadable remote is part of the answer
117
+ (`remote.readable: false` with the error), not a failure. Without
118
+ `--workspace`, a deployment's own workspace is read.
119
+ - `oats onboard <dir> --workspace <ref> --json`, only when the directory is
120
+ absent or empty. This is the only way connect onboards on a host; `onboard`
121
+ itself is not routed with `--server`.
122
+
123
+ ### Git on a macOS host
124
+
125
+ Git on macOS usually keeps the forge token in the login keychain, through
126
+ its credential helper. A session without a terminal (an ssh command, which is
127
+ how every routed command runs, or a background job) cannot open that
128
+ keychain, so reading a private remote fails as `auth` even though the same
129
+ command works in a terminal on that Mac.
130
+
131
+ When a remote read fails as `auth` on macOS in a session with no terminal on
132
+ stdin or with `SSH_CONNECTION` set, `E_REMOTE_UNREADABLE` carries
133
+ `details.hint: "keychain-non-interactive"` and `details.remedy`, and the
134
+ message names the two ways out, run once on that Mac:
135
+
136
+ ```bash
137
+ gh auth login --insecure-storage # keep the token in gh's own file, not the keychain
138
+ gh auth setup-git # let Git use gh's token
139
+ ```
140
+
141
+ or read the remote with an SSH key the session can reach. A key with a
142
+ passphrase fails in the same sessions for the same reason: an ssh session has
143
+ no `SSH_AUTH_SOCK` of its own, so it cannot reach the desktop login's
144
+ ssh-agent. Use a key without a passphrase, or point `SSH_AUTH_SOCK` at the
145
+ login agent in the shell's startup file (for zsh, `~/.zshenv`, which
146
+ non-interactive sessions read). OATS never reads or changes credentials
147
+ itself; the hint is about where Git ran, not a probe of the keychain.
35
148
 
36
149
  ## Connections
37
150
 
@@ -139,6 +252,25 @@ explicit `--dir` names a directory on the server and travels as is. Every
139
252
  other routed command refuses `--dir`; its scope comes from the
140
253
  registration.
141
254
 
255
+ **Capability commands.** Any capability command routes with `--server`:
256
+
257
+ ```bash
258
+ oats aweb setup --join aweb --invite-stdin --soul dev --server altair-aweb < invite.txt
259
+ ```
260
+
261
+ The host runs the same argv, minus `--server <id>` (or `--server=<id>`;
262
+ after a `--` the argv is the provider's and is never read), as `oats <namespace>
263
+ <command> …` from the registered workspace directory: the kernel's
264
+ capability dispatch finds the deployment from its working directory, so no
265
+ `--dir` is added to the provider's argv. `--soul` and every other flag go
266
+ through untouched. Stdin is forwarded as is and never read or logged here;
267
+ when stdin is a terminal nothing is forwarded and the host's stdin is
268
+ closed. The host's stdout, stderr and exit status are relayed; with
269
+ `--json` its envelope is relayed verbatim, and when nothing comes back this
270
+ side answers one envelope (`E_SSH`, or `E_REMOTE_ENVELOPE`). Wherever this
271
+ side prints the argv, `--invite` values are replaced by `<redacted>`.
272
+ Kernel commands keep the table above.
273
+
142
274
  **Not routed.** `session input` runs on the execution host, where schedules
143
275
  and messaging capabilities call it. `session restart --stop-grace` is refused
144
276
  with `--server`; the remote default applies. `session attach --print` shows
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A mkdir lock for short read-modify-write sections over files several oats
3
+ * processes share (the host schedule state, the server registry).
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+
9
+ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
10
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
+
12
+ /** A mkdir lock that is never reclaimed by another process: an existing
13
+ * lock whose owner is unreadable or dead is refused with the directory to
14
+ * remove, because the gap between mkdir and owner.json belongs to a live
15
+ * acquirer and a dead-owner reclaim races every other acquirer. The
16
+ * holder removes its own lock in finally and on SIGINT/SIGTERM. A lock still
17
+ * held after `retryMs` is refused with `busy(why)`, the caller's own error. */
18
+ export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
19
+ mkdirSync(dirname(dir), { recursive: true });
20
+ const deadline = Date.now() + retryMs;
21
+ for (;;) {
22
+ try { mkdirSync(dir); break; }
23
+ catch (e) {
24
+ if (e.code !== "EEXIST") throw e;
25
+ let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
26
+ if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
27
+ throw busy(!owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`);
28
+ }
29
+ }
30
+ writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid: process.pid, at: new Date().toISOString(), what }) + "\n");
31
+ const release = () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
32
+ const onSignal = (sig) => { release(); process.exit(sig === "SIGINT" ? 130 : 143); };
33
+ process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal);
34
+ try { return fn(); }
35
+ finally { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); release(); }
36
+ }
@@ -112,25 +112,30 @@ export function homeSoulMatches(name, meta) {
112
112
  * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
113
113
  * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
114
114
  * or an external source, `<package>/<soul>` a package soul. */
115
- export function findSoulEntry(discovery, name) {
116
- const [repoPart, soulPart] = splitSoulName(name);
117
- const hits = [];
118
- // A standalone view's one row is the repo's own (unconfirmed by definition — the
119
- // workspace could not be read); resolveSoul admits exactly that case.
115
+ /** Every soul a discovery offers, each with the rule a typed name matches it by: the confirmed
116
+ * members' souls (a standalone view's one row is the repo's own, unconfirmed by definition — the
117
+ * workspace could not be read; resolveSoul admits exactly that case), the external souls, and the
118
+ * package souls. → [{ entry, matches(name) }] */
119
+ export function soulCandidates(discovery) {
120
+ const out = [];
120
121
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
121
122
  for (const m of discovery.members || []) {
122
123
  if (!m.confirmed && m.key !== standaloneOwn) continue;
123
- for (const s of m.souls || []) {
124
- if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
125
- hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
126
- }
124
+ for (const s of m.souls || []) out.push({ entry: { ...s, repoKey: m.key, memberCommit: m.commit, external: false }, matches: (name) => soulNameMatches(name, { name: s.name, repoKey: m.key }) });
127
125
  }
128
126
  for (const x of discovery.external || []) {
129
- if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
130
- }
131
- for (const s of discovery.packageSouls || []) {
132
- if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
127
+ if (!x.soul?.name) continue;
128
+ out.push({
129
+ entry: { ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true },
130
+ matches: (name) => { const [repoPart, soulPart] = splitSoulName(name); return x.soul.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart))); },
131
+ });
133
132
  }
133
+ for (const s of discovery.packageSouls || []) out.push({ entry: { ...s, external: false }, matches: (name) => soulNameMatches(name, { name: s.name, package: s.package }) });
134
+ return out;
135
+ }
136
+
137
+ export function findSoulEntry(discovery, name) {
138
+ const hits = soulCandidates(discovery).filter((c) => c.matches(name)).map((c) => c.entry);
134
139
  if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
135
140
  if (hits.length > 1) {
136
141
  const qualified = hits.map(qualifiedSoulName);
@@ -473,13 +478,13 @@ export async function soulSpawnability(local, discovery, lock, soulEntry, { remo
473
478
 
474
479
  /** The async half of a spawn: everything that touches the network. Returns a
475
480
  * PREPARED object that `spawnInstance` consumes synchronously. */
476
- export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
481
+ export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride, soulEntry: entryOverride } = {}) {
477
482
  const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
478
483
  const local = found.local;
479
484
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
480
485
  const lock = readLockIfPresent(deployment);
481
486
  const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
482
- const soulEntry = findSoulEntry(discovery, soulName);
487
+ const soulEntry = entryOverride ?? findSoulEntry(discovery, soulName);
483
488
  const disabled = disabledEntry(local, soulEntry);
484
489
  if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
485
490
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
@@ -488,6 +493,26 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
488
493
  return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn, launch };
489
494
  }
490
495
 
496
+ /** The first soul of the deployment, by name (then qualified name), not disabled here, whose
497
+ * resolution `provides(resolution)`: each soul is prepared in turn over one discovery, and the search
498
+ * stops at the first that does. A soul whose resolution is refused cannot provide anything and is
499
+ * skipped, named with its code. → { prepared | null, skipped: [{ soul, code }] } */
500
+ export async function prepareFirstProviding(contextDir, provides, { remoteOptions, remote } = {}) {
501
+ const found = loadLocal(contextDir);
502
+ const deployment = dirname(found.path);
503
+ const discovery = await discoverOrStandalone(found.local, { lock: readLockIfPresent(deployment), remoteOptions, remote });
504
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : qualifiedSoulName(a) < qualifiedSoulName(b) ? -1 : 1);
505
+ const entries = soulCandidates(discovery).map((c) => c.entry).filter((e) => disabledEntry(found.local, e) === null).sort(byName);
506
+ const skipped = [];
507
+ for (const soulEntry of entries) {
508
+ let prepared;
509
+ try { prepared = await prepareInstance(contextDir, null, { remoteOptions, remote, discovery, soulEntry }); }
510
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) { skipped.push({ soul: qualifiedSoulName(soulEntry), code: e.code }); continue; } throw e; }
511
+ if (provides(prepared.resolution)) return { prepared, skipped };
512
+ }
513
+ return { prepared: null, skipped };
514
+ }
515
+
491
516
  /** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
492
517
  * operator's access context cannot see this host" — the ONLY reasons that turn a
493
518
  * workspace spawn into the standalone view. `network` / `timeout` are transient: the
@@ -24,7 +24,7 @@ import { loadLocal } from "./workspace.mjs";
24
24
  import { packageRef, refForKey } from "./resolve.mjs";
25
25
  import { MODULES_DIR } from "./materialize.mjs";
26
26
  import * as defaultRemote from "./remote.mjs";
27
- import { prepareInstance } from "./instance-resolution.mjs";
27
+ import { prepareFirstProviding, prepareInstance, qualifiedSoulName } from "./instance-resolution.mjs";
28
28
 
29
29
  function err(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
30
30
 
@@ -105,32 +105,42 @@ export async function ensureModuleTree(deployment, module, lock, { catalog = nul
105
105
  *
106
106
  * contextDir — where the operator stands (oats-local.yaml is found walking up)
107
107
  * namespace — the `<ns>` the operator typed (`okf`)
108
- * soulName — the value of --soul; REQUIRED (undefined/true/"" → E_BAD_ARGS)
108
+ * soulName — the value of --soul (true or "" → E_BAD_ARGS). Absent: the first soul of the
109
+ * deployment, by name, not disabled here, whose resolution provides the namespace
110
+ * (feature operator-default-soul); none → E_BAD_ARGS
109
111
  *
110
112
  * → { deployment, soul: { name, repoKey, commit, team }, module, manifest, commands,
111
- * settings: <resolution.payloads[cap] or {}>, resolution, prepared,
112
- * ensureTree(): Promise<dir> } — or null when no module of the soul's
113
+ * settings: <resolution.payloads[cap] or {}>, resolution, prepared, defaultSoul,
114
+ * ensureTree(): Promise<dir> } — or null when no module of the named soul's
113
115
  * resolution claims the namespace (the caller answers E_UNKNOWN_COMMAND).
116
+ * `defaultSoul` is the qualified name of the soul chosen without --soul, else null.
114
117
  * Two modules claiming one namespace → E_DUPLICATE_NAMESPACE. Everything
115
118
  * prepareInstance can throw (E_SOUL_UNKNOWN,
116
119
  * E_REMOTE_UNREADABLE, …) propagates untouched.
117
120
  */
118
121
  export async function resolveOperatorDispatch(contextDir, namespace, soulName, { remoteOptions, remote, catalog = null, fetch, prepare = prepareInstance } = {}) {
119
122
  if (typeof namespace !== "string" || !namespace) throw err("E_BAD_ARGS", "a command namespace is required");
120
- if (typeof soulName !== "string" || !soulName.trim()) {
121
- throw err("E_BAD_ARGS", `oats ${namespace}: outside an instance home a capability command resolves as a spawn would — pass --soul <name> (the soul whose resolution provides the "${namespace}" namespace)`, { namespace, flag: "--soul" });
123
+ const claimantsOf = (resolution) => (resolution.modules || []).filter((m) => m?.manifest && m.manifest.command === namespace && m.manifest.commands && typeof m.manifest.commands === "object");
124
+ let prepared, defaultSoul = null;
125
+ if (soulName === undefined) {
126
+ const first = await prepareFirstProviding(resolvePath(contextDir), (resolution) => claimantsOf(resolution).length > 0, { remoteOptions, remote });
127
+ if (!first.prepared) throw err("E_BAD_ARGS", `no soul of this deployment provides the ${namespace} namespace; pass --soul <name>`, { namespace, flag: "--soul", ...(first.skipped.length ? { skipped: first.skipped } : {}) });
128
+ prepared = first.prepared;
129
+ defaultSoul = qualifiedSoulName(prepared.soulEntry);
130
+ } else {
131
+ if (typeof soulName !== "string" || !soulName.trim()) throw err("E_BAD_ARGS", `oats ${namespace}: --soul needs a soul name (the soul whose resolution provides the "${namespace}" namespace)`, { namespace, flag: "--soul" });
132
+ prepared = await prepare(resolvePath(contextDir), soulName, { remoteOptions, remote });
122
133
  }
123
- const prepared = await prepare(resolvePath(contextDir), soulName, { remoteOptions, remote });
124
134
  const { resolution, lock } = prepared;
125
135
  const deployment = prepared.deployment ?? dirname(loadLocal(contextDir).path);
126
- const claimants = (resolution.modules || []).filter((m) => m?.manifest && m.manifest.command === namespace && m.manifest.commands && typeof m.manifest.commands === "object");
136
+ const claimants = claimantsOf(resolution);
127
137
  if (!claimants.length) return null;
128
138
  if (claimants.length > 1) throw err("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${namespace}": ${claimants.map((m) => m.name).join(", ")}`, { namespace, modules: claimants.map((m) => m.name) });
129
139
  const module = claimants[0];
130
140
  const payload = resolution.payloads?.[module.name];
131
141
  const settings = payload && typeof payload === "object" ? { ...payload } : {};
132
142
  return {
133
- deployment, soul: resolution.soul, module, manifest: module.manifest, commands: module.manifest.commands, settings, resolution, prepared,
143
+ deployment, soul: resolution.soul, module, manifest: module.manifest, commands: module.manifest.commands, settings, resolution, prepared, defaultSoul,
134
144
  ensureTree: () => ensureModuleTree(deployment, module, lock, { catalog, remote, remoteOptions: remoteOptions ?? prepared.remoteOptions, fetch }),
135
145
  };
136
146
  }
package/lib/remote.mjs CHANGED
@@ -441,11 +441,24 @@ function classifyText(error) {
441
441
  return null;
442
442
  }
443
443
 
444
+ /** What to do when git on a macOS host cannot reach its keychain: the credentials git uses must live
445
+ * somewhere a session without a terminal can read. */
446
+ export const KEYCHAIN_REMEDY = "on macOS a session without a terminal (an ssh command, a background job) cannot open the login keychain where git's credential helper keeps the forge token; store git credentials outside the keychain (`gh auth login --insecure-storage`, then `gh auth setup-git`), or use an SSH key the session can reach: one without a passphrase, or the desktop login's ssh-agent (point SSH_AUTH_SOCK at it in the shell's startup file)";
447
+
448
+ /** "keychain-non-interactive" when an `auth` failure happened on macOS in a non-interactive session
449
+ * (no terminal on stdin, or an ssh session), else null. A fact about where git ran, never a probe of
450
+ * the keychain or of any credential. */
451
+ export function keychainHint({ platform = process.platform, stdinIsTTY = process.stdin.isTTY, env = process.env, reason } = {}) {
452
+ if (platform !== "darwin" || reason !== "auth") return null;
453
+ return !stdinIsTTY || !!env.SSH_CONNECTION ? "keychain-non-interactive" : null;
454
+ }
455
+
444
456
  function unreadable(ref, error, extra = {}) {
445
457
  const reason = classifyRemoteFailure(error), killed = reason === "killed";
446
458
  if (reason === "cache") return cacheFailure(ref, error, extra);
447
- return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}`,
448
- { url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...extra });
459
+ const hint = keychainHint({ reason });
460
+ return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}${hint ? `: ${KEYCHAIN_REMEDY}` : ""}`,
461
+ { url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...(hint ? { hint, remedy: KEYCHAIN_REMEDY } : {}), ...extra });
449
462
  }
450
463
 
451
464
  /** The lock file a git lock error names (`Unable to create '<path>.lock'`, `could not lock config file <path>`),
package/lib/schedule.mjs CHANGED
@@ -30,6 +30,7 @@ import { Cron } from "croner";
30
30
  import { loadLocal } from "./workspace.mjs";
31
31
  import { noteRuntimeName } from "./deprecation.mjs";
32
32
  import { herdrSettingRemoved } from "./errors.mjs";
33
+ import { withDirLock as withSharedDirLock } from "./dir-lock.mjs";
33
34
  import { tickTriggers } from "./triggers.mjs";
34
35
  import { resolveMemberClone } from "./instance-resolution.mjs";
35
36
  import { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
@@ -224,33 +225,9 @@ export function writeHostState(st) { writeJson(join(hostScheduleDir(), "state.js
224
225
 
225
226
  // ---------------------------------------------------------------- locks
226
227
 
227
- function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
228
- const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
229
-
230
- /** A mkdir lock that is never reclaimed by another process: an existing
231
- * lock whose owner is unreadable or dead is refused with the directory to
232
- * remove, because the gap between mkdir and owner.json belongs to a live
233
- * acquirer and a dead-owner reclaim races every other acquirer. The
234
- * holder removes its own lock in finally and on SIGINT/SIGTERM. */
228
+ /** Schedule locks (lib/dir-lock.mjs): a holder still there after the retry is E_SCHEDULER_BUSY. */
235
229
  function withDirLock(dir, what, fn, { retryMs = 0 } = {}) {
236
- mkdirSync(dirname(dir), { recursive: true });
237
- const deadline = Date.now() + retryMs;
238
- for (;;) {
239
- try { mkdirSync(dir); break; }
240
- catch (e) {
241
- if (e.code !== "EEXIST") throw e;
242
- let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
243
- if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
244
- const why = !owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`;
245
- throw scheduleError("E_SCHEDULER_BUSY", `${what} is locked (${why}). If no oats schedule process is running, remove ${dir} and retry; nothing was changed.`);
246
- }
247
- }
248
- writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid: process.pid, at: new Date().toISOString(), what }) + "\n");
249
- const release = () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
250
- const onSignal = (sig) => { release(); process.exit(sig === "SIGINT" ? 130 : 143); };
251
- process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal);
252
- try { return fn(); }
253
- finally { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); release(); }
230
+ return withSharedDirLock(dir, what, fn, { retryMs, busy: (why) => scheduleError("E_SCHEDULER_BUSY", `${what} is locked (${why}). If no oats schedule process is running, remove ${dir} and retry; nothing was changed.`) });
254
231
  }
255
232
  export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join(hostScheduleDir(), "host.lock"), "the host scheduler", fn, { retryMs }); }
256
233
  /** Registry read-modify-write is serialized on its own short lock. */