@awebai/oats 0.23.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/bin/oats-pi-sdk-host.mjs +17 -0
- package/bin/oats.mjs +442 -51
- package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
- package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
- package/capabilities/oats-okf/lib/captured-worker.mjs +101 -0
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/inspection.mjs +16 -1
- package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
- package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
- package/capabilities/oats-okf/lib/io.mjs +1 -1
- package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
- package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
- package/capabilities/oats-okf/lib/sources.mjs +123 -3
- package/capabilities/oats-okf/lib/stores.mjs +104 -25
- package/capabilities/oats-okf/lib/worker.mjs +69 -10
- package/capabilities/oats-okf/oats.json +35 -7
- package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
- package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
- package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
- package/docs/artifact-approvals.schema.json +7 -0
- package/docs/capability-manifest.schema.json +37 -66
- package/docs/captured-invocation-context.schema.json +7 -0
- package/docs/captured-resolution.schema.json +7 -0
- package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
- package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
- package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
- package/docs/design/2026-09-15-captured-dispatch.md +127 -0
- package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
- package/docs/design/2026-09-15-package-preparation.md +100 -0
- package/docs/design/2026-09-15-portable-data-contract.md +121 -0
- package/docs/design/2026-09-15-portable-declarations.md +189 -0
- package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
- package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
- package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
- package/docs/design/2026-09-15-source-observation.md +119 -0
- package/docs/design/2026-09-16-captured-admission.md +77 -0
- package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
- package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
- package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
- package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
- package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
- package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
- package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
- package/docs/design/2026-09-16-portable-onboarding.md +177 -0
- package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
- package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
- package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
- package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
- package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
- package/docs/design/2026-09-17-captured-native-start.md +58 -0
- package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
- package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
- package/docs/design/2026-09-17-public-captured-start.md +108 -0
- package/docs/design/2026-09-17-public-prepare-request.md +90 -0
- package/docs/design/2026-09-18-captured-pi-host.md +205 -0
- package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
- package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
- package/docs/desktop-cli-api.md +5 -2
- package/docs/execution-capsule.schema.json +108 -0
- package/docs/execution-targets.md +20 -7
- package/docs/oats-lock-v3.schema.json +7 -0
- package/docs/oats-member.schema.json +38 -0
- package/docs/oats-workspace.schema.json +68 -0
- package/docs/portable.schema.json +2512 -0
- package/docs/provider-check-input.schema.json +7 -0
- package/docs/release-notes/v0.24.0.md +104 -0
- package/docs/schedules.md +126 -14
- package/docs/soul.schema.json +82 -0
- package/injects/oats-portable.md +17 -0
- package/injects/portable-instance-boundary.md +39 -0
- package/injects/portable-work-directory.md +29 -0
- package/lib/artifact-approvals.mjs +120 -0
- package/lib/artifact-tree.mjs +141 -0
- package/lib/capability-artifacts.mjs +179 -0
- package/lib/capability-execution.mjs +15 -0
- package/lib/capability-inputs.mjs +39 -0
- package/lib/capability-provenance.mjs +231 -0
- package/lib/captured-action-shape.mjs +21 -0
- package/lib/captured-admission-shape.mjs +20 -0
- package/lib/captured-binding-file.mjs +36 -0
- package/lib/captured-dispatch.mjs +66 -0
- package/lib/captured-instance-index.mjs +277 -0
- package/lib/captured-invocation-context.mjs +130 -0
- package/lib/captured-launch-request.mjs +46 -0
- package/lib/captured-operation-process.mjs +15 -0
- package/lib/captured-pi-custody.mjs +29 -0
- package/lib/captured-pi-host.mjs +167 -0
- package/lib/captured-pi-outcome.mjs +172 -0
- package/lib/captured-resolutions.mjs +275 -0
- package/lib/captured-scaffold.mjs +87 -0
- package/lib/captured-selector.mjs +28 -0
- package/lib/captured-session-backend.mjs +52 -0
- package/lib/captured-source-receipt-file.mjs +72 -0
- package/lib/config-data.mjs +104 -0
- package/lib/core.mjs +918 -562
- package/lib/errors.mjs +7 -0
- package/lib/helper-injection-policy.mjs +98 -0
- package/lib/herdr.mjs +18 -7
- package/lib/instruction-composition.mjs +31 -0
- package/lib/legacy-lock-codec.mjs +106 -0
- package/lib/manifest-settings.mjs +84 -0
- package/lib/package-closure.mjs +48 -0
- package/lib/package-materialization.mjs +83 -0
- package/lib/pi-sdk-host.mjs +229 -0
- package/lib/portable-artifacts.mjs +115 -0
- package/lib/portable-choices.mjs +82 -0
- package/lib/portable-composition.mjs +136 -0
- package/lib/portable-digest.mjs +105 -0
- package/lib/portable-files.mjs +26 -0
- package/lib/portable-identity.mjs +40 -0
- package/lib/portable-lock.mjs +117 -0
- package/lib/portable-migration-artifacts.mjs +135 -0
- package/lib/portable-migration-evidence.mjs +305 -0
- package/lib/portable-migration-store.mjs +199 -0
- package/lib/portable-migration.mjs +104 -0
- package/lib/portable-onboarding-acceptance.mjs +66 -0
- package/lib/portable-onboarding-request.mjs +49 -0
- package/lib/portable-onboarding.mjs +230 -0
- package/lib/portable-package-preparation.mjs +188 -0
- package/lib/portable-policy.mjs +44 -0
- package/lib/portable-shape.mjs +35 -0
- package/lib/portable-soul.mjs +38 -0
- package/lib/portable-state.mjs +80 -0
- package/lib/portable-values.mjs +181 -0
- package/lib/prepare-composition.mjs +151 -0
- package/lib/prepared-bindings.mjs +78 -0
- package/lib/prepared-resources.mjs +127 -0
- package/lib/provider-binding-broker.mjs +59 -0
- package/lib/provider-binding-wire.mjs +110 -0
- package/lib/provider-binding.mjs +22 -0
- package/lib/repository-observation.mjs +226 -0
- package/lib/resolution-shape.mjs +393 -0
- package/lib/schedule-capsule.mjs +206 -0
- package/lib/schedule.mjs +259 -38
- package/lib/servers.mjs +15 -0
- package/lib/soul-constraints.mjs +40 -0
- package/lib/source-projection.mjs +84 -0
- package/lib/source-spec.mjs +189 -0
- package/lib/workspace-definition.mjs +126 -0
- package/lib/workspace-discovery.mjs +146 -0
- package/package-catalog.json +1 -1
- package/package.json +3 -2
- package/packages/record/lib/capture-cc.mjs +14 -6
- package/packages/record/lib/formats.mjs +14 -3
- package/packages/record/lib/native-history.mjs +277 -7
- package/packages/record/lib/session-snapshot.mjs +25 -5
- package/packages/record/lib/sessions-for-home.mjs +30 -13
- package/skills/oats/SKILL.md +12 -7
- package/skills/oats-config/SKILL.md +12 -9
- package/skills/oats-packages/SKILL.md +12 -8
- package/skills/oats-portable/SKILL.md +116 -0
- package/skills/oats-portable-artifacts/SKILL.md +63 -0
- package/skills/oats-portable-setup/SKILL.md +69 -0
package/lib/schedule.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** OATS schedules v1
|
|
1
|
+
/** OATS schedules: legacy v1 plus explicit captured-execution v2,
|
|
2
|
+
* workspace-scoped definitions and host-owned execution.
|
|
2
3
|
*
|
|
3
4
|
* A definition lives in <scope>/oats-schedules.json, where the scope is the
|
|
4
5
|
* team workspace (the config level declaring the team, else the outermost
|
|
@@ -24,10 +25,12 @@ import { homedir } from "node:os";
|
|
|
24
25
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
25
26
|
import { fileURLToPath } from "node:url";
|
|
26
27
|
import { Cron } from "croner";
|
|
27
|
-
import {
|
|
28
|
+
import { portableScope } from "./portable-state.mjs";
|
|
29
|
+
import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath, loadCapturedDispatch, prepareCapturedComposition } from "./core.mjs";
|
|
30
|
+
import { RECURRENCE_POLICIES, SCHEDULE_ATTEMPT_VERSION, SCHEDULE_DEFINITION_VERSION, admitExecutionTemplate, buildCommandExecutionTemplate, buildWakeExecutionTemplate, capturedDispatchAction, commandArgvWithExecutionBinding, executionContentIntegrity, validateExecutionBinding, validateExecutionCapsule, validateExecutionTemplate, validatePreparationInput } from "./schedule-capsule.mjs";
|
|
28
31
|
|
|
29
32
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
30
|
-
export const SCHEDULE_API =
|
|
33
|
+
export const SCHEDULE_API = 2;
|
|
31
34
|
const ID_RE = /^[a-z0-9-]{1,40}$/;
|
|
32
35
|
const RUNTIMES = new Set(["pi", "claude", "codex"]);
|
|
33
36
|
const BACKENDS = new Set(["tmux", "herdr"]);
|
|
@@ -38,6 +41,26 @@ const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
38
41
|
export function scheduleError(code, message, extra) { return Object.assign(new Error(message), { code, ...(extra || {}) }); }
|
|
39
42
|
/** A stored definition that is not even shaped like one (a hand edit): it
|
|
40
43
|
* is never observed or executed, and is reported invalid on that job only. */
|
|
44
|
+
export function scheduleExecutionStatus(def, intent, lock) {
|
|
45
|
+
const versioned = def?.definitionVersion !== undefined || def?.recurrencePolicy !== undefined || def?.execution !== undefined || def?.preparation !== undefined;
|
|
46
|
+
let result;
|
|
47
|
+
if (!versioned) result = { kind: "legacy", capture: "unknown", migrationRequired: true };
|
|
48
|
+
else {
|
|
49
|
+
const invalid = shapeError(def);
|
|
50
|
+
if (invalid) result = { kind: "invalid", capture: "unknown", migrationRequired: true, reason: invalid };
|
|
51
|
+
else if (def.recurrencePolicy === "prepare-on-tick") result = { kind: "prepare-on-tick", capture: "not-captured", migrationRequired: false, reason: "explicit preparation runs at admission" };
|
|
52
|
+
else result = { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: def.execution.schemaVersion, contentIntegrity: executionContentIntegrity(def.execution), resolution: def.execution.resolution };
|
|
53
|
+
}
|
|
54
|
+
if (!intent) return result;
|
|
55
|
+
try {
|
|
56
|
+
const execution = validateAttemptExecution(intent, lock);
|
|
57
|
+
return { ...result, intent: execution ? { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: intent.schemaVersion, executionId: execution.executionId, resolution: execution.resolution }
|
|
58
|
+
: { kind: "legacy", capture: "unknown", migrationRequired: true } };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return { ...result, intent: { kind: "invalid", capture: "unknown", migrationRequired: true, reason: error.message, errorCode: error.code } };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
export function shapeError(def) {
|
|
42
65
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
43
66
|
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
@@ -46,6 +69,29 @@ export function shapeError(def) {
|
|
|
46
69
|
if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
|
|
47
70
|
if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
|
|
48
71
|
if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
|
|
72
|
+
if (def.definitionVersion !== undefined || def.recurrencePolicy !== undefined || def.execution !== undefined || def.preparation !== undefined) {
|
|
73
|
+
if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) return `definitionVersion must be ${SCHEDULE_DEFINITION_VERSION}`;
|
|
74
|
+
if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) return "recurrencePolicy must be capture or prepare-on-tick";
|
|
75
|
+
if (!["command", "wake"].includes(def.kind)) return "versioned execution currently supports command and captured wake definitions only";
|
|
76
|
+
if (def.kind === "wake" && def.recurrencePolicy !== "capture") return "wake definitions require capture policy";
|
|
77
|
+
if (def.recurrencePolicy === "capture") {
|
|
78
|
+
if (def.preparation !== undefined) return "capture policy cannot carry prepare-on-tick input";
|
|
79
|
+
try {
|
|
80
|
+
validateExecutionTemplate(def.execution);
|
|
81
|
+
const expected = def.kind === "command"
|
|
82
|
+
? buildCommandExecutionTemplate({ cwd: def.cwd, argv: def.argv, inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman })
|
|
83
|
+
: buildWakeExecutionTemplate({ home: def.home, message: def.message,
|
|
84
|
+
executionBinding: { schemaVersion: 1, deployment: def.execution.deployment, resolution: def.execution.resolution },
|
|
85
|
+
inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman });
|
|
86
|
+
if (executionContentIntegrity(expected).value !== executionContentIntegrity(def.execution).value) return "execution template differs from the saved target";
|
|
87
|
+
} catch (error) { return `execution template is invalid: ${error.message}`; }
|
|
88
|
+
} else {
|
|
89
|
+
if (def.execution !== undefined) return "prepare-on-tick cannot carry a pinned execution template";
|
|
90
|
+
try { validatePreparationInput(def.preparation); } catch (error) { return `preparation input is invalid: ${error.message}`; }
|
|
91
|
+
try { commandArgvWithExecutionBinding(def.argv, { schemaVersion: 1, deployment: def.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } }); }
|
|
92
|
+
catch (error) { return `prepare-on-tick command is invalid: ${error.message}`; }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
49
95
|
return null;
|
|
50
96
|
}
|
|
51
97
|
|
|
@@ -94,7 +140,7 @@ function writeJson(path, value) {
|
|
|
94
140
|
}
|
|
95
141
|
export function readDefinitions(ws) {
|
|
96
142
|
const doc = readJson(definitionsPath(ws), { version: 1, jobs: {} });
|
|
97
|
-
if (doc.version
|
|
143
|
+
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" });
|
|
98
144
|
return doc;
|
|
99
145
|
}
|
|
100
146
|
export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
|
|
@@ -238,6 +284,46 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
238
284
|
if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
|
|
239
285
|
out.operation = def.operation; out.home = resolve(def.home);
|
|
240
286
|
} else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command, wake or operation", { field: "kind" });
|
|
287
|
+
const versioned = def.definitionVersion !== undefined || def.recurrencePolicy !== undefined || def.execution !== undefined || def.preparation !== undefined;
|
|
288
|
+
if (versioned) {
|
|
289
|
+
if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("E_SCHEDULE_INVALID", `definitionVersion: ${SCHEDULE_DEFINITION_VERSION} required for captured schedules`, { field: "definitionVersion" });
|
|
290
|
+
if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) throw scheduleError("E_SCHEDULE_INVALID", "recurrencePolicy: capture or prepare-on-tick", { field: "recurrencePolicy" });
|
|
291
|
+
if (!["command", "wake"].includes(def.kind)) throw scheduleError("E_SCHEDULE_INVALID", "versioned execution currently supports command and captured wake jobs; spawn and operation await their captured consumer adapters", { field: "recurrencePolicy" });
|
|
292
|
+
if (def.kind === "wake" && def.recurrencePolicy !== "capture") throw scheduleError("E_SCHEDULE_INVALID", "versioned wake jobs require capture policy", { field: "recurrencePolicy" });
|
|
293
|
+
out.definitionVersion = SCHEDULE_DEFINITION_VERSION;
|
|
294
|
+
out.recurrencePolicy = def.recurrencePolicy;
|
|
295
|
+
if (def.recurrencePolicy === "capture") {
|
|
296
|
+
if (def.preparation !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "preparation belongs only to prepare-on-tick", { field: "preparation" });
|
|
297
|
+
let execution;
|
|
298
|
+
try {
|
|
299
|
+
const responsibleHuman = Object.hasOwn(def, "responsibleHuman") ? def.responsibleHuman : def.execution?.responsibleHuman;
|
|
300
|
+
if (responsibleHuman === undefined) throw new Error("responsibleHuman must be explicit for captured execution (null means messaging disabled)");
|
|
301
|
+
if (def.kind === "command") execution = buildCommandExecutionTemplate({ cwd: out.cwd, argv: out.argv,
|
|
302
|
+
inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
|
|
303
|
+
else {
|
|
304
|
+
const meta = readJson(join(out.home, "instance.json"), null);
|
|
305
|
+
if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding", { field: "executionBinding" });
|
|
306
|
+
validateExecutionBinding(meta.executionBinding);
|
|
307
|
+
execution = buildWakeExecutionTemplate({ home: out.home, message: out.message, executionBinding: meta.executionBinding,
|
|
308
|
+
inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
|
|
309
|
+
}
|
|
310
|
+
if (def.execution !== undefined) {
|
|
311
|
+
validateExecutionTemplate(def.execution);
|
|
312
|
+
if (executionContentIntegrity(def.execution).value !== executionContentIntegrity(execution).value) throw new Error("template differs from target, binding, inputs or responsible human");
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error.code === "migration-required") throw error;
|
|
316
|
+
throw scheduleError("E_SCHEDULE_INVALID", `execution: ${error.message}`, { field: "execution" });
|
|
317
|
+
}
|
|
318
|
+
out.execution = execution;
|
|
319
|
+
} else {
|
|
320
|
+
if (def.execution !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "execution: prepare-on-tick captures only when a future tick is admitted", { field: "execution" });
|
|
321
|
+
try {
|
|
322
|
+
out.preparation = validatePreparationInput(def.preparation);
|
|
323
|
+
commandArgvWithExecutionBinding(out.argv, { schemaVersion: 1, deployment: out.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } });
|
|
324
|
+
} catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `preparation: ${error.message}`, { field: "preparation" }); }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
241
327
|
return out;
|
|
242
328
|
}
|
|
243
329
|
|
|
@@ -305,6 +391,14 @@ export function findHomesInScope(ws, instance) {
|
|
|
305
391
|
const homes = new Set();
|
|
306
392
|
for (const root of scopeRoots(ws)) {
|
|
307
393
|
try { for (const h of findInstanceHomes(root, instance)) homes.add(resolve(h.home || h)); } catch { /* unreadable root */ }
|
|
394
|
+
// Durable homes remain discoverable after their source soul definition is
|
|
395
|
+
// removed. The live-soul index is a convenience, never reconciliation
|
|
396
|
+
// authority for an already admitted execution.
|
|
397
|
+
if (existsSync(root)) for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
398
|
+
if (!e.isDirectory()) continue;
|
|
399
|
+
const h = join(root, e.name, "instances", instance);
|
|
400
|
+
if (existsSync(join(h, "instance.json"))) homes.add(resolve(h));
|
|
401
|
+
}
|
|
308
402
|
const local = join(dirname(root), "local-agents");
|
|
309
403
|
if (existsSync(local)) for (const e of readdirSync(local, { withFileTypes: true })) { if (!e.isDirectory()) continue; const h = join(local, e.name, "instances", instance); if (existsSync(join(h, "instance.json"))) homes.add(resolve(h)); }
|
|
310
404
|
}
|
|
@@ -361,7 +455,7 @@ function launchCommand(ws, def, io) {
|
|
|
361
455
|
// caller's frozen capability snapshot (including legacy OATS_HOME).
|
|
362
456
|
// Keep host configuration such as OATS_HOME_DIR and credentials intact.
|
|
363
457
|
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
364
|
-
"OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
458
|
+
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
365
459
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
366
460
|
"OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
367
461
|
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
@@ -392,6 +486,72 @@ function launchCommand(ws, def, io) {
|
|
|
392
486
|
}
|
|
393
487
|
return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
|
|
394
488
|
}
|
|
489
|
+
|
|
490
|
+
/** Verify an immutable capsule and its current exact-artifact authorization
|
|
491
|
+
* before the scheduler acquires a slot or invokes a child. Legacy definitions
|
|
492
|
+
* retain their old release boundary; prepare-on-tick remains fail-closed until
|
|
493
|
+
* the preparation owner supplies its transaction API. */
|
|
494
|
+
function homeExecutionBinding(home) {
|
|
495
|
+
const meta = readJson(join(home, "instance.json"), null);
|
|
496
|
+
if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding");
|
|
497
|
+
try { return validateExecutionBinding(meta.executionBinding); }
|
|
498
|
+
catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `wake target executionBinding is invalid: ${error.message}`); }
|
|
499
|
+
}
|
|
500
|
+
function validateAttemptExecution(attempt, lock) {
|
|
501
|
+
if (attempt?.execution === undefined) {
|
|
502
|
+
if (attempt?.schemaVersion !== undefined) throw scheduleError("migration-required", "versioned scheduled attempt has no captured execution");
|
|
503
|
+
if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "captured job lock has no matching admitted capsule");
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
if (attempt.schemaVersion !== SCHEDULE_ATTEMPT_VERSION) throw scheduleError("migration-required", "scheduled attempt uses an unsupported version");
|
|
507
|
+
const execution = validateExecutionCapsule(attempt.execution);
|
|
508
|
+
if (lock && lock.executionId !== execution.executionId) throw scheduleError("E_SCHEDULE_INVALID", "job lock does not name the admitted execution intent");
|
|
509
|
+
return execution;
|
|
510
|
+
}
|
|
511
|
+
function validateActiveExecution(js, lock) {
|
|
512
|
+
if (lock?.unreadable) throw scheduleError("E_SCHEDULE_INVALID", "job lock custody is unreadable");
|
|
513
|
+
if (js.attempt) return validateAttemptExecution(js.attempt, lock);
|
|
514
|
+
if (lock && (js.lastRun?.execution || lock.executionId !== undefined)) {
|
|
515
|
+
return validateAttemptExecution(js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}, lock);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function receiptMatchesExecution(receipt, execution) {
|
|
519
|
+
return !!receipt?.execution && receipt.execution.executionId === execution.executionId
|
|
520
|
+
&& executionContentIntegrity(validateExecutionCapsule(receipt.execution)).value === executionContentIntegrity(execution).value;
|
|
521
|
+
}
|
|
522
|
+
function admitExecution(def, io, { createIntent = true } = {}) {
|
|
523
|
+
if (def.definitionVersion === undefined) return undefined;
|
|
524
|
+
if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("migration-required", "scheduled definition uses an unsupported execution version");
|
|
525
|
+
let execution;
|
|
526
|
+
if (def.recurrencePolicy === "prepare-on-tick") {
|
|
527
|
+
if (!createIntent) return undefined; // dry-run never prepares or publishes
|
|
528
|
+
const prepare = io && Object.hasOwn(io, "prepare") ? io.prepare : prepareCapturedComposition;
|
|
529
|
+
if (typeof prepare !== "function") throw scheduleError("migration-required", "prepare-on-tick needs the preparation transaction adapter before this tick can be admitted");
|
|
530
|
+
const request = validatePreparationInput(def.preparation);
|
|
531
|
+
const requestedDeployment = portableScope(request.deployment); // Freeze identity before preparation, including platform path aliases.
|
|
532
|
+
const prepared = prepare(request);
|
|
533
|
+
if (!prepared || typeof prepared !== "object" || !prepared.executionBinding || !Object.hasOwn(prepared, "responsibleHuman")) {
|
|
534
|
+
throw scheduleError("needs-configuration", "preparation did not produce a complete executionBinding and responsibleHuman", { problems: prepared?.problems || [] });
|
|
535
|
+
}
|
|
536
|
+
const binding = validateExecutionBinding(prepared.executionBinding);
|
|
537
|
+
let sameDeployment = false;
|
|
538
|
+
try { sameDeployment = portableScope(binding.deployment) === requestedDeployment && portableScope(request.deployment) === requestedDeployment; } catch { /* absent/changed scope is not the admitted deployment */ }
|
|
539
|
+
if (!sameDeployment) throw scheduleError("E_SCHEDULE_INVALID", "prepared executionBinding changed the requested deployment");
|
|
540
|
+
binding.deployment = requestedDeployment; // Persist canonical authority, not a retargetable spelling.
|
|
541
|
+
if (prepared.resolution && prepared.resolution.id !== binding.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "prepared resolution differs from executionBinding");
|
|
542
|
+
const argv = commandArgvWithExecutionBinding(def.argv, binding);
|
|
543
|
+
execution = buildCommandExecutionTemplate({ cwd: def.cwd, argv, inputRefs: prepared.inputRefs ?? {}, responsibleHuman: prepared.responsibleHuman });
|
|
544
|
+
} else execution = validateExecutionTemplate(def.execution);
|
|
545
|
+
const action = capturedDispatchAction(execution);
|
|
546
|
+
if (action.kind === "wake") {
|
|
547
|
+
const binding = homeExecutionBinding(execution.target.home);
|
|
548
|
+
if (binding.deployment !== execution.deployment || binding.resolution.id !== execution.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "wake target executionBinding differs from its captured template");
|
|
549
|
+
}
|
|
550
|
+
const request = { deployment: execution.deployment, resolution: execution.resolution, action };
|
|
551
|
+
(io?.admit || loadCapturedDispatch)(request);
|
|
552
|
+
if (action.kind === "wake") throw scheduleError("migration-required", "captured wake lifecycle dispatch is not integrated yet");
|
|
553
|
+
return createIntent ? admitExecutionTemplate(execution) : undefined;
|
|
554
|
+
}
|
|
395
555
|
/** One wake. `startIfStopped` false between due minutes (a harness that
|
|
396
556
|
* keeps exiting is not restarted every minute); `canStart` false when the
|
|
397
557
|
* host bound is reached (a new runtime takes a slot; a delivery does not). */
|
|
@@ -437,9 +597,10 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
437
597
|
* marker) or its home is gone: a persistent home outliving its process
|
|
438
598
|
* does not keep a slot. */
|
|
439
599
|
function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
440
|
-
if (shapeError(def)) return;
|
|
441
|
-
const lr = js.lastRun;
|
|
442
600
|
const lock = jobLockInfo(ws, id);
|
|
601
|
+
validateActiveExecution(js, lock); // No observation may erase invalid custody.
|
|
602
|
+
if (js.attempt || shapeError(def)) return; // Unconfirmed effects need reconcile.
|
|
603
|
+
const lr = js.lastRun;
|
|
443
604
|
const home = def.kind === "wake" ? (lock?.home || lr?.home || def.home) : (lock?.home || lr?.home);
|
|
444
605
|
const tracked = def.kind === "wake" ? !!lock : !!home && ["launched", "active", "starting", "stopped", "unknown"].includes(lr?.outcome);
|
|
445
606
|
if (!tracked || !home) return;
|
|
@@ -485,11 +646,29 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
485
646
|
for (const id of order) {
|
|
486
647
|
const def = defs.jobs[id];
|
|
487
648
|
const js = stateOf(id);
|
|
649
|
+
validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
|
|
488
650
|
let due, cronError;
|
|
489
651
|
const shape = shapeError(def);
|
|
490
652
|
if (shape) { cronError = shape; due = false; }
|
|
491
653
|
else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
|
|
492
654
|
if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}` }; record(id, "invalid", { error: cronError }); continue; }
|
|
655
|
+
// A captured wake is bound to the target home's exact executionBinding,
|
|
656
|
+
// but remains gated until lifecycle exposes captured wake dispatch. Keep
|
|
657
|
+
// this before both pending delivery and due-start branches so neither can
|
|
658
|
+
// fall through to the legacy home path.
|
|
659
|
+
const versionedWakeDue = def.kind === "wake" && def.definitionVersion !== undefined && !dryRun
|
|
660
|
+
&& (def.enabled || only) && ((js.pendingWake && !only) || only || (due && js.lastAttemptedMinute !== key));
|
|
661
|
+
if (versionedWakeDue) {
|
|
662
|
+
let error;
|
|
663
|
+
try { admitExecution(def, io, { createIntent: false }); } catch (caught) { error = caught; }
|
|
664
|
+
if (error) {
|
|
665
|
+
if (only || due) js.lastAttemptedMinute = key;
|
|
666
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: "wake", launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
|
|
667
|
+
record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
throw scheduleError("migration-required", "captured wake unexpectedly passed its lifecycle integration gate");
|
|
671
|
+
}
|
|
493
672
|
if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
|
|
494
673
|
const dueNow = due && js.lastAttemptedMinute !== key;
|
|
495
674
|
const held = !!jobLockInfo(ws, id);
|
|
@@ -507,12 +686,24 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
507
686
|
if (js.lastAttemptedMinute === key && !only) continue;
|
|
508
687
|
if (!due) continue;
|
|
509
688
|
if (dryRun) {
|
|
510
|
-
|
|
511
|
-
|
|
689
|
+
let admission;
|
|
690
|
+
if (!js.attempt && !jobLockInfo(ws, id) && (def.kind === "wake" || liveLocks(wsAll) < bound)) {
|
|
691
|
+
try { admitExecution(def, io, { createIntent: false }); } catch (error) { admission = error; }
|
|
692
|
+
}
|
|
693
|
+
const reason = js.attempt ? "an earlier launch has no recorded result; run oats schedule reconcile" : jobLockInfo(ws, id) && def.kind !== "wake" ? "still running" : liveLocks(wsAll) >= bound && def.kind !== "wake" ? "host busy" : admission ? `admission blocked (${admission.code || "error"}): ${admission.message}` : undefined;
|
|
694
|
+
record(id, reason ? (admission ? "blocked" : "skipped") : "due", reason ? { reason, ...(admission ? { errorCode: admission.code } : {}) } : {});
|
|
512
695
|
continue;
|
|
513
696
|
}
|
|
514
697
|
js.lastAttemptedMinute = key;
|
|
515
|
-
if (js.attempt) {
|
|
698
|
+
if (js.attempt) {
|
|
699
|
+
let invalid;
|
|
700
|
+
try { validateAttemptExecution(js.attempt, jobLockInfo(ws, id)); } catch (error) { invalid = error; }
|
|
701
|
+
record(id, invalid ? "invalid" : "skipped", { reason: invalid ? `admitted attempt is invalid (${invalid.code || "error"}): ${invalid.message}` : "an earlier launch has no recorded result; run oats schedule reconcile", ...(invalid ? { errorCode: invalid.code } : {}) });
|
|
702
|
+
if (!js.attempt.execution || receiptMatchesExecution(js.lastRun, js.attempt.execution)) {
|
|
703
|
+
js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: invalid ? `invalid admitted attempt: ${invalid.message}` : "launch attempt without a recorded result", ...(invalid ? { errorCode: invalid.code } : {}), scheduledFor: js.attempt.scheduledFor };
|
|
704
|
+
}
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
516
707
|
if (def.kind === "wake") {
|
|
517
708
|
// A delivery to a running session takes no slot; starting a stopped
|
|
518
709
|
// one does, and the slot is held (the job lock) until that home ends.
|
|
@@ -527,12 +718,26 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
527
718
|
}
|
|
528
719
|
if (jobLockInfo(ws, id)) { record(id, "skipped", { reason: "still running" }); continue; }
|
|
529
720
|
if (liveLocks(wsAll) >= bound) { record(id, "skipped", { reason: "host busy" }); continue; }
|
|
530
|
-
|
|
531
|
-
|
|
721
|
+
let execution;
|
|
722
|
+
try { execution = admitExecution(def, io); }
|
|
723
|
+
catch (error) {
|
|
724
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: def.kind, launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
|
|
725
|
+
record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
// Persist the admitted authority before even reserving the launch slot.
|
|
729
|
+
// A crash in the following gap leaves an inspectable unresolved attempt,
|
|
730
|
+
// never an ownerless lock whose intended software cannot be recovered.
|
|
731
|
+
js.attempt = { ...(execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION } : {}), scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString(), ...(execution ? { execution } : {}) };
|
|
732
|
+
writeState(ws, st);
|
|
733
|
+
if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), ...(execution ? { executionId: execution.executionId } : {}) })) {
|
|
734
|
+
delete js.attempt; writeState(ws, st); record(id, "skipped", { reason: "still running" }); continue;
|
|
735
|
+
}
|
|
532
736
|
js.lastLaunchedAt = now.toISOString();
|
|
533
737
|
writeState(ws, st);
|
|
534
738
|
let run;
|
|
535
|
-
|
|
739
|
+
const admitted = execution ? { ...def, cwd: execution.target.cwd, argv: execution.target.argv } : def;
|
|
740
|
+
try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : admitted, io); }
|
|
536
741
|
catch (e) {
|
|
537
742
|
// spawnInstance compensates its own failures, but its rollback can be
|
|
538
743
|
// INCOMPLETE (a pane it could not stop, a quarantined home it kept):
|
|
@@ -542,13 +747,13 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
542
747
|
}
|
|
543
748
|
if (run.unconfirmed) {
|
|
544
749
|
// Side effects unconfirmed: keep the attempt and the slot; reconcile decides.
|
|
545
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
|
|
750
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...(execution ? { execution } : {}), ...run, outcome: "unknown" };
|
|
546
751
|
record(id, "unknown", { error: run.error });
|
|
547
752
|
continue;
|
|
548
753
|
}
|
|
549
754
|
delete js.attempt;
|
|
550
755
|
const outcome = run.launched ? "launched" : "launch-failed";
|
|
551
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome };
|
|
756
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...(execution ? { execution } : {}), ...run, outcome };
|
|
552
757
|
if (!run.launched || !run.home) releaseJobLock(ws, id);
|
|
553
758
|
record(id, outcome, { ...(run.error ? { error: run.error } : {}), ...(run.instance ? { instance: run.instance } : {}) });
|
|
554
759
|
}
|
|
@@ -621,29 +826,40 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
621
826
|
return withHostLock(() => {
|
|
622
827
|
const st = readState(ws);
|
|
623
828
|
const js = st.jobs[id] || (st.jobs[id] = {});
|
|
624
|
-
const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt } : undefined);
|
|
829
|
+
const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { ...(js.lastRun.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION } : {}), scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt, ...(js.lastRun.execution ? { execution: js.lastRun.execution } : {}) } : undefined);
|
|
625
830
|
if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
|
|
831
|
+
try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
|
|
832
|
+
catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `admitted execution capsule is invalid: ${error.message}`); }
|
|
833
|
+
const attemptKind = attempt.execution?.action.kind || def.kind;
|
|
626
834
|
let adopted, ambiguous;
|
|
627
|
-
if (
|
|
835
|
+
if (attemptKind === "spawn" && attempt.scheduledFor) {
|
|
628
836
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(new Date(attempt.scheduledFor))}`;
|
|
629
837
|
const homes = findHomesInScope(ws, `${def.agent}-${purpose}`);
|
|
630
838
|
if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
|
|
631
839
|
else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
|
|
632
|
-
} else if (
|
|
633
|
-
|
|
840
|
+
} else if (attemptKind === "command" || attemptKind === "operation") {
|
|
841
|
+
let named = [];
|
|
842
|
+
if (attempt.execution) {
|
|
843
|
+
// Only this admission's receipt may establish effects. Never borrow the
|
|
844
|
+
// previous run's name or consult current-config roster discovery.
|
|
845
|
+
if (receiptMatchesExecution(js.lastRun, attempt.execution) && typeof js.lastRun.home === "string" && isAbsolute(js.lastRun.home)) {
|
|
846
|
+
const home = resolve(js.lastRun.home), meta = readJson(join(home, "instance.json"), null);
|
|
847
|
+
if (meta?.instance === js.lastRun.instance && typeof meta.home === "string" && resolve(meta.home) === home) named = [{ instance: meta.instance, home }];
|
|
848
|
+
}
|
|
849
|
+
} else if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
|
|
634
850
|
if (named.length === 1) adopted = named[0];
|
|
635
851
|
else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
|
|
636
|
-
else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven
|
|
852
|
+
else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven from a matching admitted receipt; check the roster and the host by hand, then run oats schedule reconcile ${id} --clear to record launch-failed and free the slot, or oats schedule remove --force ${id}` };
|
|
637
853
|
}
|
|
638
854
|
if (ambiguous && !clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `${ambiguous}; retire or adopt by hand, then run oats schedule reconcile ${id} --clear or oats schedule remove --force ${id}` };
|
|
639
855
|
if (ambiguous) adopted = undefined;
|
|
640
856
|
delete js.attempt;
|
|
641
857
|
if (adopted) {
|
|
642
858
|
const seen = observeHome(adopted.home, io);
|
|
643
|
-
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind:
|
|
644
|
-
if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true }); } else releaseJobLock(ws, id);
|
|
859
|
+
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, ...(attempt.execution ? { execution: attempt.execution } : {}), launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
|
|
860
|
+
if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true, ...(attempt.execution ? { executionId: attempt.execution.executionId } : {}) }); } else releaseJobLock(ws, id);
|
|
645
861
|
} else {
|
|
646
|
-
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind:
|
|
862
|
+
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, ...(attempt.execution ? { execution: attempt.execution } : {}), launched: false, outcome: "launch-failed", error: clear ? "cleared by the operator after checking by hand" : "no instance found in the scope's roster for the unrecorded attempt" };
|
|
647
863
|
releaseJobLock(ws, id);
|
|
648
864
|
}
|
|
649
865
|
writeState(ws, st);
|
|
@@ -659,7 +875,9 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
659
875
|
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
660
876
|
const js = st.jobs[id] || {};
|
|
661
877
|
let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
|
|
662
|
-
|
|
878
|
+
const lock = jobLockInfo(ws, id);
|
|
879
|
+
const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
|
|
880
|
+
return { ...def, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
|
|
663
881
|
}
|
|
664
882
|
export function listSchedules(ws, io, { now = new Date() } = {}) {
|
|
665
883
|
const defs = readDefinitions(ws), st = readState(ws);
|
|
@@ -670,6 +888,8 @@ export function addSchedule(ws, spec, io) {
|
|
|
670
888
|
return withScopeLock(ws, () => {
|
|
671
889
|
const defs = readDefinitions(ws);
|
|
672
890
|
if (defs.jobs[def.id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${def.id} already exists in ${ws}`);
|
|
891
|
+
if (defs.version === 2 && def.definitionVersion === undefined) throw scheduleError("migration-required", "version 2 schedule files require an explicit recurrence policy for new definitions");
|
|
892
|
+
if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
|
|
673
893
|
const now = new Date().toISOString();
|
|
674
894
|
defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
|
|
675
895
|
writeDefinitions(ws, defs);
|
|
@@ -685,9 +905,11 @@ export function updateSchedule(ws, id, spec, io) {
|
|
|
685
905
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
686
906
|
const defs = readDefinitions(ws);
|
|
687
907
|
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
688
|
-
|
|
908
|
+
if (defs.jobs[id].definitionVersion !== undefined && def.definitionVersion === undefined) throw scheduleError("migration-required", "a versioned schedule definition cannot be downgraded to legacy execution");
|
|
909
|
+
if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
|
|
910
|
+
const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation, d.definitionVersion, d.recurrencePolicy, d.execution, d.preparation]);
|
|
689
911
|
const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
|
|
690
|
-
if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its
|
|
912
|
+
if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its execution identity, kind, target and captured policy cannot change until it ends (cron, tz and enabled can)`);
|
|
691
913
|
defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
|
|
692
914
|
writeDefinitions(ws, defs);
|
|
693
915
|
return describe(ws, id, io);
|
|
@@ -708,27 +930,26 @@ export function removeSchedule(ws, id, { force = false } = {}) {
|
|
|
708
930
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
709
931
|
const defs = readDefinitions(ws);
|
|
710
932
|
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
711
|
-
|
|
933
|
+
const st = readState(ws), job = st.jobs[id];
|
|
934
|
+
if ((jobLockInfo(ws, id) || job?.attempt || job?.lastRun?.outcome === "unknown") && !force) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has unresolved effects; reconcile first, or --force forgets the job without stopping anything`);
|
|
712
935
|
delete defs.jobs[id];
|
|
713
936
|
writeDefinitions(ws, defs);
|
|
714
|
-
|
|
937
|
+
delete st.jobs[id]; writeState(ws, st);
|
|
715
938
|
releaseJobLock(ws, id);
|
|
716
939
|
return { removed: id };
|
|
717
940
|
}));
|
|
718
941
|
}
|
|
719
942
|
/** Save the wake job for a freshly spawned instance (id wake-<instance>). */
|
|
720
|
-
export function saveWakeForHome(ws, { instance, home, wake }) {
|
|
943
|
+
export function saveWakeForHome(ws, { instance, home, wake, executionBinding, responsibleHuman }) {
|
|
721
944
|
const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
|
|
722
945
|
const spec = { id, enabled: wake.enabled === undefined ? true : wake.enabled, cron: wake.cron, tz: wake.tz, kind: "wake", home, message: wake.message };
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
return describe(ws, id);
|
|
731
|
-
});
|
|
946
|
+
if (executionBinding !== undefined || responsibleHuman !== undefined) {
|
|
947
|
+
if (executionBinding === undefined || responsibleHuman === undefined) throw scheduleError("needs-configuration", "captured wake creation needs both executionBinding and responsibleHuman");
|
|
948
|
+
const binding = validateExecutionBinding(executionBinding), actual = homeExecutionBinding(home);
|
|
949
|
+
if (binding.deployment !== actual.deployment || binding.resolution.id !== actual.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "spawn result executionBinding differs from the new home");
|
|
950
|
+
Object.assign(spec, { definitionVersion: SCHEDULE_DEFINITION_VERSION, recurrencePolicy: "capture", responsibleHuman });
|
|
951
|
+
}
|
|
952
|
+
return addSchedule(ws, spec); // Same whole-file version/policy gate as manual add.
|
|
732
953
|
}
|
|
733
954
|
/** Retirement hook: wake definitions bound to this home are removed (nothing is stopped). */
|
|
734
955
|
export function removeWakeForHome(ws, home) {
|
package/lib/servers.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { createHash } from "node:crypto";
|
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join, resolve } from "node:path";
|
|
24
|
+
import { parseStrictJson } from "./portable-values.mjs";
|
|
25
|
+
import { capturedSelector } from "./captured-selector.mjs";
|
|
24
26
|
|
|
25
27
|
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
26
28
|
export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
|
|
@@ -233,6 +235,7 @@ export function checkRemote(target, io = {}) {
|
|
|
233
235
|
remote: list("remote", []),
|
|
234
236
|
features: list("features", []),
|
|
235
237
|
operationsApi: probe.operationsApi === 1 ? 1 : null,
|
|
238
|
+
scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
|
|
236
239
|
advertised: Array.isArray(probe.runtimes),
|
|
237
240
|
};
|
|
238
241
|
}
|
|
@@ -761,6 +764,18 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
|
|
|
761
764
|
const target = targetOf(server);
|
|
762
765
|
const remote = checkRemote(target, io);
|
|
763
766
|
if (!remote.features.includes("schedule")) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise schedules; upgrade it there`);
|
|
767
|
+
if (["add", "update"].includes(oatsArgs[0])) {
|
|
768
|
+
const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
|
|
769
|
+
if (indexes.length > 1) throw serverError("E_BAD_ARGS", "remote schedule spec must be unambiguous");
|
|
770
|
+
let versioned = oatsArgs.includes("--file"); // Cannot classify remote file bytes here.
|
|
771
|
+
if (indexes.length) {
|
|
772
|
+
const spec = parseStrictJson(oatsArgs[indexes[0] + 1]);
|
|
773
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw serverError("E_BAD_ARGS", "remote schedule spec must be an object");
|
|
774
|
+
versioned ||= ["definitionVersion", "recurrencePolicy", "execution", "preparation"].some(key => Object.hasOwn(spec, key));
|
|
775
|
+
if (Array.isArray(spec.argv)) versioned ||= !!capturedSelector(spec.argv.slice(1), {});
|
|
776
|
+
}
|
|
777
|
+
if (versioned && remote.scheduleApi !== 2) throw serverError("E_REMOTE_INCOMPATIBLE", "captured schedule mutation requires advertised scheduleApi 2; no request was forwarded");
|
|
778
|
+
}
|
|
764
779
|
const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
|
|
765
780
|
const { envelope, stderr } = runRemote(target, args, io);
|
|
766
781
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId } } : envelope, stderr, target };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Source-derived hard field constraints, shared by preparation and verification.
|
|
2
|
+
* No defaults are resolved here and no source/artifact is acquired. */
|
|
3
|
+
import { invalidShape, pointerKey } from "./portable-shape.mjs";
|
|
4
|
+
import { validateRepoAnchor } from "./source-spec.mjs";
|
|
5
|
+
import { FUNDAMENTAL_SLOTS } from "./portable-policy.mjs";
|
|
6
|
+
|
|
7
|
+
export const capabilityChoiceKey = (id) => `/capabilities/${pointerKey(id)}`;
|
|
8
|
+
export const settingChoiceKey = (id, name) => `/settings/${pointerKey(id)}/${pointerKey(name)}`;
|
|
9
|
+
export const layerChoiceKey = (slot) => `/layers/${slot}`;
|
|
10
|
+
|
|
11
|
+
/** A repo: relation belongs to the declaring source, not the consumer's cwd or
|
|
12
|
+
* another authority's identically spelled relative path. Operator inputs need
|
|
13
|
+
* an explicit source context because their origin is not a repository document. */
|
|
14
|
+
export function sourceSelectionValue(id, parsed, pointer, sourceContext) {
|
|
15
|
+
const source = parsed.sources[`${pointer}/source`];
|
|
16
|
+
if (!source) invalidShape(`${pointer}/source`, "missing normalized selection source");
|
|
17
|
+
if (source.kind !== "repo") return { capability: id, source };
|
|
18
|
+
const anchor = sourceContext ?? parsed.origins[`${pointer}/source`]?.document;
|
|
19
|
+
if (!anchor || typeof anchor.source !== "string") invalidShape(`${pointer}/source`, "repo source needs an explicit declaring repository snapshot", "needs-configuration");
|
|
20
|
+
const snapshot = validateRepoAnchor({ source: anchor.source, revision: anchor.revision });
|
|
21
|
+
return { capability: id, source: { ...source, anchor: snapshot } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function soulConstraints(parsed) {
|
|
25
|
+
const result = [], source = parsed.declaration.requires ?? {};
|
|
26
|
+
const origin = (pointer) => ({ ...parsed.origins[pointer], kind: "soul-requirement" });
|
|
27
|
+
const selection = (id, item, pointer, key) => {
|
|
28
|
+
result.push({ key, kind: "equals", value: sourceSelectionValue(id, parsed, pointer), origin: origin(pointer) });
|
|
29
|
+
for (const [name, value] of Object.entries(item.settings ?? {})) {
|
|
30
|
+
result.push({ key: settingChoiceKey(id, name), kind: "equals", value, origin: origin(`${pointer}/settings/${pointerKey(name)}`) });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
for (const [id, item] of Object.entries(source.capabilities ?? {})) selection(id, item, `/requires/capabilities/${pointerKey(id)}`, capabilityChoiceKey(id));
|
|
34
|
+
for (const slot of FUNDAMENTAL_SLOTS) {
|
|
35
|
+
if (!Object.hasOwn(source, slot)) continue;
|
|
36
|
+
if (source[slot] === "any") result.push({ key: layerChoiceKey(slot), kind: "required", origin: origin(`/requires/${slot}`) });
|
|
37
|
+
else selection(source[slot].capability, source[slot], `/requires/${slot}`, layerChoiceKey(slot));
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|