@awebai/oats 0.27.2 → 0.29.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/bin/oats.mjs +445 -96
- package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
- package/capabilities/oats-okf/injects/okf.md +36 -28
- package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
- package/capabilities/oats-okf/lib/config.mjs +6 -1
- package/capabilities/oats-okf/lib/consult.mjs +496 -0
- package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
- package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
- package/capabilities/oats-okf/lib/inspection.mjs +11 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -2
- package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
- package/capabilities/oats-okf/lib/sources.mjs +42 -55
- package/capabilities/oats-okf/lib/stores.mjs +19 -11
- package/capabilities/oats-okf/lib/worker.mjs +90 -8
- package/capabilities/oats-okf/oats.json +24 -9
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
- package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
- package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
- package/capabilities/oats-okf-harvest/oats.json +26 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
- package/capabilities/oats-okf-maintenance/oats.json +21 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
- package/capabilities/oats-review/injects/review.md +3 -2
- package/capabilities/oats-review/oats.json +3 -4
- package/docs/capabilities.md +41 -9
- package/docs/capability-manifest.schema.json +0 -7
- package/docs/design/2026-09-24-phase-d-plan.md +11 -0
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
- package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
- package/docs/desktop-cli-api.md +342 -10
- package/docs/implementation.md +1 -1
- package/docs/knowledge-capability-authoring.md +8 -2
- package/docs/knowledge-reference/package-craft.md +8 -5
- package/docs/knowledge.md +101 -0
- package/docs/oats-local.schema.json +33 -2
- package/docs/oats-package.schema.json +39 -0
- package/docs/official-catalog.md +7 -4
- package/docs/packages.md +76 -6
- package/docs/release-lane.md +1 -1
- package/docs/release-notes/v0.28.0.md +144 -0
- package/docs/release-notes/v0.29.0.md +240 -0
- package/docs/schedules.md +230 -4
- package/docs/souls-and-instances.md +11 -9
- package/docs/workspaces.md +18 -3
- package/lib/automations.mjs +369 -0
- package/lib/core.mjs +87 -158
- package/lib/instance-inspect.mjs +16 -8
- package/lib/instance-resolution.mjs +90 -197
- package/lib/materialize.mjs +18 -7
- package/lib/operator-dispatch.mjs +1 -2
- package/lib/packages.mjs +107 -6
- package/lib/remote.mjs +21 -1
- package/lib/resolve.mjs +71 -9
- package/lib/schedule.mjs +228 -45
- package/lib/triggers.mjs +678 -0
- package/lib/workspace.mjs +81 -4
- package/package-catalog.json +6 -4
- package/package.json +1 -1
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
- package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
- /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/lib/schedule.mjs
CHANGED
|
@@ -29,6 +29,9 @@ import { fileURLToPath } from "node:url";
|
|
|
29
29
|
import { Cron } from "croner";
|
|
30
30
|
import { loadLocal } from "./workspace.mjs";
|
|
31
31
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
32
|
+
import { tickTriggers } from "./triggers.mjs";
|
|
33
|
+
import { resolveMemberClone } from "./instance-resolution.mjs";
|
|
34
|
+
import { AUTOMATION_ID_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
|
|
32
35
|
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
33
36
|
|
|
34
37
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
@@ -65,6 +68,7 @@ const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/
|
|
|
65
68
|
export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
|
|
66
69
|
export function shapeError(def) {
|
|
67
70
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
71
|
+
if (def.kind === "trigger") return "a trigger is managed by oats trigger, not oats schedule";
|
|
68
72
|
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
69
73
|
if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
|
|
70
74
|
if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
|
|
@@ -246,7 +250,7 @@ export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join
|
|
|
246
250
|
/** Registry read-modify-write is serialized on its own short lock. */
|
|
247
251
|
function withRegistryLock(fn) { return withDirLock(join(hostScheduleDir(), "registry.lock"), "the host schedule registry", fn, { retryMs: 3000 }); }
|
|
248
252
|
/** Definitions and state are read-modify-write; CRUD serializes here briefly. */
|
|
249
|
-
function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
|
|
253
|
+
export function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
|
|
250
254
|
|
|
251
255
|
export function acquireJobLock(ws, id, owner) {
|
|
252
256
|
const dir = lockDir(ws, id);
|
|
@@ -288,6 +292,7 @@ function validateMessage(message, field) {
|
|
|
288
292
|
/** Validate and normalize one definition against its scope. */
|
|
289
293
|
export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
290
294
|
if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
|
|
295
|
+
if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_INVALID", "kind: a trigger is added with `oats trigger add`, not `oats schedule add`", { field: "kind" });
|
|
291
296
|
const id = def.id;
|
|
292
297
|
if (typeof id !== "string" || !ID_RE.test(id)) throw scheduleError("E_SCHEDULE_INVALID", "id: lowercase letters, digits and dashes, 1 to 40 characters", { field: "id" });
|
|
293
298
|
const enabled = def.enabled === undefined ? true : def.enabled;
|
|
@@ -316,6 +321,7 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
316
321
|
noteRuntimeName(`schedule ${id}: runtime (written as harness)`);
|
|
317
322
|
def = { ...def, harness: def.runtime };
|
|
318
323
|
}
|
|
324
|
+
if (def.launchConfig !== undefined) { if (typeof def.launchConfig !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(def.launchConfig)) throw scheduleError("E_SCHEDULE_INVALID", "launchConfig: the name of a launch configuration on the running host (oats-local.yaml launch-configs)", { field: "launchConfig" }); out.launchConfig = def.launchConfig; }
|
|
319
325
|
if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
|
|
320
326
|
if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
|
|
321
327
|
if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
|
|
@@ -351,11 +357,65 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
351
357
|
return out;
|
|
352
358
|
}
|
|
353
359
|
|
|
360
|
+
/** The filesystem-safe key a workspace schedule is stored under in the local run state (job locks
|
|
361
|
+
* are directories named by it): `<member>~<id>`. Repository names never carry `~`; local ids
|
|
362
|
+
* (SCHEDULE_ID_RE) never do either. */
|
|
363
|
+
export const scheduleStateKey = (qid) => String(qid).replace("/", "~");
|
|
364
|
+
/** The jobs a tick, run or reconcile works on: the deployment's local jobs (stored under their
|
|
365
|
+
* bare id) and, from the automations context (its `schedules`), the workspace schedules THIS host runs (stored
|
|
366
|
+
* under scheduleStateKey(<member>/<id>), a filesystem-safe key: job locks are directories). */
|
|
367
|
+
export function effectiveDefinitions(ws, ctx) {
|
|
368
|
+
const defs = readDefinitions(ws);
|
|
369
|
+
for (const a of ctx?.schedules || []) {
|
|
370
|
+
if (!a.placement.runsHere) continue;
|
|
371
|
+
const key = scheduleStateKey(a.id);
|
|
372
|
+
defs.jobs[key] = { ...a.definition, id: key, enabled: true, purpose: a.definition.purpose ?? a.name, workspace: { qid: a.id, name: a.name, member: a.member, origin: a.origin, runsOn: a.runsOn, owner: a.owner } };
|
|
373
|
+
}
|
|
374
|
+
return defs;
|
|
375
|
+
}
|
|
376
|
+
/** The stored key of a schedule argument (`local/<id>`, a bare local id, or `<member>/<id>`). */
|
|
377
|
+
export function scheduleKeyOf(id) { const { scope, name } = splitId(id); return scope === "local" ? name : scheduleStateKey(`${scope}/${name}`); }
|
|
378
|
+
/** A workspace schedule file (0.29.0; the shared header is lib/automations.mjs's):
|
|
379
|
+
* `oats-schedules/<id>.yaml` or `*.oats-schedule.yaml`, `kind: oats-schedule`. Its body is the
|
|
380
|
+
* run (`run: spawn | command`, cron, tz, …); a wake or an operation targets an instance home on
|
|
381
|
+
* one machine and stays local. `expand` checks it against the deployment `dep`. */
|
|
382
|
+
export function scheduleKind({ dep = null } = {}) {
|
|
383
|
+
return {
|
|
384
|
+
kind: "schedule", folder: "oats-schedules", suffix: "oats-schedule", fileKind: "oats-schedule",
|
|
385
|
+
bodyKeys: ["run", "cron", "tz", "agent", "task", "purpose", "launchConfig", "harness", "model", "yolo", "backend", "wake", "argv", "cwd"],
|
|
386
|
+
parseBody: (body) => {
|
|
387
|
+
const bad = (message) => Object.assign(new Error(message), { field: "run" });
|
|
388
|
+
const { run = "spawn", ...rest } = body;
|
|
389
|
+
if (run === "wake" || run === "operation") throw bad(`run: ${run} targets an instance home on one machine; declare it locally (oats schedule add), not in Git`);
|
|
390
|
+
if (run !== "spawn" && run !== "command") throw bad("run: spawn or command");
|
|
391
|
+
return { definition: { ...rest, kind: run } };
|
|
392
|
+
},
|
|
393
|
+
expand: (a) => validateWorkspaceSchedule(dep, a),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/** A workspace schedule, as a validated kernel definition for the snapshot: the file's run facts
|
|
397
|
+
* checked like `oats schedule add` (the soul is resolved when it runs, through discovery); a
|
|
398
|
+
* command's cwd is relative to the deployment. */
|
|
399
|
+
export function validateWorkspaceSchedule(dep, a) {
|
|
400
|
+
const d = a.source?.definition || {};
|
|
401
|
+
const spec = { ...d, id: a.name, enabled: true };
|
|
402
|
+
if (spec.kind === "command") {
|
|
403
|
+
if (d.cwd !== undefined && (typeof d.cwd !== "string" || isAbsolute(d.cwd))) throw scheduleError("E_SCHEDULE_INVALID", "cwd: a directory relative to the deployment (a workspace schedule never names a machine path)", { field: "cwd" });
|
|
404
|
+
spec.cwd = resolve(dep, d.cwd ?? ".");
|
|
405
|
+
}
|
|
406
|
+
const out = validateDefinition(dep, spec, { checkAgent: false });
|
|
407
|
+
delete out.enabled;
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
354
411
|
/** The soul a spawn job launches, under the deployment's one agents root
|
|
355
412
|
* (a definition's agentsRoot, when given, must be that root). `repo` is the
|
|
356
413
|
* work repository and never selects a soul. */
|
|
357
414
|
export function resolveScheduledAgent(ws, def) {
|
|
358
415
|
const [root] = scopeRoots(ws);
|
|
416
|
+
// A workspace schedule (0.29.0) names its soul as `oats spawn <soul>` does: discovery resolves
|
|
417
|
+
// it (a member or package soul), whether or not this deployment has spawned it before.
|
|
418
|
+
if (def.workspace) return { root, agent: { name: def.agent }, discover: true };
|
|
359
419
|
if (def.agentsRoot && resolve(def.agentsRoot) !== root) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${def.agentsRoot} is not this deployment's agents root (${root})`, { field: "agentsRoot" });
|
|
360
420
|
const agent = findAgent(root, def.agent);
|
|
361
421
|
if (!agent) throw scheduleError("E_SCHEDULE_INVALID", `agent: no soul named ${JSON.stringify(def.agent)} under ${root}`, { field: "agent" });
|
|
@@ -424,20 +484,20 @@ export function findHomesInScope(ws, instance) {
|
|
|
424
484
|
// --------------------------------------------------------------- runner
|
|
425
485
|
|
|
426
486
|
function scheduleBlock(def, minute) {
|
|
427
|
-
return `\n\n## Scheduled run\n\nThis instance was launched by OATS schedule "${def.id}" for ${minute.toISOString()} (cron "${def.cron}" in ${def.tz}). It is disposable: finish the task above, bring your memory files up to date, and end with \`oats retire --self\`.\n`;
|
|
487
|
+
return `\n\n## Scheduled run\n\nThis instance was launched by OATS schedule "${def.workspace?.qid ?? def.id}" for ${minute.toISOString()} (cron "${def.cron}" in ${def.tz}). It is disposable: finish the task above, bring your memory files up to date, and end with \`oats retire --self\`.\n`;
|
|
428
488
|
}
|
|
429
489
|
/** The environment a scheduler-launched oats child runs with: the caller's
|
|
430
490
|
* frozen instance identity and capability snapshot removed (a tick or run-now
|
|
431
491
|
* may be invoked from inside an unrelated instance; dispatch belongs to the
|
|
432
492
|
* job's cwd and explicit selectors, never to that caller), host configuration
|
|
433
493
|
* such as OATS_HOME_DIR and credentials kept. Shared by command and spawn jobs. */
|
|
434
|
-
function childEnv() {
|
|
494
|
+
export function childEnv() {
|
|
435
495
|
const env = { ...process.env };
|
|
436
496
|
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
437
497
|
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
438
498
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
439
499
|
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
440
|
-
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
500
|
+
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE", "OATS_TRIGGER_EVENT_FILE",
|
|
441
501
|
]) delete env[key];
|
|
442
502
|
return env;
|
|
443
503
|
}
|
|
@@ -461,7 +521,8 @@ export function workspaceSpawnContext(ws) {
|
|
|
461
521
|
* `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
|
|
462
522
|
function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
463
523
|
if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
|
|
464
|
-
const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
|
|
524
|
+
const argv = ["spawn", agent.name, "--dir", context.dir, ...(opts.discover ? [] : ["--agents-root", root]), "--purpose", opts.purpose, "--json"];
|
|
525
|
+
if (opts.launchConfig) argv.push("--launch-config", opts.launchConfig);
|
|
465
526
|
if (opts.harness) argv.push("--harness", opts.harness);
|
|
466
527
|
if (opts.model) argv.push("--model", opts.model);
|
|
467
528
|
if (opts.backend) argv.push("--backend", opts.backend);
|
|
@@ -496,9 +557,9 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
|
496
557
|
* deployment (workspace materialization: `.oats/modules`, module skills and
|
|
497
558
|
* injects). */
|
|
498
559
|
function launchSpawn(ws, def, minute, io) {
|
|
499
|
-
const { root, agent } = resolveScheduledAgent(ws, def);
|
|
560
|
+
const { root, agent, discover } = resolveScheduledAgent(ws, def);
|
|
500
561
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
|
|
501
|
-
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
|
|
562
|
+
const opts = { discover, task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, launchConfig: def.launchConfig, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
|
|
502
563
|
let r;
|
|
503
564
|
if (io?.spawn) r = io.spawn(root, agent, opts);
|
|
504
565
|
else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
|
|
@@ -656,13 +717,13 @@ function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
|
656
717
|
* reads only: no lock, state or definition is touched. Due jobs are
|
|
657
718
|
* visited least-recently-launched first so one job cannot monopolize the
|
|
658
719
|
* slot. An invalid definition is recorded on that job and the rest continue. */
|
|
659
|
-
export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun = false, only, observeOnly = false, candidates } = {}) {
|
|
720
|
+
export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun = false, only, observeOnly = false, candidates, ctx = null } = {}) {
|
|
660
721
|
const minute = minuteStart(now);
|
|
661
722
|
const key = minuteKey(minute);
|
|
662
723
|
const registry = reg || readRegistry();
|
|
663
724
|
const bound = registry.maxConcurrent || 1;
|
|
664
725
|
const wsAll = wsList || [ws];
|
|
665
|
-
const defs =
|
|
726
|
+
const defs = effectiveDefinitions(ws, ctx);
|
|
666
727
|
const st = readState(ws);
|
|
667
728
|
const considered = [];
|
|
668
729
|
const stateOf = (id) => (st.jobs[id] ||= {});
|
|
@@ -671,6 +732,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
671
732
|
// are gone), then decide launches: a job ordered earlier must not see a
|
|
672
733
|
// slot still held by a home that has already ended.
|
|
673
734
|
// `candidates` (the host tick) means the host already observed every scope.
|
|
735
|
+
// Triggers (kind "trigger") live in the same file and are evaluated by tickTriggers, not here.
|
|
736
|
+
for (const [id, def] of Object.entries(defs.jobs)) if (def && def.kind === "trigger") delete defs.jobs[id];
|
|
674
737
|
if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
|
|
675
738
|
if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
|
|
676
739
|
// Least recently LAUNCHED first: only an actual harness launch counts, a
|
|
@@ -763,6 +826,15 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
763
826
|
return considered;
|
|
764
827
|
}
|
|
765
828
|
|
|
829
|
+
/** One scope's automations context (lib/automations.mjs automationContext) with its oats-local.yaml;
|
|
830
|
+
* an unreadable oats-local.yaml means no workspace automations (the local jobs still run). */
|
|
831
|
+
export function scopeAutomations(ws, { io, now = new Date(), refresh = false, cache = new Map() } = {}) {
|
|
832
|
+
let local = null;
|
|
833
|
+
try { local = loadLocal(ws).local; } catch { local = null; }
|
|
834
|
+
const clonePathOf = (repoKey) => resolveMemberClone({ deployment: ws, soulEntry: { repoKey }, local });
|
|
835
|
+
const ctx = automationContext(ws, { local, io: { ...(io || {}), childEnv }, now, refresh, cache, clonePathOf });
|
|
836
|
+
return Object.assign(ctx, { ws, local });
|
|
837
|
+
}
|
|
766
838
|
/** The host tick: every registered scope under the host lock. */
|
|
767
839
|
export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
768
840
|
const reg = readRegistry();
|
|
@@ -774,18 +846,29 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
774
846
|
// every-minute job in the first registered scope cannot monopolize the
|
|
775
847
|
// bound. An unreadable scope is recorded and the rest continue.
|
|
776
848
|
const jobs = [];
|
|
849
|
+
// Each scope's workspace automations, placed on this host once per tick (one gh identity
|
|
850
|
+
// lookup per GitHub host), the snapshot refreshed first when it is ten minutes old.
|
|
851
|
+
const cache = new Map(), contexts = new Map();
|
|
852
|
+
for (const ws of wsList) contexts.set(ws, scopeAutomations(ws, { io, now, refresh: !dryRun, cache }));
|
|
853
|
+
for (const ctx of contexts.values()) if (ctx.refresh && !ctx.refresh.ok) considered.push({ workspace: ctx.ws, action: "error", error: `automations refresh: ${ctx.refresh.error}` });
|
|
777
854
|
for (const ws of wsList) {
|
|
855
|
+
const ctx = contexts.get(ws);
|
|
778
856
|
try {
|
|
779
|
-
considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true }));
|
|
780
|
-
const defs =
|
|
781
|
-
for (const id of Object.
|
|
857
|
+
considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true, ctx }));
|
|
858
|
+
const defs = effectiveDefinitions(ws, ctx), st = readState(ws);
|
|
859
|
+
for (const [id, def] of Object.entries(defs.jobs)) if (def?.kind !== "trigger") jobs.push({ ws, id, key: launchOrderKey(st.jobs[id]) });
|
|
782
860
|
} catch (e) { considered.push({ workspace: ws, action: "error", error: e.message }); }
|
|
783
861
|
}
|
|
784
862
|
jobs.sort((a, b) => a.key.localeCompare(b.key) || a.ws.localeCompare(b.ws) || a.id.localeCompare(b.id));
|
|
785
863
|
for (const j of jobs) {
|
|
786
|
-
try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id] })); }
|
|
864
|
+
try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id], ctx: contexts.get(j.ws) })); }
|
|
787
865
|
catch (e) { considered.push({ workspace: j.ws, id: j.id, action: "error", error: e.message }); }
|
|
788
866
|
}
|
|
867
|
+
// Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
|
|
868
|
+
for (const ws of wsList) {
|
|
869
|
+
try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws) })); }
|
|
870
|
+
catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
|
|
871
|
+
}
|
|
789
872
|
if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
|
|
790
873
|
return { tickedAt: now.toISOString(), minute: minuteKey(minuteStart(now)), considered, scheduler: schedulerStatus(undefined, io) };
|
|
791
874
|
};
|
|
@@ -794,10 +877,11 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
794
877
|
|
|
795
878
|
/** Run one job now, ignoring its cron, under the same lock and bound; the
|
|
796
879
|
* previous run is observed under the host lock before any lock is judged. */
|
|
797
|
-
export function runNow(ws,
|
|
798
|
-
const
|
|
880
|
+
export function runNow(ws, qid, { now = new Date(), io, force = false, ctx = null } = {}) {
|
|
881
|
+
const id = scheduleKeyOf(qid);
|
|
882
|
+
const defs = effectiveDefinitions(ws, ctx);
|
|
799
883
|
const def = defs.jobs[id];
|
|
800
|
-
if (!def
|
|
884
|
+
if (!def || def.kind === "trigger") throw notHere(ws, qid, ctx);
|
|
801
885
|
if (!def.enabled && !force) throw scheduleError("E_SCHEDULE_DISABLED", `schedule ${id} is disabled; enable it or pass --force`);
|
|
802
886
|
const reg = readRegistry();
|
|
803
887
|
const wsList = reg.workspaces.includes(resolve(ws)) ? reg.workspaces : [...reg.workspaces, resolve(ws)];
|
|
@@ -809,10 +893,10 @@ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
|
|
|
809
893
|
if (js.attempt) throw scheduleError("E_SCHEDULE_UNRESOLVED", `schedule ${id} has a launch attempt without a recorded result; run oats schedule reconcile ${id} first`);
|
|
810
894
|
if (def.kind !== "wake" && jobLockInfo(ws, id)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is still running`);
|
|
811
895
|
if (def.kind !== "wake" && liveLocks(wsList) >= reg.maxConcurrent) throw scheduleError("E_SCHEDULER_BUSY", `host concurrency ${reg.maxConcurrent} reached`);
|
|
812
|
-
const considered = tickWorkspace(ws, { now, io, reg, wsList, only: id });
|
|
896
|
+
const considered = tickWorkspace(ws, { now, io, reg, wsList, only: id, ctx });
|
|
813
897
|
const after = readState(ws);
|
|
814
898
|
if (after.jobs[id]) { after.jobs[id].lastAttemptedMinute = minuteKey(minuteStart(now)); writeState(ws, after); }
|
|
815
|
-
return { schedule: describe(ws,
|
|
899
|
+
return { schedule: describe(ws, qid, io, { ctx }), run: after.jobs[id]?.lastRun || null, considered };
|
|
816
900
|
});
|
|
817
901
|
}
|
|
818
902
|
|
|
@@ -821,15 +905,17 @@ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
|
|
|
821
905
|
* identity), a command attempt only the instance its answer NAMED. Nothing
|
|
822
906
|
* is inferred from file times. An unnamed command effect stays unknown
|
|
823
907
|
* until the operator has checked by hand and passes `clear`. */
|
|
824
|
-
export function reconcile(ws,
|
|
825
|
-
const
|
|
908
|
+
export function reconcile(ws, qid, { io, now = new Date(), clear = false, ctx = null } = {}) {
|
|
909
|
+
const id = scheduleKeyOf(qid);
|
|
910
|
+
const defs = effectiveDefinitions(ws, ctx);
|
|
826
911
|
const def = defs.jobs[id];
|
|
827
|
-
if (!def
|
|
912
|
+
if (!def || def.kind === "trigger") throw notHere(ws, qid, ctx);
|
|
913
|
+
const describeHere = () => describe(ws, qid, io, { ctx });
|
|
828
914
|
return withHostLock(() => {
|
|
829
915
|
const st = readState(ws);
|
|
830
916
|
const js = st.jobs[id] || (st.jobs[id] = {});
|
|
831
917
|
const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt, ...(js.lastRun.execution !== undefined ? { execution: js.lastRun.execution } : {}) } : undefined);
|
|
832
|
-
if (!attempt) return { schedule:
|
|
918
|
+
if (!attempt) return { schedule: describeHere(), reconciled: "nothing to reconcile" };
|
|
833
919
|
try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
|
|
834
920
|
catch (error) { throw scheduleError("E_SCHEDULE_INVALID", error.message); }
|
|
835
921
|
const attemptKind = def.kind;
|
|
@@ -849,9 +935,9 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
849
935
|
if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
|
|
850
936
|
if (named.length === 1) adopted = named[0];
|
|
851
937
|
else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
|
|
852
|
-
else if (!clear) return { schedule:
|
|
938
|
+
else if (!clear) return { schedule: describeHere(), 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}` };
|
|
853
939
|
}
|
|
854
|
-
if (ambiguous && !clear) return { schedule:
|
|
940
|
+
if (ambiguous && !clear) return { schedule: describeHere(), reconciled: "unknown", remedy: `${ambiguous}; retire or adopt by hand, then run oats schedule reconcile ${id} --clear or oats schedule remove --force ${id}` };
|
|
855
941
|
if (ambiguous) adopted = undefined;
|
|
856
942
|
delete js.attempt;
|
|
857
943
|
if (adopted) {
|
|
@@ -863,13 +949,61 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
863
949
|
releaseJobLock(ws, id);
|
|
864
950
|
}
|
|
865
951
|
writeState(ws, st);
|
|
866
|
-
return { schedule:
|
|
952
|
+
return { schedule: describeHere(), reconciled: adopted ? "adopted" : "cleared" };
|
|
867
953
|
});
|
|
868
954
|
}
|
|
869
955
|
|
|
870
956
|
// -------------------------------------------------------- definitions CRUD
|
|
871
957
|
|
|
872
958
|
export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
959
|
+
/** A schedule id this host cannot run: unknown, or a workspace schedule placed elsewhere. */
|
|
960
|
+
/** `oats schedule test <id>`: what running it on this host would do, without running it — its
|
|
961
|
+
* placement, whether its soul resolves (the kernel's own `oats spawn <soul> --preview`, which
|
|
962
|
+
* writes nothing) and when it is next due. Spawns nothing, records nothing, takes no lock. */
|
|
963
|
+
export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {}) {
|
|
964
|
+
const row = describe(ws, qid, io, { now, ctx });
|
|
965
|
+
const { scope, name } = splitId(qid);
|
|
966
|
+
const entry = scope === "local" ? null : (ctx?.schedules || []).find((x) => x.id === `${scope}/${name}`);
|
|
967
|
+
const def = entry ? { ...entry.definition, workspace: { qid: entry.id } } : readDefinitions(ws).jobs[name];
|
|
968
|
+
const problems = [];
|
|
969
|
+
if (row.invalid) problems.push(`${row.qualifiedId} is invalid: ${row.invalid.message}`);
|
|
970
|
+
if (!row.runsHere) problems.push(`${row.qualifiedId} does not run on this host (${row.reason ?? "disabled here"}${row.reasonDetail ? `: ${row.reasonDetail}` : ""})`);
|
|
971
|
+
if (!row.enabled) problems.push(`${row.qualifiedId} is disabled (oats schedule enable ${row.qualifiedId})`);
|
|
972
|
+
// The next cron match, whether or not this host runs it (placement says that).
|
|
973
|
+
let nextDue = null; try { nextDue = def?.cron ? nextRunAfter(def, now) : null; } catch (e) { problems.push(`cron: ${e.message}`); }
|
|
974
|
+
let soul = null;
|
|
975
|
+
if (def?.kind === "spawn") {
|
|
976
|
+
soul = { name: def.agent, origin: row.soul?.origin ?? null, resolves: false, error: null };
|
|
977
|
+
try {
|
|
978
|
+
const { root, discover } = resolveScheduledAgent(ws, def);
|
|
979
|
+
const context = workspaceSpawnContext(ws);
|
|
980
|
+
if (context.error) throw scheduleError(context.error.code, context.error.message);
|
|
981
|
+
const argv = ["spawn", def.agent, "--dir", context.dir, ...(discover ? [] : ["--agents-root", root]), "--preview", "--json"];
|
|
982
|
+
if (def.launchConfig) argv.push("--launch-config", def.launchConfig);
|
|
983
|
+
if (def.harness) argv.push("--harness", def.harness);
|
|
984
|
+
if (def.model) argv.push("--model", def.model);
|
|
985
|
+
const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
|
|
986
|
+
const envelope = parseEnvelopeText(r.stdout);
|
|
987
|
+
if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
|
|
988
|
+
if (!envelope.ok) throw scheduleError(envelope.error?.code || "E_SPAWN_FAILED", String(envelope.error?.message || "spawn preview failed"));
|
|
989
|
+
soul.resolves = true;
|
|
990
|
+
} catch (e) {
|
|
991
|
+
soul.error = { code: e.code || "E_SPAWN_FAILED", message: e.message };
|
|
992
|
+
problems.push(`soul ${def.agent} does not resolve: ${e.message}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
id: row.id, qualifiedId: row.qualifiedId, kind: row.kind,
|
|
997
|
+
placement: { runsHere: row.runsHere, reason: row.reason, ...(row.reasonDetail ? { reasonDetail: row.reasonDetail } : {}), enabledHere: row.enabledHere, runsOn: row.runsOn, owner: row.owner, host: ctx?.host?.name ?? null },
|
|
998
|
+
soul, nextDue, spawned: false, problems, ok: problems.length === 0,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function notHere(ws, qid, ctx) {
|
|
1002
|
+
const { scope } = splitId(qid);
|
|
1003
|
+
const a = scope !== "local" ? (ctx?.schedules || []).find((x) => x.id === qid) : null;
|
|
1004
|
+
if (a) return automationError("E_AUTOMATION_NOT_HERE", `${qid} does not run on this host (${a.placement.reason ?? (a.invalid ? "invalid" : "disabled here")}${a.placement.detail ? `: ${a.placement.detail}` : a.invalid ? `: ${a.invalid.message}` : ""})`, { id: qid, reason: a.placement.reason, runsOn: a.runsOn, owner: a.owner });
|
|
1005
|
+
return scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(qid)} in ${ws}${scope !== "local" && !ctx?.snapshot ? " (no automations snapshot yet: run oats sync)" : ""}`);
|
|
1006
|
+
}
|
|
873
1007
|
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; } }
|
|
874
1008
|
/** K8b — session PROVENANCE of a run (facts the recorder had), never a
|
|
875
1009
|
* transcript: there is no reader behind this block. */
|
|
@@ -893,11 +1027,22 @@ function projectHistory(js) {
|
|
|
893
1027
|
}
|
|
894
1028
|
return { history: { status: "ok", stored: stored.length, truncated: stored.length > RECENT_RUNS_MAX }, recentRuns: rows };
|
|
895
1029
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1030
|
+
/** One schedule's row: the stored job's facts (a workspace schedule this host runs is a job
|
|
1031
|
+
* under its state key) plus the automations row (docs/desktop-cli-api.md "Automations"). A
|
|
1032
|
+
* workspace schedule another host runs has no local facts: its definition and placement only. */
|
|
1033
|
+
export function describe(ws, qid, io, { defs, st, now = new Date(), ctx = null } = {}) {
|
|
1034
|
+
if (typeof qid !== "string" || !qid) throw scheduleError("E_BAD_ARGS", `oats schedule show <id> (local/<id> or <member>/<id>)`);
|
|
1035
|
+
const { scope, name } = splitId(qid);
|
|
1036
|
+
if (scope !== "local" && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(scope) || !AUTOMATION_ID_RE.test(name))) throw scheduleError("E_BAD_ARGS", `a workspace schedule id is <member>/<id> (got ${JSON.stringify(qid)})`);
|
|
1037
|
+
const entry = scope === "local" ? null : (ctx?.schedules || []).find((x) => x.id === `${scope}/${name}`);
|
|
1038
|
+
if (scope !== "local" && !entry) throw notHere(ws, `${scope}/${name}`, ctx);
|
|
1039
|
+
if (entry && !entry.placement.runsHere) return scheduleRow(ws, entry, { ...(entry.definition || {}), scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, nextRun: null, lastRun: null, running: false }, ctx);
|
|
1040
|
+
const id = scope === "local" ? name : scheduleStateKey(entry.id);
|
|
1041
|
+
if (scope === "local" && !SCHEDULE_ID_RE.test(id)) throw scheduleError("E_BAD_ARGS", `schedule id must match ${SCHEDULE_ID_RE} (got ${JSON.stringify(qid)})`);
|
|
1042
|
+
defs ||= effectiveDefinitions(ws, ctx); st ||= readState(ws);
|
|
899
1043
|
const def = defs.jobs[id];
|
|
900
|
-
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(
|
|
1044
|
+
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(qid)} in ${ws}`);
|
|
1045
|
+
if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `${qid} is a trigger, not a schedule — use oats trigger show ${qid}`);
|
|
901
1046
|
// Subject truth: the definition's own id must be the key it is stored under.
|
|
902
1047
|
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 });
|
|
903
1048
|
const js = st.jobs[id] || {};
|
|
@@ -905,16 +1050,44 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
905
1050
|
const lock = jobLockInfo(ws, id);
|
|
906
1051
|
const intent = js.attempt || (lock ? (js.lastRun?.execution !== undefined ? { execution: js.lastRun.execution } : {}) : undefined);
|
|
907
1052
|
const { history, recentRuns } = projectHistory(js);
|
|
908
|
-
|
|
1053
|
+
const { workspace: _w, ...stored } = def; void _w;
|
|
1054
|
+
const base = { ...stored, 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 } : {}) };
|
|
1055
|
+
return scheduleRow(ws, entry ?? localEntry("schedule", { ...def, id: name }, { dep: ws }), base, ctx);
|
|
1056
|
+
}
|
|
1057
|
+
/** A schedule's list row: its stored facts, the fields every trigger and schedule row share
|
|
1058
|
+
* (lib/automations.mjs baseRow) and the schedule's own: `kind` is its run (spawn, command, wake,
|
|
1059
|
+
* operation), the soul it spawns, the task, the cron and tz, the last run and the next due. */
|
|
1060
|
+
function scheduleRow(ws, entry, base, ctx) {
|
|
1061
|
+
const { enabled: _e, ...rest } = base; void _e;
|
|
1062
|
+
const def = entry.definition || {};
|
|
1063
|
+
const soul = def.kind === "spawn" ? def.agent ?? null : null;
|
|
1064
|
+
const local = entry.member === "local";
|
|
1065
|
+
return {
|
|
1066
|
+
...rest, ...baseRow(entry),
|
|
1067
|
+
// A local schedule keeps its bare id (the 0.28 contract every consumer reads); its qualified
|
|
1068
|
+
// form is qualifiedId. A workspace schedule's id IS qualified: <member>/<id>.
|
|
1069
|
+
id: local ? entry.name : entry.id, qualifiedId: entry.id,
|
|
1070
|
+
kind: base.kind ?? def.kind ?? null,
|
|
1071
|
+
soul: soul ? { name: soul, origin: soulOriginOf(ctx?.snapshot?.souls, soul) } : null,
|
|
1072
|
+
task: def.task ?? null, cron: def.cron ?? null, tz: def.tz ?? null,
|
|
1073
|
+
teams: [], launchConfig: def.launchConfig ?? null, harness: def.harness ?? null, model: def.model ?? null, concurrency: null,
|
|
1074
|
+
lastRun: base.lastRun ?? null, nextDue: entry.placement.runsHere ? base.nextRun ?? null : null,
|
|
1075
|
+
enabled: entry.enabled !== false,
|
|
1076
|
+
};
|
|
909
1077
|
}
|
|
910
|
-
export function listSchedules(ws, io, { now = new Date() } = {}) {
|
|
1078
|
+
export function listSchedules(ws, io, { now = new Date(), ctx = null } = {}) {
|
|
911
1079
|
const defs = readDefinitions(ws), st = readState(ws);
|
|
1080
|
+
const all = effectiveDefinitions(ws, ctx);
|
|
912
1081
|
// One job's bad identity or history does not fail the others: it is reported as its own row.
|
|
913
|
-
const
|
|
914
|
-
try { return describe(ws,
|
|
915
|
-
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: [] }; }
|
|
916
|
-
}
|
|
917
|
-
|
|
1082
|
+
const row = (qid) => {
|
|
1083
|
+
try { return describe(ws, qid, io, { defs: all, st, now, ctx }); }
|
|
1084
|
+
catch (e) { return { id: splitId(qid).scope === "local" ? splitId(qid).name : qid, qualifiedId: qid, name: splitId(qid).name, 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: [] }; }
|
|
1085
|
+
};
|
|
1086
|
+
const local = Object.keys(defs.jobs).filter((id) => defs.jobs[id]?.kind !== "trigger").sort().map((id) => row(localId(id)));
|
|
1087
|
+
const workspace = (ctx?.schedules || []).map((a) => row(a.id));
|
|
1088
|
+
// Triggers share the definitions file but not this listing: a pointer, so they are never invisible.
|
|
1089
|
+
const triggers = Object.values(defs.jobs).filter((d) => d?.kind === "trigger").length + (ctx?.triggers || []).length;
|
|
1090
|
+
return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), host: { name: ctx?.host?.name ?? null, ghUser: ctx?.ghUsers ? ctx.ghUsers() : {} }, schedules: [...local, ...workspace], triggers: { count: triggers, command: "oats trigger list" }, snapshot: ctx?.snapshot ? { takenAt: ctx.snapshot.takenAt, problems: (ctx.snapshot.problems || []).length } : null, scheduler: schedulerStatus(ws, io) };
|
|
918
1091
|
}
|
|
919
1092
|
export function addSchedule(ws, spec, io) {
|
|
920
1093
|
const def = validateDefinition(ws, spec);
|
|
@@ -924,41 +1097,44 @@ export function addSchedule(ws, spec, io) {
|
|
|
924
1097
|
const now = new Date().toISOString();
|
|
925
1098
|
defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
|
|
926
1099
|
writeDefinitions(ws, defs);
|
|
927
|
-
return describe(ws, def.id, io);
|
|
1100
|
+
return describe(ws, localId(def.id), io);
|
|
928
1101
|
});
|
|
929
1102
|
}
|
|
930
1103
|
/** Update under the host lock (so the busy check cannot race a tick's
|
|
931
1104
|
* admission) and the scope lock. What a run is tracked or reconciled by
|
|
932
1105
|
* (kind, agent, agentsRoot, repo, purpose, home, cwd, argv) cannot change
|
|
933
1106
|
* while the job holds a slot or has an unresolved attempt; timing and text can. */
|
|
934
|
-
export function updateSchedule(ws,
|
|
1107
|
+
export function updateSchedule(ws, qid, spec, io) {
|
|
1108
|
+
const id = localScheduleId(qid, "update");
|
|
935
1109
|
const def = validateDefinition(ws, { ...spec, id });
|
|
936
1110
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
937
1111
|
const defs = readDefinitions(ws);
|
|
938
|
-
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
1112
|
+
if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
|
|
939
1113
|
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]);
|
|
940
1114
|
const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
|
|
941
1115
|
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)`);
|
|
942
1116
|
defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
|
|
943
1117
|
writeDefinitions(ws, defs);
|
|
944
|
-
return describe(ws, id, io);
|
|
1118
|
+
return describe(ws, localId(id), io);
|
|
945
1119
|
}), { retryMs: 3000 });
|
|
946
1120
|
}
|
|
947
|
-
export function setEnabled(ws,
|
|
1121
|
+
export function setEnabled(ws, qid, enabled, io) {
|
|
1122
|
+
const id = localScheduleId(qid, "change");
|
|
948
1123
|
return withScopeLock(ws, () => {
|
|
949
1124
|
const defs = readDefinitions(ws);
|
|
950
|
-
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
1125
|
+
if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
|
|
951
1126
|
defs.jobs[id] = { ...defs.jobs[id], enabled, updatedAt: new Date().toISOString() };
|
|
952
1127
|
writeDefinitions(ws, defs);
|
|
953
|
-
return describe(ws, id, io);
|
|
1128
|
+
return describe(ws, localId(id), io);
|
|
954
1129
|
});
|
|
955
1130
|
}
|
|
956
1131
|
/** Remove under the host lock (state changes under a live tick are refused
|
|
957
1132
|
* by the lock, not raced). */
|
|
958
|
-
export function removeSchedule(ws,
|
|
1133
|
+
export function removeSchedule(ws, qid, { force = false } = {}) {
|
|
1134
|
+
const id = localScheduleId(qid, "remove");
|
|
959
1135
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
960
1136
|
const defs = readDefinitions(ws);
|
|
961
|
-
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
1137
|
+
if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
|
|
962
1138
|
const st = readState(ws), job = st.jobs[id];
|
|
963
1139
|
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`);
|
|
964
1140
|
delete defs.jobs[id];
|
|
@@ -968,6 +1144,13 @@ export function removeSchedule(ws, id, { force = false } = {}) {
|
|
|
968
1144
|
return { removed: id };
|
|
969
1145
|
}));
|
|
970
1146
|
}
|
|
1147
|
+
/** The stored id of a LOCAL schedule argument; a workspace schedule is changed in Git (enabled or
|
|
1148
|
+
* disabled on this host through oats-local.yaml schedules.disabled). */
|
|
1149
|
+
function localScheduleId(qid, verb) {
|
|
1150
|
+
const { scope, name } = splitId(qid);
|
|
1151
|
+
if (scope !== "local") throw automationError("E_AUTOMATION_WORKSPACE", `${qid} is a workspace schedule, defined in Git: ${verb} its file there${verb === "remove" ? `, or stop it on this host: oats schedule disable ${qid}` : ""}`, { id: qid });
|
|
1152
|
+
return name;
|
|
1153
|
+
}
|
|
971
1154
|
/** Save the wake job for a freshly spawned instance (id wake-<instance>). */
|
|
972
1155
|
export function saveWakeForHome(ws, { instance, home, wake }) {
|
|
973
1156
|
const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
|