@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) {
|
|
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:
|
|
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
|
|
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
|
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
197
|
+
const runId = runIdOf(lr); if (!runId) continue;
|
|
159
198
|
js.recentRuns ||= [];
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
899
|
-
return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun
|
|
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
|
-
|
|
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.
|
|
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",
|