@awebai/oats 0.24.12 → 0.24.13

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
@@ -4545,7 +4545,11 @@ function scheduleCmd() {
4545
4545
  }
4546
4546
  default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
4547
4547
  }
4548
- } catch (e) { cmdFail(e.code || "E_SCHEDULE_FAILED", e.message); }
4548
+ } catch (e) {
4549
+ // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
4550
+ const details = Object.fromEntries(["key", "declared", "source", "field"].filter((k) => e[k] !== undefined).map((k) => [k, e[k]]));
4551
+ if (JSON_MODE) jsonFail(e.code || "E_SCHEDULE_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
4552
+ }
4549
4553
  }
4550
4554
 
4551
4555
  async function sessionCmd() {
@@ -5034,7 +5038,7 @@ function versionCmd() {
5034
5038
  // on it (an older CLI without the surface must fail closed with a
5035
5039
  // reason, not an argument error). `features`: kernel abilities a peer
5036
5040
  // must see before relying on them (retire-home: retire --home).
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"] }));
5041
+ 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", "schedule-read-2", "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: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5038
5042
  return;
5039
5043
  }
5040
5044
  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: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
5
+ **Last update:** 2026-09-22 23:45Z · v0.24.12 published · **slice 8 approved** (no polling; command-running GET `/api/schedules` to be REMOVED; remote history unsupported; captured edit = honest limitation; transcript = provenance only, read-only transcript verb = K12 Decision later) · **K8b PR95 → main `ee60d1e2`** (`schedule-read-2` / `scheduleHistoryApi 3`: runId by time, bounded fd state reads + 1 MiB budget, per-job isolation, scope/id echo, `session` provenance replaces `transcript`) · 12 receipts to engineer · engineer wiring 8 → then rest of frame 10 · open: K11, K12 transcript, 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
 
@@ -245,7 +245,7 @@ torn lines and cleared claims silently. API 2:
245
245
 
246
246
  Desktop passes `--limit` (50|100|200) only; `--since` remains a human flag.
247
247
 
248
- ## Schedule run history (`scheduleApi: 2`, OATS 0.24.8+) — K8
248
+ ## Schedule run history (`scheduleApi: 2`, `scheduleHistoryApi: 2` → **3**, OATS 0.24.8+) — K8
249
249
 
250
250
  `oats schedule show|list --json` entries gain **`recentRuns`**: the last 50
251
251
  settled runs (newest first) — every `lastRun` the scheduler recorded once its
@@ -259,6 +259,67 @@ Schedules view (frame 08) renders `recentRuns` as the recent-runs list and the
259
259
  transcript pointer as the handoff; captured-policy definitions are preserved
260
260
  as they are (definition fields are untouched by this addition).
261
261
 
262
+ ### History API 3 (`scheduleHistoryApi: 3`, feature `schedule-read-2`, OATS 0.24.13+) — K8b
263
+
264
+ Gate a Desktop history read on **both** `scheduleHistoryApi === 3` and
265
+ `"schedule-read-2"` in `features[]` (`scheduleApi` stays 2 — mutation verbs are
266
+ unchanged). API 2's reader keyed runs by outcome, read state files whole and
267
+ unchecked, echoed a stored `definition.id` without checking it, and named a
268
+ `transcript` that no reader backs. API 3:
269
+
270
+ - **Run identity is time, not outcome.** `runId = sha256(scheduledFor|startedAt|attemptId)[0:24]`.
271
+ A run's later facts update its one row; `transitions[]` keeps the outcome
272
+ sequence (`["started","unknown","ended"]`); `settled: boolean`
273
+ (`pending: true` is never settled); `recordedAt`. Pre-API-3 rows are returned
274
+ with `runId: null, legacy: true, settled: null, transitions: null` and are
275
+ never merged. `lastRun` carries the same `runId` as its history row.
276
+ - **Bounded, descriptor-safe state.** `oats-schedules.json` and
277
+ `.agents/schedules/state.json` are `lstat`ed (regular file only), opened
278
+ `O_NOFOLLOW|O_NONBLOCK`, `fstat`-verified (dev+ino), and read whole **only
279
+ within a 1 MiB budget** — over budget is a typed `E_SCHEDULE_STATE_OVERSIZE`
280
+ refusal with `details.source`, never truncated JSON. `list`/`show` carry
281
+ `integrity: {sources: [{path: "definitions"|"state", status: "ok"|"absent"|"refused"|"oversize"|"corrupt", bytes}]}`.
282
+ History is capped at 50 rows **at read** (`history: {status, stored, truncated}`);
283
+ one job's corrupt history (`history.status: "corrupt"`, `recentRuns: []`) or
284
+ bad identity (`unreadable: {code, message}`) never fails the other jobs in `list`.
285
+ - **Subject truth.** `list` and `show` echo `scope` (the resolved schedule-owning
286
+ workspace) and canonical `id`. A definition whose own `id` differs from its
287
+ key → `E_SCHEDULE_IDENTITY` (`details.key`, `details.declared`). IDs must match
288
+ `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` (`E_BAD_ARGS` otherwise, before any read).
289
+ - **Session provenance, never a transcript.** The `transcript` key is gone.
290
+ Each run (and `lastRun`) carries
291
+ `session: {instance: string|null, home: string|null, incarnation: string|null, server: string|null, delivery: "launched"|"delivered-active"|"none"}`
292
+ — facts the recorder had at write time (an active-session wake names the
293
+ instance only when the input result did; `incarnation` = the home's
294
+ `instance.json.createdAt` at record time; `server` = the answering peer for a
295
+ remote command). **There is no reader behind this block**: a consumer renders
296
+ provenance and a precise unavailable reason. A read-only transcript verb is a
297
+ separate seam (K12), not implied by this API.
298
+
299
+ ```
300
+ {"scope":"/abs/ws","scheduleApi":2,"scheduleHistoryApi":3,
301
+ "integrity":{"sources":[{"path":"definitions","status":"ok","bytes":812},{"path":"state","status":"ok","bytes":4410}]},
302
+ "schedules":[{"id":"nightly","scope":"/abs/ws","scheduleApi":2,"scheduleHistoryApi":3,…,
303
+ "history":{"status":"ok","stored":7,"truncated":false},
304
+ "recentRuns":[{"runId":"3f…","scheduledFor":"<iso>","startedAt":"<iso>","outcome":"ended","settled":true,"transitions":["started","ended"],"recordedAt":"<iso>",
305
+ "session":{"instance":"dev-1","home":"/abs/home","incarnation":"<iso>","server":null,"delivery":"launched"}}]}],
306
+ "scheduler":{…}}
307
+ ```
308
+
309
+ **Exact shapes (API 3):**
310
+ - `schedule list --json` → `result = {scope, scheduleApi: 2, scheduleHistoryApi: 3, integrity, schedules: Entry[], scheduler}`.
311
+ - `schedule show <id> --json` → `result = {schedule: Entry}` (one level of nesting; `integrity` is NOT on `show` — it is a scope fact reported by `list`).
312
+ - `Entry` (readable) = definition fields (`id, kind, home, message|operation, cron, enabled, …`) + `{scope, scheduleApi: 2, scheduleHistoryApi: 3, executionStatus, nextRun: ISO|null, lastRun: Run|null, history, recentRuns: Run[], running: boolean, attempt?, pendingWake?}`.
313
+ - `Entry` (unreadable, `list` only) = `{id, scope, scheduleApi: 2, scheduleHistoryApi: 3, unreadable: {code, message}, history: {status: "corrupt", stored: null, truncated: false}, recentRuns: []}` — no definition fields.
314
+ - `history` = `{status: "ok", stored: integer, truncated: boolean}` | `{status: "corrupt", stored: null, truncated: false}`.
315
+ - `Run` (API 3 row) = producer-written fields (`scheduledFor, startedAt, kind, outcome, …`) + `{runId: string, legacy: false, settled: boolean, recordedAt: ISO, transitions: string[], session}`; `transitions[]` elements are outcome strings in write order, first element = the first recorded outcome.
316
+ - `Run` (legacy row) = producer-written fields + `{runId: null, legacy: true, settled: null, transitions: null, session}` — no `recordedAt`, no `key`.
317
+ - `Run` (corrupt element) = `{runId: null, legacy: true, corrupt: true}` only.
318
+ - `session` = `{instance: string|null, home: string|null, incarnation: ISO|null, server: string|null, delivery: "launched"|"delivered-active"|"none"}`; always present on rows and on `lastRun`.
319
+ - `runId` is opaque to consumers: never recompute, never dedup client-side.
320
+ - **Refusals** (`ok:false`): `E_SCHEDULE_STATE_OVERSIZE` / `E_SCHEDULE_INVALID` carry `error.details.source = {path, status, bytes}` (+ `field`); `E_SCHEDULE_IDENTITY` carries `error.details.key` and `error.details.declared`; `E_BAD_ARGS` (id shape) carries no details. A refusal has no `integrity` block — `list` refuses as a whole only when a scope file itself is unreadable.
321
+ - **Open path** (both files): `lstat` → regular file → `open(O_RDONLY|O_NOFOLLOW|O_NONBLOCK)` → `fstat` regular + same dev/ino + `size ≤ 1 MiB` → read exactly `fstat.size` bytes by descriptor (a file that grows past the budget between lstat and fstat is refused, never partially read).
322
+
262
323
  ## Spawn preview (`oats spawn … --preview`, `spawnPreviewApi: 1`, OATS 0.24.8+)
263
324
 
264
325
  The Spawn modal's fields are backed by the kernel's own decision, taken **before
@@ -0,0 +1,51 @@
1
+ # OATS v0.24.13 — schedule history API 3 and the Desktop schedules table
2
+
3
+ Kernel/Pi/Desktop **0.24.13**. Tag `v0.24.13` → 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 — `schedule-read-2` (`scheduleHistoryApi: 3`; `scheduleApi` stays 2)
8
+
9
+ `oats schedule list|show` history had four defects found by the Desktop
10
+ engineer's exact-source review; each is closed under a new advertised name.
11
+
12
+ - **A run's identity is when it was scheduled and started, never its outcome.**
13
+ `runId = sha256(scheduledFor|startedAt|attemptId)[0:24]`; a run's later facts
14
+ update its one row; `transitions[]` keeps the outcome sequence
15
+ (`["started","unknown","ended"]` is one run, not three); `settled`,
16
+ `recordedAt`. Pre-API-3 rows are returned `legacy: true` with `runId: null`
17
+ and are never merged.
18
+ - **Bounded, descriptor-safe state.** Both scope files are `lstat`ed (regular
19
+ file only), opened `O_NOFOLLOW|O_NONBLOCK`, `fstat`-verified (dev+ino) and
20
+ read whole **only within a 1 MiB budget** — over budget is a typed
21
+ `E_SCHEDULE_STATE_OVERSIZE` refusal, never truncated JSON. `list` carries
22
+ `integrity.sources[]`; history is capped at 50 rows at read
23
+ (`history: {status, stored, truncated}`); one job's corrupt history or bad
24
+ identity is its own row and never fails the others.
25
+ - **Subject truth.** `list`/`show` echo the resolved `scope` and canonical `id`;
26
+ a definition whose own `id` differs from its key → `E_SCHEDULE_IDENTITY`; ids
27
+ are validated before any read. Typed refusal details travel through the CLI's
28
+ JSON failure.
29
+ - **Session provenance, never a transcript.** The `transcript` pointer named a
30
+ reader that does not exist and is gone. Every run carries
31
+ `session: {instance|null, home|null, incarnation|null, server|null, delivery}`
32
+ — what the recorder knew at write time. A read-only transcript verb is a
33
+ separate seam (K12), not implied by this API.
34
+
35
+ ## Desktop
36
+
37
+ - **Schedules** view: the compact table (enabled, schedule, target, cadence,
38
+ next run, last reported outcome, actions) with **Recent runs** for the
39
+ workspace (≤50), read once on entry and on explicit Refresh — no polling.
40
+ History enables nothing; `ended` is not success and `delivered` is not
41
+ consumption; missing time facts are shown as unreported. Session provenance
42
+ is displayed with a precise unavailable reason — no transcript, terminal or
43
+ link. Remote history and editing a captured wake are shown as honest
44
+ limitations. Gated on `scheduleHistoryApi === 3` and `schedule-read-2`.
45
+ - **Security**: the command-running `GET /api/schedules` is removed (405, no
46
+ command, no default workspace); legacy POST `list|show` aliases run through
47
+ the same strict admission, budgets and two coalesced read slots.
48
+
49
+ ## Upgrade
50
+
51
+ `npm i -g @awebai/oats@0.24.13`, then `oats doctor`.
package/lib/schedule.mjs CHANGED
@@ -20,7 +20,8 @@
20
20
  * succeeded. A launch whose side effects cannot be confirmed stays
21
21
  * `unknown` with its slot held until `reconcile` proves what happened. */
22
22
  import { execFileSync, spawnSync } from "node:child_process";
23
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { createHash } from "node:crypto";
24
25
  import { homedir } from "node:os";
25
26
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
26
27
  import { fileURLToPath } from "node:url";
@@ -127,11 +128,32 @@ export function stateDir(ws) { return join(ws, ".agents", "schedules"); }
127
128
  function statePath(ws) { return join(stateDir(ws), "state.json"); }
128
129
  function lockDir(ws, id) { return join(stateDir(ws), "locks", id); }
129
130
 
130
- function readJson(path, fallback) {
131
- if (!existsSync(path)) return fallback;
132
- try { return JSON.parse(readFileSync(path, "utf8")); }
133
- catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file" }); }
131
+ export const SCHEDULE_HISTORY_API = 3;
132
+ export const SCHEDULE_FILE_BUDGET = 1024 * 1024;
133
+ /** K8b — bounded, descriptor-safe JSON read: lstat → regular file only →
134
+ * O_NOFOLLOW|O_NONBLOCK open → fstat dev/ino identity → whole file only if
135
+ * within budget (an over-budget file is REFUSED as a typed error, never
136
+ * truncated into fabricated JSON). Returns { value, source }. */
137
+ function readJsonBounded(path, fallback, label) {
138
+ let st;
139
+ try { st = lstatSync(path); }
140
+ catch (e) { if (e.code === "ENOENT") return { value: fallback, source: { path: label, status: "absent", bytes: 0 } }; throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: 0 } }); }
141
+ if (!st.isFile()) throw scheduleError("E_SCHEDULE_INVALID", `${path} is not a regular file`, { field: "file", source: { path: label, status: "refused", bytes: 0 } });
142
+ if (st.size > SCHEDULE_FILE_BUDGET) throw scheduleError("E_SCHEDULE_STATE_OVERSIZE", `${path} is ${st.size} bytes; the read budget is ${SCHEDULE_FILE_BUDGET}`, { field: "file", source: { path: label, status: "oversize", bytes: st.size } });
143
+ let fd, text;
144
+ try {
145
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
146
+ const fst = fstatSync(fd);
147
+ if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
148
+ const buf = Buffer.alloc(fst.size); let got = 0;
149
+ while (got < fst.size) { const n = readSync(fd, buf, got, fst.size - got, got); if (n <= 0) break; got += n; }
150
+ text = buf.toString("utf8"); st = fst;
151
+ } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read safely: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: st.size } }); }
152
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } }
153
+ try { return { value: JSON.parse(text), source: { path: label, status: "ok", bytes: st.size } }; }
154
+ catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file", source: { path: label, status: "corrupt", bytes: st.size } }); }
134
155
  }
156
+ function readJson(path, fallback) { return readJsonBounded(path, fallback, basename(path)).value; }
135
157
  function writeJson(path, value) {
136
158
  mkdirSync(dirname(path), { recursive: true });
137
159
  const tmp = `${path}.tmp-${process.pid}`;
@@ -139,27 +161,52 @@ function writeJson(path, value) {
139
161
  renameSync(tmp, path);
140
162
  }
141
163
  export function readDefinitions(ws) {
142
- const doc = readJson(definitionsPath(ws), { version: 1, jobs: {} });
164
+ const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
143
165
  if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
144
166
  return doc;
145
167
  }
146
168
  export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
147
- export function readState(ws) { const st = readJson(statePath(ws), { jobs: {} }); if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
169
+ export function readState(ws) { const st = readJsonBounded(statePath(ws), { jobs: {} }, "state").value; if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
170
+ /** Integrity facts for a scope's two files, without throwing. */
171
+ export function scheduleIntegrity(ws) {
172
+ const sources = [];
173
+ for (const [path, label, fallback] of [[definitionsPath(ws), "definitions", { version: 1, jobs: {} }], [statePath(ws), "state", { jobs: {} }]]) {
174
+ try { sources.push(readJsonBounded(path, fallback, label).source); }
175
+ catch (e) { sources.push(e.source ?? { path: label, status: "refused", bytes: 0 }); }
176
+ }
177
+ return { sources };
178
+ }
148
179
  /** K8 — bounded run history. Every time a job's lastRun reaches a settled
149
180
  * outcome (not "active"/"starting"), it is appended once to `recentRuns`
150
181
  * (newest first, max 50), keyed by scheduledFor+startedAt so re-saves of the
151
182
  * same run never duplicate it. Recording happens on save: producers keep
152
183
  * writing lastRun exactly as they do; nothing is inferred. */
153
184
  export const RECENT_RUNS_MAX = 50;
154
- function recordRecentRuns(st) {
185
+ /** K8b — a run's identity is WHEN it was scheduled and started (plus the
186
+ * attempt id when one exists), never its outcome: unknown→ended is one run
187
+ * with two transitions, not two rows. */
188
+ export function runIdOf(lr) {
189
+ if (!lr || typeof lr !== "object" || (!lr.scheduledFor && !lr.startedAt)) return null;
190
+ return createHash("sha256").update(`${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.execution?.attemptId ?? lr.attemptId ?? ""}`).digest("hex").slice(0, 24);
191
+ }
192
+ const SETTLED_OUTCOMES = new Set(["ended", "stopped", "delivered", "skipped", "blocked", "invalid", "failed", "unknown", "refused", "completed"]);
193
+ function recordRecentRuns(st, now = new Date()) {
155
194
  for (const js of Object.values(st.jobs || {})) {
156
195
  const lr = js.lastRun;
157
196
  if (!lr || typeof lr !== "object" || ["active", "starting"].includes(lr.outcome)) continue;
158
- const key = `${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.outcome ?? ""}`;
197
+ const runId = runIdOf(lr); if (!runId) continue;
159
198
  js.recentRuns ||= [];
160
- if (js.recentRuns[0]?.key === key) { js.recentRuns[0] = { key, ...lr }; continue; } // same run, later fields
161
- if (js.recentRuns.some((r) => r.key === key)) continue;
162
- js.recentRuns.unshift({ key, ...lr });
199
+ const settled = SETTLED_OUTCOMES.has(lr.outcome) && lr.pending !== true;
200
+ const existing = js.recentRuns.find((r) => r.runId === runId);
201
+ if (existing) {
202
+ // same run, later facts: update in place, keep the outcome sequence
203
+ const prev = existing.outcome;
204
+ Object.assign(existing, lr, { runId, settled, recordedAt: now.toISOString() });
205
+ existing.transitions = [...(existing.transitions || [prev]), ...(prev !== lr.outcome ? [lr.outcome] : [])];
206
+ continue;
207
+ }
208
+ // legacy rows (pre-K8b, keyed by outcome) are left as they are: never merged, reported as legacy
209
+ js.recentRuns.unshift({ runId, ...lr, settled, recordedAt: now.toISOString(), transitions: [lr.outcome] });
163
210
  if (js.recentRuns.length > RECENT_RUNS_MAX) js.recentRuns.length = RECENT_RUNS_MAX;
164
211
  }
165
212
  }
@@ -502,7 +549,10 @@ function launchCommand(ws, def, io) {
502
549
  const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
503
550
  return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
504
551
  }
505
- return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
552
+ // Provenance the recorder has at write time: the answering server (remote
553
+ // spawn) and the launched home's incarnation. Never a transcript.
554
+ const server = typeof res.server === "string" ? res.server : undefined;
555
+ return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home, incarnation: incarnationOfHome(home) } : {}), ...(server ? { server } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
506
556
  }
507
557
 
508
558
  /** Verify an immutable capsule and its current exact-artifact authorization
@@ -596,12 +646,16 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
596
646
  if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
597
647
  try {
598
648
  const r = (io?.start || startInstanceSession)(def.home);
599
- return { kind: "wake", action: "started", instance: r.instance, target: r.target, reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
649
+ return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
600
650
  } catch (e) {
601
651
  return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedRuntime: "unconfirmed" };
602
652
  }
603
653
  }
604
- try { (io?.input || inputInstanceSession)(def.home, def.message); return { kind: "wake", action: "delivered", pending: false }; }
654
+ try {
655
+ const r = (io?.input || inputInstanceSession)(def.home, def.message);
656
+ // Delivery custody: name the instance ONLY when the input result names it; the home's current incarnation at record time.
657
+ return { kind: "wake", action: "delivered", pending: false, ...(typeof r?.instance === "string" ? { instance: r.instance } : {}), home: def.home, incarnation: incarnationOfHome(def.home) };
658
+ }
605
659
  catch (e) { return { kind: "wake", action: "skipped", reason: `input refused: ${e.message}`, pending: true }; }
606
660
  }
607
661
 
@@ -887,20 +941,52 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
887
941
 
888
942
  // -------------------------------------------------------- definitions CRUD
889
943
 
944
+ export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
945
+ function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; } }
946
+ /** K8b — session PROVENANCE of a run (facts the recorder had), never a
947
+ * transcript: there is no reader behind this block. */
948
+ function sessionProvenanceOf(r) {
949
+ if (!r || typeof r !== "object") return null;
950
+ const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
951
+ return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
952
+ }
953
+ /** Read-time projection of one job's stored history: bounded to
954
+ * RECENT_RUNS_MAX, legacy rows marked, corruption isolated to the job. */
955
+ function projectHistory(js) {
956
+ const stored = js?.recentRuns;
957
+ if (stored === undefined || stored === null) return { history: { status: "ok", stored: 0, truncated: false }, recentRuns: [] };
958
+ if (!Array.isArray(stored)) return { history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] };
959
+ const rows = [];
960
+ for (const r of stored.slice(0, RECENT_RUNS_MAX)) {
961
+ if (!r || typeof r !== "object") { rows.push({ runId: null, legacy: true, corrupt: true }); continue; }
962
+ const { key, ...rest } = r;
963
+ const legacy = typeof r.runId !== "string";
964
+ rows.push({ ...rest, runId: legacy ? null : r.runId, legacy, ...(legacy ? { settled: null, transitions: null } : {}), session: sessionProvenanceOf(rest) });
965
+ }
966
+ return { history: { status: "ok", stored: stored.length, truncated: stored.length > RECENT_RUNS_MAX }, recentRuns: rows };
967
+ }
890
968
  export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
969
+ if (typeof id !== "string" || !SCHEDULE_ID_RE.test(id)) throw scheduleError("E_BAD_ARGS", `schedule id must match ${SCHEDULE_ID_RE} (got ${JSON.stringify(id)})`);
891
970
  defs ||= readDefinitions(ws); st ||= readState(ws);
892
971
  const def = defs.jobs[id];
893
972
  if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
973
+ // Subject truth: the definition's own id must be the key it is stored under.
974
+ if (def.id !== undefined && def.id !== id) throw scheduleError("E_SCHEDULE_IDENTITY", `schedule stored under ${JSON.stringify(id)} declares id ${JSON.stringify(def.id)}`, { key: id, declared: def.id });
894
975
  const js = st.jobs[id] || {};
895
976
  let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
896
977
  const lock = jobLockInfo(ws, id);
897
978
  const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
898
- const recentRuns = (js.recentRuns || []).map(({ key, ...r }) => ({ ...r, ...(r.instance && r.home ? { transcript: { instance: r.instance, home: r.home, kind: "session" } } : {}) }));
899
- return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
979
+ const { history, recentRuns } = projectHistory(js);
980
+ return { ...def, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
900
981
  }
901
982
  export function listSchedules(ws, io, { now = new Date() } = {}) {
902
983
  const defs = readDefinitions(ws), st = readState(ws);
903
- return { schedules: Object.keys(defs.jobs).sort().map((id) => describe(ws, id, io, { defs, st, now })), scheduler: schedulerStatus(ws, io) };
984
+ // One job's bad identity or history does not fail the others: it is reported as its own row.
985
+ const schedules = Object.keys(defs.jobs).sort().map((id) => {
986
+ try { return describe(ws, id, io, { defs, st, now }); }
987
+ catch (e) { return { id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, unreadable: { code: e.code || "E_SCHEDULE_INVALID", message: e.message }, history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] }; }
988
+ });
989
+ return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, scheduler: schedulerStatus(ws, io) };
904
990
  }
905
991
  export function addSchedule(ws, spec, io) {
906
992
  const def = validateDefinition(ws, spec);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.12",
3
+ "version": "0.24.13",
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",