@awebai/oats 0.34.4 → 0.35.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 +4 -1
- package/docs/desktop-cli-api.md +8 -7
- package/docs/desktop.md +3 -0
- package/docs/implementation.md +17 -0
- package/docs/integrations.md +1 -1
- package/docs/knowledge.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +8 -8
- package/docs/release-notes/v0.35.0.md +53 -0
- package/docs/release-notes/v0.35.1.md +42 -0
- package/docs/souls-and-instances.md +2 -2
- package/docs/workspaces.md +1 -1
- package/lib/remote.mjs +96 -16
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/README.md +55 -0
- package/packages/record/bin/capture.mjs +204 -27
- package/packages/record/lib/capture-cc.mjs +10 -4
- package/packages/record/lib/sessions-for-home.mjs +3 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -3059,7 +3059,7 @@ function versionCmd() {
|
|
|
3059
3059
|
// Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
|
|
3060
3060
|
// runs on resolve/materialize (contract §6); a feature the binary does not implement is
|
|
3061
3061
|
// never listed.
|
|
3062
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
|
|
3062
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
|
|
3063
3063
|
return;
|
|
3064
3064
|
}
|
|
3065
3065
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -3778,6 +3778,9 @@ The turn record (core — every conversation captured, searchable, replicated):
|
|
|
3778
3778
|
oats capture [--watch|--status] land Claude Code/pi/codex sessions and aw
|
|
3779
3779
|
[--owner <name>] [--root <dir>] client logs in the record; reconciliation
|
|
3780
3780
|
is the capture
|
|
3781
|
+
oats capture --file <path> --format cc|pi|codex --home <instance home> [--json]
|
|
3782
|
+
one session file (an archived session),
|
|
3783
|
+
captured as --home capture would
|
|
3781
3784
|
oats recall [--kind k] [--thread t] search the whole record — mail, chat,
|
|
3782
3785
|
[--from f] [--show id] <query> sessions — with exact turn provenance
|
|
3783
3786
|
oats setup [--owner <name>] [--dry-run] install capture hooks + background watcher
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -38,7 +38,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
|
|
|
38
38
|
"instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
|
|
39
39
|
"workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
|
|
40
40
|
"team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
|
|
41
|
-
"preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show"],
|
|
41
|
+
"preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show","capture-file"],
|
|
42
42
|
"automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
|
|
43
43
|
"readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2,
|
|
44
44
|
"capabilityShowApi":1}
|
|
@@ -99,6 +99,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
|
|
|
99
99
|
| `observe-max-age` | `--max-age <s>` on the read verbs and their `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311)) | |
|
|
100
100
|
| `spawn-preview-max-age` | `--max-age <s>` on `spawn --preview` and its `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311), [The preview](#the-preview)) | |
|
|
101
101
|
| `capability-show` | `oats capabilities show <name>` and its `--file` form, OATS 0.34.0 ([`oats capabilities show`](#oats-capabilities-show)) | `capabilityShowApi: 1` |
|
|
102
|
+
| `capture-file` | `oats capture --file <path> --format cc\|pi\|codex --home <instance home> [--json]`: one session file captured as `--home` capture would, with a receipt bound to its bytes, OATS 0.35.0 (the capture USAGE and packages/record/README.md) | |
|
|
102
103
|
|
|
103
104
|
Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
|
|
104
105
|
`workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
|
|
@@ -723,7 +724,7 @@ Read-only (it writes no lock):
|
|
|
723
724
|
"souls":["rm"],"capabilities":["nw-house-style"],"publishes":null,"url":"https://github.com/nw/agents/tree/66566512…",
|
|
724
725
|
"membershipFile":{"path":"oats-membership.yaml","url":"https://github.com/nw/agents/blob/66566512…/oats-membership.yaml"}}],
|
|
725
726
|
"packages":[{"id":"oats.okf","version":"3.0.0","source":"catalog:oats.okf","commit":"ab897841…","integrity":"sha256-bada35…",
|
|
726
|
-
"capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.0
|
|
727
|
+
"capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.1.0","ref":"v4.1.0"}}],
|
|
727
728
|
"declaredPackages":["oats.framework","oats.okf"],"unsynced":["oats.framework"],"stale":[],
|
|
728
729
|
"external":[{"source":"git:github.com/oss/experts@3c606e09…","soul":"security-reviewer"}],
|
|
729
730
|
"problems":[],"warnings":[],
|
|
@@ -782,8 +783,8 @@ packages' capabilities and souls, sorted by name, then origin. Both carry
|
|
|
782
783
|
"defaultTeam":{"label":"mine","team":"mine:ana.aweb.ai","from":"deployment"},
|
|
783
784
|
"private":false,"path":"souls/writer","work":"directory","description":"Drafts campaigns.","harness":"pi","model":null,"harnessFrom":"kernel-default",
|
|
784
785
|
"file":{"path":"souls/writer/soul.yaml","url":null},"spawnable":true,"problem":null},
|
|
785
|
-
{"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.0
|
|
786
|
-
"version":"4.0
|
|
786
|
+
{"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.1.0","kind":"package","package":"oats.okf",
|
|
787
|
+
"version":"4.1.0","repoKey":"github.com/awebai/oats-okf","commit":"e331a996…","teams":null,"defaultTeam":null,"private":false,
|
|
787
788
|
"path":"oats-package/souls/knowledge-maintainer","work":"directory","description":"Reviews harvested knowledge.","harness":"pi","model":null,
|
|
788
789
|
"harnessFrom":"kernel-default","file":{"path":"oats-package/souls/knowledge-maintainer/soul.yaml","url":null},
|
|
789
790
|
"spawnable":false,"problem":{"code":"E_TEAM_UNKNOWN","message":"team \"reviewers\" is not declared (oats-local.yaml#/souls/teams/…)"}}],
|
|
@@ -872,8 +873,8 @@ nothing reads a working clone.
|
|
|
872
873
|
**The show:**
|
|
873
874
|
|
|
874
875
|
```json
|
|
875
|
-
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0
|
|
876
|
-
"commit":"
|
|
876
|
+
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.1.0",
|
|
877
|
+
"commit":"e331a996…","path":"oats-package/capabilities/oats-okf",
|
|
877
878
|
"inject":{"path":"injects/okf.md","bytes":2422,"text":"## Knowledge: OKF\n\nYou have two kinds of knowledge. …","binary":false,"truncated":false},
|
|
878
879
|
"skills":[{"name":"okf-consultation","path":"skills/okf-consultation","description":"Consulting your soul's knowledge with the `oats okf` CLI: …",
|
|
879
880
|
"files":[{"path":"skills/okf-consultation/SKILL.md","bytes":6947},{"path":"skills/okf-consultation/references/consult.md","bytes":4465}],
|
|
@@ -899,7 +900,7 @@ nothing reads a working clone.
|
|
|
899
900
|
**The `--file` answer:**
|
|
900
901
|
|
|
901
902
|
```json
|
|
902
|
-
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"
|
|
903
|
+
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"e331a996…",
|
|
903
904
|
"file":{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787,"text":"---\nname: okf-instance-knowledge\n…","binary":false,"truncated":false}}
|
|
904
905
|
```
|
|
905
906
|
|
package/docs/desktop.md
CHANGED
|
@@ -81,6 +81,9 @@ Desktop never parses the deployment: its members, lock state and header come
|
|
|
81
81
|
from `oats workspace status`, and its instances from the deployment's one
|
|
82
82
|
`agents/` root.
|
|
83
83
|
Added workspaces are remembered and offered as suggestions next time.
|
|
84
|
+
**Browse** opens in the folder holding the workspace most recently added or
|
|
85
|
+
opened, or your home directory when there is none (never ~/Downloads);
|
|
86
|
+
**Choose oats…** opens in the directory of the chosen or discovered CLI.
|
|
84
87
|
|
|
85
88
|
Launch flags for scripted use: `--dir <workspace>` and `OATS_DESKTOP_PORT`.
|
|
86
89
|
|
package/docs/implementation.md
CHANGED
|
@@ -237,3 +237,20 @@ contacts GitHub, aweb, Jira or Linear.
|
|
|
237
237
|
|
|
238
238
|
Tests pin behaviour, so a change that alters behaviour changes its test in the
|
|
239
239
|
same commit. Never weaken an assertion to make a change pass.
|
|
240
|
+
|
|
241
|
+
Since Git 2.47, a commit or fetch starts `git maintenance run --auto
|
|
242
|
+
--detach`, a daemon that outlives the command. On a loaded CI runner it can
|
|
243
|
+
still be repacking into a repository while the test's cleanup removes it,
|
|
244
|
+
which fails with `ENOTEMPTY` (awebai/oats#451). So:
|
|
245
|
+
|
|
246
|
+
- The shared fixture's repositories (`test/helpers/v2-deployment.mjs`: the
|
|
247
|
+
bare remote, the seed and the member clone) never run it. Each sets
|
|
248
|
+
`maintenance.auto=false` in its own config, which covers every git run in
|
|
249
|
+
it, a test's raw `execFileSync("git", …)` and the kernel's included.
|
|
250
|
+
- A test that runs git itself goes through the fixture's `git()` helper,
|
|
251
|
+
which disables it per command, or sets `maintenance.auto=false` in a
|
|
252
|
+
repository it creates.
|
|
253
|
+
- The kernel's remote cache passes `-c maintenance.auto=false` on every git
|
|
254
|
+
it runs there.
|
|
255
|
+
|
|
256
|
+
Never paper over such a race with a retry around the cleanup.
|
package/docs/integrations.md
CHANGED
package/docs/knowledge.md
CHANGED
package/docs/official-catalog.md
CHANGED
|
@@ -10,7 +10,7 @@ or workspace membership alone does not make a package official.
|
|
|
10
10
|
| package | release | capabilities | package souls |
|
|
11
11
|
|---|---|---|---|
|
|
12
12
|
| `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
|
|
13
|
-
| `oats.okf` | `v4.0
|
|
13
|
+
| `oats.okf` | `v4.1.0` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
|
|
14
14
|
| `oats.aweb` | `v1.17.7` | `oats.aweb` (messaging) | |
|
|
15
15
|
| `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
|
|
16
16
|
| `oats.authoring` | `v1.0.3` | `oats.authoring` | |
|
|
@@ -27,7 +27,7 @@ no lock and adds nothing to an existing workspace.
|
|
|
27
27
|
## Find and use packages
|
|
28
28
|
|
|
29
29
|
- A workspace pins an official package by **bare version** in its
|
|
30
|
-
`packages:` map (`oats.okf: v4.0
|
|
30
|
+
`packages:` map (`oats.okf: v4.1.0`); `oats sync` resolves it through the
|
|
31
31
|
catalog to an exact commit, fetches it, verifies its integrity and locks it.
|
|
32
32
|
A package outside the catalog is written `git:<repo>@<ref>`. Pinning does
|
|
33
33
|
not join a team or adopt the publisher's workspace. See
|
package/docs/packages.md
CHANGED
|
@@ -44,14 +44,14 @@ whole organisation:
|
|
|
44
44
|
|
|
45
45
|
```yaml
|
|
46
46
|
packages:
|
|
47
|
-
oats.okf: v4.0
|
|
47
|
+
oats.okf: v4.1.0 # bare version → the official catalog
|
|
48
48
|
acme.tools: git:github.com/acme/tools@v0.4.0 # direct ref: git:<repo>@<tag or full OID>
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
- **Bare version** (`v4.0
|
|
51
|
+
- **Bare version** (`v4.1.0`, `4.1.0`, `1.0.0-rc.1`): the id is looked up in
|
|
52
52
|
the official catalog — `package-catalog.json` in the `oats` repo, or the file
|
|
53
53
|
named by `OATS_PACKAGE_CATALOG` — which supplies the repo url, the tag
|
|
54
|
-
convention (`v4.0
|
|
54
|
+
convention (`v4.1.0` or `oats-framework/v1.4.1`) and the payload path. An id
|
|
55
55
|
the catalog does not know is `E_PACKAGE_MISSING` ("use `git:<repo>@<ref>` for
|
|
56
56
|
a package outside the catalog"). The catalog is the reviewed official list
|
|
57
57
|
([official-catalog.md](official-catalog.md)) and the only way a
|
|
@@ -75,7 +75,7 @@ members:
|
|
|
75
75
|
- git:github.com/acme/platform
|
|
76
76
|
packages:
|
|
77
77
|
oats.framework: v1.4.1
|
|
78
|
-
oats.okf: v4.0
|
|
78
|
+
oats.okf: v4.1.0
|
|
79
79
|
oats.aweb: v1.17.7
|
|
80
80
|
teams:
|
|
81
81
|
platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
|
|
@@ -105,7 +105,7 @@ decision recorded in the lock.
|
|
|
105
105
|
$ oats sync
|
|
106
106
|
workspace acme (github.com/acme/agents @ 3f2a9c1e)
|
|
107
107
|
members agents ✓↔ (@ 3f2a9c1e) platform ✓↔ (@ 77c0a1b2) billing ✗ (no-backlink)
|
|
108
|
-
packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.0
|
|
108
|
+
packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.1.0 ✓ (@ e331a996)
|
|
109
109
|
changed acme.tools — → 0.4.0 (@ 47f4b816)
|
|
110
110
|
souls 9 discovered (6 members, 1 external, 2 package, 0 disabled here) · 0 private capabilities
|
|
111
111
|
teams platform (shared) · this deployment's: oats teams
|
|
@@ -160,8 +160,8 @@ same workspace commit hold identical locks.
|
|
|
160
160
|
"source": "catalog:oats.okf",
|
|
161
161
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
162
162
|
"path": "oats-package",
|
|
163
|
-
"version": "4.0
|
|
164
|
-
"commit": "
|
|
163
|
+
"version": "4.1.0",
|
|
164
|
+
"commit": "e331a9969d10aabddaa5824991f1846c7dedb388",
|
|
165
165
|
"integrity": "sha256-…",
|
|
166
166
|
"capabilities": ["oats.okf", "oats.okf-harvest", "oats.okf-maintenance"]
|
|
167
167
|
},
|
|
@@ -331,7 +331,7 @@ A soul that names one of the package's capabilities with
|
|
|
331
331
|
{
|
|
332
332
|
"policy": "docs/official-catalog.md",
|
|
333
333
|
"packages": {
|
|
334
|
-
"oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0
|
|
334
|
+
"oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.1.0", "path": "oats-package" },
|
|
335
335
|
"oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.1", "path": "oats-package" }
|
|
336
336
|
}
|
|
337
337
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# OATS 0.35.0
|
|
2
|
+
|
|
3
|
+
## Added
|
|
4
|
+
|
|
5
|
+
- **oats.okf 4.1.0** (catalog and workspace pin, and the bundled mirrors):
|
|
6
|
+
`oats okf harvest --once`, a one-shot reviewed harvest of one seat from an
|
|
7
|
+
explicit record set (awebai/oats-okf#37). A seat that moved (classic to v2)
|
|
8
|
+
or was spawned with harvest off had no way to get its notes into the
|
|
9
|
+
knowledge base. The operator now runs, from the deployment:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
oats okf harvest --once --home <seat home> --records <manifest> --soul <its soul> [--override-opt-out]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- The manifest lists note files, each with its sha256, in the seat's home
|
|
16
|
+
or a listed archive directory. Each file is checked against the bytes
|
|
17
|
+
used, with no symlinks, directories or globbing. A bad entry refuses
|
|
18
|
+
everything before anything is stored.
|
|
19
|
+
- The harvester judges and opens a PR that the knowledge-maintainer
|
|
20
|
+
reviews, as for every harvest. It writes only to the soul's own nodes.
|
|
21
|
+
- Nothing is registered: no schedule and no capture. Rerunning the same
|
|
22
|
+
command continues a large set run by run (each run says how many inputs
|
|
23
|
+
remain), or answers `already-delivered`.
|
|
24
|
+
- A soul's opt-out is refused unless `--override-opt-out`, which the PR
|
|
25
|
+
records. The host's harvest switch does not apply.
|
|
26
|
+
- Archived session records come in a later oats.okf, through `oats capture
|
|
27
|
+
--file` (below).
|
|
28
|
+
|
|
29
|
+
- **`oats capture --file <path> --format cc|pi|codex --home <instance home> [--json]`**
|
|
30
|
+
(feature `capture-file`). It captures one session file, such as an
|
|
31
|
+
archived session the `--home` sweep can no longer find, exactly as
|
|
32
|
+
`--home` capture of that instance would. It is for oats.okf's one-shot
|
|
33
|
+
reviewed harvest of archived sessions (awebai/oats-okf#37).
|
|
34
|
+
- The owner is explicit (`--owner` or `TURN_RECORD_OWNER`, never the
|
|
35
|
+
hostname), and `--home` must be an instance home.
|
|
36
|
+
- The stream identity is the one `--home` capture writes, so capturing the
|
|
37
|
+
same session either way, or again, appends nothing.
|
|
38
|
+
- The format is stated and checked against the file's session header,
|
|
39
|
+
never sniffed.
|
|
40
|
+
- Only one regular file is read, once, from its descriptor: a symlink,
|
|
41
|
+
FIFO or directory is refused.
|
|
42
|
+
- The receipt binds to the bytes: `{thread, firstTurnId, lastTurnId,
|
|
43
|
+
turns, ignored, sha256, ...}`.
|
|
44
|
+
- An outcome that binds nothing is an error code: `E_IGNORED`,
|
|
45
|
+
`E_NO_TURNS`, `E_FORMAT`, `E_NOT_REGULAR_FILE`, `E_FILE_UNREADABLE`,
|
|
46
|
+
`E_USAGE`, and `E_CAPTURE_FAILED` for a pass that failed (`appended:
|
|
47
|
+
null`). See packages/record/README.md.
|
|
48
|
+
|
|
49
|
+
## Changed
|
|
50
|
+
|
|
51
|
+
- **The Desktop accepts OATS CLIs `>=0.25.8 <0.36.0`**, so it runs against
|
|
52
|
+
this release's kernel. Install the CLI and the Desktop 0.35.0 together: the
|
|
53
|
+
Desktop 0.34.x refuses a 0.35 CLI.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# OATS 0.35.1
|
|
2
|
+
|
|
3
|
+
## Fixed
|
|
4
|
+
|
|
5
|
+
- **The remote cache no longer leaves a background git maintenance running
|
|
6
|
+
after a fetch** (awebai/oats#451). Since Git 2.47, a `git fetch` starts
|
|
7
|
+
`git maintenance run --auto --detach`, a daemon that outlives the fetch.
|
|
8
|
+
Every fetch the kernel made into its remote cache (`<cache>/<hash>`)
|
|
9
|
+
started one, which could still be repacking the cache after the command
|
|
10
|
+
that fetched had returned. The cache is the kernel's own repository and
|
|
11
|
+
never runs gc, so its git now runs with `maintenance.auto=false` as well.
|
|
12
|
+
The same daemon, started by a test's commit, was what made a retire test
|
|
13
|
+
fail its cleanup with `ENOTEMPTY` in CI; the test fixtures' repositories
|
|
14
|
+
turn it off too.
|
|
15
|
+
- **Desktop's folder pickers open in a sensible place** (awebai/oats#460).
|
|
16
|
+
**Add workspace → Browse…** opened wherever macOS last was, often
|
|
17
|
+
~/Downloads. It now opens in the folder holding the workspace most
|
|
18
|
+
recently added or opened, remembered across launches, and in the home
|
|
19
|
+
directory before the first one. **Choose oats…** opens in the directory
|
|
20
|
+
of the chosen or discovered CLI.
|
|
21
|
+
|
|
22
|
+
- **A tree object the remote cache cannot read is a problem, never an empty
|
|
23
|
+
directory, and the cache heals** (awebai/oats#455). A fetch into the cache
|
|
24
|
+
that failed partway (on one host, one run by hand) left a member's commit
|
|
25
|
+
without its `souls/` tree. git answers "names no tree" the same way for an
|
|
26
|
+
absent path and for a tree object it cannot read, and the kernel took both
|
|
27
|
+
as an absent directory. So the member enumerated to zero souls with no
|
|
28
|
+
problem, the parsed cache (`<cache>/.parsed/`) kept that answer, and every
|
|
29
|
+
later command hid the member's souls (`E_SOUL_UNKNOWN`). Because a present
|
|
30
|
+
commit is never refetched, the cache stayed broken. Now:
|
|
31
|
+
- an absent directory is confirmed against the commit's own listing;
|
|
32
|
+
- a tree the commit lists but that cannot be read is `E_REMOTE_UNREADABLE`
|
|
33
|
+
(reason `cache`). It is shown as a problem and never kept in the parsed
|
|
34
|
+
cache;
|
|
35
|
+
- the read marks that cache repository damaged, and the next command
|
|
36
|
+
rebuilds it and fetches afresh.
|
|
37
|
+
|
|
38
|
+
Upgrading also stops a bad answer kept by an earlier kernel from being
|
|
39
|
+
served: the parsed cache is keyed by the kernel's own code.
|
|
40
|
+
|
|
41
|
+
Never fetch into `~/.cache/oats/remotes` by hand. It is the kernel's cache,
|
|
42
|
+
written only under its own locks.
|
|
@@ -152,8 +152,8 @@ composed skills and instructions, a spawn records:
|
|
|
152
152
|
"commit": "3f2a9c1e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.118Z"
|
|
153
153
|
},
|
|
154
154
|
"oats.okf": {
|
|
155
|
-
"from": { "kind": "package", "package": "oats.okf", "version": "4.0
|
|
156
|
-
"commit": "
|
|
155
|
+
"from": { "kind": "package", "package": "oats.okf", "version": "4.1.0", "commit": "e331a996…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
|
|
156
|
+
"commit": "e331a996…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
|
|
157
157
|
}
|
|
158
158
|
},
|
|
159
159
|
"providers": {
|
package/docs/workspaces.md
CHANGED
|
@@ -55,7 +55,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
55
55
|
|
|
56
56
|
packages: # the ONLY versioned things
|
|
57
57
|
oats.framework: v1.4.1 # bare version → resolves through the official catalog
|
|
58
|
-
oats.okf: v4.0
|
|
58
|
+
oats.okf: v4.1.0
|
|
59
59
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
60
60
|
|
|
61
61
|
teams: # SHARED teams: the same provider team for everyone
|
package/lib/remote.mjs
CHANGED
|
@@ -696,7 +696,9 @@ function redactUrl(url) {
|
|
|
696
696
|
}
|
|
697
697
|
|
|
698
698
|
function repoHandle(dir, exec, session) {
|
|
699
|
-
|
|
699
|
+
// The cache is the kernel's own: no gc, and no `git maintenance run --auto --detach` (Git >= 2.47) left
|
|
700
|
+
// repacking it after the command that fetched has returned (awebai/oats#451).
|
|
701
|
+
const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
|
|
700
702
|
return { dir, exec, local, session };
|
|
701
703
|
}
|
|
702
704
|
|
|
@@ -781,6 +783,7 @@ class ReadSession {
|
|
|
781
783
|
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
782
784
|
this.peels = new Map(); // `${cacheRepo}\0${oid}` → peeled commit (positive answers only)
|
|
783
785
|
this.trees = new Map(); // `${cacheRepo}\0${commit}` → Promise<tree index | null>
|
|
786
|
+
this.damaged = new Set(); // cache repos this session found damaged: rebuilt by the next one, not mid-command
|
|
784
787
|
this.batches = new Map(); // cacheRepo → batch reader, in least-recently-used order
|
|
785
788
|
this.retiring = new Set(); // evicted batch readers still ending
|
|
786
789
|
this.parsedDirs = new Set(); // fingerprint dirs touched (mtime bumped) this session
|
|
@@ -1173,7 +1176,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1173
1176
|
// Already present (the common case): a read, so no write lock.
|
|
1174
1177
|
let peeled = false; // the object store was asked already, and said no
|
|
1175
1178
|
const usable = async (repo) => {
|
|
1176
|
-
if (!existsSync(join(dir, "HEAD"))) return null;
|
|
1179
|
+
if (!existsSync(join(dir, "HEAD")) || toRebuild(dir, session)) return null;
|
|
1177
1180
|
if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
|
|
1178
1181
|
peeled = true;
|
|
1179
1182
|
return peelCommit(repo, oid);
|
|
@@ -1187,6 +1190,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1187
1190
|
const version = await readVersion(repo, ref);
|
|
1188
1191
|
// A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
|
|
1189
1192
|
if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
|
|
1193
|
+
if (toRebuild(repo.dir, session)) repo = await freshCache(repo, ref, options);
|
|
1190
1194
|
const cached = peeled && !waited ? null : await peelCommit(repo, oid);
|
|
1191
1195
|
if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
|
|
1192
1196
|
const mode = await fetchMode(repo)
|
|
@@ -1300,18 +1304,45 @@ export function keepsPartialCache(version) {
|
|
|
1300
1304
|
/** Delete a partial cache an older git cannot read (it would fetch a missing blob on its own, or die), and start
|
|
1301
1305
|
* it again recording whole-tree fetches. The cache is disposable: everything in it is fetched again. */
|
|
1302
1306
|
async function rebuildWhole(repo, ref, options, version) {
|
|
1303
|
-
const
|
|
1304
|
-
if (session) {
|
|
1305
|
-
session.batches.get(repo.dir)?.kill();
|
|
1306
|
-
session.batches.delete(repo.dir);
|
|
1307
|
-
for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
|
|
1308
|
-
}
|
|
1309
|
-
rmSync(repo.dir, { recursive: true, force: true });
|
|
1310
|
-
const fresh = await cacheRepo(ref, options);
|
|
1307
|
+
const fresh = await freshCache(repo, ref, options);
|
|
1311
1308
|
await recordFullFetches(fresh, ref, olderGitNotice(version, ref));
|
|
1312
1309
|
return fresh;
|
|
1313
1310
|
}
|
|
1314
1311
|
|
|
1312
|
+
/** Drop a cache repository and start it afresh (under the cache's write lock); the session forgets it. */
|
|
1313
|
+
async function freshCache(repo, ref, options) {
|
|
1314
|
+
forgetCacheRepo(repo);
|
|
1315
|
+
rmSync(repo.dir, { recursive: true, force: true });
|
|
1316
|
+
return cacheRepo(ref, options);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** The session's peels, tree indexes and batch reader of this cache repository: what it read there is void. */
|
|
1320
|
+
function forgetCacheRepo(repo) {
|
|
1321
|
+
const session = repo.session;
|
|
1322
|
+
if (!session) return;
|
|
1323
|
+
session.batches.get(repo.dir)?.kill();
|
|
1324
|
+
session.batches.delete(repo.dir);
|
|
1325
|
+
for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** A cache repository that holds a commit without a tree of it (a fetch into it that failed partway, say: a
|
|
1329
|
+
* present commit is never refetched) is marked damaged by the read that finds it, and the next ensureCommit
|
|
1330
|
+
* rebuilds it (awebai/oats#455). The mark lives in the repository, so it goes with it. The session that found
|
|
1331
|
+
* the damage keeps the repository as it is and reports problems: rebuilding it under that command's other
|
|
1332
|
+
* reads would remove what they are reading. The next session (or a read without one) rebuilds, inside
|
|
1333
|
+
* ensureCommit's cache write lock as every other cache write. The mark is a plain file in the bare
|
|
1334
|
+
* repository's root, which git ignores (as it does FETCH_HEAD or gc.log); writing it needs no lock: it only
|
|
1335
|
+
* asks for a rebuild, and a rebuild that already happened took the mark with it. */
|
|
1336
|
+
const DAMAGED_MARK = "oats-damaged";
|
|
1337
|
+
function markCacheDamaged(repo, why) {
|
|
1338
|
+
try { writeFileSync(join(repo.dir, DAMAGED_MARK), `${why}\n`); } catch { /* the read's problem stands either way */ }
|
|
1339
|
+
// The finding session keeps its reader, peels and tree indexes of the repository: they are still true, and
|
|
1340
|
+
// freshCache forgets them when the repository actually goes.
|
|
1341
|
+
repo.session?.damaged.add(repo.dir);
|
|
1342
|
+
}
|
|
1343
|
+
/** The repository carries a damage mark this session did not set: the next fetch rebuilds it first. */
|
|
1344
|
+
const toRebuild = (dir, session) => existsSync(join(dir, DAMAGED_MARK)) && !session?.damaged.has(dir);
|
|
1345
|
+
|
|
1315
1346
|
/** `git fetch` from the remote into the cache (args start at the subcommand and name the remote "origin"):
|
|
1316
1347
|
* a lost on-disk `.lock` race is retried, a timeout names the fetch (`what`) and how long it ran, any other
|
|
1317
1348
|
* failure is E_REMOTE_UNREADABLE (`raw`: the git error itself, for a caller that reads its stderr). → { stdout, stderr } */
|
|
@@ -1696,7 +1727,13 @@ function assertNoCollisions(entries, ref, commit, prefix) {
|
|
|
1696
1727
|
/** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
|
|
1697
1728
|
* Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, "killed"
|
|
1698
1729
|
* for any other signal exit, else "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
|
|
1699
|
-
* row and would otherwise abort the whole discovery on one unexplained listing (L4).
|
|
1730
|
+
* row and would otherwise abort the whole discovery on one unexplained listing (L4).
|
|
1731
|
+
*
|
|
1732
|
+
* git answers "names no tree" the same way for a path that is absent and for a tree object it cannot read
|
|
1733
|
+
* (`<commit>:souls` with that tree missing is "not a tree object"; a path below it is "not a valid object
|
|
1734
|
+
* name"), so the message is never trusted alone: null only when the commit's own listing confirms the path is
|
|
1735
|
+
* absent or not a tree (awebai/oats#455). A tree it lists but cannot read is E_REMOTE_UNREADABLE, which no
|
|
1736
|
+
* parsed-cache item is ever written with: an absent directory would be cached as zero souls. */
|
|
1700
1737
|
async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
1701
1738
|
try {
|
|
1702
1739
|
const args = ["ls-tree", "-l", "-z", ...flags, spec];
|
|
@@ -1706,11 +1743,54 @@ async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 6
|
|
|
1706
1743
|
} catch (error) {
|
|
1707
1744
|
if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
|
|
1708
1745
|
const text = stderrText(error).toLowerCase();
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1746
|
+
const namesNoTree = /not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text);
|
|
1747
|
+
if (namesNoTree) {
|
|
1748
|
+
let absent;
|
|
1749
|
+
// The confirming listing's own failure (a timeout, a kill, the session's abort) is classified as any listing
|
|
1750
|
+
// failure is: it says nothing about the tree.
|
|
1751
|
+
try { absent = await namesNoTreeIn(repo, spec); }
|
|
1752
|
+
catch (confirmFailed) { throw listingUnreadable(confirmFailed, { repo, spec, path, ref, commit }); }
|
|
1753
|
+
if (absent) return null;
|
|
1754
|
+
const bare = !spec.includes(":");
|
|
1755
|
+
markCacheDamaged(repo, bare ? `${spec}: the commit cannot be read` : `${spec}: listed by its commit, unreadable`);
|
|
1756
|
+
throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec} in ${ref?.key ?? repo.dir} (cache: ${bare ? "the commit's own tree cannot be read" : "its commit lists a tree there that cannot be read"})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason: "cache", commit: commit ?? null, spec, path: path ?? null });
|
|
1757
|
+
}
|
|
1758
|
+
throw listingUnreadable(error, { repo, spec, path, ref, commit });
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** A failed `git ls-tree` as E_REMOTE_UNREADABLE: reason "timeout" for the timeout kill, "killed" for any other
|
|
1763
|
+
* signal exit, "cache" for an object the cache lacks (a tree below the commit that git cannot read: the
|
|
1764
|
+
* repository is marked damaged), else "unknown". An oats error passes through as itself. */
|
|
1765
|
+
function listingUnreadable(error, { repo, spec, path, ref, commit }) {
|
|
1766
|
+
if (typeof error?.code === "string" && error.code.startsWith("E_")) return error;
|
|
1767
|
+
const text = stderrText(error).toLowerCase();
|
|
1768
|
+
const missingObject = /could not read [0-9a-f]{40,64}|missing (?:tree|blob|object) [0-9a-f]{40,64}|bad tree object/.test(text);
|
|
1769
|
+
if (missingObject) markCacheDamaged(repo, `${spec}: ${text.trim().split("\n")[0]}`);
|
|
1770
|
+
const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
|
|
1771
|
+
const reason = error?.timedOut ? "timeout" : killed ? "killed" : missingObject ? "cache" : "unknown";
|
|
1772
|
+
const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
|
|
1773
|
+
return fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** Whether `<commit>:<rel>` really names no tree: the commit's own listing of <rel>, which reads every tree on
|
|
1777
|
+
* the way, has no entry there, or a non-tree one. A bare commit spec, a tree entry, or a listing git ran and
|
|
1778
|
+
* failed means the tree is there and unreadable: false. A listing that did not run to its end (a timeout,
|
|
1779
|
+
* a kill, the session's abort, an oats error) answers nothing: it is thrown, for lsTree to classify. */
|
|
1780
|
+
async function namesNoTreeIn(repo, spec) {
|
|
1781
|
+
const colon = spec.indexOf(":");
|
|
1782
|
+
if (colon < 0) return false;
|
|
1783
|
+
const rel = spec.slice(colon + 1);
|
|
1784
|
+
if (!rel) return false;
|
|
1785
|
+
try {
|
|
1786
|
+
const out = await repo.local(["ls-tree", "-l", "-z", spec.slice(0, colon), "--", rel]);
|
|
1787
|
+
const entry = parseLsTree(out.stdout).find((e) => e.path === rel);
|
|
1788
|
+
return !entry || entry.type !== "tree";
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
const unfinished = error?.timedOut || error?.overflowed || (typeof error?.signal === "string" && error.signal !== "")
|
|
1791
|
+
|| error?.code === "ABORT_ERR" || (typeof error?.code === "string" && error.code.startsWith("E_"));
|
|
1792
|
+
if (unfinished) throw error;
|
|
1793
|
+
return false;
|
|
1714
1794
|
}
|
|
1715
1795
|
}
|
|
1716
1796
|
|
package/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -249,3 +249,58 @@ source custody. The library alternative is `sessionsForHome(home, { roots })`:
|
|
|
249
249
|
unspecified formats are excluded, and missing supplied roots fail. Synthetic
|
|
250
250
|
standalone tests must choose one of these explicitly, not masquerade as a
|
|
251
251
|
managed native launch. Background capture without `--home` is unchanged.
|
|
252
|
+
|
|
253
|
+
## One session file: `capture --file`
|
|
254
|
+
|
|
255
|
+
```text
|
|
256
|
+
capture --file <path> --format cc|pi|codex --home <instance home> [--owner <name>] [--json]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Captures ONE session file, for example an archived session that the
|
|
260
|
+
`--home` sweep can no longer find, exactly as `--home` capture of that
|
|
261
|
+
instance would capture it. It runs under the capture lock, as a final pass,
|
|
262
|
+
with the ignore rules applied. The stream is `<owner>~<source>.<session id>`,
|
|
263
|
+
the same identity `--home` capture writes. So the same session captured
|
|
264
|
+
either way, or again with `--file`, appends nothing.
|
|
265
|
+
|
|
266
|
+
- **The owner is explicit.** It is `--owner` or `TURN_RECORD_OWNER`; the
|
|
267
|
+
hostname is never assumed. `--home` must be an OATS instance home (an
|
|
268
|
+
`instance.json` naming an instance). It is recorded in the receipt, not
|
|
269
|
+
checked against the file's recorded cwd.
|
|
270
|
+
- **The format is stated, never sniffed.** The file must carry that format's
|
|
271
|
+
session header somewhere in it:
|
|
272
|
+
- `cc`: a record with a `cwd`, and no other format's session header anywhere
|
|
273
|
+
in the file (a cc transcript never holds one);
|
|
274
|
+
- `pi`: the `session` record;
|
|
275
|
+
- `codex`: `session_meta`.
|
|
276
|
+
- **The session id comes from the file name**, as for live capture: the cc
|
|
277
|
+
basename, the part of a pi name after its last `_`, or a codex name's
|
|
278
|
+
trailing uuid. A renamed file lands in another stream.
|
|
279
|
+
- **One regular file, read once.** It is opened with `O_NOFOLLOW` and
|
|
280
|
+
`O_NONBLOCK` and fstat'ed. A symlink, FIFO, socket, device or directory is
|
|
281
|
+
refused. The bytes read from that descriptor are the ones captured and
|
|
282
|
+
hashed.
|
|
283
|
+
|
|
284
|
+
`--json` prints the receipt:
|
|
285
|
+
`{home, owner, file, format, instance, thread, stream, sessionId, turns,
|
|
286
|
+
firstTurnId, lastTurnId, appended, skipped, held, incomplete, failed, ignored,
|
|
287
|
+
status, complete, sha256, issues?}`. `sha256` is of the bytes captured;
|
|
288
|
+
`issues` (`[{source, path, reason, offset}]`, present when the result is
|
|
289
|
+
incomplete or held) says why. As for
|
|
290
|
+
`--home`, `complete` is true only with no hold, no incomplete tail and no
|
|
291
|
+
failure. A lock skip, a hold (no timestamp yet) and an incomplete tail (torn
|
|
292
|
+
or invalid UTF-8) exit 0 with `complete: false`. Without `--json`, the output
|
|
293
|
+
is one line.
|
|
294
|
+
|
|
295
|
+
Anything that binds nothing is an error,
|
|
296
|
+
`{status: "failed", complete: false, code, error}`:
|
|
297
|
+
|
|
298
|
+
| code | exit | when |
|
|
299
|
+
|---|---|---|
|
|
300
|
+
| `E_USAGE` | 2 | a missing or invalid flag, a non-instance `--home`, no explicit owner, a second `--file`, another mode |
|
|
301
|
+
| `E_FILE_UNREADABLE` | 1 | the file cannot be opened or read |
|
|
302
|
+
| `E_NOT_REGULAR_FILE` | 1 | a symlink, FIFO, socket, device or directory |
|
|
303
|
+
| `E_IGNORED` | 1 | a capture ignore rule excludes the file; nothing was opened, read or written |
|
|
304
|
+
| `E_NO_TURNS` | 1 | no records: an empty file, or only blank lines |
|
|
305
|
+
| `E_FORMAT` | 1 | records, but no session header of the stated format; the message names the format whose header it does carry |
|
|
306
|
+
| `E_CAPTURE_FAILED` | 1 | the pass failed (`appended: null`: part of the file may be in the record) |
|
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
// accounts) whose sources are never captured — see lib/ignore.mjs.
|
|
13
13
|
// Reconciliation is the capture; hooks and watch only decide when to run it.
|
|
14
14
|
|
|
15
|
-
import { watch } from "node:fs";
|
|
15
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, watch } from "node:fs";
|
|
16
16
|
import { homedir, hostname } from "node:os";
|
|
17
|
-
import { join } from "node:path";
|
|
17
|
+
import { basename, join, resolve } from "node:path";
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
|
|
20
20
|
import { RecordStore } from "../lib/store.mjs";
|
|
21
21
|
import { captureAllSessions, captureSessions } from "../lib/capture-cc.mjs";
|
|
22
22
|
import { sessionsForHome } from "../lib/sessions-for-home.mjs";
|
|
23
|
-
import { SESSION_FORMATS } from "../lib/formats.mjs";
|
|
23
|
+
import { jsonlLines, SESSION_FORMATS } from "../lib/formats.mjs";
|
|
24
|
+
import { cwdOfLine } from "../lib/sessions-for-home.mjs";
|
|
25
|
+
import { digest, readRange } from "../lib/session-snapshot.mjs";
|
|
24
26
|
import { captureAwLogs, defaultCommLogDir } from "../lib/capture-aw.mjs";
|
|
25
27
|
import { RecordIndex } from "../lib/index-db.mjs";
|
|
26
28
|
import { IgnoreError, ignoreFilePath, loadIgnore } from "../lib/ignore.mjs";
|
|
@@ -43,7 +45,7 @@ function loadIgnoreOrExit(recordRoot) {
|
|
|
43
45
|
// hostname-derived owner, which forked the whole record into a second owner
|
|
44
46
|
// namespace: 571 duplicate journals from one typo. Parsing must refuse what
|
|
45
47
|
// it does not understand before anything can be written.
|
|
46
|
-
const VALUE_FLAGS = new Set(["root", "owner", "home"]);
|
|
48
|
+
const VALUE_FLAGS = new Set(["root", "owner", "home", "file", "format"]);
|
|
47
49
|
const BOOL_FLAGS = new Set([
|
|
48
50
|
"watch",
|
|
49
51
|
"status",
|
|
@@ -54,6 +56,7 @@ const BOOL_FLAGS = new Set([
|
|
|
54
56
|
"aw-only",
|
|
55
57
|
"no-index",
|
|
56
58
|
"current-roots",
|
|
59
|
+
"json",
|
|
57
60
|
]);
|
|
58
61
|
|
|
59
62
|
const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
@@ -81,6 +84,29 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
|
81
84
|
explicit observer-time inventory for standalone
|
|
82
85
|
or legacy sources lacking launch history. Not a
|
|
83
86
|
certificate of all historical source locations.
|
|
87
|
+
capture --file <path> --format cc|pi|codex --home <dir> [--json]
|
|
88
|
+
capture ONE session file (an archived session,
|
|
89
|
+
say) as the --home capture of <dir> would: under
|
|
90
|
+
the capture lock, as a final pass, ignore rules
|
|
91
|
+
applied. <dir> must be an OATS instance home and
|
|
92
|
+
the owner explicit (--owner or TURN_RECORD_OWNER;
|
|
93
|
+
never the hostname). The format is never sniffed.
|
|
94
|
+
The file must be a regular file (no symlink, FIFO
|
|
95
|
+
or directory); it is read once. The session id
|
|
96
|
+
comes from the file NAME, as for live capture: a
|
|
97
|
+
renamed file lands in another stream. Again with
|
|
98
|
+
the same file and owner appends nothing.
|
|
99
|
+
--json prints {thread, stream, firstTurnId,
|
|
100
|
+
lastTurnId, turns, appended, ignored, sha256 (of
|
|
101
|
+
the bytes captured), status, complete, ...};
|
|
102
|
+
without it, one line. Lock skips, holds and
|
|
103
|
+
incomplete tails exit 0 with complete:false.
|
|
104
|
+
Errors print {status:"failed", complete:false,
|
|
105
|
+
code, error}: E_USAGE (exit 2); E_FILE_UNREADABLE,
|
|
106
|
+
E_NOT_REGULAR_FILE, E_IGNORED (an ignore rule
|
|
107
|
+
excludes it; nothing read), E_NO_TURNS (no
|
|
108
|
+
records), E_FORMAT (no session header of that
|
|
109
|
+
format) and E_CAPTURE_FAILED (exit 1).
|
|
84
110
|
capture --install-hint print the Claude Code hook snippet
|
|
85
111
|
capture --help this text
|
|
86
112
|
capture --quiet suppress per-pass progress
|
|
@@ -106,6 +132,7 @@ function parseArgs(argv) {
|
|
|
106
132
|
if (VALUE_FLAGS.has(name)) {
|
|
107
133
|
const value = argv[++i];
|
|
108
134
|
if (value === undefined) throw new UsageError(`${a} needs a value`);
|
|
135
|
+
if (name === "file" && args.file !== undefined) throw new UsageError("one --file per call");
|
|
109
136
|
args[name] = value;
|
|
110
137
|
} else if (BOOL_FLAGS.has(name)) {
|
|
111
138
|
args[name] = true;
|
|
@@ -121,6 +148,11 @@ try {
|
|
|
121
148
|
args = parseArgs(process.argv.slice(2));
|
|
122
149
|
} catch (err) {
|
|
123
150
|
if (!(err instanceof UsageError)) throw err;
|
|
151
|
+
// --file --json answers JSON even for a usage error: it is for programs.
|
|
152
|
+
if (process.argv.includes("--json")) {
|
|
153
|
+
console.log(JSON.stringify({ status: "failed", complete: false, code: "E_USAGE", error: err.message }, null, 2));
|
|
154
|
+
process.exit(2);
|
|
155
|
+
}
|
|
124
156
|
console.error(`capture: ${err.message}\n\n${USAGE}`);
|
|
125
157
|
process.exit(2);
|
|
126
158
|
}
|
|
@@ -279,6 +311,171 @@ function pass() {
|
|
|
279
311
|
});
|
|
280
312
|
}
|
|
281
313
|
|
|
314
|
+
/** A final capture of exact files, one batch per format, under the lock: counts into `outcome`, issues into
|
|
315
|
+
* `issues`, then the index unless --no-index. → withCaptureLock's answer (`outcome`, or a skip). */
|
|
316
|
+
function captureUnderLock(recordStore, recordOwner, batches, ignore, outcome, issues) {
|
|
317
|
+
return withCaptureLock(() => {
|
|
318
|
+
for (const [format, files] of batches) {
|
|
319
|
+
const r = captureSessions(recordStore, { owner: recordOwner, files, format, ignore, final: true });
|
|
320
|
+
outcome.appended += r.appended;
|
|
321
|
+
outcome.held += r.held;
|
|
322
|
+
outcome.incomplete += r.incomplete;
|
|
323
|
+
outcome.ignored += r.ignored;
|
|
324
|
+
issues.push(...r.issues);
|
|
325
|
+
}
|
|
326
|
+
if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
|
|
327
|
+
const index = new RecordIndex(recordStore);
|
|
328
|
+
try {
|
|
329
|
+
index.update();
|
|
330
|
+
} finally {
|
|
331
|
+
index.close();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return outcome;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A stream's turns that no tombstone hides. A tombstoned turn is hidden everywhere; a boundary naming one
|
|
339
|
+
* would be refused by recall, so boundaries come from the visible turns only. */
|
|
340
|
+
function visibleTurns(recordStore, stream, claims = recordStore.tombstoneClaims()) {
|
|
341
|
+
return recordStore.readStream(stream).filter((t) => !recordStore.claimHides(claims, t));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The outcome vocabulary: complete only with no failure, skip, hold or incomplete tail (and, for --home, no
|
|
345
|
+
* unattributed source). */
|
|
346
|
+
function statusOf(outcome, unattributed = 0) {
|
|
347
|
+
return outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed ? "incomplete" : "complete";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// capture --file: ONE session file the operator attributes to an instance home (an archived session the
|
|
351
|
+
// --home sweep can no longer find), captured exactly as --home capture would capture it: same stream
|
|
352
|
+
// identity, same lock, final, ignore rules applied. The file is opened once and read from that descriptor;
|
|
353
|
+
// the receipt's sha256 is of the bytes captured. Every outcome that binds nothing is an error code.
|
|
354
|
+
const SESSION_HEADER = { cc: "a record with a cwd", pi: "a session record with a cwd", codex: "a session_meta record with a payload cwd" };
|
|
355
|
+
const HEADER_HINT = { cc: "a cc record with a cwd", pi: "a pi session header", codex: "a codex session_meta header" };
|
|
356
|
+
// pi and codex headers are specific; any record with a cwd reads as cc, so cc is named last.
|
|
357
|
+
const HEADER_ORDER = ["pi", "codex", "cc"];
|
|
358
|
+
|
|
359
|
+
/** Whether the headers `found` in a file make it a `stated` session. pi and codex need their header. cc needs a
|
|
360
|
+
* record with a cwd and NO other format's session header anywhere in the file: a cc transcript never holds
|
|
361
|
+
* one, so a single one is decisive (pi's session record carries a top-level cwd too). */
|
|
362
|
+
function isFormat(found, stated) {
|
|
363
|
+
return stated === "cc" ? found.has("cc") && !found.has("pi") && !found.has("codex") : found.has(stated);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Records (non-blank lines, an undecodable one included) and the formats whose session header a COMPLETE
|
|
367
|
+
* line carries. A pi or codex file is settled at its header; a cc file is judged whole. A line its writer
|
|
368
|
+
* never terminated is no header. */
|
|
369
|
+
function sessionHeaders(bytes, stated) {
|
|
370
|
+
let records = 0;
|
|
371
|
+
const found = new Set();
|
|
372
|
+
const terminated = bytes.length > 0 && bytes[bytes.length - 1] === 10;
|
|
373
|
+
const take = (text, complete) => {
|
|
374
|
+
if (text === null || text.trim() !== "") records++;
|
|
375
|
+
if (complete && text !== null) for (const source of HEADER_ORDER) if (cwdOfLine(source, text) !== undefined) found.add(source);
|
|
376
|
+
};
|
|
377
|
+
let pending;
|
|
378
|
+
for (const { text } of jsonlLines(bytes)) {
|
|
379
|
+
if (pending !== undefined) take(pending, true);
|
|
380
|
+
if (stated !== "cc" && found.has(stated)) return { records, found };
|
|
381
|
+
pending = text;
|
|
382
|
+
}
|
|
383
|
+
if (pending !== undefined) take(pending, terminated);
|
|
384
|
+
return { records, found };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function fileKind(stat) {
|
|
388
|
+
return stat.isDirectory() ? "a directory" : stat.isFIFO() ? "a FIFO" : stat.isSocket() ? "a socket" : stat.isCharacterDevice() || stat.isBlockDevice() ? "a device" : "not a regular file";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** → the exit status. */
|
|
392
|
+
function fileCapture() {
|
|
393
|
+
const json = Boolean(args.json);
|
|
394
|
+
const fail = (code, error, extra = {}) => {
|
|
395
|
+
if (json) console.log(JSON.stringify({ ...extra, status: "failed", complete: false, code, error }, null, 2));
|
|
396
|
+
else console.error(`capture --file: ${code}: ${error}`);
|
|
397
|
+
return code === "E_USAGE" ? 2 : 1;
|
|
398
|
+
};
|
|
399
|
+
if (args.file === undefined) return fail("E_USAGE", args.format !== undefined ? "--format needs --file" : "--json needs --file");
|
|
400
|
+
for (const mode of ["watch", "status", "install-hint", "sessions-only", "aw-only", "current-roots"]) {
|
|
401
|
+
if (args[mode]) return fail("E_USAGE", `--file does not combine with --${mode}`);
|
|
402
|
+
}
|
|
403
|
+
if (args.home === undefined) return fail("E_USAGE", "--file needs --home <instance home>: the instance the session belongs to");
|
|
404
|
+
let instance;
|
|
405
|
+
try {
|
|
406
|
+
const meta = JSON.parse(readFileSync(join(args.home, "instance.json"), "utf8"));
|
|
407
|
+
if (typeof meta?.instance === "string" && meta.instance) instance = meta.instance;
|
|
408
|
+
} catch { /* not an instance home: said below */ }
|
|
409
|
+
if (!instance) return fail("E_USAGE", `--home ${args.home} is not an instance home (no instance.json naming an instance)`);
|
|
410
|
+
const fileOwner = args.owner ?? process.env.TURN_RECORD_OWNER;
|
|
411
|
+
if (!fileOwner) return fail("E_USAGE", "--file needs an explicit owner: --owner <name> or TURN_RECORD_OWNER (the hostname is never assumed)");
|
|
412
|
+
if (args.format === undefined) return fail("E_USAGE", "--file needs --format cc|pi|codex (the format is never guessed)");
|
|
413
|
+
if (!Object.hasOwn(SESSION_FORMATS, args.format)) return fail("E_USAGE", `--format must be cc, pi or codex, not ${JSON.stringify(args.format)}`);
|
|
414
|
+
const fmt = SESSION_FORMATS[args.format];
|
|
415
|
+
// Absolute, as every other capture caller passes: path ignore rules, the offsets key and the receipt all
|
|
416
|
+
// read the path, and a relative spelling would slip past a path rule.
|
|
417
|
+
const path = resolve(args.file);
|
|
418
|
+
const where = { home: args.home, owner: fileOwner, file: path, format: args.format };
|
|
419
|
+
|
|
420
|
+
let ignore;
|
|
421
|
+
try { ignore = loadIgnore(root); } catch (err) { return fail("E_CAPTURE_FAILED", err.message, where); }
|
|
422
|
+
const sessionId = fmt.sessionId(path);
|
|
423
|
+
// Before the open, on the path and its keys, as every capture checks: an ignored file is never read.
|
|
424
|
+
if (ignore.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
|
|
425
|
+
return fail("E_IGNORED", `a capture ignore rule (${ignoreFilePath(root)}) excludes ${path}; nothing was read`, where);
|
|
426
|
+
}
|
|
427
|
+
let fd;
|
|
428
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
429
|
+
catch (err) {
|
|
430
|
+
if (err.code === "ELOOP" || err.code === "EMLINK") return fail("E_NOT_REGULAR_FILE", `${path} is a symbolic link; pass the file it names`, where);
|
|
431
|
+
return fail("E_FILE_UNREADABLE", `cannot open ${path}: ${err.message}`, where);
|
|
432
|
+
}
|
|
433
|
+
let stat, bytes;
|
|
434
|
+
try {
|
|
435
|
+
stat = fstatSync(fd);
|
|
436
|
+
if (!stat.isFile()) return fail("E_NOT_REGULAR_FILE", `${path} is ${fileKind(stat)}, not a regular file`, where);
|
|
437
|
+
bytes = readRange(fd, 0, stat.size, path);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return fail("E_FILE_UNREADABLE", `cannot read ${path}: ${err.message}`, where);
|
|
440
|
+
} finally { closeSync(fd); }
|
|
441
|
+
|
|
442
|
+
const { records, found } = sessionHeaders(bytes, args.format);
|
|
443
|
+
if (!records) return fail("E_NO_TURNS", `${path} has no records${bytes.length ? " (only blank lines)" : " (it is empty)"}`, where);
|
|
444
|
+
if (!isFormat(found, args.format)) {
|
|
445
|
+
const other = HEADER_ORDER.find((f) => f !== args.format && isFormat(found, f));
|
|
446
|
+
return fail("E_FORMAT", other
|
|
447
|
+
? `${path} is not a ${args.format} session: it has ${HEADER_HINT[other]}; pass --format ${other}`
|
|
448
|
+
: `${path} has no ${args.format} session header (${SESSION_HEADER[args.format]})`, where);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const sha256 = digest(bytes);
|
|
452
|
+
const fileStore = new RecordStore(root, { owner: fileOwner });
|
|
453
|
+
const outcome = { appended: 0, skipped: false, held: 0, incomplete: 0, failed: 0, ignored: 0 };
|
|
454
|
+
const issues = [];
|
|
455
|
+
try {
|
|
456
|
+
Object.assign(outcome, captureUnderLock(fileStore, fileOwner, [[args.format, [{ path, pinned: { bytes, stat } }]]], ignore, outcome, issues));
|
|
457
|
+
} catch (err) {
|
|
458
|
+
// A failed pass may have appended part of the file: never claim a count for it.
|
|
459
|
+
return fail("E_CAPTURE_FAILED", err.message || String(err), { ...where, sha256, appended: null });
|
|
460
|
+
}
|
|
461
|
+
if (process.exitCode) return fail("E_CAPTURE_FAILED", "capture lock release failed; see stderr for recovery", { ...where, sha256 });
|
|
462
|
+
|
|
463
|
+
const stream = `${fileOwner}~${fmt.source}.${sessionId}`;
|
|
464
|
+
const turns = visibleTurns(fileStore, stream);
|
|
465
|
+
const status = statusOf(outcome);
|
|
466
|
+
const { lock: _lock, ...counts } = outcome;
|
|
467
|
+
const receipt = {
|
|
468
|
+
...where, instance, thread: `${fmt.source}:session:${sessionId}`, stream, sessionId,
|
|
469
|
+
turns: turns.length, firstTurnId: turns[0]?.id ?? null, lastTurnId: turns.at(-1)?.id ?? null,
|
|
470
|
+
...counts, status, complete: status === "complete", sha256, ...(issues.length ? { issues } : {}),
|
|
471
|
+
};
|
|
472
|
+
if (json) console.log(JSON.stringify(receipt, null, 2));
|
|
473
|
+
else console.log(`capture --file: ${status}, ${receipt.turns} turns (${outcome.appended} new) in ${stream}, sha256 ${sha256}`);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (args.file !== undefined || args.format !== undefined || args.json) process.exit(fileCapture());
|
|
478
|
+
|
|
282
479
|
if (args["install-hint"]) {
|
|
283
480
|
const self = new URL(import.meta.url).pathname;
|
|
284
481
|
console.log(`Add to Claude Code settings.json to capture on session stop/end:
|
|
@@ -337,31 +534,11 @@ if (args.home) {
|
|
|
337
534
|
if (!formats.has(s.source)) formats.set(s.source, []);
|
|
338
535
|
formats.get(s.source).push(s);
|
|
339
536
|
}
|
|
340
|
-
Object.assign(outcome,
|
|
341
|
-
for (const [format, files] of formats) {
|
|
342
|
-
const r = captureSessions(store, { owner, files, format, ignore, final: true });
|
|
343
|
-
outcome.appended += r.appended;
|
|
344
|
-
outcome.held += r.held;
|
|
345
|
-
outcome.incomplete += r.incomplete;
|
|
346
|
-
outcome.ignored += r.ignored;
|
|
347
|
-
issues.push(...r.issues);
|
|
348
|
-
}
|
|
349
|
-
if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
|
|
350
|
-
const index = new RecordIndex(store);
|
|
351
|
-
try {
|
|
352
|
-
index.update();
|
|
353
|
-
} finally {
|
|
354
|
-
index.close();
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return outcome;
|
|
358
|
-
}));
|
|
359
|
-
// A tombstoned turn is hidden everywhere; a boundary naming one would be
|
|
360
|
-
// refused by recall, so boundaries come from the visible turns only.
|
|
537
|
+
Object.assign(outcome, captureUnderLock(store, owner, formats, ignore, outcome, issues));
|
|
361
538
|
const claims = store.tombstoneClaims();
|
|
362
539
|
for (const s of found) {
|
|
363
540
|
const stream = `${owner}~${s.source}.${s.sessionId}`;
|
|
364
|
-
const turns = store
|
|
541
|
+
const turns = visibleTurns(store, stream, claims);
|
|
365
542
|
if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
|
|
366
543
|
sessions.push({
|
|
367
544
|
thread: s.thread,
|
|
@@ -389,7 +566,7 @@ if (args.home) {
|
|
|
389
566
|
outcome.failed++;
|
|
390
567
|
error = "capture lock release failed; see stderr for recovery";
|
|
391
568
|
}
|
|
392
|
-
const status = outcome
|
|
569
|
+
const status = statusOf(outcome, unattributed.length);
|
|
393
570
|
console.log(JSON.stringify({ home: args.home, owner, ...outcome, status, complete: status === "complete", sessions, sourceRoots: args["current-roots"] ? "current-env" : "launch-history",
|
|
394
571
|
...(error ? { error } : {}), ...(issues.length ? { issues } : {}), ...(unattributed.length ? { unattributed } : {}),
|
|
395
572
|
}, null, 2));
|
|
@@ -189,6 +189,10 @@ function offsetFromJournal(store, streamId, sourcePath, final, sourceBytes) {
|
|
|
189
189
|
// `final` also verifies unchanged offsets against journals and checks source
|
|
190
190
|
// stability through the pass. The caller must quiesce writers for retirement;
|
|
191
191
|
// a performed pass is a snapshot, not a promise about future writes.
|
|
192
|
+
//
|
|
193
|
+
// A file given as `{ path, pinned: { bytes, stat } }` is PINNED: its caller opened it once and read these bytes from
|
|
194
|
+
// that descriptor (`capture --file`). It is captured from them as a final pass, never opened again, so the
|
|
195
|
+
// turns are exactly those bytes; `path` keys its offsets as for any other file.
|
|
192
196
|
export function captureSessions(store, { owner, roots, files, format = "cc", ignore = null, final = false }) {
|
|
193
197
|
const fmt = SESSION_FORMATS[format];
|
|
194
198
|
if (!fmt) throw new Error(`unknown session format ${format}`);
|
|
@@ -205,6 +209,7 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
|
|
|
205
209
|
|
|
206
210
|
for (const file of files ?? fmt.listFiles(roots)) {
|
|
207
211
|
const path = typeof file === "string" ? file : file.path;
|
|
212
|
+
const pinned = typeof file === "string" ? null : file.pinned ?? null;
|
|
208
213
|
const expected = typeof file === "string" ? null : file.snapshot;
|
|
209
214
|
const capturedPi = typeof file === "string" ? undefined : file.capturedPi ?? expected?.capturedPi;
|
|
210
215
|
if (capturedPi && (fmt.source !== "pi" || (expected?.capturedPi && !isDeepStrictEqual(capturedPi, expected.capturedPi)))) throw new Error("protected capture proof/format differs from discovery");
|
|
@@ -215,20 +220,21 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
|
|
|
215
220
|
ignored++;
|
|
216
221
|
continue; // never opened: nothing stored, nothing remembered
|
|
217
222
|
}
|
|
218
|
-
const fd = openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
|
|
223
|
+
const fd = pinned ? null : openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
|
|
219
224
|
try {
|
|
220
225
|
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
221
|
-
const stat = fstatSync(fd);
|
|
226
|
+
const stat = pinned ? pinned.stat : fstatSync(fd);
|
|
222
227
|
const snapshot = { ...identity(stat), ...(capturedPi ? { capturedPi } : {}) };
|
|
223
228
|
if (expected) assertIdentity(stat, expected, path); // BEFORE reading bytes
|
|
224
229
|
// Final home capture stages a descriptor-pinned snapshot. All attribution
|
|
225
230
|
// and stability checks precede the first append, never a post-write alarm.
|
|
226
|
-
const sourceBytes = final || expected || capturedPi ? readRange(fd, 0, stat.size, path, capturedPi) : undefined;
|
|
231
|
+
const sourceBytes = pinned ? pinned.bytes : final || expected || capturedPi ? readRange(fd, 0, stat.size, path, capturedPi) : undefined;
|
|
227
232
|
if (expected && digest(sourceBytes.subarray(0, expected.size)) !== expected.hash) {
|
|
228
233
|
throw new Error(`session source content changed since attribution: ${path}`);
|
|
229
234
|
}
|
|
230
235
|
if (sourceBytes) snapshot.hash = digest(sourceBytes);
|
|
231
236
|
const verifySource = () => {
|
|
237
|
+
if (pinned) return; // the bytes are the source: nothing is read from the path again
|
|
232
238
|
guardCapturedPath(path, capturedPi);
|
|
233
239
|
if (sourceBytes) verifySnapshot(fd, path, snapshot);
|
|
234
240
|
};
|
|
@@ -354,7 +360,7 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
|
|
|
354
360
|
// wasted append-only bytes).
|
|
355
361
|
if (grew) saveOffsets(store, offsets);
|
|
356
362
|
verifySource();
|
|
357
|
-
} finally { closeSync(fd); }
|
|
363
|
+
} finally { if (fd !== null) closeSync(fd); }
|
|
358
364
|
}
|
|
359
365
|
saveOffsets(store, offsets);
|
|
360
366
|
return {
|
|
@@ -58,7 +58,9 @@ function* wholeLines(fd, bound, path, capturedPi) {
|
|
|
58
58
|
// Never attribute a source using a line its writer has not terminated.
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/** The cwd a native line carries as its format's session header (cc: any record's `cwd`; pi: the `session`
|
|
62
|
+
* record; codex: `session_meta`), else undefined. */
|
|
63
|
+
export function cwdOfLine(source, line) {
|
|
62
64
|
if (line === null || !line.trim()) return undefined;
|
|
63
65
|
let d;
|
|
64
66
|
try {
|
|
@@ -61,7 +61,7 @@ members:
|
|
|
61
61
|
- git:github.com/acme/platform
|
|
62
62
|
packages:
|
|
63
63
|
oats.framework: v1.4.1 # bare versions resolve through the official catalog
|
|
64
|
-
oats.okf: v4.0
|
|
64
|
+
oats.okf: v4.1.0
|
|
65
65
|
defaults:
|
|
66
66
|
capabilities: { oats.core: { from: package } }
|
|
67
67
|
knowledge: { oats.okf: { from: package } }
|