pi-subagents 0.58.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/docs/agents.md +5 -3
- package/docs/configuration.md +4 -4
- package/docs/extension-api.md +1 -1
- package/docs/models.md +24 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +93 -5
- package/docs/workflows.md +94 -3
- package/package.json +3 -1
- package/prompts/review-loop.md +2 -2
- package/skills/council-mode/SKILL.md +48 -243
- package/skills/council-mode/references/pass-contracts.md +150 -0
- package/skills/pi-subagents/SKILL.md +87 -37
- package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
- package/skills/pi-subagents/references/execution-controls.md +52 -7
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
- package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
- package/skills/pi-subagents/references/review-and-validation.md +73 -0
- package/src/agents/agent-management.ts +159 -42
- package/src/agents/agent-serializer.ts +4 -2
- package/src/agents/agents.ts +67 -26
- package/src/agents/runtime-agent-registry.ts +9 -16
- package/src/api/background-work.ts +5 -1
- package/src/api/delegation.ts +0 -7
- package/src/api/preflight.ts +6 -9
- package/src/api/shared-types.ts +2 -0
- package/src/extension/fanout-child.ts +5 -3
- package/src/extension/index.ts +51 -23
- package/src/extension/public-execution.ts +16 -2
- package/src/extension/schemas.ts +36 -10
- package/src/extension/tool-description.ts +24 -5
- package/src/intercom/result-intercom.ts +2 -0
- package/src/profiles/profiles.ts +5 -6
- package/src/runs/background/active-async-capacity.ts +2 -2
- package/src/runs/background/async-execution.ts +52 -37
- package/src/runs/background/async-job-tracker.ts +17 -12
- package/src/runs/background/async-resume.ts +23 -25
- package/src/runs/background/async-status-snapshot.ts +23 -261
- package/src/runs/background/async-status.ts +110 -8
- package/src/runs/background/chain-append.ts +6 -3
- package/src/runs/background/chain-root-attachment.ts +60 -8
- package/src/runs/background/control-channel.ts +3 -2
- package/src/runs/background/fleet-view.ts +21 -11
- package/src/runs/background/notify.ts +158 -6
- package/src/runs/background/result-files.ts +2 -1
- package/src/runs/background/result-watcher.ts +2 -0
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/retained-children.ts +1 -1
- package/src/runs/background/run-status.ts +30 -9
- package/src/runs/background/scheduled-runs.ts +86 -7
- package/src/runs/background/stale-run-reconciler.ts +10 -4
- package/src/runs/background/steering.ts +4 -14
- package/src/runs/background/subagent-runner.ts +351 -358
- package/src/runs/background/subagent-wait.ts +68 -12
- package/src/runs/background/terminal-run-index.ts +1 -1
- package/src/runs/background/wait-completions.ts +25 -1
- package/src/runs/background/wait-config.ts +23 -9
- package/src/runs/background/wait-tool.ts +9 -2
- package/src/runs/foreground/async-steering-action.ts +2 -2
- package/src/runs/foreground/execution.ts +125 -122
- package/src/runs/foreground/foreground-control.ts +3 -0
- package/src/runs/foreground/foreground-history.ts +1 -0
- package/src/runs/foreground/subagent-executor.ts +576 -240
- package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
- package/src/runs/shared/abort-recovery.ts +119 -0
- package/src/runs/shared/async-status-projection.ts +597 -0
- package/src/runs/shared/background-process-options.ts +9 -0
- package/src/runs/shared/child-identity.ts +19 -4
- package/src/runs/shared/child-launch-plan.ts +151 -0
- package/src/runs/shared/completion-evidence.ts +89 -0
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/host-step-status.ts +230 -0
- package/src/runs/shared/lane-metadata.ts +105 -0
- package/src/runs/shared/mcp-config-sources.ts +42 -6
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
- package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
- package/src/runs/shared/model-fallback.ts +9 -2
- package/src/runs/shared/mutation-evidence.ts +52 -3
- package/src/runs/shared/nested-events.ts +6 -2
- package/src/runs/shared/nested-render.ts +7 -3
- package/src/runs/shared/parallel-handoff.ts +419 -7
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +67 -3
- package/src/runs/shared/single-output.ts +72 -22
- package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
- package/src/runs/shared/workflow-graph.ts +15 -0
- package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
- package/src/runs/shared/worktree.ts +18 -0
- package/src/shared/child-session-name.ts +46 -0
- package/src/shared/extension-context.ts +24 -0
- package/src/shared/formatters.ts +5 -2
- package/src/shared/launch-contract.ts +1 -1
- package/src/shared/settings.ts +9 -103
- package/src/shared/types.ts +232 -30
- package/src/shared/utils.ts +35 -55
- package/src/slash/delegation-adapters.ts +1 -8
- package/src/slash/delegation-request.ts +0 -4
- package/src/slash/slash-bridge.ts +1 -2
- package/src/slash/slash-commands.ts +369 -90
- package/src/slash/slash-live-state.ts +22 -11
- package/src/tui/fleet-status.ts +134 -75
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render-helpers.ts +31 -0
- package/src/tui/render.ts +895 -121
- package/src/watchdog/change-signature.ts +40 -1
- package/src/watchdog/turn-delta.ts +1 -1
- package/src/workflows/chat-progress.ts +6 -3
- package/src/workflows/host-command.ts +235 -0
- package/src/workflows/scripted-workflow.ts +503 -38
- package/src/workflows/workflow-child-summary.ts +9 -5
- package/src/workflows/workflow-preflight.ts +270 -0
- package/src/workflows/workflow-receipt.ts +43 -4
- package/src/workflows/workflow-settlement.ts +246 -0
- package/src/runs/shared/turn-budget.ts +0 -98
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../shared/artifacts.ts";
|
|
6
7
|
|
|
@@ -11,6 +12,7 @@ const IGNORED_CHANGE_SEGMENTS = new Set([".git", "node_modules"]);
|
|
|
11
12
|
const DEFAULT_MAX_HASH_FILE_BYTES = 64 * 1024 * 1024;
|
|
12
13
|
const DEFAULT_MAX_HASH_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
13
14
|
const DEFAULT_MAX_HASH_ENTRIES = 2_000;
|
|
15
|
+
const TRACKED_ENTRY_PROBE_MAX_BYTES = 1_024;
|
|
14
16
|
|
|
15
17
|
function positiveEnvNumber(name: string, fallback: number): number {
|
|
16
18
|
const parsed = Number(process.env[name]);
|
|
@@ -43,6 +45,37 @@ function git(cwd: string, args: string[]): string | undefined {
|
|
|
43
45
|
return result.stdout;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function comparablePath(value: string): string {
|
|
49
|
+
let resolved = path.resolve(value);
|
|
50
|
+
try {
|
|
51
|
+
resolved = fs.realpathSync.native(resolved);
|
|
52
|
+
} catch {
|
|
53
|
+
// Keep the lexical path when canonicalization is unavailable.
|
|
54
|
+
}
|
|
55
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isHomeRepoRoot(root: string): boolean {
|
|
59
|
+
return comparablePath(root) === comparablePath(os.homedir());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Probe the index without asking Git to inspect the working tree. The output
|
|
64
|
+
* cap keeps this check bounded even for repositories with a very large index;
|
|
65
|
+
* an ENOBUFS result still proves that at least one tracked path exists.
|
|
66
|
+
*/
|
|
67
|
+
function hasTrackedEntries(root: string): boolean | undefined {
|
|
68
|
+
const result = spawnSync("git", ["-C", root, "ls-files", "--cached", "--error-unmatch", "--", "."], {
|
|
69
|
+
encoding: "utf-8",
|
|
70
|
+
maxBuffer: TRACKED_ENTRY_PROBE_MAX_BYTES,
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
});
|
|
73
|
+
const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
|
|
74
|
+
if (result.status === 0 || errorCode === "ENOBUFS") return true;
|
|
75
|
+
if (result.status === 1) return false;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
46
79
|
function normalizeRelPath(value: string): string {
|
|
47
80
|
return value.replaceAll(path.sep, "/").replace(/^\.\//, "");
|
|
48
81
|
}
|
|
@@ -181,7 +214,13 @@ function buildRepoChangeSignature(root: string, statusOutput: string): WatchdogR
|
|
|
181
214
|
export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {
|
|
182
215
|
const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
183
216
|
if (!root) return undefined;
|
|
184
|
-
const
|
|
217
|
+
const skipUntracked = isHomeRepoRoot(root) || hasTrackedEntries(root) === false;
|
|
218
|
+
const statusOutput = git(root, [
|
|
219
|
+
"status",
|
|
220
|
+
"--porcelain=v1",
|
|
221
|
+
"-z",
|
|
222
|
+
`--untracked-files=${skipUntracked ? "no" : "all"}`,
|
|
223
|
+
]);
|
|
185
224
|
if (statusOutput === undefined) return undefined;
|
|
186
225
|
try {
|
|
187
226
|
return buildRepoChangeSignature(root, statusOutput);
|
|
@@ -47,7 +47,7 @@ function formatValue(value: unknown, indent = ""): string {
|
|
|
47
47
|
if (value === undefined) return "undefined";
|
|
48
48
|
if (value === null || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
|
|
49
49
|
if (Array.isArray(value)) {
|
|
50
|
-
return value.map((item
|
|
50
|
+
return value.map((item) => `${indent}- ${formatValue(item, `${indent} `)}`).join("\n");
|
|
51
51
|
}
|
|
52
52
|
if (typeof value === "object") {
|
|
53
53
|
const lines: string[] = [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import type { Details } from "../shared/types.ts";
|
|
4
|
+
import type { Details, WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../shared/types.ts";
|
|
5
5
|
|
|
6
6
|
export const WORKFLOW_CHAT_PROGRESS_MODES = ["auto", "off", "live-card"] as const;
|
|
7
7
|
export type WorkflowChatProgressMode = typeof WORKFLOW_CHAT_PROGRESS_MODES[number];
|
|
@@ -96,20 +96,22 @@ export function resolveWorkflowChatProgress(input: ResolveWorkflowChatProgressIn
|
|
|
96
96
|
|
|
97
97
|
export interface WorkflowChatProgressRow {
|
|
98
98
|
key: string;
|
|
99
|
-
state: "running" | "complete" | "failed" | "detached" | "stopped";
|
|
99
|
+
state: "planned" | "running" | "complete" | "failed" | "detached" | "stopped";
|
|
100
100
|
label?: string;
|
|
101
101
|
phase?: string;
|
|
102
102
|
runId?: string;
|
|
103
103
|
durationMs?: number;
|
|
104
104
|
error?: string;
|
|
105
|
+
preflight?: WorkflowPreflightLaneV1;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
function cleanLabel(value: unknown): string | undefined {
|
|
108
109
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workflow"]>["trace"]): WorkflowChatProgressRow[] {
|
|
112
|
+
export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workflow"]>["trace"], preflight?: WorkflowPreflightV1): WorkflowChatProgressRow[] {
|
|
112
113
|
const rows = new Map<string, WorkflowChatProgressRow>();
|
|
114
|
+
for (const lane of preflight?.lanes ?? []) rows.set(lane.key, { key: lane.key, state: "planned", preflight: lane });
|
|
113
115
|
for (const entry of trace) {
|
|
114
116
|
if (entry.operation !== "run") continue;
|
|
115
117
|
const existing = rows.get(entry.key);
|
|
@@ -123,6 +125,7 @@ export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workfl
|
|
|
123
125
|
continue;
|
|
124
126
|
}
|
|
125
127
|
const next: WorkflowChatProgressRow = existing ?? { key: entry.key, state: "running" };
|
|
128
|
+
if (existing?.preflight) next.preflight = existing.preflight;
|
|
126
129
|
next.state = entry.state === "completed"
|
|
127
130
|
? "complete"
|
|
128
131
|
: entry.state === "failed"
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { quoteExecutableForShell } from "../runs/shared/acceptance.ts";
|
|
6
|
+
import { createOwnedProcessTreeController, type OwnedProcessTreeController } from "../runs/background/owned-process-tree.ts";
|
|
7
|
+
import { resolveSingleOutputClaimPath } from "../runs/shared/single-output.ts";
|
|
8
|
+
import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, runFileSystemOperationWithRetry } from "../shared/file-system-retry.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_COMMAND_BYTES = 16 * 1024;
|
|
11
|
+
const MAX_OUTPUT_PATH_BYTES = 240;
|
|
12
|
+
const MAX_CAPTURE_BYTES = 1024 * 1024;
|
|
13
|
+
const MAX_PREVIEW_BYTES = 4 * 1024;
|
|
14
|
+
const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export interface WorkflowHostCommandParams {
|
|
17
|
+
kind: "command";
|
|
18
|
+
command: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
output?: string;
|
|
21
|
+
role?: "ci" | "gate";
|
|
22
|
+
provider?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WorkflowHostCommandResult {
|
|
26
|
+
key: string;
|
|
27
|
+
kind: "command";
|
|
28
|
+
ok: boolean;
|
|
29
|
+
state: "passed" | "failed" | "timed-out" | "stopped";
|
|
30
|
+
exitCode: number | null;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
outputPath: string;
|
|
34
|
+
durationMs: number;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ProcessTreeTerminal = Awaited<ReturnType<OwnedProcessTreeController["terminate"]>>;
|
|
39
|
+
type ProcessTreeCleanup = ProcessTreeTerminal | { state: "unknown"; reason: "missing-process-id" };
|
|
40
|
+
|
|
41
|
+
function byteLength(value: string): number {
|
|
42
|
+
return Buffer.byteLength(value, "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function boundedText(value: string, maxBytes: number): string {
|
|
46
|
+
if (byteLength(value) <= maxBytes) return value;
|
|
47
|
+
let end = Math.max(0, maxBytes - 3);
|
|
48
|
+
while (end > 0 && byteLength(value.slice(0, end)) > maxBytes - 3) end -= 1;
|
|
49
|
+
return `${value.slice(0, end)}...`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeWorkflowHostCommandParams(value: unknown, label = "runs.host params"): WorkflowHostCommandParams {
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
54
|
+
const params = value as Record<string, unknown>;
|
|
55
|
+
const unknownFields = Object.keys(params).filter((field) => !["kind", "command", "timeoutMs", "output", "role", "provider"].includes(field));
|
|
56
|
+
if (unknownFields.length) {
|
|
57
|
+
const cwdHint = unknownFields.includes("cwd")
|
|
58
|
+
? " The host step does not accept per-step cwd; commands and relative output paths use the workflow cwd. Set cwd on the outer subagent request, or put a trusted directory change in command (for example, 'cd /path/to/worktree && npm test')."
|
|
59
|
+
: "";
|
|
60
|
+
throw new Error(`${label} has unsupported fields: ${unknownFields.join(", ")}.${cwdHint}`);
|
|
61
|
+
}
|
|
62
|
+
if (params.kind !== "command") throw new Error(`${label}.kind must be 'command'.`);
|
|
63
|
+
if (typeof params.command !== "string" || !params.command.trim()) throw new Error(`${label}.command must be a non-empty string.`);
|
|
64
|
+
const command = params.command.trim();
|
|
65
|
+
if (byteLength(command) > MAX_COMMAND_BYTES || /[\u0000]/.test(command)) throw new Error(`${label}.command exceeds the ${MAX_COMMAND_BYTES}-byte limit or contains NUL.`);
|
|
66
|
+
if (typeof params.timeoutMs !== "number" || !Number.isInteger(params.timeoutMs) || params.timeoutMs < 1 || params.timeoutMs > MAX_TIMEOUT_MS) throw new Error(`${label}.timeoutMs must be an integer from 1 to ${MAX_TIMEOUT_MS}.`);
|
|
67
|
+
let output: string | undefined;
|
|
68
|
+
if (params.output !== undefined) {
|
|
69
|
+
if (typeof params.output !== "string" || !params.output.trim()) throw new Error(`${label}.output must be a non-empty relative path.`);
|
|
70
|
+
output = params.output.trim();
|
|
71
|
+
if (path.isAbsolute(output) || output.split(/[\\/]/).includes("..") || /[\u0000-\u001f\u007f]/.test(output) || byteLength(output) > MAX_OUTPUT_PATH_BYTES) throw new Error(`${label}.output must be a bounded relative path without traversal or control characters.`);
|
|
72
|
+
}
|
|
73
|
+
if (params.role !== undefined && params.role !== "ci" && params.role !== "gate") throw new Error(`${label}.role must be 'ci' or 'gate'.`);
|
|
74
|
+
let provider: string | undefined;
|
|
75
|
+
if (params.provider !== undefined) {
|
|
76
|
+
if (typeof params.provider !== "string" || !params.provider.trim() || /[\r\n\u0000]/.test(params.provider) || byteLength(params.provider.trim()) > 64) throw new Error(`${label}.provider must be a non-empty single-line string of at most 64 bytes.`);
|
|
77
|
+
provider = params.provider.trim();
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind: "command",
|
|
81
|
+
command,
|
|
82
|
+
timeoutMs: params.timeoutMs,
|
|
83
|
+
...(output ? { output } : {}),
|
|
84
|
+
...(params.role ? { role: params.role } : {}),
|
|
85
|
+
...(provider ? { provider } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function appendBounded(current: string, chunk: Buffer, limit: number): string {
|
|
90
|
+
if (byteLength(current) >= limit) return current;
|
|
91
|
+
return boundedText(current + chunk.toString("utf8"), limit);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveWorkflowHostOutputClaimPath(outputPath: string): string {
|
|
95
|
+
return resolveSingleOutputClaimPath(outputPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertSafeExplicitOutput(cwd: string, outputPath: string, key: string): string {
|
|
99
|
+
const root = fs.realpathSync(cwd);
|
|
100
|
+
const outputParent = path.dirname(outputPath);
|
|
101
|
+
let existingParent = outputParent;
|
|
102
|
+
while (!fs.existsSync(existingParent)) existingParent = path.dirname(existingParent);
|
|
103
|
+
const existingParentReal = fs.realpathSync(existingParent);
|
|
104
|
+
const existingRelative = path.relative(root, existingParentReal);
|
|
105
|
+
if (existingRelative === ".." || existingRelative.startsWith(`..${path.sep}`) || path.isAbsolute(existingRelative)) throw new Error(`runs.host('${key}') output resolves outside the workflow cwd.`);
|
|
106
|
+
fs.mkdirSync(outputParent, { recursive: true });
|
|
107
|
+
const parent = fs.realpathSync(outputParent);
|
|
108
|
+
const relative = path.relative(root, parent);
|
|
109
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`runs.host('${key}') output resolves outside the workflow cwd.`);
|
|
110
|
+
try {
|
|
111
|
+
const stat = fs.lstatSync(outputPath);
|
|
112
|
+
if (!stat.isFile() || stat.nlink > 1) throw new Error(`runs.host('${key}') output must be a regular, non-linked file path.`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
115
|
+
}
|
|
116
|
+
return parent;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeExplicitOutput(outputParent: string, outputPath: string, capture: string, key: string): void {
|
|
120
|
+
const destination = path.join(outputParent, path.basename(outputPath));
|
|
121
|
+
const temporaryPath = path.join(outputParent, `.pi-host-output-${process.pid}-${randomUUID()}.tmp`);
|
|
122
|
+
let descriptor: number | undefined;
|
|
123
|
+
try {
|
|
124
|
+
descriptor = fs.openSync(temporaryPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
125
|
+
fs.writeFileSync(descriptor, capture, { encoding: "utf8" });
|
|
126
|
+
fs.closeSync(descriptor);
|
|
127
|
+
descriptor = undefined;
|
|
128
|
+
fs.chmodSync(temporaryPath, 0o600);
|
|
129
|
+
runFileSystemOperationWithRetry(() => fs.renameSync(temporaryPath, destination), {
|
|
130
|
+
retryDelaysMs: process.platform === "win32" ? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS : [],
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`runs.host('${key}') could not atomically replace its output.`, { cause: error instanceof Error ? error : undefined });
|
|
134
|
+
} finally {
|
|
135
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(temporaryPath);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function terminateProcessTree(pid: number, controller: OwnedProcessTreeController): Promise<ProcessTreeTerminal> {
|
|
145
|
+
if (process.platform !== "win32") return controller.terminate();
|
|
146
|
+
return new Promise<ProcessTreeTerminal>((resolve) => {
|
|
147
|
+
const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
148
|
+
cleanup.once("error", () => { void controller.terminate().then(resolve); });
|
|
149
|
+
cleanup.once("close", () => { void controller.terminate().then(resolve); });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function executeWorkflowHostCommand(input: {
|
|
154
|
+
key: string;
|
|
155
|
+
params: WorkflowHostCommandParams;
|
|
156
|
+
cwd: string;
|
|
157
|
+
defaultOutputPath: string;
|
|
158
|
+
claimedOutputPath?: string;
|
|
159
|
+
signal: AbortSignal;
|
|
160
|
+
}): Promise<WorkflowHostCommandResult> {
|
|
161
|
+
const outputPath = input.params.output ? path.resolve(input.cwd, input.params.output) : input.defaultOutputPath;
|
|
162
|
+
const relative = path.relative(input.cwd, outputPath);
|
|
163
|
+
if (input.params.output && (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error(`runs.host('${input.key}') output escapes the workflow cwd.`);
|
|
164
|
+
const explicitOutputParent = input.params.output ? assertSafeExplicitOutput(input.cwd, outputPath, input.key) : undefined;
|
|
165
|
+
if (!input.params.output) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
166
|
+
const startedAt = Date.now();
|
|
167
|
+
let stdout = "";
|
|
168
|
+
let stderr = "";
|
|
169
|
+
let capture = "";
|
|
170
|
+
let timedOut = false;
|
|
171
|
+
let stopped = false;
|
|
172
|
+
let settled = false;
|
|
173
|
+
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const child = spawn(quoteExecutableForShell(input.params.command), {
|
|
176
|
+
cwd: input.cwd,
|
|
177
|
+
env: process.env,
|
|
178
|
+
shell: true,
|
|
179
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
180
|
+
windowsHide: true,
|
|
181
|
+
detached: process.platform !== "win32",
|
|
182
|
+
});
|
|
183
|
+
const processTree = typeof child.pid === "number" ? createOwnedProcessTreeController(child.pid, { termGraceMs: 1_000 }) : undefined;
|
|
184
|
+
let termination: Promise<ProcessTreeCleanup> | undefined;
|
|
185
|
+
const terminate = (reason: "timeout" | "abort") => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
timedOut = reason === "timeout";
|
|
188
|
+
stopped = reason === "abort";
|
|
189
|
+
if (processTree && child.pid !== undefined) termination ??= terminateProcessTree(child.pid, processTree);
|
|
190
|
+
else {
|
|
191
|
+
child.kill("SIGTERM");
|
|
192
|
+
termination ??= Promise.resolve({ state: "unknown", reason: "missing-process-id" });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const timeout = setTimeout(() => terminate("timeout"), input.params.timeoutMs);
|
|
196
|
+
timeout.unref?.();
|
|
197
|
+
const onAbort = () => terminate("abort");
|
|
198
|
+
if (input.signal.aborted) onAbort();
|
|
199
|
+
else input.signal.addEventListener("abort", onAbort, { once: true });
|
|
200
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
201
|
+
stdout = appendBounded(stdout, chunk, MAX_PREVIEW_BYTES);
|
|
202
|
+
capture = appendBounded(capture, chunk, MAX_CAPTURE_BYTES);
|
|
203
|
+
});
|
|
204
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
205
|
+
stderr = appendBounded(stderr, chunk, MAX_PREVIEW_BYTES);
|
|
206
|
+
capture = appendBounded(capture, chunk, MAX_CAPTURE_BYTES);
|
|
207
|
+
});
|
|
208
|
+
const finish = async (exitCode: number | null, spawnError?: unknown) => {
|
|
209
|
+
if (settled) return;
|
|
210
|
+
settled = true;
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
213
|
+
const terminal = termination ? await termination : await processTree?.finishAfterWriterClose();
|
|
214
|
+
const cleanupError = process.platform !== "win32" && terminal?.state === "unknown" ? terminal.reason : undefined;
|
|
215
|
+
const state = timedOut ? "timed-out" : stopped ? "stopped" : exitCode === 0 && !spawnError && !cleanupError ? "passed" : "failed";
|
|
216
|
+
const error = spawnError instanceof Error ? spawnError.message : spawnError ? String(spawnError) : cleanupError ? `Process-tree cleanup failed: ${cleanupError}.` : state === "timed-out" ? `Command timed out after ${input.params.timeoutMs}ms.` : state === "stopped" ? "Command stopped because the workflow was aborted." : state === "failed" ? `Command exited with code ${exitCode ?? "unknown"}.` : undefined;
|
|
217
|
+
try {
|
|
218
|
+
if (input.claimedOutputPath && resolveWorkflowHostOutputClaimPath(outputPath) !== input.claimedOutputPath) throw new Error(`output path changed after it was claimed.`);
|
|
219
|
+
if (explicitOutputParent) {
|
|
220
|
+
const currentParent = assertSafeExplicitOutput(input.cwd, outputPath, input.key);
|
|
221
|
+
if (currentParent !== explicitOutputParent) throw new Error(`output parent changed while the command was running.`);
|
|
222
|
+
writeExplicitOutput(explicitOutputParent, outputPath, capture, input.key);
|
|
223
|
+
} else {
|
|
224
|
+
fs.writeFileSync(outputPath, capture, { encoding: "utf8", mode: 0o600 });
|
|
225
|
+
}
|
|
226
|
+
} catch (writeError) {
|
|
227
|
+
reject(new Error(`runs.host('${input.key}') could not save command output: ${writeError instanceof Error ? writeError.message : String(writeError)}`, { cause: writeError instanceof Error ? writeError : undefined }));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
resolve({ key: input.key, kind: "command", ok: state === "passed", state, exitCode, stdout, stderr, outputPath, durationMs: Date.now() - startedAt, ...(error ? { error } : {}) });
|
|
231
|
+
};
|
|
232
|
+
child.on("close", (code) => { void finish(code); });
|
|
233
|
+
child.on("error", (error) => { void finish(null, error); });
|
|
234
|
+
});
|
|
235
|
+
}
|