cool-workflow 0.2.4 → 0.2.6
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.gemini-plugin/mcp.json +10 -0
- package/.gemini-plugin/plugin.json +40 -0
- package/.opencode-plugin/mcp.json +10 -0
- package/.opencode-plugin/plugin.json +40 -0
- package/README.md +204 -48
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +32 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +337 -8
- package/dist/core/format/help.js +73 -3
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/state/run-paths.js +3 -30
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +22 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +215 -36
- package/dist/mcp/tool-process.js +181 -0
- package/dist/mcp-server.js +28 -1
- package/dist/shell/agent-config.js +11 -2
- package/dist/shell/audit-cli.js +1 -1
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +195 -128
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +201 -26
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +33 -0
- package/dist/shell/fs-atomic.js +96 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/metrics-cli.js +1 -1
- package/dist/shell/multi-agent-cli.js +9 -2
- package/dist/shell/multi-agent-operator-ux.js +1 -1
- package/dist/shell/observability.js +7 -4
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/operator-ux.js +1 -1
- package/dist/shell/perf-trace.js +136 -0
- package/dist/shell/pipeline.js +3 -2
- package/dist/shell/reclamation-io.js +10 -9
- package/dist/shell/report-view-cli.js +2 -0
- package/dist/shell/run-export-cli.js +5 -2
- package/dist/shell/run-export.js +293 -31
- package/dist/shell/run-registry-io.js +8 -0
- package/dist/shell/run-store.js +183 -3
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/state-explosion-cli.js +4 -1
- package/dist/shell/telemetry-demo.js +1 -1
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +178 -36
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +83 -6
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/wiring/capability-table/basics.js +5 -0
- package/dist/wiring/capability-table/exec-backend.js +40 -22
- package/dist/wiring/capability-table/parity.js +10 -5
- package/dist/wiring/capability-table/pipeline.js +32 -0
- package/dist/wiring/capability-table/registry-core.js +26 -3
- package/dist/wiring/capability-table/reporting.js +7 -1
- package/dist/wiring/capability-table/scheduling-registry.js +8 -2
- package/dist/wiring/capability-table/trust-ledger.js +54 -12
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +46 -5
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +38 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +4 -4
- package/docs/getting-started.md +40 -32
- package/docs/index.md +17 -0
- package/docs/launch/demo.tape +4 -3
- package/docs/mcp-app-surface.7.md +27 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +53 -9
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +12 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +37 -9
- package/docs/run-retention-reclamation.7.md +12 -4
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +72 -2
- package/docs/workflow-app-framework.7.md +31 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +4 -3
- package/package.json +6 -3
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/gen-manifests.js +32 -61
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/parity-check.js +42 -23
- package/scripts/purity-baseline.json +0 -3
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +169 -41
- package/scripts/release-gate.js +208 -0
- package/scripts/release-oneclick.js +38 -7
- package/scripts/release-tags.js +47 -0
- package/scripts/schema-version-inventory.json +31 -0
- package/scripts/validate-run-state-schema.js +95 -4
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-release-verdict.js +139 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/ui/workbench/app.css +50 -10
- package/ui/workbench/app.js +269 -34
- package/ui/workbench/index.html +4 -2
- package/ui/workbench/inspection.js +51 -0
- package/ui/workbench/navigation.js +44 -0
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -75
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
package/dist/shell/run-store.js
CHANGED
|
@@ -44,14 +44,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
44
44
|
};
|
|
45
45
|
})();
|
|
46
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
-
exports.
|
|
47
|
+
exports.createRunPaths = void 0;
|
|
48
|
+
exports.ensureRunDirs = ensureRunDirs;
|
|
48
49
|
exports.hashArtifactFile = hashArtifactFile;
|
|
49
50
|
exports.loadRunStateFile = loadRunStateFile;
|
|
50
51
|
exports.checkRunStateFile = checkRunStateFile;
|
|
51
52
|
exports.migrateRunStateFile = migrateRunStateFile;
|
|
52
53
|
exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
|
|
53
54
|
exports.loadRunFromCwd = loadRunFromCwd;
|
|
55
|
+
exports.resolveRunDir = resolveRunDir;
|
|
54
56
|
exports.withRunStateLock = withRunStateLock;
|
|
57
|
+
exports.withDriveLock = withDriveLock;
|
|
58
|
+
exports.withDriveLockAsync = withDriveLockAsync;
|
|
55
59
|
exports.saveCheckpoint = saveCheckpoint;
|
|
56
60
|
exports.compactCheckpoint = compactCheckpoint;
|
|
57
61
|
exports.createRun = createRun;
|
|
@@ -60,9 +64,32 @@ const path = __importStar(require("node:path"));
|
|
|
60
64
|
const fs_atomic_1 = require("./fs-atomic");
|
|
61
65
|
const run_paths_1 = require("../core/state/run-paths");
|
|
62
66
|
Object.defineProperty(exports, "createRunPaths", { enumerable: true, get: function () { return run_paths_1.createRunPaths; } });
|
|
63
|
-
Object.defineProperty(exports, "ensureRunDirs", { enumerable: true, get: function () { return run_paths_1.ensureRunDirs; } });
|
|
64
67
|
const migrations_1 = require("../core/state/migrations");
|
|
65
68
|
const hash_1 = require("../core/hash");
|
|
69
|
+
/** `mkdirSync` (recursive) every dir this run needs. Missing optional dir
|
|
70
|
+
* fields fall back to `path.join(runDir, "<name>")`, matching the old
|
|
71
|
+
* build's defensive default (a RunPaths loaded from an old/partial
|
|
72
|
+
* state.json may be missing an optional key). */
|
|
73
|
+
function ensureRunDirs(paths) {
|
|
74
|
+
const dirs = [
|
|
75
|
+
paths.runDir,
|
|
76
|
+
paths.tasksDir,
|
|
77
|
+
paths.resultsDir,
|
|
78
|
+
paths.dispatchesDir,
|
|
79
|
+
paths.artifactsDir,
|
|
80
|
+
paths.commitsDir,
|
|
81
|
+
paths.stateNodesDir,
|
|
82
|
+
paths.feedbackDir,
|
|
83
|
+
paths.auditDir || path.join(paths.runDir, "audit"),
|
|
84
|
+
paths.workersDir || path.join(paths.runDir, "workers"),
|
|
85
|
+
paths.candidatesDir || path.join(paths.runDir, "candidates"),
|
|
86
|
+
paths.multiAgentDir || path.join(paths.runDir, "multi-agent"),
|
|
87
|
+
paths.blackboardDir || path.join(paths.runDir, "blackboard"),
|
|
88
|
+
paths.topologiesDir || path.join(paths.runDir, "topologies"),
|
|
89
|
+
];
|
|
90
|
+
for (const dir of dirs)
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
66
93
|
/** Read the file at artifact.path and stamp sha256 (the core `sha256:`+hex
|
|
67
94
|
* form) + sizeBytes onto the StateArtifact. A missing/unreadable file is
|
|
68
95
|
* silently skipped so hashing an absent artifact never throws. Byte-exact
|
|
@@ -185,6 +212,22 @@ function loadRunFromCwd(runId, cwd = process.cwd()) {
|
|
|
185
212
|
assertNotSuspectedDataLoss(runId, result);
|
|
186
213
|
return result.run;
|
|
187
214
|
}
|
|
215
|
+
/** The canonical run directory for `runId` under `cwd` — the SAME
|
|
216
|
+
* deterministic location loadRunFromCwd resolves `state.json` in (it too
|
|
217
|
+
* ignores any stored `paths.runDir`). Guards an empty/unsafe id and throws
|
|
218
|
+
* the exact `Run not found: <runId>` loadRunFromCwd throws, but via a plain
|
|
219
|
+
* `existsSync` so it adds no `state.json` READ and never creates the run dir
|
|
220
|
+
* for a nonexistent run. Used by the drive mutex, which needs the directory
|
|
221
|
+
* before it can acquire, without perturbing read accounting. */
|
|
222
|
+
function resolveRunDir(runId, cwd = process.cwd()) {
|
|
223
|
+
if (!runId)
|
|
224
|
+
throw new Error("Missing run id");
|
|
225
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
226
|
+
const runDir = path.join(cwd, ".cw", "runs", runId);
|
|
227
|
+
if (!fs.existsSync(path.join(runDir, "state.json")))
|
|
228
|
+
throw new Error(`Run not found: ${runId}`);
|
|
229
|
+
return runDir;
|
|
230
|
+
}
|
|
188
231
|
/** Hold the state.json lock over a WHOLE load -> change -> save cycle.
|
|
189
232
|
* A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
|
|
190
233
|
* processes both load the same state and the later save silently drops
|
|
@@ -201,6 +244,143 @@ function withRunStateLock(runId, cwd, fn) {
|
|
|
201
244
|
const probe = loadRunFromCwd(runId, cwd);
|
|
202
245
|
return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
|
|
203
246
|
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// withDriveLock — a run-scoped DRIVE mutex, held across a WHOLE drive() call.
|
|
249
|
+
//
|
|
250
|
+
// The state.json write lock (withFileLock) covers only the instant of a
|
|
251
|
+
// write; a concurrent round loads the run once, spawns agents for MINUTES,
|
|
252
|
+
// then flushes the object it loaded at round start. A second drive on the
|
|
253
|
+
// same run therefore clobbers this one's flush (lost update) and both drives
|
|
254
|
+
// mint the same worker id from an in-memory count (double dispatch). This
|
|
255
|
+
// mutex closes both: only one process advances a run at a time.
|
|
256
|
+
//
|
|
257
|
+
// It is held far too long for withFileLock's 30s stale-steal window, so it is
|
|
258
|
+
// NEVER time-stale-stolen. A lock whose recorded pid is a LIVE process refuses
|
|
259
|
+
// (fail closed — never a silent double-drive); a lock whose owner is GONE (a
|
|
260
|
+
// crashed prior drive) or this process's own leaked lock is stolen so a crash
|
|
261
|
+
// can never wedge the run. Lock body is `"<pid>@<ISO>\n"`, the same shape as
|
|
262
|
+
// the fs-atomic lock, acquired with the same single-winner `link(2)` idiom.
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
/** Drive-lock paths this process holds right now. A nested drive() on the
|
|
265
|
+
* SAME run id (sub-workflows use a DIFFERENT run id, so this should not
|
|
266
|
+
* happen — defensive) re-enters instead of self-refusing. */
|
|
267
|
+
const HELD_DRIVE_LOCKS = new Set();
|
|
268
|
+
function driveLockPath(runDir) {
|
|
269
|
+
return path.join(runDir, "drive.lock");
|
|
270
|
+
}
|
|
271
|
+
/** True when `pid` names a live process on this machine. `process.kill(pid,
|
|
272
|
+
* 0)` delivers no signal — it only probes existence/permission. `EPERM` (the
|
|
273
|
+
* pid exists but is not signalable by us) counts as ALIVE, the conservative
|
|
274
|
+
* never-wrongly-steal direction; a malformed/absent pid is "not alive". */
|
|
275
|
+
function driveLockOwnerAlive(pid) {
|
|
276
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
277
|
+
return false;
|
|
278
|
+
try {
|
|
279
|
+
process.kill(pid, 0);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
return Boolean(error && typeof error === "object" && error.code === "EPERM");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function readDriveLockPid(lock) {
|
|
287
|
+
try {
|
|
288
|
+
const match = /^(\d+)@/.exec(fs.readFileSync(lock, "utf8"));
|
|
289
|
+
return match ? Number(match[1]) : 0;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return 0; // vanished or unreadable — treat as no owner
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Acquire the drive mutex for `runId`'s run directory. Returns a release
|
|
296
|
+
* function (a no-op when this call re-entered a lock already held in-process).
|
|
297
|
+
* Throws a fail-closed refusal — naming the resume command — when another
|
|
298
|
+
* LIVE process holds it. */
|
|
299
|
+
function acquireDriveLock(runDir, runId) {
|
|
300
|
+
const lock = driveLockPath(runDir);
|
|
301
|
+
const key = path.resolve(lock);
|
|
302
|
+
if (HELD_DRIVE_LOCKS.has(key))
|
|
303
|
+
return () => { };
|
|
304
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
305
|
+
const pid = process.pid;
|
|
306
|
+
const body = `${pid}@${new Date().toISOString()}\n`;
|
|
307
|
+
const refuse = (ownerPid) => new Error(`Run ${runId} is already being driven by another process (pid ${ownerPid || "unknown"}). ` +
|
|
308
|
+
`Wait for it to finish, or if it has crashed remove ${lock}, then resume: cw run resume ${runId} --drive`);
|
|
309
|
+
let acquired = false;
|
|
310
|
+
// A live owner refuses at once; only a stealable (dead-owner / own-leak) lock
|
|
311
|
+
// loops to re-acquire, so this never hot-spins for a genuinely busy run.
|
|
312
|
+
for (let attempt = 0; attempt < 3 && !acquired; attempt++) {
|
|
313
|
+
const tmp = `${lock}.${pid}.${attempt}.tmp`;
|
|
314
|
+
try {
|
|
315
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
316
|
+
fs.linkSync(tmp, lock);
|
|
317
|
+
acquired = true;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (!(error && typeof error === "object" && error.code === "EEXIST")) {
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const ownerPid = readDriveLockPid(lock);
|
|
324
|
+
// Stealable: our own leaked lock, or an owner that is no longer alive.
|
|
325
|
+
if (ownerPid === pid || !driveLockOwnerAlive(ownerPid)) {
|
|
326
|
+
try {
|
|
327
|
+
fs.unlinkSync(lock);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
/* another process took it first — the next attempt re-judges */
|
|
331
|
+
}
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
throw refuse(ownerPid);
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
try {
|
|
338
|
+
fs.unlinkSync(tmp);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* the link consumed it, or it is already gone */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!acquired)
|
|
346
|
+
throw refuse(readDriveLockPid(lock));
|
|
347
|
+
HELD_DRIVE_LOCKS.add(key);
|
|
348
|
+
return () => {
|
|
349
|
+
HELD_DRIVE_LOCKS.delete(key);
|
|
350
|
+
try {
|
|
351
|
+
// Release only while we still own it (a force-stale steal by a later
|
|
352
|
+
// process must not have its lock removed by us).
|
|
353
|
+
if (fs.readFileSync(lock, "utf8").startsWith(`${pid}@`))
|
|
354
|
+
fs.rmSync(lock, { force: true });
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
/* already released/removed */
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/** Run `fn` while holding the run-scoped drive mutex (see the block comment
|
|
362
|
+
* above); always released, even on throw. Synchronous callers (drive()). */
|
|
363
|
+
function withDriveLock(runDir, runId, fn) {
|
|
364
|
+
const release = acquireDriveLock(runDir, runId);
|
|
365
|
+
try {
|
|
366
|
+
return fn();
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
release();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Async form of `withDriveLock` — releases only AFTER `fn`'s promise
|
|
373
|
+
* settles, so driveAsync()'s awaited multi-round loop stays inside the lock
|
|
374
|
+
* for its whole lifetime. */
|
|
375
|
+
async function withDriveLockAsync(runDir, runId, fn) {
|
|
376
|
+
const release = acquireDriveLock(runDir, runId);
|
|
377
|
+
try {
|
|
378
|
+
return await fn();
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
release();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
204
384
|
/** state.json is the single source of truth — set `updatedAt`, then write
|
|
205
385
|
* it DURABLY with a lock so concurrent processes never lose an update. */
|
|
206
386
|
function saveCheckpoint(run) {
|
|
@@ -237,7 +417,7 @@ function compactCheckpoint(run) {
|
|
|
237
417
|
* same primitive. */
|
|
238
418
|
function createRun(runDir, runId, workflowId, cwd) {
|
|
239
419
|
const paths = (0, run_paths_1.createRunPaths)(runDir);
|
|
240
|
-
|
|
420
|
+
ensureRunDirs(paths);
|
|
241
421
|
const seed = {
|
|
242
422
|
id: runId,
|
|
243
423
|
cwd,
|
|
@@ -74,12 +74,18 @@ class Scheduler {
|
|
|
74
74
|
create(options) {
|
|
75
75
|
const kind = normalizeKind(options.kind);
|
|
76
76
|
const now = new Date();
|
|
77
|
-
|
|
77
|
+
// Fail closed on a bad number rather than silently clamping a typo into a
|
|
78
|
+
// runaway: interval is a whole number of 0 or more (0 = due now, a real
|
|
79
|
+
// supported value), maxRuns a whole number more than 0, jitter/delay 0 or
|
|
80
|
+
// more, ttlDays more than 0. Use `??` (not `||`) when picking the option so
|
|
81
|
+
// an explicit 0 is kept -- for interval a valid "due now", not swallowed
|
|
82
|
+
// into the default 1; for the others kept and then rejected.
|
|
83
|
+
const intervalMinutes = requireNonNegativeInt(options.intervalMinutes ?? options.interval, "interval");
|
|
78
84
|
const cron = stringOption(options.cron);
|
|
79
|
-
const delayMinutes =
|
|
80
|
-
const jitterSeconds =
|
|
85
|
+
const delayMinutes = requireNonNegative(options.delayMinutes ?? options.delay, "delay");
|
|
86
|
+
const jitterSeconds = requireNonNegative(options.jitterSeconds, "jitterSeconds") ?? 0;
|
|
81
87
|
const nextRunAt = computeInitialNextRunAt({ kind, now, intervalMinutes, cron, delayMinutes, jitterSeconds });
|
|
82
|
-
const ttlDays =
|
|
88
|
+
const ttlDays = requirePositive(options.ttlDays, "ttlDays") ?? DEFAULT_TTL_DAYS;
|
|
83
89
|
const task = {
|
|
84
90
|
id: createScheduleId(kind),
|
|
85
91
|
kind,
|
|
@@ -95,7 +101,7 @@ class Scheduler {
|
|
|
95
101
|
intervalMinutes,
|
|
96
102
|
cron,
|
|
97
103
|
jitterSeconds,
|
|
98
|
-
maxRuns:
|
|
104
|
+
maxRuns: requirePositiveInt(options.maxRuns, "maxRuns"),
|
|
99
105
|
runCount: 0,
|
|
100
106
|
};
|
|
101
107
|
return this.locked(() => {
|
|
@@ -125,7 +131,7 @@ class Scheduler {
|
|
|
125
131
|
const store = this.load();
|
|
126
132
|
let changed = false;
|
|
127
133
|
for (const task of store.tasks) {
|
|
128
|
-
if (task.status === "active" &&
|
|
134
|
+
if (task.status === "active" && reachedBy(task.expiresAt, now)) {
|
|
129
135
|
task.status = "expired";
|
|
130
136
|
task.updatedAt = now.toISOString();
|
|
131
137
|
changed = true;
|
|
@@ -133,7 +139,7 @@ class Scheduler {
|
|
|
133
139
|
}
|
|
134
140
|
if (changed)
|
|
135
141
|
this.save(store);
|
|
136
|
-
const dueTasks = store.tasks.filter((task) => task.status === "active" &&
|
|
142
|
+
const dueTasks = store.tasks.filter((task) => task.status === "active" && reachedBy(task.nextRunAt, now));
|
|
137
143
|
if (dueTasks.length) {
|
|
138
144
|
for (const task of dueTasks) {
|
|
139
145
|
const alreadyRecorded = task.lastDueAt && new Date(task.lastDueAt).getTime() >= new Date(task.nextRunAt).getTime();
|
|
@@ -219,6 +225,7 @@ class Scheduler {
|
|
|
219
225
|
if (!fs.existsSync(this.storePath))
|
|
220
226
|
return { schemaVersion: 1, tasks: [], history: [] };
|
|
221
227
|
const value = (0, fs_atomic_1.readJson)(this.storePath);
|
|
228
|
+
ensureKnownSchemaVersion(value, "schedule");
|
|
222
229
|
return {
|
|
223
230
|
schemaVersion: 1,
|
|
224
231
|
tasks: Array.isArray(value.tasks) ? value.tasks : [],
|
|
@@ -353,6 +360,64 @@ function numberOption(value) {
|
|
|
353
360
|
const parsed = Number(value);
|
|
354
361
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
355
362
|
}
|
|
363
|
+
// Number guards for create(): an absent option (undefined/null/bare flag) is
|
|
364
|
+
// left undefined for the caller's default; a GIVEN option that is out of
|
|
365
|
+
// bounds throws a clear, named refusal (fail closed) instead of being clamped.
|
|
366
|
+
function requirePositiveInt(value, name) {
|
|
367
|
+
if (value === undefined || value === null || value === true)
|
|
368
|
+
return undefined;
|
|
369
|
+
const parsed = Number(value);
|
|
370
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
371
|
+
throw new Error(`${name} must be a whole number more than 0`);
|
|
372
|
+
}
|
|
373
|
+
return parsed;
|
|
374
|
+
}
|
|
375
|
+
// interval only: a whole number of 0 or more. 0 means "due now" (nextRunAt is
|
|
376
|
+
// this moment) and is a real, supported value, so it must pass; a negative or
|
|
377
|
+
// non-whole interval is the typo we fail closed on.
|
|
378
|
+
function requireNonNegativeInt(value, name) {
|
|
379
|
+
if (value === undefined || value === null || value === true)
|
|
380
|
+
return undefined;
|
|
381
|
+
const parsed = Number(value);
|
|
382
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
383
|
+
throw new Error(`${name} must be a whole number of 0 or more`);
|
|
384
|
+
}
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
function requirePositive(value, name) {
|
|
388
|
+
if (value === undefined || value === null || value === true)
|
|
389
|
+
return undefined;
|
|
390
|
+
const parsed = Number(value);
|
|
391
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
392
|
+
throw new Error(`${name} must be a number more than 0`);
|
|
393
|
+
}
|
|
394
|
+
return parsed;
|
|
395
|
+
}
|
|
396
|
+
function requireNonNegative(value, name) {
|
|
397
|
+
if (value === undefined || value === null || value === true)
|
|
398
|
+
return undefined;
|
|
399
|
+
const parsed = Number(value);
|
|
400
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
401
|
+
throw new Error(`${name} must be a number of 0 or more`);
|
|
402
|
+
}
|
|
403
|
+
return parsed;
|
|
404
|
+
}
|
|
405
|
+
// Fail closed on a store written by a newer/unknown runtime: forcing it to v1
|
|
406
|
+
// would silently drop fields it does not understand. A missing schemaVersion is
|
|
407
|
+
// treated as legacy v1 for backward tolerance.
|
|
408
|
+
function ensureKnownSchemaVersion(value, store) {
|
|
409
|
+
const version = value.schemaVersion;
|
|
410
|
+
if (version !== undefined && version !== 1) {
|
|
411
|
+
throw new Error(`Unsupported ${store} store schemaVersion: ${String(version)}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// A stored date that parses to NaN is corrupt. `NaN <= now` is false, which
|
|
415
|
+
// would make such a task silently never fire and never expire; fail closed by
|
|
416
|
+
// treating a corrupt date as already reached (due / expired), never inert.
|
|
417
|
+
function reachedBy(iso, now) {
|
|
418
|
+
const at = new Date(iso).getTime();
|
|
419
|
+
return Number.isNaN(at) || at <= now.getTime();
|
|
420
|
+
}
|
|
356
421
|
class DesktopSchedulerDaemon {
|
|
357
422
|
cwd;
|
|
358
423
|
intervalSeconds;
|
|
@@ -369,11 +434,36 @@ class DesktopSchedulerDaemon {
|
|
|
369
434
|
(0, fs_atomic_1.writeJson)(inboxPath, { schemaVersion: 1, checkedAt, due });
|
|
370
435
|
return { checkedAt, dueCount: due.length, dueIds: due.map((task) => task.id), inboxPath };
|
|
371
436
|
}
|
|
437
|
+
/** One tick, guarded. A long-running daemon must survive a transient
|
|
438
|
+
* failure — chiefly file-lock contention when a concurrent `cw schedule`
|
|
439
|
+
* command holds tasks.json (withFileLock gives up after ~6s). Returns
|
|
440
|
+
* true to keep going, false to stop the daemon cleanly.
|
|
441
|
+
* - lock contention → warn to STDERR (stdout stays pure NDJSON), keep going;
|
|
442
|
+
* - any other error (e.g. a corrupt tasks.json) → one `cw:` line + stop,
|
|
443
|
+
* a clean fail-closed shutdown instead of a raw stack dump. */
|
|
444
|
+
safeTick() {
|
|
445
|
+
try {
|
|
446
|
+
process.stdout.write(`${JSON.stringify(this.tick())}\n`);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
451
|
+
if (/could not acquire file lock/i.test(message)) {
|
|
452
|
+
process.stderr.write(`cw: schedule daemon: skipped a tick (${message})\n`);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
process.stderr.write(`cw: ${message}\n`);
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
372
460
|
async run() {
|
|
373
461
|
fs.mkdirSync(path.join(this.cwd, ".cw", "schedules"), { recursive: true });
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
462
|
+
if (!this.safeTick())
|
|
463
|
+
return;
|
|
464
|
+
const timer = setInterval(() => {
|
|
465
|
+
if (!this.safeTick())
|
|
466
|
+
clearInterval(timer);
|
|
377
467
|
}, Math.max(1, this.intervalSeconds) * 1000);
|
|
378
468
|
}
|
|
379
469
|
}
|
|
@@ -465,6 +555,7 @@ class RoutineTriggerBridge {
|
|
|
465
555
|
if (!fs.existsSync(this.storePath))
|
|
466
556
|
return { schemaVersion: 1, triggers: [], events: [], nextTriggerSeq: 0 };
|
|
467
557
|
const value = (0, fs_atomic_1.readJson)(this.storePath);
|
|
558
|
+
ensureKnownSchemaVersion(value, "routine");
|
|
468
559
|
const triggers = Array.isArray(value.triggers) ? value.triggers : [];
|
|
469
560
|
const maxExisting = triggers.reduce((max, trigger) => {
|
|
470
561
|
const n = Number((String(trigger.id).match(/(\d+)$/) || [])[1] || 0);
|
|
@@ -176,7 +176,10 @@ function showStateExplosionSummary(run, options = {}) {
|
|
|
176
176
|
// ---------------------------------------------------------------------
|
|
177
177
|
function loadRun(runId, options = {}) {
|
|
178
178
|
const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
|
|
179
|
-
|
|
179
|
+
const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
|
|
180
|
+
if (options.__cwWorkbenchReadOnlyProjection === true)
|
|
181
|
+
run.__cwWorkbenchReadOnlyProjection = true;
|
|
182
|
+
return run;
|
|
180
183
|
}
|
|
181
184
|
/** `cw summary refresh <run-id> [--json]` — refresh also runs `writeReport`
|
|
182
185
|
* + `saveCheckpoint` in the old build; `writeReport` (report.md) is a
|
|
@@ -219,7 +219,7 @@ function runBundleDemo(options = {}) {
|
|
|
219
219
|
const runId = "demo-bundle-run";
|
|
220
220
|
const runDir = path.join(workdir, ".cw", "runs", runId);
|
|
221
221
|
const paths = (0, run_paths_1.createRunPaths)(runDir);
|
|
222
|
-
(0,
|
|
222
|
+
(0, run_store_1.ensureRunDirs)(paths);
|
|
223
223
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
224
224
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
225
225
|
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
@@ -94,31 +94,43 @@ function loadTelemetryLedger(run) {
|
|
|
94
94
|
/** Append one attestation record DURABLY to the append-only chain,
|
|
95
95
|
* linking it to the prior record (or genesis). Returns the committed
|
|
96
96
|
* record. Throws `TelemetryLedgerCorruptError` if the file on disk is
|
|
97
|
-
* present but unparseable — never silently re-genesis.
|
|
97
|
+
* present but unparseable — never silently re-genesis.
|
|
98
|
+
*
|
|
99
|
+
* The whole load->append->write is held under `withFileLock` (like every
|
|
100
|
+
* other read-modify-write in this codebase — see `recordTrustAuditEvent`).
|
|
101
|
+
* `writeJson` REPLACES the file, so without the lock two processes
|
|
102
|
+
* appending for the same run at once both read the same N-record ledger,
|
|
103
|
+
* both compute a record at chain position N+1, and the last atomic rename
|
|
104
|
+
* WINS — silently dropping the loser's record. The surviving chain still
|
|
105
|
+
* links correctly, so the loss is invisible to `verifyTelemetryLedger`.
|
|
106
|
+
* The `prevHash` and chain position MUST be read from the ledger loaded
|
|
107
|
+
* INSIDE the lock so the record links to the true current tail. */
|
|
98
108
|
function appendTelemetryAttestation(run, input) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
return (0, fs_atomic_1.withFileLock)(telemetryLedgerPath(run), () => {
|
|
110
|
+
const ledger = loadTelemetryLedger(run);
|
|
111
|
+
const now = input.now || new Date().toISOString();
|
|
112
|
+
const prevHash = ledger.records.length ? ledger.records[ledger.records.length - 1].recordHash : (0, telemetry_ledger_1.genesisPrevHash)(run.id);
|
|
113
|
+
const base = {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
runId: run.id,
|
|
116
|
+
recordId: (0, telemetry_ledger_1.recordId)(ledger.records.length + 1),
|
|
117
|
+
recordedAt: now,
|
|
118
|
+
workerId: input.workerId,
|
|
119
|
+
taskId: input.taskId,
|
|
120
|
+
promptDigest: input.promptDigest,
|
|
121
|
+
reportedUsageDigest: (0, telemetry_ledger_1.reportedUsageDigest)(input.reportedUsage),
|
|
122
|
+
...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
|
|
123
|
+
usageSignature: input.usageSignature,
|
|
124
|
+
...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
|
|
125
|
+
attestation: input.attestation,
|
|
126
|
+
attestationReason: input.attestationReason,
|
|
127
|
+
prevHash,
|
|
128
|
+
};
|
|
129
|
+
const record = { ...base, recordHash: (0, telemetry_ledger_1.computeRecordHash)(base) };
|
|
130
|
+
ledger.records.push(record);
|
|
131
|
+
(0, fs_atomic_1.writeJson)(telemetryLedgerPath(run), ledger, { durable: true });
|
|
132
|
+
return record;
|
|
133
|
+
});
|
|
122
134
|
}
|
|
123
135
|
/** Re-prove the whole telemetry chain for a run, reading from disk.
|
|
124
136
|
* Absent -> present:false/verified:true (nothing to prove). Corrupt ->
|