@awebai/oats 0.38.2 → 0.39.1
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.
- package/bin/oats.mjs +168 -21
- package/docs/capabilities.md +13 -1
- package/docs/desktop-cli-api.md +124 -1
- package/docs/desktop.md +19 -0
- package/docs/integrations.md +13 -3
- package/docs/knowledge.md +3 -2
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.39.0.md +99 -0
- package/docs/release-notes/v0.39.1.md +42 -0
- package/docs/servers.md +135 -3
- package/lib/dir-lock.mjs +36 -0
- package/lib/instance-inspect.mjs +51 -5
- package/lib/instance-resolution.mjs +55 -17
- package/lib/operator-dispatch.mjs +19 -9
- package/lib/remote.mjs +19 -4
- package/lib/schedule.mjs +3 -26
- package/lib/servers.mjs +320 -2
- package/package-catalog.json +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# OATS 0.39.1
|
|
2
|
+
|
|
3
|
+
## Changed
|
|
4
|
+
|
|
5
|
+
- **oats.aweb 1.21.1: a Claude Code start says that it will wait at Claude
|
|
6
|
+
Code's development-channels confirmation** (awebai/oats-aweb#44). Under
|
|
7
|
+
`delivery: channel`, Claude Code loads the `aweb-channel` plugin with
|
|
8
|
+
`--dangerously-load-development-channels`, because the plugin is not on
|
|
9
|
+
Claude Code's approved channel list. Claude Code then stops at its "Loading
|
|
10
|
+
development channels" confirmation before the session starts, and waits
|
|
11
|
+
until someone answers it in the instance's terminal. Unattended starts
|
|
12
|
+
(Desktop starts and restarts, automations, successors) waited there with
|
|
13
|
+
nothing saying so. Now:
|
|
14
|
+
- every spawn or start that adds the flag carries the warning
|
|
15
|
+
`channel-dev-confirmation` once;
|
|
16
|
+
- `oats readiness` reports it, without changing the status, for a home
|
|
17
|
+
whose last start was Claude Code under `channel`.
|
|
18
|
+
|
|
19
|
+
Nothing answers the confirmation on the human's behalf. Launch arguments
|
|
20
|
+
and settings are unchanged. `--channels` registers only plugins on Claude
|
|
21
|
+
Code's approved list; for a plugin not on it, Claude Code prints a startup
|
|
22
|
+
warning and the channel does not register. That list is Anthropic's
|
|
23
|
+
default, or a Team/Enterprise organization's managed
|
|
24
|
+
`allowedChannelPlugins`, which replaces the default and requires
|
|
25
|
+
`channelsEnabled: true`.
|
|
26
|
+
|
|
27
|
+
## Fixed
|
|
28
|
+
|
|
29
|
+
- **`oats readiness` no longer fails package souls on membership**
|
|
30
|
+
(awebai/oats#533). A package soul (`oats.okf/knowledge-maintainer`,
|
|
31
|
+
`oats.engineering/code-reviewer`, …) failed its `member` check with
|
|
32
|
+
"member <its package's repository>: not a member of the workspace", and the
|
|
33
|
+
remedy asked to list the package's repository in `members:`. A package
|
|
34
|
+
soul is trusted through the workspace's `packages:` pin, never through
|
|
35
|
+
membership (see [the non-collapse rule](../workspaces.md#member-tier-vs-package-tier-the-non-collapse-rule)).
|
|
36
|
+
Its `member` item is now `package <package>/<soul>`. It passes when the
|
|
37
|
+
package is declared and locked, ships the soul at the locked commit, and
|
|
38
|
+
the soul's copy matches its digest: for `--soul`, the copy a spawn would
|
|
39
|
+
link against the lock; for `--home`, the home's copy against the digest it
|
|
40
|
+
recorded, and the lock against that same digest while it still pins the
|
|
41
|
+
same commit. A mismatch fails with `E_PACKAGE_INTEGRITY`. Member and
|
|
42
|
+
external souls are unchanged. Spawn already applied this rule.
|
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
|
|
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
|
package/lib/dir-lock.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
|
|
|
15
15
|
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir, launchConfigsAt, launchFolderTrust, launchReportFor, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
|
|
18
|
-
import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
18
|
+
import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance, soulCopyDigests } from "./instance-resolution.mjs";
|
|
19
19
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
20
20
|
import { kernelCompatibility } from "./resolve.mjs";
|
|
21
21
|
import { discoveredTeamKeys, isRemovedTeamKeys, isTeamRefusal, recordedTeams, reportRows, soulKeyOf, teamKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
|
|
@@ -133,7 +133,8 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
|
|
|
133
133
|
kind: "instance", home: realHome, meta, deployment, agentsRoot, teams: live.teams, defaultTeam: live.defaultTeam ?? null, teamsSource: live.source, launch, launchCurrent, session: newSessionDefaults(deployment),
|
|
134
134
|
recordedDefaultTeam: recordedTeams(meta).defaultTeam, teamModel: model, teamKey,
|
|
135
135
|
subject: { kind: "instance", instance: meta.instance, home, soul: meta.agent },
|
|
136
|
-
soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems]
|
|
136
|
+
soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems],
|
|
137
|
+
...(obj(ws.soul?.package) && typeof ws.soul.package.id === "string" ? { package: { id: ws.soul.package.id, soul: ws.soul.name ?? meta.agent, version: ws.soul.package.version ?? null, commit: ws.soul.package.commit ?? ws.soul.commit ?? null, integrity: ws.soul.package.digest ?? null } } : {}) },
|
|
137
138
|
workspace: { key: ws.key ?? null, name: typeof ws.name === "string" ? ws.name : discovery?.workspace?.name ?? null, deployment, commit: ws.commit ?? null, standalone: ws.standalone === true },
|
|
138
139
|
modules: Object.keys(modules).sort().map((name) => ({ name, from: modules[name]?.from ?? null, manifest: manifests[name] ?? null, dir: join(realHome, ".oats", "modules", name) })),
|
|
139
140
|
payloads: obj(meta.providers) ? meta.providers : {},
|
|
@@ -189,7 +190,8 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
189
190
|
kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, defaultTeam, teamsSource: "live", teamModel: model, teamKey, launch, session: newSessionDefaults(deployment),
|
|
190
191
|
subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null },
|
|
191
192
|
soul: { name: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, external: soulEntry.external === true, path: soulEntry.path ?? null,
|
|
192
|
-
soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {})
|
|
193
|
+
soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {}),
|
|
194
|
+
...(typeof soulEntry.package === "string" ? { package: { id: soulEntry.package, soul: soulEntry.name, version: soulEntry.version ?? null, commit: soulEntry.commit ?? null, integrity: soulEntry.digest ?? null } } : {}) },
|
|
193
195
|
workspace: { key: discovery?.key ?? null, name: discovery?.workspace?.name ?? null, deployment, commit: discovery?.commit ?? null, standalone: discovery?.standalone === true },
|
|
194
196
|
modules: (res?.modules || []).map((m) => ({ name: m.name, from: m.from ?? null, manifest: m.manifest ?? null, dir: null, module: m })).sort((a, b) => a.name.localeCompare(b.name)),
|
|
195
197
|
payloads: obj(res?.payloads) ? res.payloads : {}, payloadOrigins: obj(res?.payloadOrigins) ? res.payloadOrigins : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
|
|
@@ -404,6 +406,48 @@ export function runProviderCheck(t, mod, dir, { timeoutMs = 30000 } = {}) {
|
|
|
404
406
|
|| (res.status === "ready" && res.problems.length)) return invalid();
|
|
405
407
|
return { outcome: "result", result: { status: res.status, problems: codeMessages(res.problems), warnings: codeMessages(res.warnings ?? []) } };
|
|
406
408
|
}
|
|
409
|
+
/** The `member` item of a package soul. A package soul is trusted through the workspace's
|
|
410
|
+
* `packages:` pin (the lock's commit and integrity), never through membership: its
|
|
411
|
+
* repository is not a member and need not be (docs/workspaces.md, the non-collapse rule).
|
|
412
|
+
* It passes when the discovery lists the soul from the lock (the package declared and
|
|
413
|
+
* locked, the soul shipped at the locked commit) and the soul's copy is intact: for a
|
|
414
|
+
* soul, the copy a spawn would link matches the locked digest; for a home, its copy
|
|
415
|
+
* matches the digest it recorded, and a lock still at that commit records the same one.
|
|
416
|
+
* A copy is digested every read: a complete copy is reused, never refetched. */
|
|
417
|
+
function packageSoulItem(t, d) {
|
|
418
|
+
const p = t.soul.package;
|
|
419
|
+
const subject = `package ${p.id}/${p.soul}`;
|
|
420
|
+
const from = { kind: "package", package: p.id, version: p.version, commit: p.commit, integrity: p.integrity };
|
|
421
|
+
const evidence = { repoKey: t.soul.repoKey, workspace: d?.key ?? null, commit: p.commit, version: p.version, integrity: p.integrity, from };
|
|
422
|
+
if (!d) return item(subject, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence });
|
|
423
|
+
if (d.standalone === true) return item(subject, "not-applicable", { required: false, producer: "workspace discovery", reason: `standalone view (${d.standaloneReason ?? "explicit"}): the workspace's packages: is not read, so the package pin is recorded, not observed`, evidence });
|
|
424
|
+
const shipped = (d.packageSouls || []).filter((s) => s.package === p.id);
|
|
425
|
+
const live = shipped.find((s) => s.name === p.soul);
|
|
426
|
+
if (!live) {
|
|
427
|
+
return item(subject, "fail", { producer: "workspace lock", evidence,
|
|
428
|
+
reason: shipped.length ? `the version of ${p.id} locked in this workspace does not ship the soul ${p.soul}` : `${p.id} is not locked in this workspace's packages:`,
|
|
429
|
+
remedy: shipped.length ? `pin a release of ${p.id} that ships ${p.soul} in the workspace's packages:, then oats sync` : `declare ${p.id} in the workspace's packages:, then oats sync` });
|
|
430
|
+
}
|
|
431
|
+
const integrity = (reason, remedy) => item(subject, "fail", { producer: "workspace lock", code: "E_PACKAGE_INTEGRITY", reason, evidence, remedy });
|
|
432
|
+
const lockRemedy = "oats sync (a lock that was edited: remove the entry and sync again)";
|
|
433
|
+
const copy = t.soul.copyError;
|
|
434
|
+
if (copy?.code === "E_PACKAGE_INTEGRITY") return integrity(copy.message, lockRemedy);
|
|
435
|
+
const dir = t.soul.soulDir;
|
|
436
|
+
if (!dir) return item(subject, "unknown", { producer: "workspace lock", reason: "the soul's copy is not available, so its integrity cannot be checked", evidence });
|
|
437
|
+
const expected = t.home ? "recorded" : "locked";
|
|
438
|
+
let digests;
|
|
439
|
+
try { digests = soulCopyDigests(dir); }
|
|
440
|
+
catch (e) { return integrity(`the soul's copy at ${dir} cannot be digested: ${e.message}`, `move ${dir} aside, then spawn again (a fresh copy is fetched and verified)`); }
|
|
441
|
+
if (!digests.includes(p.integrity)) {
|
|
442
|
+
return integrity(`the soul's copy at ${dir} digests ${digests.at(-1)}, not the ${expected} ${p.integrity}${t.home ? "" : " (the lock's)"}`,
|
|
443
|
+
t.home ? `the copy changed after its spawn, and a spawn reuses it: move ${dir} aside, then spawn a new instance of ${p.id}/${p.soul} (a fresh copy is fetched and verified against the lock)` : `move ${dir} aside, then spawn again (a fresh copy is fetched and verified against the lock); if the lock was edited: ${lockRemedy}`);
|
|
444
|
+
}
|
|
445
|
+
if (t.home && live.commit === p.commit && live.digest !== p.integrity) {
|
|
446
|
+
return integrity(`the lock records ${live.digest} for the same commit ${String(p.commit).slice(0, 12)}; this home recorded ${p.integrity} at its spawn`, lockRemedy);
|
|
447
|
+
}
|
|
448
|
+
return item(subject, "pass", { producer: "workspace lock", evidence });
|
|
449
|
+
}
|
|
450
|
+
|
|
407
451
|
/** The total time the providers check may take in one readiness read. */
|
|
408
452
|
export const PROVIDER_CHECK_BUDGET_MS = 60_000;
|
|
409
453
|
|
|
@@ -430,9 +474,11 @@ export async function readinessDocument(t, { selector = null, remoteOptions, cat
|
|
|
430
474
|
}
|
|
431
475
|
configured.push(...teamItems(t), ...launchItems(t), ...folderTrustItems(t));
|
|
432
476
|
// member — the soul's member repository is a confirmed member of the workspace
|
|
433
|
-
// (oats-membership.yaml backlink observed over the remotes)
|
|
477
|
+
// (oats-membership.yaml backlink observed over the remotes); a package soul's is its
|
|
478
|
+
// package instead (packageSoulItem).
|
|
434
479
|
const d = t.discovery;
|
|
435
|
-
if (t.soul.
|
|
480
|
+
if (t.soul.package) member.push(packageSoulItem(t, d));
|
|
481
|
+
else if (t.soul.external) member.push(item(`soul ${t.soul.name}`, "not-applicable", { required: false, producer: "workspace discovery", reason: "an external soul is declared by the workspace; it has no member repository", evidence: { repoKey: t.soul.repoKey } }));
|
|
436
482
|
else if (!d) member.push(item(`member ${t.soul.repoKey ?? "?"}`, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence: { repoKey: t.soul.repoKey } }));
|
|
437
483
|
// A standalone view is an allowed mode (decision 10): membership cannot be confirmed
|
|
438
484
|
// there by definition, so it is not a readiness requirement (as for an external soul).
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Nothing here reads `oats-config.yaml`, an installed-capability directory or a
|
|
18
18
|
* per-soul `source:` — those do not exist in this model. */
|
|
19
|
-
import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
20
20
|
import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from "node:path";
|
|
21
21
|
import { oatsError } from "./errors.mjs";
|
|
22
22
|
import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace } from "./workspace.mjs";
|
|
@@ -25,7 +25,7 @@ import { BY_TEAM_REMOVED, isRemovedTeamKeys, isTeamRefusal, memberNameOf, record
|
|
|
25
25
|
import { launchLayers } from "./launch-preference.mjs";
|
|
26
26
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
27
27
|
import { materialize, moduleSkills, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
28
|
-
import { fetchRemoteTree } from "./remote.mjs";
|
|
28
|
+
import { contentDigest, fetchRemoteTree } from "./remote.mjs";
|
|
29
29
|
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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);
|
|
@@ -227,6 +232,19 @@ async function fetchSoulSource(prepared, dest) {
|
|
|
227
232
|
if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
|
|
228
233
|
}
|
|
229
234
|
|
|
235
|
+
/** The digests a soul copy may carry as its source was fetched (fetchSoulSource): the copy itself, and,
|
|
236
|
+
* when it holds the CLAUDE.md → AGENTS.md alias, the copy without it, since fetchSoulSource adds that
|
|
237
|
+
* alias to a source that has none. A package soul's copy is intact when one of them is its digest. */
|
|
238
|
+
export function soulCopyDigests(dir) {
|
|
239
|
+
const options = { allowSymlinks: SOUL_ALIAS_SYMLINK };
|
|
240
|
+
const alias = join(dir, "CLAUDE.md");
|
|
241
|
+
let aliased = false;
|
|
242
|
+
try { aliased = lstatSync(alias).isSymbolicLink() && readlinkSync(alias) === "AGENTS.md"; } catch { aliased = false; }
|
|
243
|
+
const digests = [contentDigest(dir, options)];
|
|
244
|
+
if (aliased) digests.push(contentDigest(dir, { ...options, omit: (rel) => rel === "CLAUDE.md" }));
|
|
245
|
+
return digests;
|
|
246
|
+
}
|
|
247
|
+
|
|
230
248
|
/** A preview's soul source. A preview writes NOTHING in the deployment (K6b), so it
|
|
231
249
|
* never populates the per-commit cache or moves the `soul` pointer: it reads the
|
|
232
250
|
* cache entry for the prepared commit when one is already complete, else fetches
|
|
@@ -473,13 +491,13 @@ export async function soulSpawnability(local, discovery, lock, soulEntry, { remo
|
|
|
473
491
|
|
|
474
492
|
/** The async half of a spawn: everything that touches the network. Returns a
|
|
475
493
|
* PREPARED object that `spawnInstance` consumes synchronously. */
|
|
476
|
-
export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
|
|
494
|
+
export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride, soulEntry: entryOverride } = {}) {
|
|
477
495
|
const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
|
|
478
496
|
const local = found.local;
|
|
479
497
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
480
498
|
const lock = readLockIfPresent(deployment);
|
|
481
499
|
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
|
|
482
|
-
const soulEntry = findSoulEntry(discovery, soulName);
|
|
500
|
+
const soulEntry = entryOverride ?? findSoulEntry(discovery, soulName);
|
|
483
501
|
const disabled = disabledEntry(local, soulEntry);
|
|
484
502
|
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
503
|
const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
|
|
@@ -488,6 +506,26 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
488
506
|
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn, launch };
|
|
489
507
|
}
|
|
490
508
|
|
|
509
|
+
/** The first soul of the deployment, by name (then qualified name), not disabled here, whose
|
|
510
|
+
* resolution `provides(resolution)`: each soul is prepared in turn over one discovery, and the search
|
|
511
|
+
* stops at the first that does. A soul whose resolution is refused cannot provide anything and is
|
|
512
|
+
* skipped, named with its code. → { prepared | null, skipped: [{ soul, code }] } */
|
|
513
|
+
export async function prepareFirstProviding(contextDir, provides, { remoteOptions, remote } = {}) {
|
|
514
|
+
const found = loadLocal(contextDir);
|
|
515
|
+
const deployment = dirname(found.path);
|
|
516
|
+
const discovery = await discoverOrStandalone(found.local, { lock: readLockIfPresent(deployment), remoteOptions, remote });
|
|
517
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : qualifiedSoulName(a) < qualifiedSoulName(b) ? -1 : 1);
|
|
518
|
+
const entries = soulCandidates(discovery).map((c) => c.entry).filter((e) => disabledEntry(found.local, e) === null).sort(byName);
|
|
519
|
+
const skipped = [];
|
|
520
|
+
for (const soulEntry of entries) {
|
|
521
|
+
let prepared;
|
|
522
|
+
try { prepared = await prepareInstance(contextDir, null, { remoteOptions, remote, discovery, soulEntry }); }
|
|
523
|
+
catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) { skipped.push({ soul: qualifiedSoulName(soulEntry), code: e.code }); continue; } throw e; }
|
|
524
|
+
if (provides(prepared.resolution)) return { prepared, skipped };
|
|
525
|
+
}
|
|
526
|
+
return { prepared: null, skipped };
|
|
527
|
+
}
|
|
528
|
+
|
|
491
529
|
/** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
|
|
492
530
|
* operator's access context cannot see this host" — the ONLY reasons that turn a
|
|
493
531
|
* 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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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
|
}
|