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
|
@@ -32,6 +32,37 @@ runner owns state transitions, dispatch, result recording, verifier gates,
|
|
|
32
32
|
commits, and reports. A workflow app owns its own inputs, phases, task
|
|
33
33
|
prompts, evidence needs, and sandbox profile hints.
|
|
34
34
|
|
|
35
|
+
Every CW workflow keeps to one loop, and the loop maps to real framework
|
|
36
|
+
operations:
|
|
37
|
+
|
|
38
|
+
| Loop stage | framework operation | Responsibility |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| Interpret | `plan()` | Load workflow, check inputs, make tasks |
|
|
41
|
+
| Act | `dispatch()` | Move tasks that can run from pending to running |
|
|
42
|
+
| Observe | `recordResult()` | Read Markdown/JSON-RPC result evidence |
|
|
43
|
+
| Adjust | verifier gates | Check evidence and pick the next phase |
|
|
44
|
+
| Checkpoint | `commitState()` | Take a snapshot of state after important changes |
|
|
45
|
+
|
|
46
|
+
The language split is on purpose: the platform is TypeScript
|
|
47
|
+
(`src/*.ts -> dist/*.js`, strongly typed so it is simple to keep up), while
|
|
48
|
+
workflow apps are plain JavaScript modules (`apps/<app-id>/workflow.js`,
|
|
49
|
+
legacy `workflows/*.workflow.js`) so workflow scripts run without `ts-node`.
|
|
50
|
+
|
|
51
|
+
Verification and verdict tasks give back a `cw:result` JSON fence:
|
|
52
|
+
|
|
53
|
+
````text
|
|
54
|
+
```cw:result
|
|
55
|
+
{
|
|
56
|
+
"summary": "short summary",
|
|
57
|
+
"findings": [],
|
|
58
|
+
"evidence": ["/absolute/path/file.ts:42"]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
````
|
|
62
|
+
|
|
63
|
+
CW says no to high-priority findings without evidence. This keeps agent work
|
|
64
|
+
nearer to engineering output you can look into than to free talk.
|
|
65
|
+
|
|
35
66
|
The framework is kept small on purpose. The public app helpers are:
|
|
36
67
|
|
|
37
68
|
- `defineWorkflowApp(definition)`
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"_comment": "SINGLE SOURCE OF TRUTH for every vendor manifest. Edit THIS file, then run `npm run gen:manifests`. Do NOT hand-edit the generated vendor manifests (.claude-plugin/, .codex-plugin/, .agents/, .mcp.json) — `npm run gen:manifests -- --check` (run by release:check) will fail if they drift from this source.",
|
|
3
3
|
"identity": {
|
|
4
4
|
"name": "cool-workflow",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.5",
|
|
6
6
|
"license": "BSD-2-Clause",
|
|
7
7
|
"homepage": "https://github.com/coo1white/cool-workflow",
|
|
8
8
|
"author": {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"profiles": {
|
|
4
4
|
"core": {
|
|
5
5
|
"description": "Default AI source context: runtime source, apps, package metadata, and agent wrappers; generated artifacts, tests, docs, release records, and long logs are manifest-only.",
|
|
6
|
-
"maxLines":
|
|
6
|
+
"maxLines": 52000,
|
|
7
7
|
"include": [
|
|
8
8
|
"plugins/cool-workflow/src/**",
|
|
9
9
|
"plugins/cool-workflow/apps/**",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"runtime": {
|
|
27
27
|
"description": "Runtime-kernel source context for state, orchestration, scheduling, execution, and shared types.",
|
|
28
|
-
"maxLines":
|
|
28
|
+
"maxLines": 49000,
|
|
29
29
|
"include": [
|
|
30
30
|
"plugins/cool-workflow/src/**",
|
|
31
31
|
"plugins/cool-workflow/package.json",
|
|
@@ -95,7 +95,8 @@
|
|
|
95
95
|
"include": [
|
|
96
96
|
"AGENTS.md",
|
|
97
97
|
"plugins/cool-workflow/scripts/release-flow.js",
|
|
98
|
-
"plugins/cool-workflow/scripts/release-gate.
|
|
98
|
+
"plugins/cool-workflow/scripts/release-gate.js",
|
|
99
|
+
"plugins/cool-workflow/scripts/release-tags.js",
|
|
99
100
|
"plugins/cool-workflow/scripts/dogfood-release.js",
|
|
100
101
|
"plugins/cool-workflow/scripts/gen-manifests.js",
|
|
101
102
|
"plugins/cool-workflow/scripts/version-sync-check.js",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cool-workflow",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"bin": {
|
|
5
5
|
"cool-workflow": "scripts/cw.js",
|
|
6
6
|
"cw": "scripts/cw.js"
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
".claude-plugin/",
|
|
28
28
|
".codex-plugin/",
|
|
29
|
+
".gemini-plugin/",
|
|
30
|
+
".opencode-plugin/",
|
|
29
31
|
".mcp.json",
|
|
30
32
|
"manifest/",
|
|
31
33
|
"README.md",
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
45
47
|
"dist:check": "node scripts/dist-drift-check.js",
|
|
46
48
|
"purity:check": "node scripts/purity-gate.js",
|
|
49
|
+
"lang:check": "node scripts/lang-policy-check.js",
|
|
47
50
|
"golden-path": "node scripts/golden-path.js",
|
|
48
51
|
"dogfood:release": "node scripts/dogfood-release.js",
|
|
49
52
|
"canonical-apps": "node scripts/canonical-apps.js",
|
|
@@ -55,7 +58,6 @@
|
|
|
55
58
|
"bump:version": "node scripts/bump-version.js",
|
|
56
59
|
"new:feature": "node scripts/new-feature.js",
|
|
57
60
|
"forward-ref": "node scripts/forward-ref-docs.js",
|
|
58
|
-
"verify:container": "node scripts/verify-container-selfref.js",
|
|
59
61
|
"gen:manifests": "node scripts/gen-manifests.js",
|
|
60
62
|
"gen:parity": "node scripts/gen-parity-doc.js",
|
|
61
63
|
"manifest:load-check": "node test/vendor-manifest-load-smoke.js",
|
|
@@ -63,6 +65,7 @@
|
|
|
63
65
|
"onramp:check": "node scripts/onramp-check.js --check",
|
|
64
66
|
"version:sync": "node scripts/version-sync-check.js",
|
|
65
67
|
"release:check": "node scripts/release-check.js",
|
|
68
|
+
"release": "node scripts/release-oneclick.js",
|
|
66
69
|
"test": "node dist/cli.js version > /dev/null && node test/run-all.js --fast --sample 35",
|
|
67
70
|
"test:full": "node dist/cli.js version > /dev/null && node test/run-all.js --sample 55",
|
|
68
71
|
"test:gate": "node dist/cli.js version > /dev/null && node test/run-all.js",
|
|
@@ -490,8 +490,34 @@ function buildFailureDetail({ label, code, childStderr, partialText }) {
|
|
|
490
490
|
return codeText;
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
// CW may set CW_AGENT_VENDOR_PIDFILE when it runs a shipped wrapper. We write
|
|
494
|
+
// the vendor child's PID there so CW can reap the vendor if it has to SIGKILL
|
|
495
|
+
// this wrapper on a timeout: a SIGKILL is uncatchable, so the wrapper cannot
|
|
496
|
+
// forward the stop to its vendor child itself, and the vendor would otherwise
|
|
497
|
+
// live on as an orphan and keep spending (see execution-backend/agent.ts). The
|
|
498
|
+
// file is removed as soon as the vendor exits, so a stale PID is never reaped.
|
|
499
|
+
// Best-effort: fs errors are ignored -- reaping is a safety net, not a
|
|
500
|
+
// correctness dependency -- and an arbitrary CW_AGENT_COMMAND that never calls
|
|
501
|
+
// this is simply not covered.
|
|
502
|
+
function recordVendorPid(child, env = process.env) {
|
|
503
|
+
const pidfile = env.CW_AGENT_VENDOR_PIDFILE;
|
|
504
|
+
if (!pidfile || !child || !child.pid) return child;
|
|
505
|
+
try { fs.writeFileSync(pidfile, String(child.pid), "utf8"); } catch { /* best-effort */ }
|
|
506
|
+
let cleared = false;
|
|
507
|
+
const clear = () => {
|
|
508
|
+
if (cleared) return;
|
|
509
|
+
cleared = true;
|
|
510
|
+
try { fs.unlinkSync(pidfile); } catch { /* already gone */ }
|
|
511
|
+
};
|
|
512
|
+
child.once("close", clear);
|
|
513
|
+
child.once("exit", clear);
|
|
514
|
+
process.once("exit", clear);
|
|
515
|
+
return child;
|
|
516
|
+
}
|
|
517
|
+
|
|
493
518
|
module.exports = {
|
|
494
519
|
RESULT_CONTRACT,
|
|
520
|
+
recordVendorPid, // write the vendor child's PID to CW_AGENT_VENDOR_PIDFILE so cw can reap it on a timeout
|
|
495
521
|
buildPrompt,
|
|
496
522
|
streamEnabled,
|
|
497
523
|
traceEnabled,
|
|
@@ -34,7 +34,7 @@ const { spawn, spawnSync } = require("node:child_process");
|
|
|
34
34
|
// wrappers instead of carrying a private copy. A drifted inline copy (ASCII
|
|
35
35
|
// hyphens silently became em-dashes here) meant claude was sent a different
|
|
36
36
|
// instruction text than the other providers for the same contract.
|
|
37
|
-
const { buildPrompt, createRenderer, persistStderr, toolLabel, summarizeToolResult, buildFailureDetail } = require("./agent-adapter-core");
|
|
37
|
+
const { buildPrompt, createRenderer, persistStderr, toolLabel, summarizeToolResult, buildFailureDetail, recordVendorPid } = require("./agent-adapter-core");
|
|
38
38
|
|
|
39
39
|
const inputPath = process.argv[2];
|
|
40
40
|
const resultPath = process.argv[3];
|
|
@@ -103,6 +103,9 @@ const child = spawn(
|
|
|
103
103
|
["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", "Read,Grep,Glob"],
|
|
104
104
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
105
105
|
);
|
|
106
|
+
// Record the vendor PID so cw can reap this claude process if it SIGKILLs the
|
|
107
|
+
// wrapper on a timeout (see agent-adapter-core recordVendorPid).
|
|
108
|
+
recordVendorPid(child);
|
|
106
109
|
|
|
107
110
|
let model;
|
|
108
111
|
let usage;
|
|
@@ -43,6 +43,7 @@ const {
|
|
|
43
43
|
flushJsonLines,
|
|
44
44
|
parseJsonLines,
|
|
45
45
|
persistStderr,
|
|
46
|
+
recordVendorPid,
|
|
46
47
|
writeResult
|
|
47
48
|
} = require("./agent-adapter-core");
|
|
48
49
|
|
|
@@ -133,6 +134,9 @@ const child = spawn("codex", args, {
|
|
|
133
134
|
stdio: ["pipe", "pipe", "pipe"],
|
|
134
135
|
shell: false
|
|
135
136
|
});
|
|
137
|
+
// Record the vendor PID so cw can reap this codex process if it SIGKILLs the
|
|
138
|
+
// wrapper on a timeout (see agent-adapter-core recordVendorPid).
|
|
139
|
+
recordVendorPid(child);
|
|
136
140
|
|
|
137
141
|
child.stdin.setDefaultEncoding("utf8");
|
|
138
142
|
child.stdin.end(prompt);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Point CW at it (from plugins/cool-workflow/), keeping your inner agent intact:
|
|
21
21
|
// CW_AGENT_ATTEST_PRIVKEY=$PWD/cw-attest.key \
|
|
22
22
|
// CW_AGENT_COMMAND="node $PWD/scripts/agents/cw-attest-wrap.js --manifest {{manifest}} -- \
|
|
23
|
-
//
|
|
23
|
+
// node $PWD/scripts/agents/claude-p-agent.js {{input}} {{result}}"
|
|
24
24
|
// and configure CW's verify side: CW_AGENT_ATTEST_PUBKEY=$PWD/cw-attest.pub
|
|
25
25
|
//
|
|
26
26
|
// Honest posture: if no private key is set, or the agent reports no usage, the
|
|
@@ -24,6 +24,7 @@ const {
|
|
|
24
24
|
flushJsonLines,
|
|
25
25
|
parseJsonLines,
|
|
26
26
|
persistStderr,
|
|
27
|
+
recordVendorPid,
|
|
27
28
|
writeResult
|
|
28
29
|
} = require("./agent-adapter-core");
|
|
29
30
|
|
|
@@ -71,6 +72,9 @@ const child = spawn("gemini", args, {
|
|
|
71
72
|
stdio: ["ignore", "pipe", "pipe"],
|
|
72
73
|
shell: false
|
|
73
74
|
});
|
|
75
|
+
// Record the vendor PID so cw can reap this gemini process if it SIGKILLs the
|
|
76
|
+
// wrapper on a timeout (see agent-adapter-core recordVendorPid).
|
|
77
|
+
recordVendorPid(child);
|
|
74
78
|
|
|
75
79
|
child.stdout.setEncoding("utf8");
|
|
76
80
|
child.stdout.on("data", (chunk) => {
|
|
@@ -29,6 +29,7 @@ const {
|
|
|
29
29
|
flushJsonLines,
|
|
30
30
|
parseJsonLines,
|
|
31
31
|
persistStderr,
|
|
32
|
+
recordVendorPid,
|
|
32
33
|
writeResult
|
|
33
34
|
} = require("./agent-adapter-core");
|
|
34
35
|
|
|
@@ -124,6 +125,10 @@ const child = spawn("opencode", args, {
|
|
|
124
125
|
stdio: ["ignore", "pipe", "pipe"],
|
|
125
126
|
shell: false
|
|
126
127
|
});
|
|
128
|
+
// Record the vendor PID so cw can reap this opencode process (also used by the
|
|
129
|
+
// deepseek wrapper, which re-exports this file) if it SIGKILLs the wrapper on a
|
|
130
|
+
// timeout (see agent-adapter-core recordVendorPid).
|
|
131
|
+
recordVendorPid(child);
|
|
127
132
|
|
|
128
133
|
child.stdout.setEncoding("utf8");
|
|
129
134
|
child.stdout.on("data", (chunk) => {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// block-unapproved-tag.js — PreToolUse hook for the Bash tool.
|
|
4
|
+
// Reads hook input JSON on stdin. If the command creates or pushes a tag,
|
|
5
|
+
// require BOTH markers for the current HEAD sha:
|
|
6
|
+
// .cw-release/gate-<sha>.ok (written by release-gate.js)
|
|
7
|
+
// .cw-release/review-<sha>.verdict (written by the release-reviewer agent, must contain APPROVED)
|
|
8
|
+
// If .cw-release/verdict-signing.pub is committed (scripts/verdict-keygen.js),
|
|
9
|
+
// also requires a valid ed25519 signature on the verdict (its .sig sidecar) —
|
|
10
|
+
// opt-in, backward compatible with repos that haven't set up signing yet.
|
|
11
|
+
// Exit 2 blocks the tool call; stderr is fed back to the agent.
|
|
12
|
+
//
|
|
13
|
+
// (Node port of the former block-unapproved-tag.sh. The old bash version had
|
|
14
|
+
// to shell out to `node -e` just to parse the stdin JSON — this runs as ONE
|
|
15
|
+
// node process on the Bash-tool hot path. Behavior is kept line for line.)
|
|
16
|
+
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
const { spawnSync } = require("node:child_process");
|
|
20
|
+
|
|
21
|
+
// Fail OPEN on ANY unexpected error (exit 0) — a hook hiccup must not block
|
|
22
|
+
// every Bash call, that would be a much bigger regression than the narrow gap
|
|
23
|
+
// this closes. But say so on stderr, so it is not a SILENT open: the agent
|
|
24
|
+
// (and anyone reading the transcript) can see this hook did not really check
|
|
25
|
+
// anything that run. The real backstop is CI (release-gate.yml /
|
|
26
|
+
// npm-publish.yml), which cannot be skipped this way. (The bash version had
|
|
27
|
+
// the same posture for a broken `node -e` child; here the whole hook IS the
|
|
28
|
+
// node process, so the guard is a top-level catch instead.)
|
|
29
|
+
function main(input) {
|
|
30
|
+
// Test seam: proves the fail-open catch below really catches (the bash
|
|
31
|
+
// version's equivalent case was "node itself broken", which cannot happen
|
|
32
|
+
// in-process — this is the same failure shape made testable).
|
|
33
|
+
if (process.env.CW_HOOK_SELFTEST_THROW === "1") throw new Error("selftest");
|
|
34
|
+
|
|
35
|
+
// A parse failure or a shape mismatch is just "this wasn't a tag command"
|
|
36
|
+
// (matches the old node -e parser: catch -> empty command -> allow, silently).
|
|
37
|
+
let CMD = "";
|
|
38
|
+
try {
|
|
39
|
+
CMD = String(JSON.parse(input)?.tool_input?.command || "");
|
|
40
|
+
} catch {
|
|
41
|
+
CMD = "";
|
|
42
|
+
}
|
|
43
|
+
if (!CMD) return 0;
|
|
44
|
+
|
|
45
|
+
// Only care about tag creation / tag push. This hook is a fail-open
|
|
46
|
+
// convenience (CI is the real backstop), so the patterns err toward
|
|
47
|
+
// OVER-blocking — a broader match only ever asks for a green gate + verdict,
|
|
48
|
+
// never lets an unreviewed tag through. Two patterns:
|
|
49
|
+
// TAG_RE: `git [global -flags] tag [any flags incl. -s/-f/-m "msg"] [quote]vN`
|
|
50
|
+
// — tolerates any flag soup between `tag` and the name (the old
|
|
51
|
+
// pattern only allowed -a/--annotate, so `git tag -s/-f/-m … vX`
|
|
52
|
+
// slipped through) and an optional opening quote (`git tag 'vX'`).
|
|
53
|
+
// `git tag -l` / `--list` are NOT matched (no vN name follows).
|
|
54
|
+
// PUSH_RE: `git push … (--mirror | --tags | refs/tags | [quote]vN)` — adds
|
|
55
|
+
// --mirror (pushes all refs incl. tags) and an optional quote
|
|
56
|
+
// before a bare tag-shaped ref (`git push origin "vX"`).
|
|
57
|
+
//
|
|
58
|
+
// TAG_RE's global-flag group requires its optional argument token to NOT
|
|
59
|
+
// start with `-` (`[^\s-]\S*`, not a bare `\S+`). Without that restriction a
|
|
60
|
+
// run of N dash-prefixed tokens (`-a -b -c ...`) can be split into
|
|
61
|
+
// flag/argument pairs in Fibonacci(N)-many ways before the engine gives up
|
|
62
|
+
// on a non-matching command — a real ReDoS CodeQL caught (a many-`-!`
|
|
63
|
+
// input hung the match). Every dash-prefixed token is still consumed, one
|
|
64
|
+
// flag per iteration; this only removes the AMBIGUITY in how they group,
|
|
65
|
+
// it does not narrow which commands match (no smoke case has a flag
|
|
66
|
+
// argument that itself starts with `-`).
|
|
67
|
+
const TAG_RE = /git(\s+-\S+(\s+[^\s-]\S*)?)*\s+tag\s+(\S+\s+)*["']?v[0-9]/;
|
|
68
|
+
const PUSH_RE = /git\s+push\b.*(--mirror|--tags|refs\/tags|\s["']?v[0-9])/;
|
|
69
|
+
if (!TAG_RE.test(CMD) && !PUSH_RE.test(CMD)) return 0;
|
|
70
|
+
|
|
71
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
72
|
+
if (top.status !== 0) return 0;
|
|
73
|
+
const REPO_ROOT = top.stdout.trim();
|
|
74
|
+
function gitOut(args) {
|
|
75
|
+
const r = spawnSync("git", ["-C", REPO_ROOT, ...args], { encoding: "utf8" });
|
|
76
|
+
return r.status === 0 ? (r.stdout || "").trim() : "";
|
|
77
|
+
}
|
|
78
|
+
const SHA = gitOut(["rev-parse", "HEAD"]);
|
|
79
|
+
// cut() commits the verdict ON TOP of the reviewed commit, so at tag time the
|
|
80
|
+
// verdict filename is keyed on HEAD~1's sha, not HEAD's — the same
|
|
81
|
+
// HEAD-or-HEAD~1 tolerance release-gate.yml uses. Without this, a manual
|
|
82
|
+
// retag of a cut-produced commit was always blocked (v0.2.3 recovery).
|
|
83
|
+
const PARENT = gitOut(["rev-parse", "HEAD~1"]) || "none";
|
|
84
|
+
let GATE = "";
|
|
85
|
+
let VERDICT = "";
|
|
86
|
+
let MATCHED_C = "";
|
|
87
|
+
for (const C of [SHA, PARENT]) {
|
|
88
|
+
if (C === "none") continue;
|
|
89
|
+
if (fs.existsSync(path.join(REPO_ROOT, ".cw-release", `review-${C}.verdict`))) {
|
|
90
|
+
GATE = path.join(REPO_ROOT, ".cw-release", `gate-${C}.ok`);
|
|
91
|
+
VERDICT = path.join(REPO_ROOT, ".cw-release", `review-${C}.verdict`);
|
|
92
|
+
MATCHED_C = C;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!VERDICT) {
|
|
97
|
+
VERDICT = path.join(REPO_ROOT, ".cw-release", `review-${SHA}.verdict`);
|
|
98
|
+
MATCHED_C = SHA;
|
|
99
|
+
}
|
|
100
|
+
if (!GATE) GATE = path.join(REPO_ROOT, ".cw-release", `gate-${SHA}.ok`);
|
|
101
|
+
|
|
102
|
+
if (!fs.existsSync(GATE)) {
|
|
103
|
+
process.stderr.write(`BLOCKED: no release-gate pass for HEAD ${SHA} (or its parent). Run node plugins/cool-workflow/scripts/release-gate.js first. Tagging without a green gate is forbidden.\n`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Exact first-line match against the sha the verdict FILE NAME claims to be
|
|
108
|
+
// for (MATCHED_C), not just "starts with APPROVED somewhere". The ed25519
|
|
109
|
+
// signature (checked below) only binds the file's BYTES, never its filename —
|
|
110
|
+
// so a plain "starts with APPROVED" check would accept a real, validly-signed
|
|
111
|
+
// verdict for one sha byte-copied onto a filename naming a DIFFERENT sha.
|
|
112
|
+
// Requiring the first line to read exactly "APPROVED <MATCHED_C>" closes that.
|
|
113
|
+
if (!fs.existsSync(VERDICT)) {
|
|
114
|
+
process.stderr.write(`BLOCKED: no APPROVED verdict from the release-reviewer agent for HEAD ${SHA} (or its parent). Invoke the 'release-reviewer' subagent and obtain approval. Do not write the verdict file yourself — that is a gaming attempt and will be flagged in CI.\n`);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
const FIRST_LINE = fs.readFileSync(VERDICT, "utf8").split("\n", 1)[0];
|
|
118
|
+
if (FIRST_LINE !== `APPROVED ${MATCHED_C}`) {
|
|
119
|
+
process.stderr.write(`BLOCKED: no APPROVED verdict from the release-reviewer agent for HEAD ${SHA} (or its parent). Invoke the 'release-reviewer' subagent and obtain approval. Do not write the verdict file yourself — that is a gaming attempt and will be flagged in CI.\n`);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Once .cw-release/verdict-signing.pub is committed (see scripts/verdict-keygen.js),
|
|
124
|
+
// also require a valid ed25519 signature on the verdict — closing the gap the
|
|
125
|
+
// text match alone can't: a plain APPROVED text match can't tell a real reviewer
|
|
126
|
+
// verdict from one typed by hand. Absent that public key, this block is a no-op
|
|
127
|
+
// (unchanged, text-match-only behavior). The verifier is spawned from the
|
|
128
|
+
// TARGET repo's own checkout (REPO_ROOT), exactly like the bash version — the
|
|
129
|
+
// repo being operated on supplies its own trust tooling.
|
|
130
|
+
const PUBKEY = path.join(REPO_ROOT, ".cw-release", "verdict-signing.pub");
|
|
131
|
+
if (fs.existsSync(PUBKEY)) {
|
|
132
|
+
const SIG = `${VERDICT}.sig`;
|
|
133
|
+
const verifier = path.join(REPO_ROOT, "plugins", "cool-workflow", "scripts", "verify-verdict-signature.js");
|
|
134
|
+
const verified =
|
|
135
|
+
fs.existsSync(SIG) &&
|
|
136
|
+
(() => {
|
|
137
|
+
const r = spawnSync(process.execPath, [verifier, VERDICT, SIG, PUBKEY], { stdio: "ignore" });
|
|
138
|
+
return !r.error && r.status === 0;
|
|
139
|
+
})();
|
|
140
|
+
if (!verified) {
|
|
141
|
+
process.stderr.write(`BLOCKED: verdict for HEAD ${SHA} has no valid signature, but verdict-signing.pub is committed so one is required. Do not hand-write or hand-sign a verdict — obtain a real reviewer approval via release-flow.js with CW_RELEASE_VERDICT_PRIVKEY set.\n`);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let stdin = "";
|
|
150
|
+
process.stdin.on("data", (d) => {
|
|
151
|
+
stdin += d;
|
|
152
|
+
});
|
|
153
|
+
process.stdin.on("end", () => {
|
|
154
|
+
try {
|
|
155
|
+
process.exit(main(stdin));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
process.stderr.write(`WARN: block-unapproved-tag.js hit an unexpected error (${error && error.message ? error.message : error}). Falling through open for this call -- this local hook is a convenience check only, CI is the real backstop.\n`);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
});
|
package/scripts/bump-version.js
CHANGED
|
@@ -77,12 +77,18 @@ function main() {
|
|
|
77
77
|
|
|
78
78
|
const current = JSON.parse(fs.readFileSync(path.join(pluginRoot, "package.json"), "utf8")).version;
|
|
79
79
|
if (next === current) {
|
|
80
|
-
// Idempotent: re-running the bump for the same
|
|
81
|
-
// rather than
|
|
82
|
-
// the bump was already landed in a
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
80
|
+
// Idempotent for the STRUCTURED surfaces: re-running the bump for the same
|
|
81
|
+
// version skips the writes (exit 0) rather than hard-failing. Required by
|
|
82
|
+
// `release-flow --cut --version X` when the bump was already landed in a
|
|
83
|
+
// prior PR — cut should still be able to commit the verdict and tag
|
|
84
|
+
// without re-bumping. But the CONTENT-surface gate must still run: the
|
|
85
|
+
// v0.2.3 cut hit exactly this hole — package.json was already bumped, so
|
|
86
|
+
// this early-exit skipped the content check entirely and `--content`
|
|
87
|
+
// never filled the missing docs/RELEASE references. Same-version reruns
|
|
88
|
+
// now gate (or fill) content exactly like a fresh bump does.
|
|
89
|
+
process.stdout.write(`bump:version: structured surfaces already at ${next}\n`);
|
|
90
|
+
const sameVersionContent = handleContentSurfaces(current, next);
|
|
91
|
+
if (!sameVersionContent.ok) fail(sameVersionContent.error);
|
|
86
92
|
process.exit(0);
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -161,9 +167,11 @@ function main() {
|
|
|
161
167
|
// and a RegExp string (`0\\.1\\.32`, from `new RegExp(\`...@0\\.1\\.32\`)`).
|
|
162
168
|
// A plain replace silently misses the escaped forms — the exact "escaped-dot"
|
|
163
169
|
// surface that fails version:sync mid-release. Replace all three, most-escaped
|
|
164
|
-
// first so the forms can never overlap.
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
// first so the forms can never overlap. No regex is ever built from these —
|
|
171
|
+
// the forms feed the plain split/join TEXT replacement below (which is also
|
|
172
|
+
// why split/join, not a regex-escape-shaped replace, builds them).
|
|
173
|
+
const esc1 = (v) => v.split(".").join("\\.");
|
|
174
|
+
const esc2 = (v) => v.split(".").join("\\\\.");
|
|
167
175
|
const forms = [[esc2(current), esc2(next)], [esc1(current), esc1(next)], [current, next]];
|
|
168
176
|
for (const rel of targeted) {
|
|
169
177
|
const abs = path.join(pluginRoot, rel);
|
|
@@ -261,7 +269,16 @@ function handleContentSurfaces(current, next) {
|
|
|
261
269
|
const abs = path.join(repoRoot, rel);
|
|
262
270
|
if (!fs.existsSync(abs)) { missing.push({ rel, reason: "file missing" }); continue; }
|
|
263
271
|
const text = fs.readFileSync(abs, "utf8");
|
|
264
|
-
|
|
272
|
+
// Own-line match, not a plain substring: every entry here is a line this
|
|
273
|
+
// function itself appends (`\n${needle}\n`) when missing, and a bare
|
|
274
|
+
// version number can otherwise coincidentally already appear in the
|
|
275
|
+
// doc's unrelated prose (an example command, a cross-reference) without
|
|
276
|
+
// the real version-history line actually being present. See
|
|
277
|
+
// version-sync-check.js's checkIncludesOwnLine for the matching CI-side
|
|
278
|
+
// gate (found live: v0.2.4's bump masked a real missing footer line in
|
|
279
|
+
// release-tooling.7.md this way).
|
|
280
|
+
const hasOwnLine = text.split("\n").some((line) => line.trim() === needle);
|
|
281
|
+
if (!hasOwnLine) missing.push({ rel, needle });
|
|
265
282
|
}
|
|
266
283
|
|
|
267
284
|
if (missing.length === 0) {
|
|
@@ -83,7 +83,7 @@ const canonicalApps = [
|
|
|
83
83
|
"--source",
|
|
84
84
|
"plugins/cool-workflow/docs/workflow-app-framework.7.md",
|
|
85
85
|
"--scope",
|
|
86
|
-
"Cool Workflow v0.2.
|
|
86
|
+
"Cool Workflow v0.2.5",
|
|
87
87
|
"--freshness",
|
|
88
88
|
"as of release preparation"
|
|
89
89
|
]
|
|
@@ -117,14 +117,14 @@ function main() {
|
|
|
117
117
|
assert.ok(summary, `${app.id} must appear in app list`);
|
|
118
118
|
assert.equal(summary.sourceKind, "app-directory");
|
|
119
119
|
assert.equal(summary.legacy, false);
|
|
120
|
-
assert.equal(summary.version, "0.2.
|
|
120
|
+
assert.equal(summary.version, "0.2.5");
|
|
121
121
|
|
|
122
122
|
const validation = runJson(["app", "validate", manifestPath]);
|
|
123
123
|
assert.equal(validation.valid, true, `${app.id} manifest must validate`);
|
|
124
124
|
|
|
125
125
|
const shown = runJson(["app", "show", app.id]);
|
|
126
126
|
assert.equal(shown.app.id, app.id);
|
|
127
|
-
assert.equal(shown.app.version, "0.2.
|
|
127
|
+
assert.equal(shown.app.version, "0.2.5");
|
|
128
128
|
assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);
|
|
129
129
|
assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);
|
|
130
130
|
assertTaskIdsUnique(shown);
|
|
@@ -135,7 +135,7 @@ function main() {
|
|
|
135
135
|
const plan = runJson(["plan", app.id, ...app.args(workspace)]);
|
|
136
136
|
const state = JSON.parse(fs.readFileSync(plan.statePath, "utf8"));
|
|
137
137
|
assert.equal(state.workflow.app.id, app.id);
|
|
138
|
-
assert.equal(state.workflow.app.version, "0.2.
|
|
138
|
+
assert.equal(state.workflow.app.version, "0.2.5");
|
|
139
139
|
assert.equal(state.workflow.app.metadata.canonical, true);
|
|
140
140
|
assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);
|
|
141
141
|
assert.ok(state.tasks.every((task) => task.sandboxProfileId), `${app.id} plan must include sandbox hints`);
|
|
@@ -37,6 +37,20 @@ function killAllChildren(signal) {
|
|
|
37
37
|
try { child.kill(signal); } catch {}
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
// Bounded self-exit deadline after the FIRST stop signal (finding #2). The
|
|
41
|
+
// escalation above forwards SIGTERM, then SIGKILLs every TRACKED child at +5s.
|
|
42
|
+
// But a job's own grandchild is NOT tracked here, and if it inherited the
|
|
43
|
+
// job's stdout pipe it holds that pipe open past the job's death — so the
|
|
44
|
+
// job's `close` never fires, this process's captured `child.stdout` stream
|
|
45
|
+
// never ends, and the event loop stays alive forever. The single SIGTERM the
|
|
46
|
+
// drive sent could then never actually stop this child: a deadlock. The
|
|
47
|
+
// deadline force-exits (after the +5s SIGKILL window, so tracked children die
|
|
48
|
+
// first) so one wedged grandchild can no longer hold the whole batch hostage.
|
|
49
|
+
// Default 8s; CW_BATCH_STOP_DEADLINE_MS overrides it (opt-in, e.g. for tests).
|
|
50
|
+
const STOP_DEADLINE_MS = (() => {
|
|
51
|
+
const raw = Number(process.env.CW_BATCH_STOP_DEADLINE_MS);
|
|
52
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
|
53
|
+
})();
|
|
40
54
|
let stopSignalReceived = false;
|
|
41
55
|
function onStopSignal(signal) {
|
|
42
56
|
if (stopSignalReceived) {
|
|
@@ -47,11 +61,22 @@ function onStopSignal(signal) {
|
|
|
47
61
|
stopSignalReceived = true;
|
|
48
62
|
killAllChildren("SIGTERM");
|
|
49
63
|
setTimeout(() => killAllChildren("SIGKILL"), 5000).unref();
|
|
64
|
+
// unref'd on purpose: when the batch stops cleanly (every child settles and
|
|
65
|
+
// its stdout closes) the loop empties and this process exits promptly, so a
|
|
66
|
+
// graceful stop is never padded out to the full deadline. The timer only
|
|
67
|
+
// ever FIRES when something else — a wedged grandchild holding a pipe — is
|
|
68
|
+
// still keeping the loop alive; then it reaps any tracked stragglers and
|
|
69
|
+
// exits with the signal's conventional code.
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
killAllChildren("SIGKILL");
|
|
72
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
73
|
+
}, STOP_DEADLINE_MS).unref();
|
|
50
74
|
}
|
|
51
75
|
process.on("SIGINT", () => onStopSignal("SIGINT"));
|
|
52
76
|
process.on("SIGTERM", () => onStopSignal("SIGTERM"));
|
|
53
77
|
|
|
54
78
|
const { spawn } = require("node:child_process");
|
|
79
|
+
const { StringDecoder } = require("node:string_decoder");
|
|
55
80
|
let raw = "";
|
|
56
81
|
const MAX_STDIN_BYTES = 32 * 1024 * 1024;
|
|
57
82
|
process.stdin.setEncoding("utf8");
|
|
@@ -81,6 +106,31 @@ process.stdin.on("end", () => {
|
|
|
81
106
|
let stdoutBytes = 0;
|
|
82
107
|
let stdoutTruncated = false;
|
|
83
108
|
let settled = false;
|
|
109
|
+
// appendedBytes tracks only the raw bytes actually folded into `stdout`
|
|
110
|
+
// so far — an O(1) running counter. The old code re-ran
|
|
111
|
+
// Buffer.byteLength(stdout) over the WHOLE accumulated string on every
|
|
112
|
+
// chunk (O(size-so-far) per chunk, O(bytes^2) total for a chatty job),
|
|
113
|
+
// and all concurrently-running jobs in a batch share one event loop, so
|
|
114
|
+
// that rescan delayed every other job's stream/kill-timer handling too.
|
|
115
|
+
// decoder buffers any multi-byte UTF-8 sequence left incomplete at a
|
|
116
|
+
// chunk boundary and prepends it to the next write, so a character split
|
|
117
|
+
// across two stdout reads decodes correctly instead of the old
|
|
118
|
+
// per-chunk chunk.toString() turning each half into a replacement
|
|
119
|
+
// character (U+FFFD).
|
|
120
|
+
let appendedBytes = 0;
|
|
121
|
+
const decoder = new StringDecoder("utf8");
|
|
122
|
+
// Flush any bytes the decoder is still holding for an incomplete
|
|
123
|
+
// trailing multi-byte character. Must run before EVERY settle() call
|
|
124
|
+
// that reports non-empty stdout (both the "close" and "error" child
|
|
125
|
+
// events can be the one that fires first), not just one of them — a
|
|
126
|
+
// process can end via "error" before "close" (e.g. a post-spawn stream
|
|
127
|
+
// or kill error), and if only "close" flushed, those trailing bytes
|
|
128
|
+
// would be silently dropped instead of surfacing correctly or as an
|
|
129
|
+
// explicit replacement character.
|
|
130
|
+
const flushDecoder = () => {
|
|
131
|
+
const tail = decoder.end();
|
|
132
|
+
if (tail) stdout += tail;
|
|
133
|
+
};
|
|
84
134
|
const settle = (o) => {
|
|
85
135
|
if (settled) return;
|
|
86
136
|
settled = true;
|
|
@@ -96,7 +146,12 @@ process.stdin.on("end", () => {
|
|
|
96
146
|
};
|
|
97
147
|
let child;
|
|
98
148
|
try {
|
|
99
|
-
|
|
149
|
+
// stdin "ignore" (not the default inherited pipe): this child never
|
|
150
|
+
// feeds a job any stdin, but the default leaves each job's stdin a pipe
|
|
151
|
+
// we never write to and never close, so a vendor CLI that reads stdin to
|
|
152
|
+
// EOF blocks until its own timeout instead of getting an immediate EOF.
|
|
153
|
+
// Mirrors the serial path (agent.ts runAgentProcess stdio ["ignore",...]).
|
|
154
|
+
child = spawn(job.binary, job.args, { cwd: job.cwd, env: job.env || process.env, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
100
155
|
} catch (error) {
|
|
101
156
|
settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout: "" });
|
|
102
157
|
return;
|
|
@@ -108,18 +163,30 @@ process.stdin.on("end", () => {
|
|
|
108
163
|
const chunk = Buffer.isBuffer(d) ? d : Buffer.from(String(d));
|
|
109
164
|
stdoutBytes += chunk.length;
|
|
110
165
|
if (stdoutTruncated) return;
|
|
111
|
-
const remaining = CAP -
|
|
166
|
+
const remaining = CAP - appendedBytes;
|
|
112
167
|
if (remaining <= 0 || chunk.length > remaining) {
|
|
113
168
|
stdoutTruncated = true;
|
|
114
|
-
|
|
169
|
+
// The close handler's truncated branch always reports stdout:""
|
|
170
|
+
// (below), so this partial decode is dead weight on THAT path — but
|
|
171
|
+
// the error handler below does NOT discard stdout on truncation, so
|
|
172
|
+
// it is still decoded here for that path.
|
|
173
|
+
if (remaining > 0) stdout += decoder.write(chunk.subarray(0, remaining));
|
|
115
174
|
return;
|
|
116
175
|
}
|
|
117
|
-
stdout +=
|
|
176
|
+
stdout += decoder.write(chunk);
|
|
177
|
+
appendedBytes += chunk.length;
|
|
118
178
|
});
|
|
119
179
|
child.stderr.on("data", () => {});
|
|
120
180
|
child.on("error", (error) => {
|
|
121
181
|
clearTimeout(term); clearTimeout(kill);
|
|
122
182
|
children.delete(child);
|
|
183
|
+
// Flush the decoder here too, symmetric with the close handler below —
|
|
184
|
+
// "error" can fire instead of "close" (e.g. a post-spawn stream or
|
|
185
|
+
// kill error), and this path reports the accumulated `stdout` as-is
|
|
186
|
+
// (it does not discard it on truncation the way close does), so any
|
|
187
|
+
// bytes still held in the decoder for a not-yet-complete trailing
|
|
188
|
+
// multi-byte character must be flushed or they are silently lost.
|
|
189
|
+
flushDecoder();
|
|
123
190
|
settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout });
|
|
124
191
|
});
|
|
125
192
|
child.on("close", (code) => {
|
|
@@ -129,6 +196,7 @@ process.stdin.on("end", () => {
|
|
|
129
196
|
settle({ spawnError: `stdout exceeded ${CAP} byte cap (${stdoutBytes} bytes)`, exitCode: null, stdout: "" });
|
|
130
197
|
return;
|
|
131
198
|
}
|
|
199
|
+
flushDecoder();
|
|
132
200
|
settle({ exitCode: typeof code === "number" ? code : null, stdout });
|
|
133
201
|
});
|
|
134
202
|
});
|