@awebai/oats 0.24.11 → 0.24.12
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
CHANGED
|
@@ -3144,8 +3144,9 @@ function instanceCmd() {
|
|
|
3144
3144
|
if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
|
|
3145
3145
|
try {
|
|
3146
3146
|
const { resolveInstance } = await_import_lifecycle();
|
|
3147
|
-
|
|
3148
|
-
|
|
3147
|
+
// K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
|
|
3148
|
+
// exactly this name under the scope (E_HOME_MISMATCH otherwise).
|
|
3149
|
+
const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
|
|
3149
3150
|
const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
|
|
3150
3151
|
if (JSON_MODE) { jsonOk(ev); return; }
|
|
3151
3152
|
console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
|
|
@@ -5033,7 +5034,7 @@ function versionCmd() {
|
|
|
5033
5034
|
// on it (an older CLI without the surface must fail closed with a
|
|
5034
5035
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
5035
5036
|
// must see before relying on them (retire-home: retire --home).
|
|
5036
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi:
|
|
5037
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
5037
5038
|
return;
|
|
5038
5039
|
}
|
|
5039
5040
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
|
|
4
4
|
|
|
5
|
-
**Last update:** 2026-09-22 21:
|
|
5
|
+
**Last update:** 2026-09-22 21:40Z · K7b on main (PR89 `e808fbfc`; PR90 `17182cdb` open-time O_NOFOLLOW+fstat guard + DTO pins) · **K6h PR91 → main `dcd2bc89`**: kernel-field neutrality + receipt exclusions now opt-in for instance homes only (work/recovery trees fully significant) · **7b wiring head = `dcd2bc89`** · engineer wiring 7b → 8 · 0.24.12 after 7b Desktop PR · open: K11, attach-knowledge, auto-PR, branch enumeration
|
|
6
6
|
|
|
7
7
|
Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
|
|
8
8
|
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -156,7 +156,7 @@ returns a bounded unified diff:
|
|
|
156
156
|
|
|
157
157
|
No forge (PR/checks/reviews) data here: forge connections are an ADE/workstation integration (P1 decision), read by the Desktop server through the forge's own CLI; the kernel only reports the instance's `remote` so the ADE can pick a backend.
|
|
158
158
|
|
|
159
|
-
## Instance events (`oats instance events`, `eventsApi: 1
|
|
159
|
+
## Instance events (`oats instance events`, `eventsApi: 1` → **2**, OATS 0.24.8+) — K7
|
|
160
160
|
|
|
161
161
|
Typed lifecycle events per instance, **written by the kernel action that made
|
|
162
162
|
them true**, with the receipt it produced. Nothing is inferred from
|
|
@@ -185,6 +185,66 @@ home's removal, so a retired instance's `retired` event is still readable).
|
|
|
185
185
|
- Window is bounded (`--limit`, default 200; `truncated` says so). A torn line
|
|
186
186
|
appears as `kind: "unreadable"` rather than vanishing.
|
|
187
187
|
|
|
188
|
+
### Events API 2 (`eventsApi: 2`, feature `instance-events-2`, OATS 0.24.12+) — K7b
|
|
189
|
+
|
|
190
|
+
Gate a Desktop read on **both** `eventsApi === 2` and `"instance-events-2"` in
|
|
191
|
+
`features[]`. API 1 is not a sufficient fence for a bounded read: its reader
|
|
192
|
+
opened and read a source whole, followed symlinks, kept foreign rows and lost
|
|
193
|
+
torn lines and cleared claims silently. API 2:
|
|
194
|
+
|
|
195
|
+
- **Bounded, descriptor-safe read.** Each source (`home` =
|
|
196
|
+
`<home>/.oats-events.jsonl`, `workspace` = `<ws>/.agents/events/<agent>--<instance>.jsonl`)
|
|
197
|
+
is `lstat`ed first; anything but a regular file is **refused unopened**.
|
|
198
|
+
The open itself is `O_RDONLY|O_NOFOLLOW|O_NONBLOCK` and the descriptor is
|
|
199
|
+
`fstat`ed: it must be a regular file with the same device+inode lstat saw
|
|
200
|
+
(closes the lstat→open swap). At most the last 4 MiB is read by descriptor.
|
|
201
|
+
**Canonical source shape**: `{path: "home"|"workspace", status: "ok"|"absent"|"refused"|"tail", bytes}`
|
|
202
|
+
— `"tail"` means only the last 4 MiB was read (partial first line dropped);
|
|
203
|
+
there is no separate `tail` boolean.
|
|
204
|
+
- **Row fields.** `incarnation` is the writing home's `instance.json.createdAt`
|
|
205
|
+
(ISO) or `null` for rows written before the tag; the result's top-level
|
|
206
|
+
`incarnation` is the current home's `createdAt` or `null` if unreadable. A
|
|
207
|
+
row with `incarnation: null` never matches the current incarnation, so it
|
|
208
|
+
cannot contribute a current waiting claim. **An unknown current incarnation
|
|
209
|
+
(top-level `incarnation: null`) admits NO claim**: `waitingOnYou: null`,
|
|
210
|
+
`waitingClaims: []`, rows still returned as history. A consumer must refuse
|
|
211
|
+
a null-incarnation response that nevertheless carries claims. Dedup identity is
|
|
212
|
+
`producer|at|kind|incarnation|data`.
|
|
213
|
+
- **`waitingClaims[]` row shape**: `{producer: string, waiting: boolean, since: ISO, reason: string|null}`
|
|
214
|
+
— one row per producer with a claim in the current incarnation, INCLUDING
|
|
215
|
+
cleared ones (`waiting: false`, `reason: null`, `since` = the clearing row's
|
|
216
|
+
`at`). `waitingOnYou` = the newest `waiting: true` row or `null`.
|
|
217
|
+
- **Address history.** `--home <abs>` must be a home of exactly `<instance>` under
|
|
218
|
+
the scope (`E_HOME_MISMATCH` otherwise, like K1). Rows whose `instance`/`home`
|
|
219
|
+
are not the admitted address are dropped and counted (`integrity.foreignRows`).
|
|
220
|
+
Every row carries `incarnation` (the writing home's `instance.json.createdAt`);
|
|
221
|
+
the result echoes the current home's `incarnation`. Rows tagged with an earlier
|
|
222
|
+
incarnation ARE returned — they are this address's history — so a consumer can
|
|
223
|
+
label them "earlier instance at this address". No current home → no read
|
|
224
|
+
(archived access is a separate contract).
|
|
225
|
+
- **`waitingOnYou` is a producer STATE for the current incarnation**, decided per
|
|
226
|
+
producer by that producer's latest row that carries the field: an explicit
|
|
227
|
+
`false` clears, a row without the field does not; earlier incarnations never
|
|
228
|
+
contribute; computed over the FULL admitted read (a `--limit` window cannot
|
|
229
|
+
hide a clear). `waitingClaims[]` lists every producer's current claim
|
|
230
|
+
(`{producer, waiting, since, reason}`); `waitingOnYou` is the newest positive.
|
|
231
|
+
Still `null` today — no producer emits it.
|
|
232
|
+
- **Provenance and corruption never disappear.** Dedup is by
|
|
233
|
+
`producer|at|kind|incarnation|data` (the same facts from two producers are two
|
|
234
|
+
rows). `integrity.unreadableRows` counts torn/invalid lines regardless of
|
|
235
|
+
`--since` or the window. `count` = admitted rows after `--since`, `returned` =
|
|
236
|
+
the window, `truncated` = window cut OR any source read as a tail.
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
{"eventsApi":2,"instance":"dev-1","home":"/abs/home","incarnation":"<iso>","count":7,"returned":7,"truncated":false,
|
|
240
|
+
"integrity":{"unreadableRows":0,"foreignRows":0,"sources":[{"path":"home","status":"ok","bytes":1234},{"path":"workspace","status":"ok","bytes":1234}]},
|
|
241
|
+
"events":[{"eventsApi":2,"at":"<iso>","instance":"dev-1","home":"/abs/home","incarnation":"<iso>","producer":"kernel","kind":"spawned","data":{...}}, ...],
|
|
242
|
+
"lastEvent":{"kind":"launched","at":"<iso>","producer":"kernel","incarnation":"<iso>"},
|
|
243
|
+
"waitingOnYou":null,"waitingClaims":[],"notes":[...]}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Desktop passes `--limit` (50|100|200) only; `--since` remains a human flag.
|
|
247
|
+
|
|
188
248
|
## Schedule run history (`scheduleApi: 2`, OATS 0.24.8+) — K8
|
|
189
249
|
|
|
190
250
|
`oats schedule show|list --json` entries gain **`recentRuns`**: the last 50
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# OATS v0.24.12 — instance events API 2 and the Desktop activity popover
|
|
2
|
+
|
|
3
|
+
Kernel/Pi/Desktop **0.24.12**. Tag `v0.24.12` → the commit carrying these
|
|
4
|
+
notes; the version-bump commit lands after the tag. Consumers gate on
|
|
5
|
+
`oats version --json` `features[]` names and API integers — never on the version.
|
|
6
|
+
|
|
7
|
+
## Kernel — `instance-events-2` (`eventsApi: 2`)
|
|
8
|
+
|
|
9
|
+
`oats instance events` was a working reader with four defects found by the
|
|
10
|
+
Desktop engineer's exact-source review; each is closed under a new advertised
|
|
11
|
+
name because a strengthened guarantee must be distinguishable from the installed
|
|
12
|
+
CLI that lacks it.
|
|
13
|
+
|
|
14
|
+
- **Bounded, descriptor-safe read.** Each log source is `lstat`ed first; anything
|
|
15
|
+
but a regular file is refused unopened. The open is `O_NOFOLLOW|O_NONBLOCK` and
|
|
16
|
+
the descriptor is `fstat`ed against lstat's device+inode (closes the
|
|
17
|
+
lstat→open swap). At most the last 4 MiB is read by descriptor
|
|
18
|
+
(`integrity.sources[].status: ok|absent|refused|tail`).
|
|
19
|
+
- **Address history, incarnation-tagged.** `--home` must be a home of exactly
|
|
20
|
+
the named instance (`E_HOME_MISMATCH`). Rows of another address are dropped and
|
|
21
|
+
counted (`integrity.foreignRows`). Every row carries `incarnation` (the writing
|
|
22
|
+
home's `instance.json.createdAt`); the result echoes the current one, so a
|
|
23
|
+
recreated same-address instance's earlier rows are visible *as earlier*.
|
|
24
|
+
- **`waitingOnYou` is a producer state**, per (producer, current incarnation):
|
|
25
|
+
that producer's latest row carrying the field decides, an explicit `false`
|
|
26
|
+
clears, earlier incarnations never contribute, and it is computed over the full
|
|
27
|
+
admitted read so a `--limit` window cannot hide a clear. `waitingClaims[]`
|
|
28
|
+
shows every producer's current claim. **An unknown current incarnation
|
|
29
|
+
(unreadable `instance.json`) admits no claim at all.** Still `null` today — no
|
|
30
|
+
producer emits it.
|
|
31
|
+
- **Provenance and corruption never disappear.** Dedup includes the producer;
|
|
32
|
+
`integrity.unreadableRows` counts torn lines regardless of `--since` or the
|
|
33
|
+
window; `count`/`returned`/`truncated` are consistent.
|
|
34
|
+
|
|
35
|
+
## Kernel — retirement fingerprint scope (K6h)
|
|
36
|
+
|
|
37
|
+
0.24.11's kernel-field neutrality for `instance.json` — and the pre-existing
|
|
38
|
+
`.oats-*` receipt exclusions — were keyed on a root-level *filename*, so they
|
|
39
|
+
also applied to directory work trees and recovery trees. Both are now opt-in
|
|
40
|
+
(`{ instanceHome: true }`), passed by exactly the four instance-home callers;
|
|
41
|
+
work and recovery trees are fully significant. A comparison blind spot (directory
|
|
42
|
+
work was already always recovered when non-empty), not a deletion.
|
|
43
|
+
|
|
44
|
+
## Desktop
|
|
45
|
+
|
|
46
|
+
- **Reported lifecycle activity** in the selected-instance popover: an explicit
|
|
47
|
+
*Load / Refresh activity* control (no roster or per-card fan-out) shows the
|
|
48
|
+
kernel's dated events with source, integrity (refused/tail/torn/foreign
|
|
49
|
+
visibly incomplete), earlier/unknown incarnation labels, and attributed
|
|
50
|
+
waiting claims as *claims as of a timestamp* — never as current runtime
|
|
51
|
+
authority. Gated on `eventsApi === 2` and `instance-events-2`; two coalesced
|
|
52
|
+
process-wide read slots; one observation retained per open popup; a failed
|
|
53
|
+
refresh keeps visibly stale rows rather than a false empty.
|
|
54
|
+
|
|
55
|
+
## Upgrade
|
|
56
|
+
|
|
57
|
+
`npm i -g @awebai/oats@0.24.12`, then `oats doctor`.
|
package/lib/core.mjs
CHANGED
|
@@ -73,6 +73,8 @@ import { invokeProviderBinding } from "./provider-binding-broker.mjs";
|
|
|
73
73
|
import { withCapturedBindingFile } from "./captured-binding-file.mjs";
|
|
74
74
|
import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
|
|
75
75
|
export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
|
|
76
|
+
/** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
|
|
77
|
+
export { fingerprintTree };
|
|
76
78
|
import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
|
|
77
79
|
import { createCapabilityMaterializer } from "./package-materialization.mjs";
|
|
78
80
|
import { resolvePackageClosure } from "./package-closure.mjs";
|
|
@@ -7257,7 +7259,7 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
|
7257
7259
|
* otherwise every stop or event write would read as "changed home bytes". */
|
|
7258
7260
|
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
7259
7261
|
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
7260
|
-
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
|
|
7262
|
+
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
|
|
7261
7263
|
const hash = createHash("sha256");
|
|
7262
7264
|
const rootStat = lstatSync(root);
|
|
7263
7265
|
if (!rootStat.isDirectory()) {
|
|
@@ -7269,13 +7271,13 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
7269
7271
|
}
|
|
7270
7272
|
const walk = (dir, rel = "") => {
|
|
7271
7273
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
7272
|
-
if ((!rel && excludeRoot.has(e.name)) || (!rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
7274
|
+
if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
7273
7275
|
const childRel = rel ? join(rel, e.name) : e.name;
|
|
7274
7276
|
const path = join(dir, e.name);
|
|
7275
7277
|
const st = lstatSync(path);
|
|
7276
7278
|
hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
|
|
7277
7279
|
if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
|
|
7278
|
-
else if (st.isFile()) { hash.update("file\0"); hash.update(!rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); hash.update("\0"); }
|
|
7280
|
+
else if (st.isFile()) { hash.update("file\0"); hash.update(instanceHome && !rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); /* kernel-field neutrality applies ONLY when the tree IS an instance home; a work tree's instance.json is the agent's bytes */ hash.update("\0"); }
|
|
7279
7281
|
else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
|
|
7280
7282
|
else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
|
|
7281
7283
|
}
|
|
@@ -7360,7 +7362,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
|
|
|
7360
7362
|
...(incarnationId ? { incarnationId, executionBinding } : {}),
|
|
7361
7363
|
...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
|
|
7362
7364
|
home: realPathOrNearest(home),
|
|
7363
|
-
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
|
|
7365
|
+
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
|
|
7364
7366
|
disposableReceipts,
|
|
7365
7367
|
generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
|
|
7366
7368
|
runtime: {
|
|
@@ -8453,7 +8455,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
8453
8455
|
const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
|
|
8454
8456
|
if (!baselineValid) {
|
|
8455
8457
|
classes.push("unknown instance-home provenance");
|
|
8456
|
-
} else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
|
|
8458
|
+
} else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
|
|
8457
8459
|
classes.push("changed instance-home bytes");
|
|
8458
8460
|
}
|
|
8459
8461
|
// A mutable mode must not turn owned directory bytes into an excluded shared
|
|
@@ -8478,7 +8480,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
8478
8480
|
if (nestedGitRoots(work).length) classes.push("nested repository state");
|
|
8479
8481
|
}
|
|
8480
8482
|
const stateFingerprint = createHash("sha256")
|
|
8481
|
-
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
|
|
8483
|
+
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
|
|
8482
8484
|
.update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
8483
8485
|
.digest("hex");
|
|
8484
8486
|
return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
@@ -8604,7 +8606,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
8604
8606
|
}
|
|
8605
8607
|
const recoveredHome = join(staging, "home");
|
|
8606
8608
|
copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
|
|
8607
|
-
if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
|
|
8609
|
+
if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
|
|
8608
8610
|
throw new Error("home recovery verification disagreed with the source");
|
|
8609
8611
|
}
|
|
8610
8612
|
let branchDrift;
|
package/lib/instance-events.mjs
CHANGED
|
@@ -5,11 +5,24 @@
|
|
|
5
5
|
* receipt it produced. Nothing here is inferred from transcripts, task files or
|
|
6
6
|
* prose; "waiting on you" is reported only when a producer says so — today no
|
|
7
7
|
* producer does, and the view says `null`, not a guess. Retirement's event
|
|
8
|
-
* outlives the home in the workspace-level log.
|
|
9
|
-
|
|
8
|
+
* outlives the home in the workspace-level log.
|
|
9
|
+
*
|
|
10
|
+
* eventsApi 2 (`instance-events-2`) — the read is BOUNDED and ADDRESSED:
|
|
11
|
+
* - a source is `lstat`ed first and opened only if it is a regular file; the
|
|
12
|
+
* reader reads at most the last MAX_BYTES by descriptor, never the whole file;
|
|
13
|
+
* - rows are the ADDRESS's history: a row whose instance/home is not the
|
|
14
|
+
* admitted address is dropped and counted (`integrity.foreignRows`), a torn
|
|
15
|
+
* line is counted (`integrity.unreadableRows`) — never sorted or windowed away;
|
|
16
|
+
* - every row is tagged with the `incarnation` (the home's instance.json
|
|
17
|
+
* `createdAt`) that wrote it; the result echoes the current one, so a
|
|
18
|
+
* recreated same-address instance's earlier rows are visible as earlier;
|
|
19
|
+
* - `waitingOnYou` is a producer STATE per (producer, current incarnation):
|
|
20
|
+
* that producer's latest row carrying the field decides, an explicit `false`
|
|
21
|
+
* clears, and it is computed over the full admitted read, before windowing. */
|
|
22
|
+
import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
|
|
10
23
|
import { basename, dirname, join } from "node:path";
|
|
11
24
|
|
|
12
|
-
export const EVENTS_API =
|
|
25
|
+
export const EVENTS_API = 2;
|
|
13
26
|
const HOME_LOG = ".oats-events.jsonl";
|
|
14
27
|
const MAX_BYTES = 4 * 1024 * 1024;
|
|
15
28
|
|
|
@@ -23,11 +36,16 @@ function workspaceLogPath(home) {
|
|
|
23
36
|
return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
|
|
24
37
|
}
|
|
25
38
|
|
|
39
|
+
/** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
|
|
40
|
+
export function incarnationOf(home) {
|
|
41
|
+
try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
/** Append one typed event. Never throws into the caller's action: an event is
|
|
27
45
|
* evidence, not authority — a failed write is reported in the return value. */
|
|
28
|
-
export function appendEvent(home, event, { workspaceOnly = false } = {}) {
|
|
46
|
+
export function appendEvent(home, event, { workspaceOnly = false, incarnation } = {}) {
|
|
29
47
|
if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
|
|
30
|
-
const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
|
|
48
|
+
const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
|
|
31
49
|
const line = JSON.stringify(row) + "\n";
|
|
32
50
|
const results = [];
|
|
33
51
|
// Retirement fingerprints the home against its baseline and preserves any
|
|
@@ -44,33 +62,81 @@ export function appendEvent(home, event, { workspaceOnly = false } = {}) {
|
|
|
44
62
|
return { ok: results.some((r) => r.ok), row, results };
|
|
45
63
|
}
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
/** Bounded, descriptor-safe read of one log: lstat → regular file only → read
|
|
66
|
+
* at most the last MAX_BYTES. Returns rows plus the source's integrity facts. */
|
|
67
|
+
function readLog(path, label) {
|
|
68
|
+
let st;
|
|
69
|
+
try { st = lstatSync(path); } catch (e) { return e.code === "ENOENT" ? { rows: [], unreadable: 0, source: { path: label, status: "absent", bytes: 0 } } : { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; }
|
|
70
|
+
if (!st.isFile()) return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; // symlink, FIFO, device, directory: never opened
|
|
71
|
+
let fd;
|
|
72
|
+
try {
|
|
73
|
+
// Close the lstat→open swap: open WITHOUT following a symlink and without
|
|
74
|
+
// blocking on a FIFO, then verify by descriptor that what was opened is the
|
|
75
|
+
// regular file lstat saw (same device+inode). Anything else is refused.
|
|
76
|
+
fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
77
|
+
const fst = fstatSync(fd);
|
|
78
|
+
if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino) throw new Error("swapped");
|
|
79
|
+
st = fst; // the size of what is actually open
|
|
80
|
+
} catch { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; }
|
|
81
|
+
const tail = st.size > MAX_BYTES;
|
|
82
|
+
const length = tail ? MAX_BYTES : st.size;
|
|
83
|
+
const buf = Buffer.alloc(length);
|
|
84
|
+
try {
|
|
85
|
+
let got = 0; while (got < length) { const n = readSync(fd, buf, got, length - got, (tail ? st.size - MAX_BYTES : 0) + got); if (n <= 0) break; got += n; }
|
|
86
|
+
} catch { return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: st.size } }; }
|
|
87
|
+
finally { try { closeSync(fd); } catch { /* nothing to recover */ } }
|
|
88
|
+
const text = buf.toString("utf8");
|
|
89
|
+
const lines = text.split("\n");
|
|
90
|
+
if (tail) lines.shift(); // the first line of a tail is (almost surely) partial; it is not counted as torn — the tail itself is reported
|
|
91
|
+
const rows = []; let unreadable = 0;
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
if (!line.trim()) continue;
|
|
94
|
+
try { const r = JSON.parse(line); if (r && typeof r === "object" && Number.isInteger(r.eventsApi) && r.eventsApi >= 1 && typeof r.kind === "string" && typeof r.at === "string") rows.push(r); else unreadable++; } catch { unreadable++; }
|
|
95
|
+
}
|
|
96
|
+
return { rows, unreadable, source: { path: label, status: tail ? "tail" : "ok", bytes: st.size } };
|
|
54
97
|
}
|
|
55
98
|
|
|
56
|
-
/** Events for one instance, newest last, from both logs
|
|
57
|
-
*
|
|
99
|
+
/** Events for one instance ADDRESS, newest last, from both logs, with a bounded
|
|
100
|
+
* window; integrity is reported independently of the selected rows. */
|
|
58
101
|
export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
102
|
+
const instance = basename(home);
|
|
103
|
+
const incarnation = incarnationOf(home);
|
|
104
|
+
const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
|
|
105
|
+
const seen = new Set(); const rows = []; let foreign = 0;
|
|
106
|
+
for (const r of [...a.rows, ...b.rows]) {
|
|
107
|
+
if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
|
|
108
|
+
const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
|
|
109
|
+
if (seen.has(k)) continue; seen.add(k); rows.push({ ...r, incarnation: r.incarnation ?? null });
|
|
110
|
+
}
|
|
111
|
+
rows.sort((x, y) => x.at.localeCompare(y.at));
|
|
112
|
+
// Waiting: a producer STATE for the CURRENT incarnation, decided by that
|
|
113
|
+
// producer's latest row that carries the field, over the FULL admitted read.
|
|
114
|
+
// An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
|
|
115
|
+
// unknown stays unknown, it never resurrects an earlier incarnation's positive.
|
|
116
|
+
const claims = new Map();
|
|
117
|
+
for (const r of (incarnation === null ? [] : rows)) {
|
|
118
|
+
if (r.incarnation !== incarnation) continue;
|
|
119
|
+
if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
|
|
120
|
+
const p = r.producer ?? "kernel";
|
|
121
|
+
claims.set(p, r.data.waitingOnYou ? { producer: p, waiting: true, since: r.at, reason: typeof r.data.reason === "string" ? r.data.reason : null } : { producer: p, waiting: false, since: r.at, reason: null });
|
|
122
|
+
}
|
|
123
|
+
const waitingClaims = [...claims.values()];
|
|
124
|
+
const positive = waitingClaims.filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
|
|
125
|
+
const filtered = since ? rows.filter((r) => r.at > since) : rows;
|
|
64
126
|
const window = filtered.slice(-limit);
|
|
65
127
|
const last = window.at(-1) ?? null;
|
|
66
|
-
const waiting = window.filter((r) => r.data && r.data.waitingOnYou === true).at(-1) ?? null;
|
|
67
128
|
return {
|
|
68
|
-
eventsApi: EVENTS_API, instance
|
|
69
|
-
|
|
70
|
-
|
|
129
|
+
eventsApi: EVENTS_API, instance, home, incarnation, count: filtered.length, returned: window.length,
|
|
130
|
+
truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
|
|
131
|
+
integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
|
|
132
|
+
events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer ?? "kernel", incarnation: last.incarnation } : null,
|
|
133
|
+
waitingOnYou: positive ? { since: positive.since, producer: positive.producer, reason: positive.reason } : null,
|
|
134
|
+
waitingClaims,
|
|
71
135
|
notes: [
|
|
72
136
|
"events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
|
|
73
|
-
"
|
|
137
|
+
"this is the ADDRESS's history: rows tagged with an earlier incarnation belong to a previous instance at this address",
|
|
138
|
+
"waitingOnYou is null unless a producer reported it for the current incarnation — null means unknown, not 'not waiting'; an explicit false clears that producer's claim; an unknown current incarnation (null) admits no claim at all",
|
|
139
|
+
"integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
|
|
74
140
|
],
|
|
75
141
|
};
|
|
76
142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.12",
|
|
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",
|