@awebai/oats 0.32.0 → 0.34.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/README.md +1 -1
- package/bin/oats.mjs +128 -20
- package/docs/capabilities.md +22 -0
- package/docs/configuration.md +2 -2
- package/docs/design/2026-09-23-workspace-module-contracts.md +3 -2
- package/docs/desktop-cli-api.md +231 -7
- package/docs/first-team.md +20 -2
- package/docs/implementation.md +78 -2
- package/docs/integrations.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.33.0.md +174 -0
- package/docs/release-notes/v0.34.0.md +63 -0
- package/docs/souls-and-instances.md +64 -0
- package/docs/workspaces.md +1 -1
- package/lib/capability-show.mjs +208 -0
- package/lib/core.mjs +89 -17
- package/lib/harness-trust.mjs +139 -0
- package/lib/instance-inspect.mjs +16 -2
- package/lib/packages.mjs +1 -1
- package/lib/process-group.mjs +54 -0
- package/lib/remote.mjs +721 -112
- package/lib/resolve.mjs +56 -14
- package/package-catalog.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# OATS 0.33.0
|
|
2
|
+
|
|
3
|
+
## Added
|
|
4
|
+
|
|
5
|
+
- **`oats spawn <soul> --preview --max-age <seconds>`** (feature
|
|
6
|
+
`spawn-preview-max-age`). A preview reuses member heads this machine
|
|
7
|
+
observed at most that many seconds ago, as the read verbs do since 0.32.0,
|
|
8
|
+
so a warm preview asks no remote. With the flag the preview's JSON carries
|
|
9
|
+
the read verbs' `observation {observedAt, reused, localRevision}` block;
|
|
10
|
+
without it the preview is unchanged. The decision still covers the heads
|
|
11
|
+
the preview used: an apply with `--expect-decision` observes live and
|
|
12
|
+
refuses `E_DECISION_STALE` when a reused head has moved, and a re-preview
|
|
13
|
+
then shows the new head. The apply refuses `--max-age` (`E_BAD_ARGS`), and
|
|
14
|
+
the refusal message of every form that refuses the flag now lists
|
|
15
|
+
`spawn --preview` among the read forms.
|
|
16
|
+
|
|
17
|
+
## Changed
|
|
18
|
+
|
|
19
|
+
- **OATS fetches only what it reads from a remote** (awebai/oats#384). A
|
|
20
|
+
commit comes with its trees and its files up to 64 KiB; a larger file is
|
|
21
|
+
fetched when a read or a module needs it. The first spawn from a large
|
|
22
|
+
workspace host downloads megabytes instead of its whole tree (tsm: 13 MB in
|
|
23
|
+
3 s, instead of 2.4 GB in 6.5 minutes). A server that cannot serve partial
|
|
24
|
+
fetches (a default `git daemon`, an old self-hosted git), or a local git
|
|
25
|
+
older than 2.45, gets whole trees as before, with one `oats: warning` per
|
|
26
|
+
repository.
|
|
27
|
+
|
|
28
|
+
- **Installing a soul's modules reads their files without a process per
|
|
29
|
+
file.** `oats spawn` copies each module's files from the remote cache
|
|
30
|
+
through the command's one `git cat-file --batch` reader per repository,
|
|
31
|
+
as the reads and discovery already do, instead of one `git cat-file blob`
|
|
32
|
+
per file. A Desktop developer spawn on this deployment ran 50 to 59
|
|
33
|
+
processes instead of 81 (34 per-file reads became 3 to 7 readers).
|
|
34
|
+
Budgets, digests, partial caches and errors are unchanged.
|
|
35
|
+
|
|
36
|
+
- **oats.engineering 1.4.0** (catalog and workspace pin, and the bundled
|
|
37
|
+
mirrors): the developer's loop REQUIRES loading `/understand-the-spec` and
|
|
38
|
+
`/execution-strategy` before implementation.
|
|
39
|
+
|
|
40
|
+
- **oats.aweb 1.17.5** (catalog and workspace pin, and the bundled mirror;
|
|
41
|
+
1.17.4 and 1.17.5): a faster spawn and retire. The spawn hook mints the identity with one
|
|
42
|
+
`aw init --join-from` instead of `aw team invite` + `aw team join` +
|
|
43
|
+
`aw init`, so the invite token no longer appears in any argv. The aw floor
|
|
44
|
+
check stops at `aw version`'s version line instead of waiting on its GitHub
|
|
45
|
+
update check. Retire deletes by workspace id, with `aw wake deregister`
|
|
46
|
+
running at the same time. Measured spawn hook: 7.72 s to 4.62 s mean
|
|
47
|
+
(awebai/oats-aweb#31). Hook output, compensation and the aw floor (1.36.13)
|
|
48
|
+
are unchanged. The mint now has one 120 s budget where three calls had
|
|
49
|
+
about 210 s; raise `OATS_AWEB_JOIN_TIMEOUT_MS` on a slow network. Since the
|
|
50
|
+
hook no longer holds the invite token, 1.17.5 always records the alias it
|
|
51
|
+
requested and warns, without quoting the reply, when aw reports another
|
|
52
|
+
(awebai/oats-aweb#32).
|
|
53
|
+
|
|
54
|
+
- **Pressing Spawn (Cmd-Enter) closes the spawn dialog at once.** The spawn
|
|
55
|
+
completes in the background: a pending row ("Spawning…") appears in the
|
|
56
|
+
sidebar roster where the instance will stand, and is replaced in place by
|
|
57
|
+
the real row once the roster reports it. Outcomes arrive as notifications:
|
|
58
|
+
"<name> spawned" with Open, what didn't finish for a partial or incomplete
|
|
59
|
+
spawn (with View schedules for a wake that wasn't saved), and, for a
|
|
60
|
+
refused or failed spawn, the reason with **Reopen spawn**, which restores
|
|
61
|
+
the whole draft. An unknown outcome keeps the row, reading "Outcome
|
|
62
|
+
unknown", with Check result. Prepare reuses the dialog's fresh preview
|
|
63
|
+
instead of reading it again, and the kernel's `--expect-decision` still
|
|
64
|
+
refuses a decision that changed. Dialog previews use `--max-age 60` when the
|
|
65
|
+
CLI supports it (feature `spawn-preview-max-age`)
|
|
66
|
+
([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md#background-spawn-the-dialog-closes-on-spawn)).
|
|
67
|
+
|
|
68
|
+
- **The spawn dialog never waits on the preview while you edit.** The last
|
|
69
|
+
preview stays visible while it updates, Spawn stays pressable, and answers
|
|
70
|
+
are reused for 60 s (#378).
|
|
71
|
+
|
|
72
|
+
- **Core capabilities and Capabilities read as one system** on the soul page,
|
|
73
|
+
the capability page, the instance inspector and the spawn preview. Every
|
|
74
|
+
core row says why it's there, including a slot the soul empties or that has
|
|
75
|
+
no default. The capability page opened from a soul gains a "Why" row.
|
|
76
|
+
Workspace › Capabilities lists Repo owned before Packages (#377).
|
|
77
|
+
|
|
78
|
+
- **The Desktop accepts OATS CLIs `>=0.25.8 <0.34.0`**, so it runs against
|
|
79
|
+
this release's kernel. Install the CLI and the Desktop 0.33.0 together: the
|
|
80
|
+
Desktop 0.32.x refuses a 0.33 CLI.
|
|
81
|
+
|
|
82
|
+
- **Unattended claude and codex launches** (awebai/oats#341,
|
|
83
|
+
[souls-and-instances.md](../souls-and-instances.md#unattended-launches-folder-trust)).
|
|
84
|
+
- Every codex launch passes `-c check_for_update_on_startup=false`, so
|
|
85
|
+
Codex's update prompt no longer blocks it.
|
|
86
|
+
- Codex does not apply a trusted parent to the folders below it. When
|
|
87
|
+
`~/.codex/config.toml` trusts the deployment or an ancestor, a codex
|
|
88
|
+
launch now trusts its new home for that session; the file is never
|
|
89
|
+
written.
|
|
90
|
+
- A claude or codex spawn whose home is not covered by the operator's
|
|
91
|
+
one-time trust of the deployment warns `the <harness> session will stop
|
|
92
|
+
at its folder-trust prompt: trust <deployment> once (…)`. `oats readiness`
|
|
93
|
+
reports the same as a `harness-trust` item in `checks.configured` (not
|
|
94
|
+
required).
|
|
95
|
+
|
|
96
|
+
- **The Desktop names an OATS cache problem, and keeps the roster through an
|
|
97
|
+
unreadable remote.** When the kernel can't read a remote
|
|
98
|
+
(`E_REMOTE_UNREADABLE`), the sidebar roster keeps the instances it last
|
|
99
|
+
observed instead of going empty. A cache problem (`details.reason: "cache"`)
|
|
100
|
+
reads "Couldn't refresh instances · OATS cache problem · observed <age>",
|
|
101
|
+
with the kernel's message in full (it names the lock file or the process
|
|
102
|
+
holding it, and the remedy) and Retry. With nothing observed yet, the failed
|
|
103
|
+
state shows that message. A network failure or a timeout reads "Couldn't
|
|
104
|
+
reach <host> · showing what was read <age>", with the raw code behind
|
|
105
|
+
Details. Of the kernel's structured `details`, only the reason and the
|
|
106
|
+
host of the remote cross to the window; the kernel's message is shown as
|
|
107
|
+
given, with the file or process it names
|
|
108
|
+
([desktop-cli-api.md](../desktop-cli-api.md#workspace)).
|
|
109
|
+
|
|
110
|
+
## Fixed
|
|
111
|
+
|
|
112
|
+
- **A background spawn never fails silently, and a soul can't be spawned
|
|
113
|
+
twice at once** (awebai/oats#383). While a spawn of a soul is in flight,
|
|
114
|
+
opening Spawn for it shows the press disabled with "A spawn of <soul> is in
|
|
115
|
+
progress" and a link to its pending row; it re-enables when the spawn
|
|
116
|
+
settles. After a window reload, a spawn whose outcome wasn't known yet comes
|
|
117
|
+
back as a pending row and its outcome is reported, a failure included, even
|
|
118
|
+
when it settled while another workspace was on screen; Reopen spawn then
|
|
119
|
+
restores the soul and the name. Quitting the Desktop mid-spawn
|
|
120
|
+
still loses an unreported failure
|
|
121
|
+
([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md#background-spawn-the-dialog-closes-on-spawn)).
|
|
122
|
+
|
|
123
|
+
- **A git that the system kills is no longer reported as a timeout**
|
|
124
|
+
(awebai/oats#387). A remote read reports `timeout` only when OATS's own
|
|
125
|
+
timer stopped git. When something else kills git (an out-of-memory kill
|
|
126
|
+
during a large fetch, for example), the error now reads `cannot read remote
|
|
127
|
+
<url> (killed): git was killed (signal SIGKILL)`, with `reason: killed` and
|
|
128
|
+
`details.signal`. Before, it said `(timeout)`.
|
|
129
|
+
|
|
130
|
+
- **Codex sessions see their instance** (awebai/oats#342). Codex can run tool
|
|
131
|
+
commands under its shared app-server daemon, without the session's
|
|
132
|
+
environment, so `oats aweb roster` inside Codex asked for `--soul`.
|
|
133
|
+
- A codex launch now sets the instance environment for tool commands with
|
|
134
|
+
`shell_environment_policy.set`: `OATS_INSTANCE`/`OATS_INSTANCE_HOME`, the
|
|
135
|
+
capabilities' launch environment (`AWEB_IDENTITY_HOME`, `AWEB_DELIVERY`)
|
|
136
|
+
and the launch configuration's literals.
|
|
137
|
+
- It also sets `PATH` with the home's `.oats/bin` first. The user's login
|
|
138
|
+
shell may put its profile's entries ahead of it.
|
|
139
|
+
- A capability command with no home in its environment finds the home from
|
|
140
|
+
its working directory.
|
|
141
|
+
|
|
142
|
+
- The partial-fetch tests now pass on a host whose git is older than 2.45
|
|
143
|
+
(awebai/oats#389). The kernel already fell back to whole trees there; the
|
|
144
|
+
tests now read the kernel's own git probe:
|
|
145
|
+
- on an older git they assert that fallback (whole trees, one warning per
|
|
146
|
+
repository);
|
|
147
|
+
- they skip the partial-cache mechanics that cannot happen there, and name
|
|
148
|
+
why;
|
|
149
|
+
- under CI, an older git fails those tests instead.
|
|
150
|
+
|
|
151
|
+
- **A killed fetch no longer leaves a remote cache unusable** (awebai/oats#386).
|
|
152
|
+
OATS ends git with SIGTERM first, so git removes its own lock files, and
|
|
153
|
+
SIGKILL only after a grace. A lock left by a git killed earlier is removed
|
|
154
|
+
once it is older than the longest fetch, with an `oats: warning` naming it.
|
|
155
|
+
A younger one is never removed: the error names the file and says it is
|
|
156
|
+
safe to remove once no oats or git process is running.
|
|
157
|
+
|
|
158
|
+
- **Ending git leaves no survivor in its process group** (awebai/oats#386). After
|
|
159
|
+
git's own output closes, OATS keeps checking git's process group until the grace
|
|
160
|
+
ends: a member that ignores SIGTERM and holds no pipe is still killed, and a
|
|
161
|
+
group seen empty is never signalled again (its id may already belong to another
|
|
162
|
+
process).
|
|
163
|
+
|
|
164
|
+
- **Processes making the first fetch from one remote at once all succeed**
|
|
165
|
+
(two spawns, two Desktop previews). Every write to a cache takes a
|
|
166
|
+
per-cache lock: a live holder is waited for, a dead one's lock is reclaimed,
|
|
167
|
+
and the cache repo appears whole. A failure to write the local cache is now
|
|
168
|
+
reported as `E_REMOTE_UNREADABLE` with the new reason `cache`, never as
|
|
169
|
+
`network`.
|
|
170
|
+
|
|
171
|
+
- Fetching a commit into the remote cache may take 10 minutes instead of 30 s, so
|
|
172
|
+
the first spawn from a large workspace host no longer fails with `cannot read
|
|
173
|
+
remote … (timeout)`; the timeout error names the fetch and the elapsed time
|
|
174
|
+
(awebai/oats#362).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# OATS 0.34.0
|
|
2
|
+
|
|
3
|
+
## Added
|
|
4
|
+
|
|
5
|
+
- **`oats capabilities show <name>`** (feature `capability-show`,
|
|
6
|
+
`capabilityShowApi: 1`). It answers what one capability of the catalog
|
|
7
|
+
ships: its inject text exactly as committed, and each skill, enumerated as
|
|
8
|
+
a spawn enumerates it, with its SKILL.md description and its files with
|
|
9
|
+
their sizes. `--file <path>` returns the text of one file the show lists
|
|
10
|
+
(the inject or a skill file) and refuses any other file of the capability.
|
|
11
|
+
The rows are `oats capabilities`'s own (`--member <repoKey>` or `--package
|
|
12
|
+
<id>` picks one when names collide, and `--max-age` is accepted), and the
|
|
13
|
+
reads take the trust path spawn takes: a member at the row's commit, a
|
|
14
|
+
package at its locked commit after spawn's lock check (`E_PACKAGE_INTEGRITY`),
|
|
15
|
+
always through the remote cache, never a working clone. Every text and
|
|
16
|
+
description is untrusted repository content: render it as plain text or
|
|
17
|
+
through a sanitising Markdown renderer. The Desktop's capability page reads
|
|
18
|
+
it ([desktop-cli-api.md](../desktop-cli-api.md#oats-capabilities-show)).
|
|
19
|
+
`oats capabilities --json` is unchanged; an unknown word after
|
|
20
|
+
`oats capabilities` is now `E_BAD_ARGS`. A skill path with a `.git`
|
|
21
|
+
component, which a spawn's fetch refuses, now gives `skills: null` on that
|
|
22
|
+
catalog row too.
|
|
23
|
+
|
|
24
|
+
- **The Desktop's capability page shows what a capability ships.** A new
|
|
25
|
+
*Contents* section, between *Provides* and *Used by*, lists the injected
|
|
26
|
+
instructions and every skill's files on the left and shows the selected
|
|
27
|
+
file on the right: Markdown rendered (a SKILL.md's front matter as a small
|
|
28
|
+
table), other text as highlighted code. A relative link to another listed
|
|
29
|
+
file opens it in place. The content is untrusted repository text, so no raw
|
|
30
|
+
HTML, script or image is rendered. The section reads `oats capabilities
|
|
31
|
+
show` (feature `capability-show`) and needs this release's CLI; with an
|
|
32
|
+
older one it says so and reads nothing. Remote workspaces show it later
|
|
33
|
+
([desktop-file-viewer.md](../../packages/desktop/docs/desktop-file-viewer.md#rendering-rendererviewsmarkdownmjs)).
|
|
34
|
+
*Provides* above it is now one compact card, with every skill, command and
|
|
35
|
+
hook as its own chip.
|
|
36
|
+
|
|
37
|
+
## Changed
|
|
38
|
+
|
|
39
|
+
- **The Desktop's Instance tab says how an instance works in one sentence.**
|
|
40
|
+
"Where it works" becomes **Work**: the work mode's tile and a sentence,
|
|
41
|
+
for example "Works in its own worktree of oats, on branch main." or "Has its
|
|
42
|
+
own folder, not tied to one repository". Folder and Home move into a closed
|
|
43
|
+
**Paths** disclosure, each with Copy. The ahead/behind counts stay on the
|
|
44
|
+
Developer tab. **Messaging & Teams** shows the instance's **Messaging ID**
|
|
45
|
+
with Copy, then its **Teams**, introduced as the messaging teams its soul is
|
|
46
|
+
allowed to join.
|
|
47
|
+
|
|
48
|
+
- **The Desktop accepts OATS CLIs `>=0.25.8 <0.35.0`**, so it runs against
|
|
49
|
+
this release's kernel. Install the CLI and the Desktop 0.34.0 together: the
|
|
50
|
+
Desktop 0.33.x refuses a 0.34 CLI.
|
|
51
|
+
|
|
52
|
+
## Fixed
|
|
53
|
+
|
|
54
|
+
- **`oats status` and `oats workspace status` answer within 12 s of remote
|
|
55
|
+
work, even under load.** Each git call they made had its own 30 s limit, and
|
|
56
|
+
a read could wait up to 11 minutes behind another process fetching the same
|
|
57
|
+
remote, so the Desktop's 30 s limit could end them (`E_CLI_TIMEOUT`). Now
|
|
58
|
+
every remote step of these two reads gets what is left of a 12 s budget: a
|
|
59
|
+
member not read by then is a `cannot-read` row (`… (timeout)`) and its git is
|
|
60
|
+
ended, and the command still answers. A workspace definition not read by then
|
|
61
|
+
fails as an unreadable one does today (`reason: "timeout"`). Another
|
|
62
|
+
process's write to the remote cache is waited for only as long, never taken
|
|
63
|
+
over. Spawn, sync and the other verbs are unchanged.
|
|
@@ -204,6 +204,70 @@ the harness's own precedence. The `CLAUDE.md → AGENTS.md` and
|
|
|
204
204
|
`.claude/skills → ../.agents/skills` aliases are kept. OATS composes
|
|
205
205
|
instructions and pins model/provider settings; it excludes nothing.
|
|
206
206
|
|
|
207
|
+
### Unattended launches: folder trust
|
|
208
|
+
|
|
209
|
+
Claude Code and Codex ask before they work in a folder they have not seen, and
|
|
210
|
+
every instance home is new. A launch stopped at that prompt waits for a human,
|
|
211
|
+
so the operator trusts the **deployment directory** (where `oats-local.yaml`
|
|
212
|
+
is) once per harness. OATS only reads the harnesses' configuration; it never
|
|
213
|
+
writes it.
|
|
214
|
+
|
|
215
|
+
- **Claude Code** looks for an accepted entry for its folder or an ancestor, up
|
|
216
|
+
to a git root. Instance homes are not inside a git repository, so one entry
|
|
217
|
+
for the deployment covers every home under it. To add it, run `claude` in the
|
|
218
|
+
deployment once and accept the prompt. That records
|
|
219
|
+
`projects["<deployment>"].hasTrustDialogAccepted` in `~/.claude.json`
|
|
220
|
+
(`$CLAUDE_CONFIG_DIR/.claude.json` when that is set).
|
|
221
|
+
- **Codex** applies only an exact entry: a trusted parent does not cover the
|
|
222
|
+
folders below it. To give the operator's consent, run `codex` in the
|
|
223
|
+
deployment once and choose "Trust and continue". That records
|
|
224
|
+
`[projects."<deployment>"] trust_level = "trusted"` in
|
|
225
|
+
`~/.codex/config.toml` (`$CODEX_HOME/config.toml`). With that entry, or one
|
|
226
|
+
for an ancestor of the deployment, each codex launch trusts its own new home
|
|
227
|
+
for that session (`-c 'projects={"<home>"={trust_level="trusted"}}'`) and
|
|
228
|
+
leaves the config file unchanged. A yolo launch always does this. The plan
|
|
229
|
+
re-reads the entry at every start.
|
|
230
|
+
- Every codex launch also passes `-c check_for_update_on_startup=false`, so
|
|
231
|
+
Codex's "Update available" choice cannot block it.
|
|
232
|
+
|
|
233
|
+
When a claude or codex home is not covered, the spawn says so (text and
|
|
234
|
+
`--json` `warnings`): `the <harness> session will stop at its folder-trust
|
|
235
|
+
prompt: trust <deployment> once (<the step>)`. `oats readiness` reports the
|
|
236
|
+
same in `checks.configured` (code `harness-trust`, not required).
|
|
237
|
+
|
|
238
|
+
### Codex tool commands and the instance environment
|
|
239
|
+
|
|
240
|
+
Codex can run tool commands under its shared app-server daemon rather than as
|
|
241
|
+
children of the session OATS launched, and then they do not inherit the
|
|
242
|
+
session's environment. Codex (0.157.1) runs a session that has `-c` overrides
|
|
243
|
+
embedded, without the daemon, and every kernel codex launch has them, so an
|
|
244
|
+
OATS codex session does not appear in `codex agents`. So that the environment
|
|
245
|
+
does not depend on this, a codex launch also sets it for tool
|
|
246
|
+
commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
|
|
247
|
+
|
|
248
|
+
- the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`, `PI_AGENT_INSTANCE`,
|
|
249
|
+
`PI_AGENT_HOME`;
|
|
250
|
+
- every capability's launch environment (for example the messaging
|
|
251
|
+
provider's identity home and delivery mode);
|
|
252
|
+
- the launch configuration's literal values. A reference's value never goes
|
|
253
|
+
on a command line.
|
|
254
|
+
|
|
255
|
+
`PATH` comes from the launching shell, with the home's `.oats/bin` first, so
|
|
256
|
+
only the execution passes it; the persisted command does not carry it. Codex
|
|
257
|
+
runs tool commands through the user's login shell, and a profile that prepends
|
|
258
|
+
directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
|
|
259
|
+
directory is found first.
|
|
260
|
+
|
|
261
|
+
A capability command (`oats <namespace> …`) run with none of
|
|
262
|
+
`OATS_INSTANCE_HOME`, `PI_AGENT_HOME` or `OATS_HOME` set finds its instance
|
|
263
|
+
from the working directory. It uses the nearest enclosing directory laid out as
|
|
264
|
+
`<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
|
|
265
|
+
name, and validates it like a home named by the environment. The walk uses the
|
|
266
|
+
directory as the shell names it (`$PWD`). That matters for an attached
|
|
267
|
+
instance, whose `work/` links into its owner's tree: below it, the physical
|
|
268
|
+
path is the owner's. A process that has no `$PWD` there would act as the
|
|
269
|
+
owner, so an attached instance runs capability commands from its home.
|
|
270
|
+
|
|
207
271
|
## Lifecycle
|
|
208
272
|
|
|
209
273
|
### Spawn
|
package/docs/workspaces.md
CHANGED
|
@@ -183,7 +183,7 @@ as `confirmed` or the reason it is not:
|
|
|
183
183
|
| `not-listed` | the workspace does not list the repo |
|
|
184
184
|
| `no-backlink` | no (or invalid) `oats-membership.yaml` at the member's default branch |
|
|
185
185
|
| `backlink-elsewhere` | the member names a different workspace (a case-only difference is flagged: repo paths are case-sensitive identities) |
|
|
186
|
-
| `cannot-read` | the operator cannot read the member (auth / not-found / network / timeout) |
|
|
186
|
+
| `cannot-read` | the operator cannot read the member (auth / not-found / network / timeout / killed: the system killed git, e.g. out of memory), or this machine's remote cache could not be written (cache) |
|
|
187
187
|
|
|
188
188
|
An unconfirmed member contributes nothing but its row: its souls are invisible,
|
|
189
189
|
its capabilities unresolvable (`E_NOT_A_MEMBER` / `E_MEMBERSHIP_UNCONFIRMED`).
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/capability-show.mjs — `oats capabilities show` (feature capability-show, capabilityShowApi 1): what one
|
|
3
|
+
* catalog row of `oats capabilities` ships — its inject text and each skill's files — and one file's text on
|
|
4
|
+
* request, read at the row's commit through the read path a spawn uses. Contract: docs/desktop-cli-api.md
|
|
5
|
+
* "`oats capabilities show`".
|
|
6
|
+
*
|
|
7
|
+
* - A member row is read from its member repository at the row's commit, a package row at the LOCKED commit,
|
|
8
|
+
* after the lock check a spawn makes (lockedCapability: the package's capability list there is the lock's).
|
|
9
|
+
* The package's full-tree digest is not recomputed: `oats sync` proved the lock's integrity over the tree of
|
|
10
|
+
* exactly that commit, and the commit id content-addresses the tree.
|
|
11
|
+
* - Skills are the spawn's enumeration (capabilitySkills over enumerateSkills, lib/resolve.mjs); a skill's
|
|
12
|
+
* files are every regular file under its directory (listRemoteFiles), at most FILES_PER_SKILL.
|
|
13
|
+
* - Every read goes through lib/remote.mjs at a full commit id: nothing reads a working clone and nothing is
|
|
14
|
+
* written outside the remote cache.
|
|
15
|
+
* - Paths in the answers are POSIX and relative to the capability directory. `--file` reads only a path the
|
|
16
|
+
* show lists (the inject, a listed skill file): no other file of the capability is readable through it.
|
|
17
|
+
*
|
|
18
|
+
* Every text is untrusted repository content; a consumer renders it as plain text or through a sanitising
|
|
19
|
+
* renderer (the docs say so).
|
|
20
|
+
*/
|
|
21
|
+
import { posix } from "node:path";
|
|
22
|
+
import YAML from "yaml";
|
|
23
|
+
import { oatsError } from "./errors.mjs";
|
|
24
|
+
import { bindRemote } from "./packages.mjs";
|
|
25
|
+
import { capabilitySkills, lockedCapability, lockedPackageCapabilities, manifestFilePath, memberRef, unsafeRelPath } from "./resolve.mjs";
|
|
26
|
+
import { memberRowByKey } from "./workspace.mjs";
|
|
27
|
+
|
|
28
|
+
export const CAPABILITY_SHOW_API = 1;
|
|
29
|
+
/** A text is cut to at most this many UTF-8 bytes; binary detection examines this many + 3. */
|
|
30
|
+
export const TEXT_LIMIT = 262144;
|
|
31
|
+
/** A skill lists at most this many files (`filesTruncated` beyond). */
|
|
32
|
+
export const FILES_PER_SKILL = 200;
|
|
33
|
+
/** A skill's `description` is cut to at most this many UTF-8 bytes. */
|
|
34
|
+
export const DESCRIPTION_LIMIT = 1024;
|
|
35
|
+
|
|
36
|
+
function fail(code, message, details) {
|
|
37
|
+
const e = oatsError(code, message, details);
|
|
38
|
+
if (details !== undefined) e.details = details;
|
|
39
|
+
return e;
|
|
40
|
+
}
|
|
41
|
+
const isOatsError = (e) => typeof e?.code === "string" && e.code.startsWith("E_");
|
|
42
|
+
const decoder = () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
43
|
+
const isContinuation = (byte) => (byte & 0xc0) === 0x80;
|
|
44
|
+
|
|
45
|
+
/** The cut of `bytes` at most `limit` long, on a code point boundary (bytes are valid UTF-8 there). */
|
|
46
|
+
function cutAt(bytes, limit) {
|
|
47
|
+
if (bytes.length <= limit) return bytes.length;
|
|
48
|
+
let cut = limit;
|
|
49
|
+
while (cut > 0 && isContinuation(bytes[cut])) cut--;
|
|
50
|
+
return cut;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A file's bytes as the answers carry them: → { text, binary, truncated }. Binary when the examined bytes (the
|
|
55
|
+
* first TEXT_LIMIT + 3) hold a NUL or are not valid UTF-8 (TextDecoder fatal, BOM kept); otherwise `text` is
|
|
56
|
+
* the content cut to at most TEXT_LIMIT bytes on a code point boundary, `truncated` when cut. The examined
|
|
57
|
+
* bytes include the whole code point that straddles the limit, so the cut is decided on the same bytes.
|
|
58
|
+
*/
|
|
59
|
+
export function decodeText(bytes, limit = TEXT_LIMIT) {
|
|
60
|
+
const head = bytes.subarray(0, limit + 3);
|
|
61
|
+
const binary = { text: null, binary: true, truncated: false };
|
|
62
|
+
if (head.includes(0)) return binary;
|
|
63
|
+
// A file longer than the examined bytes may continue a valid sequence they end inside of: `stream` accepts
|
|
64
|
+
// such an incomplete tail, and still refuses every invalid byte among them.
|
|
65
|
+
try { decoder().decode(head, { stream: bytes.length > head.length }); }
|
|
66
|
+
catch { return binary; }
|
|
67
|
+
const cut = cutAt(bytes, limit);
|
|
68
|
+
return { text: decoder().decode(bytes.subarray(0, cut)), binary: false, truncated: cut < bytes.length };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `text` cut to at most `limit` UTF-8 bytes on a code point boundary. */
|
|
72
|
+
export function cutUtf8(text, limit) {
|
|
73
|
+
const bytes = Buffer.from(text, "utf8");
|
|
74
|
+
return bytes.length <= limit ? text : bytes.subarray(0, cutAt(bytes, limit)).toString("utf8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FRONT_MATTER_RE = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
|
|
78
|
+
/** The `description` of a SKILL.md's leading `---` YAML front matter (a string only), cut to DESCRIPTION_LIMIT
|
|
79
|
+
* bytes; null when there is no front matter, it does not parse, or the key is absent or not a string. */
|
|
80
|
+
export function skillDescription(text) {
|
|
81
|
+
if (typeof text !== "string") return null;
|
|
82
|
+
const m = FRONT_MATTER_RE.exec(text.replace(/^\uFEFF/, ""));
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
let data;
|
|
85
|
+
try { data = YAML.parse(m[1] ?? "", { logLevel: "error" }); } catch { return null; }
|
|
86
|
+
if (data === null || typeof data !== "object" || Array.isArray(data) || typeof data.description !== "string") return null;
|
|
87
|
+
return cutUtf8(data.description, DESCRIPTION_LIMIT);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A `--file` path no listing could hold (lib/resolve.mjs unsafeRelPath): absolute, empty, a `.`/`..`/`.git`
|
|
91
|
+
* component (any case), an empty component, a trailing slash, a backslash or a NUL. */
|
|
92
|
+
export const unsafeFilePath = unsafeRelPath;
|
|
93
|
+
|
|
94
|
+
/** The text a SKILL.md's description is parsed from: the whole readable file (its front matter may run past the
|
|
95
|
+
* display cut), null when the file is binary by decodeText's rule. */
|
|
96
|
+
function skillText(bytes) {
|
|
97
|
+
if (decodeText(bytes).binary) return null;
|
|
98
|
+
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The catalog row a show reads: rows named `name`, narrowed by `--member <repoKey>` (a member row of that
|
|
102
|
+
* repository) or `--package <id>` (that package's row). 0 → E_CAPABILITY_UNKNOWN; >1 → E_CAPABILITY_AMBIGUOUS. */
|
|
103
|
+
export function selectCapabilityRow(rows, name, { member = null, package: pkg = null } = {}) {
|
|
104
|
+
let matches = rows.filter((r) => r.name === name);
|
|
105
|
+
if (member !== null) matches = matches.filter((r) => r.kind === "member" && r.repoKey === member);
|
|
106
|
+
if (pkg !== null) matches = matches.filter((r) => r.kind === "package" && r.package === pkg);
|
|
107
|
+
const selector = member !== null ? { member } : pkg !== null ? { package: pkg } : {};
|
|
108
|
+
if (matches.length === 0) {
|
|
109
|
+
const where = member !== null ? ` from member ${member}` : pkg !== null ? ` from package ${pkg}` : "";
|
|
110
|
+
throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(name)}${where} in this workspace's catalog (\`oats capabilities\` lists them; a package's capabilities appear after \`oats sync\`)`, { name, ...selector });
|
|
111
|
+
}
|
|
112
|
+
if (matches.length > 1) {
|
|
113
|
+
const candidates = matches.map((r) => (r.kind === "package" ? { kind: r.kind, package: r.package, origin: r.origin } : { kind: r.kind, repoKey: r.repoKey, origin: r.origin }));
|
|
114
|
+
throw fail("E_CAPABILITY_AMBIGUOUS", `${matches.length} capabilities are named ${JSON.stringify(name)} (${candidates.map((c) => c.origin).join("; ")}): choose one with --member <repoKey> or --package <id>`, { name, candidates });
|
|
115
|
+
}
|
|
116
|
+
return matches[0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Where a catalog row is read from: → { name, kind, repoKey, package, version, commit, ref, dir, manifest,
|
|
121
|
+
* missingCode }. A member row from discovery (its capability path and manifest, the member's ref); a package
|
|
122
|
+
* row from its manifests at the locked commit, after the spawn's lock check (E_PACKAGE_INTEGRITY).
|
|
123
|
+
*/
|
|
124
|
+
export async function capabilitySource(row, { discovery, lock, catalog = null, remote, remoteOptions }) {
|
|
125
|
+
if (row.kind === "package") {
|
|
126
|
+
const entry = lock?.packages?.[row.package];
|
|
127
|
+
if (!entry) throw fail("E_CAPABILITY_UNKNOWN", `package ${row.package} is not in the lock — run \`oats sync\``, { name: row.name, package: row.package });
|
|
128
|
+
const { ref, capabilities } = await lockedPackageCapabilities(row.package, entry, { catalog, remote, remoteOptions });
|
|
129
|
+
const cap = lockedCapability(row.name, row.package, entry, capabilities);
|
|
130
|
+
return { name: row.name, kind: "package", repoKey: remote.parseRepoRef(ref).key, package: row.package, version: entry.version, commit: entry.commit, ref, dir: cap.dir, manifest: cap.manifest, missingCode: "E_PACKAGE_MANIFEST" };
|
|
131
|
+
}
|
|
132
|
+
const cap = memberRowByKey(discovery.members, row.repoKey)?.capabilities.find((c) => c.name === row.name);
|
|
133
|
+
if (!cap) throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(row.name)} in member ${row.repoKey}`, { name: row.name, member: row.repoKey });
|
|
134
|
+
return { name: row.name, kind: "member", repoKey: row.repoKey, package: null, version: null, commit: row.commit, ref: memberRef(discovery, remote, row.repoKey), dir: cap.path, manifest: cap.manifest, missingCode: "E_CAPABILITY_MISSING" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const problemOf = (e, path) => ({ code: e.code, message: e.message, path });
|
|
138
|
+
|
|
139
|
+
/** What a source lists, without reading any file's content: → { inject: { path (null when unsafe), safe } | null,
|
|
140
|
+
* skills: [{ name, path, files: [{ path, bytes }] | null, filesTruncated }] | null, problems }. */
|
|
141
|
+
async function listing(source, { remote, remoteOptions }) {
|
|
142
|
+
const r = bindRemote(remote, remoteOptions);
|
|
143
|
+
const { ref, commit, dir, manifest, missingCode } = source;
|
|
144
|
+
const problems = [];
|
|
145
|
+
let inject = null;
|
|
146
|
+
if (typeof manifest.inject === "string" && manifest.inject) {
|
|
147
|
+
const path = manifestFilePath(manifest.inject);
|
|
148
|
+
// An unsafe declared path is never put in a `path` field (every path in the answers is safe): null, and the
|
|
149
|
+
// raw value only inside the problem's message.
|
|
150
|
+
inject = { path, safe: path !== null };
|
|
151
|
+
if (!path) problems.push({ code: missingCode, message: `${source.name} declares inject ${JSON.stringify(manifest.inject)}, which is not a relative path inside the capability`, path: null });
|
|
152
|
+
}
|
|
153
|
+
const { skills: found, problem } = await capabilitySkills({ ref, commit, dir, manifest, remote, remoteOptions, missingCode });
|
|
154
|
+
if (!found) problems.push(problemOf(problem, null));
|
|
155
|
+
const skills = found && [];
|
|
156
|
+
for (const skill of found ?? []) {
|
|
157
|
+
// A skill whose files cannot be listed has `files: null` (and a problem): never "listed nothing".
|
|
158
|
+
// Only the listed files' sizes are learned: nothing past FILES_PER_SKILL is fetched (#409).
|
|
159
|
+
let listed = null;
|
|
160
|
+
try { listed = await r.listRemoteFiles(ref, commit, posix.join(dir, skill.path), { limit: FILES_PER_SKILL }); }
|
|
161
|
+
catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, skill.path)); }
|
|
162
|
+
skills.push({ name: skill.name, path: skill.path, files: listed && listed.files.map((f) => ({ path: `${skill.path}/${f.path}`, bytes: f.size })), filesTruncated: listed !== null && listed.total > FILES_PER_SKILL });
|
|
163
|
+
}
|
|
164
|
+
return { inject, skills, problems };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const header = (source) => ({ capabilityShowApi: CAPABILITY_SHOW_API, name: source.name, kind: source.kind });
|
|
168
|
+
|
|
169
|
+
/** The show answer (capabilityShowApi 1): the inject with its text, each skill with its description and files. */
|
|
170
|
+
export async function capabilityShow(source, { remote, remoteOptions }) {
|
|
171
|
+
const r = bindRemote(remote, remoteOptions);
|
|
172
|
+
const { ref, commit, dir } = source;
|
|
173
|
+
const listed = await listing(source, { remote, remoteOptions });
|
|
174
|
+
const problems = [...listed.problems];
|
|
175
|
+
let inject = null;
|
|
176
|
+
if (listed.inject) {
|
|
177
|
+
inject = { path: listed.inject.path, bytes: null, text: null, binary: false, truncated: false };
|
|
178
|
+
if (listed.inject.safe) {
|
|
179
|
+
try { const { bytes, size } = await r.readRemoteFile(ref, commit, posix.join(dir, inject.path)); inject = { path: inject.path, bytes: size, ...decodeText(bytes) }; }
|
|
180
|
+
catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, inject.path)); }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let skills = null;
|
|
184
|
+
if (listed.skills) {
|
|
185
|
+
skills = [];
|
|
186
|
+
for (const skill of listed.skills) {
|
|
187
|
+
let description = null;
|
|
188
|
+
try {
|
|
189
|
+
const { bytes } = await r.readRemoteFile(ref, commit, posix.join(dir, skill.path, "SKILL.md"));
|
|
190
|
+
description = skillDescription(skillText(bytes));
|
|
191
|
+
} catch (e) { if (!isOatsError(e)) throw e; }
|
|
192
|
+
skills.push({ name: skill.name, path: skill.path, description, files: skill.files, filesTruncated: skill.filesTruncated });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { ...header(source), repoKey: source.repoKey, package: source.package, version: source.version, commit, path: dir, inject, skills, problems };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The `--file` answer: one file the show lists (the inject, or a listed skill file), at the row's commit.
|
|
199
|
+
* E_CAPABILITY_FILE_UNSAFE for a path no listing could hold; E_CAPABILITY_FILE_UNKNOWN for one it does not
|
|
200
|
+
* list; the remote's own refusal (E_REMOTE_FILE_OVERSIZE, …) for one it lists but cannot read. */
|
|
201
|
+
export async function capabilityFile(source, path, { remote, remoteOptions }) {
|
|
202
|
+
if (unsafeFilePath(path)) throw fail("E_CAPABILITY_FILE_UNSAFE", `${JSON.stringify(path)} is not a relative path inside the capability`, { path });
|
|
203
|
+
const listed = await listing(source, { remote, remoteOptions });
|
|
204
|
+
const known = (listed.inject?.safe && listed.inject.path === path) || (listed.skills ?? []).some((s) => (s.files ?? []).some((f) => f.path === path));
|
|
205
|
+
if (!known) throw fail("E_CAPABILITY_FILE_UNKNOWN", `${path} is not a file \`oats capabilities show ${source.name}\` lists (the inject, or a skill's files)`, { path, name: source.name });
|
|
206
|
+
const { bytes, size } = await bindRemote(remote, remoteOptions).readRemoteFile(source.ref, source.commit, posix.join(source.dir, path));
|
|
207
|
+
return { ...header(source), commit: source.commit, file: { path, bytes: size, ...decodeText(bytes) } };
|
|
208
|
+
}
|