cool-workflow 0.2.3 → 0.2.5
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 +202 -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 +33 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/io.js +6 -20
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +339 -10
- package/dist/core/format/help.js +80 -4
- 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/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +55 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +124 -13
- package/dist/mcp-server.js +20 -0
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +129 -64
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +205 -24
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +47 -10
- package/dist/shell/fs-atomic.js +93 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/multi-agent-cli.js +5 -1
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/pipeline-cli.js +20 -2
- package/dist/shell/pipeline.js +2 -1
- package/dist/shell/reclamation-io.js +76 -7
- package/dist/shell/registry-cli.js +4 -0
- package/dist/shell/run-export.js +52 -4
- package/dist/shell/run-registry-io.js +1 -0
- package/dist/shell/run-store.js +156 -0
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +104 -10
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +79 -5
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +7 -2
- package/dist/wiring/capability-table/exec-backend.js +42 -24
- package/dist/wiring/capability-table/multi-agent.js +69 -69
- package/dist/wiring/capability-table/pipeline.js +60 -28
- package/dist/wiring/capability-table/registry-core.js +29 -6
- package/dist/wiring/capability-table/reporting.js +45 -39
- package/dist/wiring/capability-table/scheduling-registry.js +66 -60
- package/dist/wiring/capability-table/state.js +34 -34
- package/dist/wiring/capability-table/trust-ledger.js +66 -24
- package/dist/wiring/capability-table/workflow-apps.js +15 -15
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +43 -13
- 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 +6 -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 +43 -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 +22 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +28 -3
- 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 +26 -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 +5 -2
- 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/bump-version.js +27 -10
- 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/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/purity-baseline.json +0 -30
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +264 -41
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +438 -0
- package/scripts/release-tags.js +47 -0
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/scripts/version-sync-check.js +39 -22
- package/ui/workbench/app.css +41 -9
- package/ui/workbench/app.js +160 -27
- package/ui/workbench/index.html +2 -2
- 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 -60
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -36,6 +36,7 @@ exports.listComments = listComments;
|
|
|
36
36
|
exports.distinctTargets = distinctTargets;
|
|
37
37
|
exports.formatReviewStatus = formatReviewStatus;
|
|
38
38
|
exports.formatCommentList = formatCommentList;
|
|
39
|
+
const cli_args_1 = require("../util/cli-args");
|
|
39
40
|
const collate_1 = require("../util/collate");
|
|
40
41
|
exports.COLLABORATION_SCHEMA_VERSION = 1;
|
|
41
42
|
/** The single, honest stand-in for an absent identity. */
|
|
@@ -166,10 +167,10 @@ function buildHandoff(input, handoffCount, runId, now, auditEventId) {
|
|
|
166
167
|
auditEventIds: [auditEventId],
|
|
167
168
|
});
|
|
168
169
|
}
|
|
169
|
-
/**
|
|
170
|
+
/** Parse a defined tri-state flag; leave `undefined` alone so the
|
|
170
171
|
* caller's `?? existing ?? default` chain still governs an unset flag. */
|
|
171
|
-
function coerceFlag(value) {
|
|
172
|
-
return
|
|
172
|
+
function coerceFlag(value, label) {
|
|
173
|
+
return (0, cli_args_1.parseBoolFlag)(value, label);
|
|
173
174
|
}
|
|
174
175
|
function toNumber(value, fallback) {
|
|
175
176
|
if (value === undefined || value === null || value === "" || value === true)
|
|
@@ -201,8 +202,8 @@ function buildReviewPolicy(input, existing, now) {
|
|
|
201
202
|
id: existing?.id || createCollabId("policy", 0),
|
|
202
203
|
requiredApprovals: Math.max(0, Math.floor(toNumber(input.requiredApprovals, existing?.requiredApprovals ?? 0))),
|
|
203
204
|
authorizedRoles: toStringList(input.authorizedRoles, existing?.authorizedRoles ?? ["*"]),
|
|
204
|
-
allowSelfApproval: coerceFlag(input.allowSelfApproval) ?? existing?.allowSelfApproval ?? false,
|
|
205
|
-
requireAttestedActor: coerceFlag(input.requireAttestedActor) ?? existing?.requireAttestedActor ?? false,
|
|
205
|
+
allowSelfApproval: coerceFlag(input.allowSelfApproval, "allowSelfApproval") ?? existing?.allowSelfApproval ?? false,
|
|
206
|
+
requireAttestedActor: coerceFlag(input.requireAttestedActor, "requireAttestedActor") ?? existing?.requireAttestedActor ?? false,
|
|
206
207
|
appliesTo: toTargetKindList(input.appliesTo, existing?.appliesTo ?? ["commit"]),
|
|
207
208
|
updatedAt: now,
|
|
208
209
|
};
|
|
@@ -235,6 +236,17 @@ function roleAuthorized(roleId, authorizedRoles) {
|
|
|
235
236
|
return false;
|
|
236
237
|
return authorizedRoles.includes(roleId);
|
|
237
238
|
}
|
|
239
|
+
/** Whether `record` itself counts for anything at all (same eligibility a
|
|
240
|
+
* record needs to be counted as an approval or a veto in the main loop
|
|
241
|
+
* below). A record that fails this can't void another record either —
|
|
242
|
+
* otherwise a disqualified self-approval could still cancel someone
|
|
243
|
+
* else's veto via `supersedes`. */
|
|
244
|
+
function recordCountsAtAll(record, policy, selfIds) {
|
|
245
|
+
const reason = disqualify(record, policy, selfIds);
|
|
246
|
+
if (record.decision === "reject")
|
|
247
|
+
return !reason || reason === "self-approval";
|
|
248
|
+
return !reason;
|
|
249
|
+
}
|
|
238
250
|
function deriveStatus(gated, required, recorded, rejectionCount, disqualified) {
|
|
239
251
|
if (!gated)
|
|
240
252
|
return "approved";
|
|
@@ -284,7 +296,24 @@ function deriveReviewState(runId, approvalsAll, target, options = {}) {
|
|
|
284
296
|
const related = (options.relatedTargets && options.relatedTargets.length ? options.relatedTargets : [normalized]).map(normalizeTarget);
|
|
285
297
|
const selfIds = new Set((options.selfActorIds || []).filter(Boolean));
|
|
286
298
|
const approvals = approvalsAll.filter((record) => matchesAnyTarget(record.target, related));
|
|
287
|
-
|
|
299
|
+
// A record's `supersedes` only takes effect when the target is that SAME
|
|
300
|
+
// actor's own prior record (an actor may only supersede their own
|
|
301
|
+
// record, never someone else's) and the superseding record itself is
|
|
302
|
+
// eligible to count for something. Otherwise a disqualified record (e.g.
|
|
303
|
+
// a self-approval a policy forbids) could still void another actor's
|
|
304
|
+
// veto just by naming it in `supersedes`.
|
|
305
|
+
const byId = new Map(approvals.map((record) => [record.id, record]));
|
|
306
|
+
const supersededIds = new Set();
|
|
307
|
+
for (const record of approvals) {
|
|
308
|
+
if (!record.supersedes)
|
|
309
|
+
continue;
|
|
310
|
+
const target = byId.get(record.supersedes);
|
|
311
|
+
if (!target || target.actor.id !== record.actor.id)
|
|
312
|
+
continue;
|
|
313
|
+
if (!recordCountsAtAll(record, policy, selfIds))
|
|
314
|
+
continue;
|
|
315
|
+
supersededIds.add(record.supersedes);
|
|
316
|
+
}
|
|
288
317
|
const gated = Boolean(policy && policy.requiredApprovals > 0 && policy.appliesTo.includes(normalized.kind));
|
|
289
318
|
const required = gated ? policy.requiredApprovals : 0;
|
|
290
319
|
const counted = [];
|
|
@@ -651,6 +651,13 @@ function collectAgentFanin(run, input, now) {
|
|
|
651
651
|
...missingRoleIds.map((roleId) => `required role ${roleId} has no membership`),
|
|
652
652
|
...missingMembershipIds.map((membershipId) => `membership ${membershipId} has not reported required evidence`),
|
|
653
653
|
];
|
|
654
|
+
// An aggregation gate that observed zero members must not report itself
|
|
655
|
+
// ready: with no required roles and no memberships every per-item check
|
|
656
|
+
// above is vacuously empty, which used to yield verifierReady:true for a
|
|
657
|
+
// fan-in over nothing (fail-open).
|
|
658
|
+
if (!requiredRoleIds.length && !scopedMemberships.length) {
|
|
659
|
+
blockedReasons.push("fan-in has no memberships and no required roles — nothing to aggregate");
|
|
660
|
+
}
|
|
654
661
|
const requiredMemberships = scopedMemberships.filter((membership) => requiredRoleIds.includes(membership.roleId));
|
|
655
662
|
const blackboardId = input.blackboardId || group.blackboardId || multiAgentRun.blackboardId;
|
|
656
663
|
const requiresBlackboardEvidence = Boolean(blackboardId || requiredMemberships.some((membership) => membership.blackboardId));
|
|
@@ -168,9 +168,15 @@ function evaluatePolicy(policy, operation, topicId, evidenceRefs) {
|
|
|
168
168
|
}
|
|
169
169
|
const missing = missingEvidence(policy, operation, evidenceRefs);
|
|
170
170
|
if (missing.length)
|
|
171
|
-
return `operation ${operation} requires evidence refs: ${missing.join(", ")}`;
|
|
171
|
+
return `operation ${operation} requires evidence refs (at least one; expected kinds: ${missing.join(", ")})`;
|
|
172
172
|
return undefined;
|
|
173
173
|
}
|
|
174
|
+
/** The requiredEvidenceFor entries are prose descriptions ("judge
|
|
175
|
+
* messages", "score evidence"), not machine-matchable ids — so this can
|
|
176
|
+
* only check that SOME evidence ref was supplied, not that each named
|
|
177
|
+
* kind is present. The denial message above says exactly that ("at least
|
|
178
|
+
* one; expected kinds: ...") rather than promising a per-item match this
|
|
179
|
+
* check cannot do. */
|
|
174
180
|
function missingEvidence(policy, operation, evidenceRefs) {
|
|
175
181
|
if (!policy)
|
|
176
182
|
return [];
|
|
@@ -71,6 +71,13 @@ function createDefaultPipelineContract() {
|
|
|
71
71
|
},
|
|
72
72
|
],
|
|
73
73
|
artifactPolicy: { root: ".cw/runs/<run-id>", requireReadablePaths: true },
|
|
74
|
+
// highPriorityRequiresEvidence is carried for byte-compat with the old
|
|
75
|
+
// build's default contract (pinned by SPEC/pipeline-run.md) but is NOT
|
|
76
|
+
// enforced anywhere — no gate reads it, and core has no task-priority
|
|
77
|
+
// concept to key it on. The enforced contract-wide flag is
|
|
78
|
+
// `requireEvidence` (state-node.ts assertRequiredEvidence, runner.ts
|
|
79
|
+
// evidenceSatisfied). The per-stage `requiredEvidence` lists are what
|
|
80
|
+
// actually gate evidence in the default contract.
|
|
74
81
|
evidencePolicy: { highPriorityRequiresEvidence: true },
|
|
75
82
|
failurePolicy: { preserveFailureNodes: true, retryableByDefault: false },
|
|
76
83
|
commitPolicy: { requiresVerifierGate: true, acceptedVerifierStatuses: ["verified"] },
|
|
@@ -85,10 +85,10 @@ function sourceFor(classification) {
|
|
|
85
85
|
return "contract";
|
|
86
86
|
return "manual";
|
|
87
87
|
}
|
|
88
|
-
/** Feedback dedup key: joined with
|
|
88
|
+
/** Feedback dedup key: joined with the ASCII unit separator "\u001f" (runId, code, message, nodeId,
|
|
89
89
|
* stageId, contractId, path). */
|
|
90
90
|
function feedbackKey(value) {
|
|
91
|
-
return [value.runId || "", value.code || "", value.message || "", value.nodeId || "", value.stageId || "", value.contractId || "", value.path || ""].join("
|
|
91
|
+
return [value.runId || "", value.code || "", value.message || "", value.nodeId || "", value.stageId || "", value.contractId || "", value.path || ""].join("\u001f");
|
|
92
92
|
}
|
|
93
93
|
function compactMetadata(metadata) {
|
|
94
94
|
const compacted = {};
|
|
@@ -116,8 +116,13 @@ function extractEvidenceContent(locator, baseDirs, ops, readFile) {
|
|
|
116
116
|
const shape = classify(locator);
|
|
117
117
|
if (shape.kind !== "file" || !shape.pathPart)
|
|
118
118
|
return undefined;
|
|
119
|
-
|
|
119
|
+
// Match the same shapes LINE_SUFFIX_RE (classify) accepts: ":<n>" and
|
|
120
|
+
// ":<n>-<m>". A range that only matched classify used to fall through to
|
|
121
|
+
// the head-of-file slice below — returning bytes that had nothing to do
|
|
122
|
+
// with the cited lines.
|
|
123
|
+
const lineMatch = locator.match(/:(\d+)(?:-(\d+))?$/);
|
|
120
124
|
const lineNum = lineMatch ? Number(lineMatch[1]) : undefined;
|
|
125
|
+
const lineEnd = lineMatch && lineMatch[2] ? Number(lineMatch[2]) : undefined;
|
|
121
126
|
const candidatePath = ops.isAbsolute(shape.pathPart)
|
|
122
127
|
? shape.pathPart
|
|
123
128
|
: baseDirs.filter(Boolean).map((base) => ops.resolve(base, shape.pathPart)).find((p) => ops.exists(p));
|
|
@@ -128,6 +133,13 @@ function extractEvidenceContent(locator, baseDirs, ops, readFile) {
|
|
|
128
133
|
return undefined;
|
|
129
134
|
if (lineNum && lineNum > 0) {
|
|
130
135
|
const lines = content.split("\n");
|
|
136
|
+
if (lineEnd !== undefined) {
|
|
137
|
+
// Range: return exactly the cited lines; a backwards or out-of-range
|
|
138
|
+
// span returns undefined, never fabricated content.
|
|
139
|
+
if (lineEnd < lineNum || lineNum > lines.length)
|
|
140
|
+
return undefined;
|
|
141
|
+
return lines.slice(lineNum - 1, Math.min(lineEnd, lines.length)).join("\n") || undefined;
|
|
142
|
+
}
|
|
131
143
|
return lines[lineNum - 1] || undefined;
|
|
132
144
|
}
|
|
133
145
|
return content.slice(0, 200);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// core/util/cli-args.ts — shared CLI argv / MCP tool-call arg-coercion
|
|
3
|
+
// helpers: required(), optionalArg(), wantsJson().
|
|
4
|
+
//
|
|
5
|
+
// Pure. No fs, no child_process, no net, no process.env, no Date.now(), no
|
|
6
|
+
// Math.random().
|
|
7
|
+
//
|
|
8
|
+
// Moved out of cli/io.ts (architecture-review P2): a cli/-layer file may
|
|
9
|
+
// not be imported by wiring/ (see scripts/purity-gate.js's layer rule),
|
|
10
|
+
// but every wiring/capability-table/*.ts slice called these three pure
|
|
11
|
+
// functions directly to coerce its own handler's args. Shared by CLI argv
|
|
12
|
+
// (cli/dispatch.ts, the wiring slices) AND MCP tool-call args (the same
|
|
13
|
+
// wiring slices' MCP handler bodies) — see core/util/numeric-flag.ts for
|
|
14
|
+
// the same CLI/MCP-shared framing.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.required = required;
|
|
17
|
+
exports.optionalArg = optionalArg;
|
|
18
|
+
exports.wantsJson = wantsJson;
|
|
19
|
+
exports.parseBoolFlag = parseBoolFlag;
|
|
20
|
+
/** Require a positional/option value or fail with a copy-pasteable recovery tip. */
|
|
21
|
+
function required(value, label) {
|
|
22
|
+
if (!value) {
|
|
23
|
+
throw new Error(`Missing ${label}.\n Tip: find run ids with "cw run list" or create one with "cw quickstart"`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
/** Normalize an optional CLI arg to a trimmed non-empty string, else undefined. */
|
|
28
|
+
function optionalArg(value) {
|
|
29
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** True when the caller asked for JSON output (`--json` or `--format json`). */
|
|
32
|
+
function wantsJson(options) {
|
|
33
|
+
return Boolean(options.json || options.format === "json");
|
|
34
|
+
}
|
|
35
|
+
/** Parse a boolean flag value that may arrive as a real boolean (bare
|
|
36
|
+
* `--flag`) or as a CLI/MCP string (`--flag false`). `Boolean("false")` is
|
|
37
|
+
* `true` in JS, so a plain Boolean() coercion silently ENABLES a flag the
|
|
38
|
+
* operator asked to turn off — on a gate-policy flag like
|
|
39
|
+
* `--allow-self-approval false` that is a fail-open. Recognized strings
|
|
40
|
+
* (case-insensitive, trimmed): true/1/yes/on and false/0/no/off/"".
|
|
41
|
+
* Anything else throws — fail closed, never guess. `undefined` and JSON
|
|
42
|
+
* `null` (an MCP caller's "unset") stay `undefined` so a caller's
|
|
43
|
+
* `?? existing ?? default` chain still governs an unset flag. */
|
|
44
|
+
function parseBoolFlag(value, label) {
|
|
45
|
+
if (value === undefined || value === null)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (typeof value === "boolean")
|
|
48
|
+
return value;
|
|
49
|
+
const text = String(value).trim().toLowerCase();
|
|
50
|
+
if (text === "true" || text === "1" || text === "yes" || text === "on")
|
|
51
|
+
return true;
|
|
52
|
+
if (text === "false" || text === "0" || text === "no" || text === "off" || text === "")
|
|
53
|
+
return false;
|
|
54
|
+
throw new Error(`Invalid boolean value for ${label}: "${String(value)}" (use true or false)`);
|
|
55
|
+
}
|
package/dist/core/version.js
CHANGED
|
@@ -7,7 +7,7 @@ exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA
|
|
|
7
7
|
// `cw version` output does not change bytes just because the code under it
|
|
8
8
|
// was rebuilt. See SPEC/cli-surface.md "Exact outputs > Version" and
|
|
9
9
|
// conformance/cases/version-basic.case.js (regex `/^\d+\.\d+\.\d+\n$/`).
|
|
10
|
-
exports.CURRENT_COOL_WORKFLOW_VERSION = "0.2.
|
|
10
|
+
exports.CURRENT_COOL_WORKFLOW_VERSION = "0.2.5";
|
|
11
11
|
// State-kernel schema version constants (SPEC/state-core.md "Version
|
|
12
12
|
// constants"). Pinned to the old build's src/version.ts byte-for-byte.
|
|
13
13
|
exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
|
package/dist/mcp/dispatch.js
CHANGED
|
@@ -81,6 +81,22 @@ function requiredToolArguments(name, value) {
|
|
|
81
81
|
}
|
|
82
82
|
return args;
|
|
83
83
|
}
|
|
84
|
+
/** MCP clients send JSON, so an argument the CLI would receive as a string
|
|
85
|
+
* can arrive as a number or boolean (e.g. `{"runId": 5}`). Every capability
|
|
86
|
+
* handler coerces its args through optionalString/numberArg/boolArg, all of
|
|
87
|
+
* which expect the CLI's string form — optionalString silently DROPS a
|
|
88
|
+
* number, so `{"runId": 5}` used to look like "no runId given" and returned
|
|
89
|
+
* a success-shaped WRONG answer (the "create a run first" payload). Coerce
|
|
90
|
+
* top-level scalars to their string form so an MCP arg behaves exactly like
|
|
91
|
+
* the CLI's argv, which is always strings. Arrays/objects (e.g.
|
|
92
|
+
* authorizedRoles) pass through untouched; null/undefined stay absent. */
|
|
93
|
+
function coerceScalarArgs(args) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const [key, value] of Object.entries(args)) {
|
|
96
|
+
out[key] = typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ? String(value) : value;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
84
100
|
/** `callTool(name, args)` — resolves `cwd` (SPEC/mcp.md invariant 7),
|
|
85
101
|
* checks required args, then calls the row's `mcp.handler`. Throws
|
|
86
102
|
* `Unknown tool: <name>` for a name with no row (src/mcp/tool-call.ts:513
|
|
@@ -90,12 +106,16 @@ function callTool(name, rawArgs) {
|
|
|
90
106
|
if (!row || !row.mcp) {
|
|
91
107
|
throw new Error(`Unknown tool: ${name}`);
|
|
92
108
|
}
|
|
93
|
-
const args = requiredToolArguments(name, rawArgs);
|
|
109
|
+
const args = coerceScalarArgs(requiredToolArguments(name, rawArgs));
|
|
94
110
|
const resolvedArgs = { ...args };
|
|
95
111
|
const cwdInput = args.cwd;
|
|
96
112
|
if (typeof cwdInput === "string" && cwdInput.length > 0) {
|
|
97
113
|
const resolved = path.resolve(cwdInput);
|
|
98
|
-
|
|
114
|
+
// throwIfNoEntry:false so a MISSING cwd yields the same crafted
|
|
115
|
+
// "not a directory" message as a non-dir path — not a raw ENOENT that
|
|
116
|
+
// gives the recovery-hint matcher nothing to key on.
|
|
117
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
118
|
+
if (!stat || !stat.isDirectory()) {
|
|
99
119
|
throw new Error(`MCP cwd is not a directory: ${resolved}`);
|
|
100
120
|
}
|
|
101
121
|
resolvedArgs.cwd = resolved;
|
package/dist/mcp/server.js
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
// "Invariants and error behavior" #9 / "Edge cases" sections:
|
|
8
8
|
// - transport: stdin/stdout, ONE JSON object per line, no
|
|
9
9
|
// Content-Length headers (mcp.md:13);
|
|
10
|
-
// - `initialize` -> protocolVersion/capabilities/serverInfo
|
|
11
|
-
//
|
|
10
|
+
// - `initialize` -> protocolVersion/capabilities/serverInfo (mcp.md:
|
|
11
|
+
// 19,263-269); the reply's protocolVersion echoes the client's
|
|
12
|
+
// requested one when it is in SUPPORTED_PROTOCOL_VERSIONS below, and
|
|
13
|
+
// falls back to the newest supported entry otherwise (with today's
|
|
14
|
+
// one-entry list this is byte-identical to the old fixed reply);
|
|
12
15
|
// - `tools/list` -> { tools: [...] } from core/capability-table.ts via
|
|
13
16
|
// mcp/dispatch.ts, ignoring params (mcp.md:20,271-277);
|
|
14
17
|
// - `tools/call` -> { content: [{ type: "text", text: <2-space pretty
|
|
@@ -48,12 +51,40 @@
|
|
|
48
51
|
// blow V8's per-string limit) becomes a small overflow notice instead
|
|
49
52
|
// of a multi-hundred-MB payload — every result under the cap is
|
|
50
53
|
// untouched, so this never affects the parity gate's own fixtures.
|
|
54
|
+
// - `tools/call` FAILURE result shape (post-v0.2.4 robustness hardening,
|
|
55
|
+
// not in the original mcp.md): an unknown tool name, a missing
|
|
56
|
+
// required tool argument, or the tool's own handler throwing is a
|
|
57
|
+
// normal RESULT, not a bare -32000 JSON-RPC protocol error — many MCP
|
|
58
|
+
// hosts never surface a protocol error back to the calling model, so
|
|
59
|
+
// it could not read the message or try again. The result is shaped
|
|
60
|
+
// { content: [{ type: "text", text: <message, plus a "Try: <hint>"
|
|
61
|
+
// line when core/format/recovery-hint.ts's recoveryHint finds one>
|
|
62
|
+
// }], isError: true }, same `resultMessage` helper as the success
|
|
63
|
+
// path. The envelope-level "missing field: name" check (right above
|
|
64
|
+
// this bullet) is unchanged — it still answers -32000.
|
|
51
65
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
66
|
+
exports.negotiateProtocolVersion = negotiateProtocolVersion;
|
|
52
67
|
exports.startServer = startServer;
|
|
53
68
|
const version_1 = require("../core/version");
|
|
54
69
|
const safe_json_1 = require("../core/format/safe-json");
|
|
70
|
+
const recovery_hint_1 = require("../core/format/recovery-hint");
|
|
55
71
|
const dispatch_1 = require("./dispatch");
|
|
56
72
|
const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
73
|
+
/** Protocol versions this server can speak, oldest first. `initialize`
|
|
74
|
+
* echoes the client's `params.protocolVersion` when it is in this list,
|
|
75
|
+
* and answers with the newest entry (the last one) otherwise — the
|
|
76
|
+
* standard MCP version-negotiation shape. With one entry this is
|
|
77
|
+
* behavior-identical to the old hard-coded reply (mechanism first; a
|
|
78
|
+
* second version is a one-line append here). */
|
|
79
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05"];
|
|
80
|
+
/** Picks the `initialize` reply's protocolVersion from the client's
|
|
81
|
+
* requested one (see SUPPORTED_PROTOCOL_VERSIONS). Exported for the
|
|
82
|
+
* protocol-version smoke; pure. */
|
|
83
|
+
function negotiateProtocolVersion(requested) {
|
|
84
|
+
if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested))
|
|
85
|
+
return requested;
|
|
86
|
+
return SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
|
|
87
|
+
}
|
|
57
88
|
// Tools whose result carries free-form text ORIGINALLY AUTHORED by a
|
|
58
89
|
// worker/agent/operator/external caller — never computed or validated by
|
|
59
90
|
// this codebase itself (a blackboard message body, a review comment, a
|
|
@@ -117,6 +148,11 @@ function resultMessage(id, result) {
|
|
|
117
148
|
if (id !== undefined)
|
|
118
149
|
message.id = id;
|
|
119
150
|
return message;
|
|
151
|
+
// NOTE: a notification-shaped request (initialize/tools/list/tools/call
|
|
152
|
+
// with no `id`) still gets a reply here, just without an `id` key — a
|
|
153
|
+
// deliberate deviation from strict JSON-RPC (which says a notification
|
|
154
|
+
// MUST NOT be answered) pinned by SPEC/mcp.md's edge-cases. Revisit only
|
|
155
|
+
// as a deliberate spec change, not as a drive-by fix.
|
|
120
156
|
}
|
|
121
157
|
/** Handles one already-parsed JSON-RPC request object. May write zero or
|
|
122
158
|
* one reply line to stdout. `await`ing callTool's result is a no-op for
|
|
@@ -142,8 +178,9 @@ async function handleRequest(message) {
|
|
|
142
178
|
try {
|
|
143
179
|
switch (message.method) {
|
|
144
180
|
case "initialize": {
|
|
181
|
+
const params = (message.params ?? {});
|
|
145
182
|
writeMessage(resultMessage(id, {
|
|
146
|
-
protocolVersion:
|
|
183
|
+
protocolVersion: negotiateProtocolVersion(params.protocolVersion),
|
|
147
184
|
capabilities: { tools: {} },
|
|
148
185
|
serverInfo: { name: "cool-workflow", version: version_1.CURRENT_COOL_WORKFLOW_VERSION },
|
|
149
186
|
}));
|
|
@@ -153,6 +190,17 @@ async function handleRequest(message) {
|
|
|
153
190
|
writeMessage(resultMessage(id, { tools: (0, dispatch_1.toolDefinitions)() }));
|
|
154
191
|
return;
|
|
155
192
|
}
|
|
193
|
+
case "ping": {
|
|
194
|
+
// MCP (2024-11-05, the version negotiateProtocolVersion advertises)
|
|
195
|
+
// makes ping mandatory: reply promptly with an EMPTY result. Hosts
|
|
196
|
+
// ping for keep-alive and may drop a connection that never answers.
|
|
197
|
+
// Answered here in the fast protocol path (not the serial tool
|
|
198
|
+
// queue), so a ping during a long cw_run drive still gets a reply.
|
|
199
|
+
// A ping notification (no id) gets no reply, per JSON-RPC.
|
|
200
|
+
if (hasId)
|
|
201
|
+
writeMessage(resultMessage(id, {}));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
156
204
|
case "tools/call": {
|
|
157
205
|
const params = message.params ?? {};
|
|
158
206
|
const name = params.name;
|
|
@@ -160,12 +208,32 @@ async function handleRequest(message) {
|
|
|
160
208
|
throw new Error("MCP tools/call missing required field: name");
|
|
161
209
|
}
|
|
162
210
|
const args = params.arguments;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
211
|
+
// A failure from HERE down (an unknown tool name, a missing
|
|
212
|
+
// required tool argument, or the tool's own handler throwing) is a
|
|
213
|
+
// normal call outcome, not a broken request — many MCP hosts never
|
|
214
|
+
// surface a bare JSON-RPC protocol error (-32000) back to the
|
|
215
|
+
// calling model at all, so it could not see the message and try
|
|
216
|
+
// again. Answer with a normal RESULT instead, shaped isError:
|
|
217
|
+
// true, so the model always sees the message (and, when one
|
|
218
|
+
// applies, a "Try: <hint>" recovery line) and can self-correct.
|
|
219
|
+
// The envelope-level "missing field: name" check above stays OUT
|
|
220
|
+
// of this inner try/catch — that one is a malformed request, not
|
|
221
|
+
// a tool-call outcome, and keeps going through the outer
|
|
222
|
+
// try/catch as a -32000 error, unchanged.
|
|
223
|
+
try {
|
|
224
|
+
const coreResult = await (0, dispatch_1.callTool)(name, args ?? {});
|
|
225
|
+
const content = [{ type: "text", text: (0, safe_json_1.safeJsonStringify)(coreResult) }];
|
|
226
|
+
const advisory = untrustedContentAdvisory(name);
|
|
227
|
+
if (advisory)
|
|
228
|
+
content.push({ type: "text", text: advisory });
|
|
229
|
+
writeMessage(resultMessage(id, { content }));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
233
|
+
const hint = (0, recovery_hint_1.recoveryHint)(text);
|
|
234
|
+
const errorText = hint ? `${text}\nTry: ${hint}` : text;
|
|
235
|
+
writeMessage(resultMessage(id, { content: [{ type: "text", text: errorText }], isError: true }));
|
|
236
|
+
}
|
|
169
237
|
return;
|
|
170
238
|
}
|
|
171
239
|
default: {
|
|
@@ -205,14 +273,52 @@ async function handleLine(line) {
|
|
|
205
273
|
* tool but `cw_run` is still a plain synchronous handler), so this adds
|
|
206
274
|
* no real delay, but it keeps replies in the same order the requests
|
|
207
275
|
* arrived even now that one tool (`cw_run`'s live drive loop) can take
|
|
208
|
-
* many real event-loop turns to answer.
|
|
276
|
+
* many real event-loop turns to answer. The one exception is `ping`,
|
|
277
|
+
* answered in handleRequest's fast path before any tool work, so a
|
|
278
|
+
* keep-alive ping still gets a reply while a long drive holds the queue. */
|
|
209
279
|
function startServer() {
|
|
210
280
|
process.stdin.setEncoding("utf8");
|
|
211
281
|
let buffer = "";
|
|
282
|
+
// True while the REST of an oversize line is still streaming in: emit one
|
|
283
|
+
// -32700 for the whole line and skip everything up to its terminating
|
|
284
|
+
// newline, instead of dropping the head and then re-parsing the tail as a
|
|
285
|
+
// fresh (also-failing) line — which produced a second, spurious parse
|
|
286
|
+
// error per 16MB crossed.
|
|
287
|
+
let discarding = false;
|
|
212
288
|
let queue = Promise.resolve();
|
|
289
|
+
// Chain each task onto `queue` WITH a per-task `.catch`. The `.catch` is
|
|
290
|
+
// load-bearing, not decoration: a task here can reject — a raw
|
|
291
|
+
// `writeMessage` (`process.stdout.write`) that throws mid-reply because the
|
|
292
|
+
// client closed the pipe, or any other throw out of handleLine — and
|
|
293
|
+
// without a handler that one rejection would leave `queue` REJECTED for
|
|
294
|
+
// good, so every later `queue.then(...)` is skipped and the server goes
|
|
295
|
+
// silent and answers no more requests (finding: one bad write poisons the
|
|
296
|
+
// queue). The `.catch` swallows the single failure onto stderr
|
|
297
|
+
// (diagnostics, never stdout data) and hands back a RESOLVED promise, so
|
|
298
|
+
// the next request is still served. Order is still kept: the next task
|
|
299
|
+
// only runs after this one settles.
|
|
300
|
+
const enqueue = (task) => {
|
|
301
|
+
queue = queue.then(task).catch((error) => {
|
|
302
|
+
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
303
|
+
process.stderr.write(`cool-workflow mcp: a request failed and its reply was dropped; still serving: ${detail}\n`);
|
|
304
|
+
});
|
|
305
|
+
};
|
|
213
306
|
process.stdin.on("data", (chunk) => {
|
|
214
307
|
buffer += chunk;
|
|
215
308
|
for (;;) {
|
|
309
|
+
if (discarding) {
|
|
310
|
+
// Skip the rest of an oversize line already reported. Until its
|
|
311
|
+
// terminating newline arrives, throw away what we have (so a huge
|
|
312
|
+
// line can't grow the buffer unboundedly); once found, resume
|
|
313
|
+
// normal parsing from the next line with no second error.
|
|
314
|
+
const nl = buffer.indexOf("\n");
|
|
315
|
+
if (nl === -1) {
|
|
316
|
+
buffer = "";
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
buffer = buffer.slice(nl + 1);
|
|
320
|
+
discarding = false;
|
|
321
|
+
}
|
|
216
322
|
const newlineIndex = buffer.indexOf("\n");
|
|
217
323
|
if (newlineIndex === -1)
|
|
218
324
|
break;
|
|
@@ -220,11 +326,16 @@ function startServer() {
|
|
|
220
326
|
buffer = buffer.slice(newlineIndex + 1);
|
|
221
327
|
const trimmed = line.trim();
|
|
222
328
|
if (trimmed)
|
|
223
|
-
|
|
329
|
+
enqueue(() => handleLine(trimmed));
|
|
224
330
|
}
|
|
225
|
-
|
|
331
|
+
// No newline yet and the pending (unterminated) line already exceeds the
|
|
332
|
+
// cap: report ONCE, drop the head, and discard the rest of this line
|
|
333
|
+
// until its newline arrives (guarded by `discarding` so a >32MB line
|
|
334
|
+
// yields a single -32700, not one per 16MB crossed).
|
|
335
|
+
if (!discarding && buffer.length > MAX_LINE_BYTES) {
|
|
226
336
|
buffer = "";
|
|
227
|
-
|
|
337
|
+
discarding = true;
|
|
338
|
+
enqueue(() => {
|
|
228
339
|
writeMessage(errorMessage(null, -32700, `Parse error: request line exceeds ${MAX_LINE_BYTES} bytes`));
|
|
229
340
|
});
|
|
230
341
|
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -6,4 +6,24 @@
|
|
|
6
6
|
// real MCP client launches it directly with `node`).
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
const server_1 = require("./mcp/server");
|
|
9
|
+
/** Broken-pipe guard for the MCP stdio server, the same idea as
|
|
10
|
+
* cli/entry.ts's `main()`. When an MCP client closes the read end of our
|
|
11
|
+
* stdout part-way through a reply, the raw write gives an async 'error'
|
|
12
|
+
* event that no promise `.catch` can see; with no listener Node comes down
|
|
13
|
+
* hard with a `write EPIPE` stack and exit 1. One process-level listener
|
|
14
|
+
* turns that into a quiet exit 0 — the reader has gone, there is nothing
|
|
15
|
+
* left to say. Any other stream error is thrown again, same as before.
|
|
16
|
+
*
|
|
17
|
+
* This is a small COPY of cli/entry.ts's helper, not an import: the purity
|
|
18
|
+
* gate (scripts/purity-gate.js) forbids an mcp/ file from importing cli/,
|
|
19
|
+
* and pulling in the whole CLI entry graph for five lines would be worse. */
|
|
20
|
+
function exitQuietOnEpipe(stream) {
|
|
21
|
+
stream.on("error", (error) => {
|
|
22
|
+
if (error && error.code === "EPIPE")
|
|
23
|
+
process.exit(0);
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
exitQuietOnEpipe(process.stdout);
|
|
28
|
+
exitQuietOnEpipe(process.stderr);
|
|
9
29
|
(0, server_1.startServer)();
|
package/dist/shell/commit.js
CHANGED
|
@@ -199,7 +199,13 @@ function commitState(run, input) {
|
|
|
199
199
|
// NOT prematurely move the run off "interpret".
|
|
200
200
|
if (gate.verifierGated)
|
|
201
201
|
run.loopStage = "checkpoint";
|
|
202
|
-
(
|
|
202
|
+
// Only store the commit's own (small, bounded) record. The whole run
|
|
203
|
+
// (all tasks, nodes, workers, feedback, and run.commits itself) used to
|
|
204
|
+
// be embedded here too, but no reader anywhere ever parses a `run` key
|
|
205
|
+
// back out of a commits/<id>.json file — see docs/run-retention-reclamation.7.md.
|
|
206
|
+
// Embedding the growing run made total commit-dir bytes grow like N^2
|
|
207
|
+
// in the number of commits.
|
|
208
|
+
(0, fs_atomic_1.writeJson)(snapshotPath, { commit });
|
|
203
209
|
run.commits.push(commit);
|
|
204
210
|
return commit;
|
|
205
211
|
}
|
|
@@ -214,7 +220,7 @@ function recordCommitNode(run, commit, options, gate) {
|
|
|
214
220
|
artifacts: [{ id: "snapshot", kind: "json", path: commit.snapshotPath }],
|
|
215
221
|
evidence: commit.evidence || verifierNode.evidence,
|
|
216
222
|
metadata: { ...(options.metadata || {}), reason: options.reason, commitId: commit.id, verifierGated: true, checkpoint: false, verifierNodeId: verifierNode.id, candidateId: gate.candidateId, selectionId: gate.selectionId, selectionNodeId: gate.selectionNodeId },
|
|
217
|
-
}, { persist: false, persistNode: node_store_1.writeRunNode });
|
|
223
|
+
}, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
|
|
218
224
|
if (gate.selectionNodeId && commitResult.outputNodeId)
|
|
219
225
|
linkAdditionalParent(run, gate.selectionNodeId, commitResult.outputNodeId);
|
|
220
226
|
return commitResult.outputNodeId;
|
|
@@ -158,6 +158,75 @@ function dirtySetsFor(state) {
|
|
|
158
158
|
function markBlackboardDirty(state, kind, id) {
|
|
159
159
|
dirtySetsFor(state)[kind].add(id);
|
|
160
160
|
}
|
|
161
|
+
// Dirty-id tracking for state.messages, PARALLEL to the 5-kind tracking
|
|
162
|
+
// above (not folded in: messages share ONE ordered log file, not a
|
|
163
|
+
// recordPath-per-id file). The only push site is postBlackboardMessage
|
|
164
|
+
// (`state.messages.push(message)`), so the dirty ids at persist time are
|
|
165
|
+
// always exactly the trailing ids of state.messages since the last
|
|
166
|
+
// persist. Kept off the serialized state for the same reason as
|
|
167
|
+
// blackboardDirtySets: a WeakMap, never a field on BlackboardState.
|
|
168
|
+
const blackboardMessagesDirty = new WeakMap();
|
|
169
|
+
function dirtyMessageIdsFor(state) {
|
|
170
|
+
let ids = blackboardMessagesDirty.get(state);
|
|
171
|
+
if (!ids) {
|
|
172
|
+
ids = new Set();
|
|
173
|
+
blackboardMessagesDirty.set(state, ids);
|
|
174
|
+
}
|
|
175
|
+
return ids;
|
|
176
|
+
}
|
|
177
|
+
function markBlackboardMessageDirty(state, id) {
|
|
178
|
+
dirtyMessageIdsFor(state).add(id);
|
|
179
|
+
}
|
|
180
|
+
// Writes messages.jsonl for a persist call, or skips it entirely when
|
|
181
|
+
// nothing changed (the common case: 7 of the 8 persistBlackboardState call
|
|
182
|
+
// sites never touch state.messages).
|
|
183
|
+
//
|
|
184
|
+
// Fast path (the common case for the ONE call site that does change
|
|
185
|
+
// messages, postBlackboardMessage): the dirty ids are exactly the trailing
|
|
186
|
+
// `k` entries of state.messages (the sole push site always appends). Sort
|
|
187
|
+
// only that small batch, check it does not need to interleave with the
|
|
188
|
+
// already-on-disk tail, and APPEND it — no read, no resort, no rewrite of
|
|
189
|
+
// the earlier messages. This turns posting M messages from O(M^2) written
|
|
190
|
+
// bytes into O(M) total.
|
|
191
|
+
//
|
|
192
|
+
// Fallback (a caller-supplied custom --id ties createdAt with an earlier
|
|
193
|
+
// message and sorts BEFORE it, so a plain append would leave the file out
|
|
194
|
+
// of order): resort the whole array and rewrite the whole file, byte-
|
|
195
|
+
// identical to the code path this replaces.
|
|
196
|
+
//
|
|
197
|
+
// Either way state.messages is left fully sorted after this call, exactly
|
|
198
|
+
// as the old unconditional `.sort()` left it — buildBlackboardGraph (which
|
|
199
|
+
// iterates state.messages in raw array order) sees the same order as
|
|
200
|
+
// before.
|
|
201
|
+
function persistBlackboardMessages(run, state) {
|
|
202
|
+
const dirty = dirtyMessageIdsFor(state);
|
|
203
|
+
if (dirty.size === 0)
|
|
204
|
+
return;
|
|
205
|
+
const file = messagesPath(run);
|
|
206
|
+
const total = state.messages.length;
|
|
207
|
+
const k = dirty.size;
|
|
208
|
+
const priorCount = total - k;
|
|
209
|
+
const batch = priorCount >= 0 ? state.messages.slice(priorCount).sort(cb.compareRecords) : [];
|
|
210
|
+
const batchMatchesDirty = batch.length === dirty.size && batch.every((message) => dirty.has(message.id));
|
|
211
|
+
const priorTail = priorCount > 0 ? state.messages[priorCount - 1] : undefined;
|
|
212
|
+
const canAppend = batchMatchesDirty && (!priorTail || cb.compareRecords(priorTail, batch[0]) <= 0);
|
|
213
|
+
if (canAppend) {
|
|
214
|
+
state.messages.splice(priorCount, k, ...batch);
|
|
215
|
+
const lines = batch.map((message) => `${JSON.stringify(message)}\n`).join("");
|
|
216
|
+
const currentBytes = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
|
217
|
+
// Same torn-tail guard as trust-audit's events.jsonl (shared helper):
|
|
218
|
+
// a crash mid-append can leave the log without its final "\n"; put the
|
|
219
|
+
// new lines on their own clean line rather than merging with a torn
|
|
220
|
+
// tail.
|
|
221
|
+
const leadingNewline = currentBytes > 0 && !(0, fs_atomic_1.logEndsWithNewline)(file, currentBytes) ? "\n" : "";
|
|
222
|
+
(0, fs_atomic_1.durableAppendFileSync)(file, leadingNewline + lines);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Same bytes the old unconditional code path always wrote.
|
|
226
|
+
(0, fs_atomic_1.writeTextDurable)(file, state.messages.sort(cb.compareRecords).map((message) => JSON.stringify(message)).join("\n") + (state.messages.length ? "\n" : ""));
|
|
227
|
+
}
|
|
228
|
+
dirty.clear();
|
|
229
|
+
}
|
|
161
230
|
function linkMultiAgent(run, blackboardId, topicIds, input) {
|
|
162
231
|
const groupId = input.agentGroupId ?? input.groupId;
|
|
163
232
|
const roleId = input.agentRoleId ?? input.roleId;
|
|
@@ -218,7 +287,9 @@ function persistBlackboardState(run) {
|
|
|
218
287
|
decisions: state.decisions.map(cb.indexRow),
|
|
219
288
|
messages: state.messages.map((message) => ({ id: message.id, blackboardId: message.blackboardId, topicId: message.topicId, createdAt: message.createdAt, status: message.status, author: message.author, evidenceRefs: message.linkedEvidenceRefs, artifactRefIds: message.linkedArtifactRefIds })),
|
|
220
289
|
});
|
|
221
|
-
|
|
290
|
+
// messages.jsonl: see persistBlackboardMessages above for the
|
|
291
|
+
// skip-if-unchanged / append-if-safe / full-rewrite-fallback split.
|
|
292
|
+
persistBlackboardMessages(run, state);
|
|
222
293
|
const dirty = dirtySetsFor(state);
|
|
223
294
|
for (const id of dirty.topics) {
|
|
224
295
|
const record = state.topics.find((entry) => entry.id === id);
|
|
@@ -308,6 +379,7 @@ function postBlackboardMessage(run, input) {
|
|
|
308
379
|
requireArtifactRefs(run, input.artifactRefIds || []);
|
|
309
380
|
const message = cb.buildMessage(run.id, board, topic, input, id, now(), trust_policy_io_1.hashText, sourceForActorLocal);
|
|
310
381
|
state.messages.push(message);
|
|
382
|
+
markBlackboardMessageDirty(state, message.id);
|
|
311
383
|
topic.messageIds = cb.unique([...topic.messageIds, message.id]);
|
|
312
384
|
board.messageCount = state.messages.filter((entry) => entry.blackboardId === board.id).length;
|
|
313
385
|
cb.touch(topic, now());
|