@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.
- package/bin/oats.mjs +168 -21
- package/docs/capabilities.md +13 -1
- package/docs/desktop-cli-api.md +113 -1
- package/docs/desktop-instance-start.md +9 -3
- package/docs/desktop.md +19 -0
- package/docs/integrations.md +6 -2
- package/docs/knowledge.md +3 -2
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.38.2.md +13 -0
- package/docs/release-notes/v0.39.0.md +99 -0
- package/docs/servers.md +135 -3
- package/lib/dir-lock.mjs +36 -0
- package/lib/instance-resolution.mjs +40 -15
- package/lib/operator-dispatch.mjs +19 -9
- package/lib/remote.mjs +15 -2
- 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.
|
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
|
+
}
|
|
@@ -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);
|
|
@@ -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
|
|
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
|
}
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|