pi-subagents 0.67.0 → 0.69.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 +90 -0
- package/README.md +1 -1
- package/docs/agents.md +41 -12
- package/docs/configuration.md +61 -19
- package/docs/extension-api.md +5 -1
- package/docs/missions.md +2 -2
- package/docs/models.md +11 -79
- package/docs/observability.md +18 -8
- package/docs/standalone-background.md +13 -3
- package/docs/tool-reference.md +38 -14
- package/docs/watchdog.md +10 -12
- package/docs/workflows.md +59 -1
- package/index.ts +5 -2
- package/package.json +4 -2
- package/runner-peer-loader.mjs +24 -0
- package/runner-peer-preload.mjs +25 -11
- package/skills/pi-subagents/SKILL.md +18 -21
- package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
- package/skills/pi-subagents/references/execution-controls.md +6 -4
- package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
- package/skills/pi-subagents/references/review-and-validation.md +3 -3
- package/src/agents/agent-management.ts +57 -58
- package/src/agents/agent-serializer.ts +4 -3
- package/src/agents/agents.ts +185 -72
- package/src/agents/chain-serializer.ts +5 -0
- package/src/agents/runtime-agent-registry.ts +7 -6
- package/src/agents/skills.ts +1 -1
- package/src/api/preflight.ts +20 -16
- package/src/api/required-child-extensions.ts +6 -0
- package/src/extension/config.ts +10 -37
- package/src/extension/fanout-child.ts +3 -0
- package/src/extension/herdr-pi-bridge.ts +160 -0
- package/src/extension/index.ts +42 -31
- package/src/extension/public-execution.ts +3 -3
- package/src/extension/schemas.ts +23 -6
- package/src/extension/tool-description.ts +8 -7
- package/src/inspectors/ghostty/plugin.ts +13 -1
- package/src/intercom/native-supervisor-channel.ts +22 -18
- package/src/policy/authority.ts +4 -0
- package/src/profiles/profiles.ts +12 -6
- package/src/runs/background/active-run-index.ts +17 -1
- package/src/runs/background/async-execution.ts +309 -126
- package/src/runs/background/async-job-tracker.ts +8 -6
- package/src/runs/background/async-resume.ts +13 -4
- package/src/runs/background/async-status.ts +15 -4
- package/src/runs/background/auto-drain.ts +20 -10
- package/src/runs/background/binary-bootstrap.ts +5 -0
- package/src/runs/background/chain-append.ts +1 -1
- package/src/runs/background/chain-root-attachment.ts +14 -33
- package/src/runs/background/notify.ts +74 -6
- package/src/runs/background/result-files.ts +8 -4
- package/src/runs/background/result-watcher.ts +19 -2
- package/src/runs/background/run-child-session.ts +20 -29
- package/src/runs/background/runner-aliases.ts +4 -33
- package/src/runs/background/runner-child-launch.ts +4 -1
- package/src/runs/background/runner-child-sessions.ts +2 -2
- package/src/runs/background/runner-http-dispatcher.ts +119 -0
- package/src/runs/background/scheduled-runs.ts +11 -5
- package/src/runs/background/stale-run-reconciler.ts +35 -11
- package/src/runs/background/subagent-runner.ts +413 -276
- package/src/runs/background/subagent-wait.ts +128 -23
- package/src/runs/background/wait-completions.ts +75 -27
- package/src/runs/background/wait-subscriptions.ts +9 -3
- package/src/runs/background/wait-tool.ts +4 -2
- package/src/runs/foreground/async-stop-action.ts +93 -3
- package/src/runs/foreground/execution.ts +115 -219
- package/src/runs/foreground/foreground-history.ts +2 -1
- package/src/runs/foreground/subagent-executor.ts +281 -80
- package/src/runs/shared/acceptance.ts +194 -37
- package/src/runs/shared/async-status-projection.ts +123 -33
- package/src/runs/shared/child-launch-plan.ts +15 -3
- package/src/runs/shared/child-launch.ts +19 -6
- package/src/runs/shared/child-runtime-config.ts +5 -0
- package/src/runs/shared/child-session.ts +94 -50
- package/src/runs/shared/child-tool-plan.ts +28 -16
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/external-cli-contract.ts +11 -1
- package/src/runs/shared/external-cli-preflight.ts +6 -2
- package/src/runs/shared/herdr-connection.ts +134 -0
- package/src/runs/shared/herdr-external-adapters.ts +169 -0
- package/src/runs/shared/herdr-machine.ts +279 -0
- package/src/runs/shared/herdr-pi-protocol.ts +59 -0
- package/src/runs/shared/herdr-placed-run.ts +263 -0
- package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
- package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
- package/src/runs/shared/model-scope.ts +1 -1
- package/src/runs/shared/nested-events.ts +11 -2
- package/src/runs/shared/parallel-utils.ts +7 -2
- package/src/runs/shared/pi-spawn.ts +1 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
- package/src/runs/shared/worktree-setup-command.ts +27 -4
- package/src/runs/shared/worktree.ts +30 -8
- package/src/shared/child-cache-retention.ts +43 -0
- package/src/shared/launch-contract.ts +6 -9
- package/src/shared/pruned-fork.ts +1 -1
- package/src/shared/required-child-extensions.ts +81 -0
- package/src/shared/settings.ts +5 -2
- package/src/shared/shortcuts.ts +0 -4
- package/src/shared/types.ts +81 -29
- package/src/slash/slash-commands.ts +0 -6
- package/src/slash/subagents-admin.ts +13 -9
- package/src/tui/render.ts +20 -10
- package/src/watchdog/child-status.ts +28 -36
- package/src/watchdog/lsp-diagnostics.ts +1 -1
- package/src/watchdog/model-selection.ts +1 -1
- package/src/watchdog/register-child.ts +10 -3
- package/src/watchdog/register-main.ts +20 -20
- package/src/watchdog/render.ts +1 -1
- package/src/watchdog/review.ts +14 -30
- package/src/watchdog/rules.ts +1 -1
- package/src/watchdog/runtime.ts +23 -12
- package/src/watchdog/settings.ts +3 -6
- package/src/watchdog/types.ts +3 -5
- package/src/watchdog/warning-format.ts +1 -1
- package/src/workflows/scripted-workflow.ts +68 -7
- package/src/workflows/workflow-receipt.ts +21 -3
- package/src/workflows/workflow-resources.ts +13 -2
- package/src/runs/shared/model-exclusions.ts +0 -374
- package/src/runs/shared/readonly-model-continuation.ts +0 -69
- package/src/runs/shared/readonly-session-evidence.ts +0 -307
|
@@ -11,12 +11,14 @@ import type {
|
|
|
11
11
|
AcceptanceLedger,
|
|
12
12
|
AcceptanceLevel,
|
|
13
13
|
AcceptanceReport,
|
|
14
|
+
AcceptanceReviewGate,
|
|
14
15
|
AcceptanceRole,
|
|
15
16
|
AcceptanceRuntimeCheck,
|
|
16
17
|
AcceptanceRuntimeCheckStatus,
|
|
17
18
|
AcceptanceReviewResult,
|
|
18
19
|
AcceptanceVerifyCommand,
|
|
19
20
|
AcceptanceVerifyResult,
|
|
21
|
+
JsonSchemaObject,
|
|
20
22
|
ResolvedAcceptanceConfig,
|
|
21
23
|
ResolvedAcceptanceGate,
|
|
22
24
|
SingleResult,
|
|
@@ -25,6 +27,7 @@ import type {
|
|
|
25
27
|
} from "../../shared/types.ts";
|
|
26
28
|
import { unresolvedChildWatchdogBlockers } from "../../watchdog/child-status.ts";
|
|
27
29
|
import { isAgentContract } from "./agent-contract.ts";
|
|
30
|
+
import { validateStructuredOutputValue } from "./structured-output.ts";
|
|
28
31
|
import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
|
|
29
32
|
|
|
30
33
|
const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
|
|
@@ -49,9 +52,9 @@ const VALID_EVIDENCE_KINDS: AcceptanceEvidenceKind[] = [
|
|
|
49
52
|
const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>(VALID_EVIDENCE_KINDS);
|
|
50
53
|
const ACCEPTANCE_EVIDENCE_HELP = `Supported evidence kinds: ${VALID_EVIDENCE_KINDS.join(", ")}. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }.`;
|
|
51
54
|
const ACCEPTANCE_OBJECT_EXAMPLE = "Example: { level: \"checked\", evidence: [\"commands-run\", \"changed-files\"] }.";
|
|
52
|
-
const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
|
|
55
|
+
const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "preserveStagedIndex", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
|
|
53
56
|
const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
|
|
54
|
-
const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
|
|
57
|
+
const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure", "output", "schema"]);
|
|
55
58
|
const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
|
|
56
59
|
const EXPLICIT_REVIEWED_UNAVAILABLE = "is an achieved status, not a requestable acceptance level. For a read-only reviewer call, omit acceptance. To require independent review of a writer result, use acceptance.review.required and orchestrate the reviewer separately.";
|
|
57
60
|
|
|
@@ -106,8 +109,9 @@ function inferLevel(input: {
|
|
|
106
109
|
const roleResolvesReadOnly = input.acceptanceRole !== undefined && inferredReadOnly;
|
|
107
110
|
const dynamicResolvesReadOnly = inferredReadOnly && !writeTask;
|
|
108
111
|
const riskyKeywordPattern = /\b(?:release|migration|migrate|security|data[- ]loss|destructive|post-review|fix pass)\b/;
|
|
112
|
+
// Topic keywords cannot override classified read-only intent; unknown tasks keep their risk gate.
|
|
109
113
|
const keywordRiskReadOnly = input.acceptanceRole === undefined
|
|
110
|
-
? intent.kind === "read-only"
|
|
114
|
+
? intent.kind === "read-only"
|
|
111
115
|
: inferredReadOnly;
|
|
112
116
|
const risky = Boolean(input.async && writeTask)
|
|
113
117
|
|| (Boolean(input.dynamic) && !roleResolvesReadOnly && !dynamicResolvesReadOnly)
|
|
@@ -198,15 +202,59 @@ type GateAcceptanceNormalizationResult =
|
|
|
198
202
|
| { ok: true; acceptance?: AcceptanceInput }
|
|
199
203
|
| { ok: false; error: string };
|
|
200
204
|
|
|
205
|
+
const GATE_OBJECT_KEYS = new Set(["command", "output", "schema", "timeoutMs"]);
|
|
206
|
+
export const GATE_INPUT_ERROR = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
|
|
207
|
+
|
|
208
|
+
export interface GateObjectInput {
|
|
209
|
+
command: string;
|
|
210
|
+
output?: "json";
|
|
211
|
+
schema?: JsonSchemaObject;
|
|
212
|
+
timeoutMs?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Validates the two accepted gate shapes without normalizing them. Shared by preflight and the workflow sandbox mirror. */
|
|
216
|
+
export function parseGateInput(gate: unknown): { ok: true; gate: GateObjectInput } | { ok: false; error: string } {
|
|
217
|
+
if (typeof gate === "string") {
|
|
218
|
+
return gate.trim() ? { ok: true, gate: { command: gate.trim() } } : { ok: false, error: GATE_INPUT_ERROR };
|
|
219
|
+
}
|
|
220
|
+
if (!gate || typeof gate !== "object" || Array.isArray(gate)) return { ok: false, error: GATE_INPUT_ERROR };
|
|
221
|
+
const value = gate as Record<string, unknown>;
|
|
222
|
+
for (const key of Object.keys(value)) {
|
|
223
|
+
if (!GATE_OBJECT_KEYS.has(key)) return { ok: false, error: `gate.${key} is not supported.` };
|
|
224
|
+
}
|
|
225
|
+
if (typeof value.command !== "string" || !value.command.trim()) return { ok: false, error: GATE_INPUT_ERROR };
|
|
226
|
+
if (value.output !== undefined && value.output !== "json") return { ok: false, error: "gate.output must be \"json\" when present." };
|
|
227
|
+
if (value.schema !== undefined) {
|
|
228
|
+
if (value.output !== "json") return { ok: false, error: "gate.schema requires gate.output: \"json\"." };
|
|
229
|
+
if (!value.schema || typeof value.schema !== "object" || Array.isArray(value.schema)) return { ok: false, error: "gate.schema must be a JSON Schema object." };
|
|
230
|
+
}
|
|
231
|
+
if (value.timeoutMs !== undefined && (!Number.isInteger(value.timeoutMs) || (value.timeoutMs as number) < 1)) return { ok: false, error: "gate.timeoutMs must be an integer >= 1." };
|
|
232
|
+
const parsed: GateObjectInput = { command: value.command.trim() };
|
|
233
|
+
if (value.output === "json") parsed.output = "json";
|
|
234
|
+
// SAFETY: schema was checked above to be a non-array object; timeoutMs to be an integer >= 1.
|
|
235
|
+
if (value.schema !== undefined) parsed.schema = value.schema as JsonSchemaObject;
|
|
236
|
+
if (value.timeoutMs !== undefined) parsed.timeoutMs = value.timeoutMs as number;
|
|
237
|
+
return { ok: true, gate: parsed };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** True when an acceptance policy declares at least one `output: "json"` verify command, from either the gate shorthand or an explicit verify list. */
|
|
241
|
+
export function acceptanceHasTypedVerify(acceptance: AcceptanceInput | undefined): boolean {
|
|
242
|
+
if (!acceptance || typeof acceptance !== "object") return false;
|
|
243
|
+
return Array.isArray(acceptance.verify) && acceptance.verify.some((command) => command && typeof command === "object" && command.output === "json");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT = "a typed verify command (output: \"json\") cannot be combined with outputSchema; the child would have two structured-output sources.";
|
|
247
|
+
|
|
201
248
|
export function normalizeGateAcceptance(gate: unknown, acceptance: AcceptanceInput | undefined): GateAcceptanceNormalizationResult {
|
|
202
249
|
if (gate === undefined) {
|
|
203
250
|
if (acceptance === undefined) return { ok: true };
|
|
204
251
|
const normalized = normalizeAcceptanceValue(acceptance);
|
|
205
252
|
return normalized.error ? { ok: false, error: normalized.error } : { ok: true, acceptance: normalized.value as AcceptanceInput };
|
|
206
253
|
}
|
|
207
|
-
|
|
254
|
+
const parsed = parseGateInput(gate);
|
|
255
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
208
256
|
if (acceptance !== undefined && acceptance !== false) return { ok: false, error: "gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(gate, acceptance) };
|
|
209
|
-
return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate",
|
|
257
|
+
return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", ...parsed.gate }] } };
|
|
210
258
|
}
|
|
211
259
|
|
|
212
260
|
export function describeGateAcceptanceConflict(gate: unknown, acceptance: unknown): string {
|
|
@@ -265,6 +313,12 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
|
|
|
265
313
|
if (value.report !== undefined && value.report !== "on" && value.report !== "off") {
|
|
266
314
|
errors.push(`${pathLabel}.report must be on or off.`);
|
|
267
315
|
}
|
|
316
|
+
if (value.preserveStagedIndex !== undefined && value.preserveStagedIndex !== true) {
|
|
317
|
+
errors.push(`${pathLabel}.preserveStagedIndex must be true when provided.`);
|
|
318
|
+
}
|
|
319
|
+
if (value.preserveStagedIndex === true && value.level !== "checked" && value.level !== "verified") {
|
|
320
|
+
errors.push(`${pathLabel}.preserveStagedIndex requires level checked or verified.`);
|
|
321
|
+
}
|
|
268
322
|
if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
|
|
269
323
|
errors.push(`${pathLabel}.reason is required when level is none.`);
|
|
270
324
|
}
|
|
@@ -346,6 +400,11 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
|
|
|
346
400
|
if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") {
|
|
347
401
|
errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
|
|
348
402
|
}
|
|
403
|
+
if (cmd.output !== undefined && cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].output must be "json" when present.`);
|
|
404
|
+
if (cmd.schema !== undefined) {
|
|
405
|
+
if (cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].schema requires output: "json".`);
|
|
406
|
+
if (!cmd.schema || typeof cmd.schema !== "object" || Array.isArray(cmd.schema)) errors.push(`${pathLabel}.verify[${index}].schema must be a JSON Schema object.`);
|
|
407
|
+
}
|
|
349
408
|
}
|
|
350
409
|
}
|
|
351
410
|
if (value.review !== undefined && value.review !== false) {
|
|
@@ -370,7 +429,7 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
|
|
|
370
429
|
return errors;
|
|
371
430
|
}
|
|
372
431
|
|
|
373
|
-
|
|
432
|
+
type ExecutionAcceptanceInput = {
|
|
374
433
|
acceptance?: unknown;
|
|
375
434
|
outputSchema?: unknown;
|
|
376
435
|
tasks?: Array<{ acceptance?: unknown; outputSchema?: unknown }>;
|
|
@@ -379,23 +438,39 @@ export function validateExecutionAcceptance(input: {
|
|
|
379
438
|
outputSchema?: unknown;
|
|
380
439
|
parallel?: Array<{ acceptance?: unknown; outputSchema?: unknown }> | { acceptance?: unknown; outputSchema?: unknown };
|
|
381
440
|
}>;
|
|
382
|
-
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export function validateExecutionAcceptancePolicy(input: ExecutionAcceptanceInput): string[] {
|
|
383
444
|
const errors = validateAcceptanceInput(input.acceptance, "acceptance");
|
|
384
|
-
errors.push(...validateAcceptanceReportMode(input.acceptance, input.outputSchema, "acceptance"));
|
|
385
445
|
for (const [index, task] of (input.tasks ?? []).entries()) {
|
|
386
446
|
errors.push(...validateAcceptanceInput(task.acceptance, `tasks[${index}].acceptance`));
|
|
387
|
-
errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `tasks[${index}].acceptance`));
|
|
388
447
|
}
|
|
389
448
|
for (const [stepIndex, step] of (input.chain ?? []).entries()) {
|
|
390
449
|
errors.push(...validateAcceptanceInput(step.acceptance, `chain[${stepIndex}].acceptance`));
|
|
391
|
-
errors.push(...validateAcceptanceReportMode(step.acceptance, step.outputSchema, `chain[${stepIndex}].acceptance`));
|
|
392
450
|
if (Array.isArray(step.parallel)) {
|
|
393
451
|
for (const [taskIndex, task] of step.parallel.entries()) {
|
|
394
452
|
errors.push(...validateAcceptanceInput(task.acceptance, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
|
|
395
|
-
errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
|
|
396
453
|
}
|
|
397
454
|
} else if (step.parallel) {
|
|
398
455
|
errors.push(...validateAcceptanceInput(step.parallel.acceptance, `chain[${stepIndex}].parallel.acceptance`));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return errors;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function validateExecutionAcceptance(input: ExecutionAcceptanceInput): string[] {
|
|
462
|
+
const errors = validateExecutionAcceptancePolicy(input);
|
|
463
|
+
errors.push(...validateAcceptanceReportMode(input.acceptance, input.outputSchema, "acceptance"));
|
|
464
|
+
for (const [index, task] of (input.tasks ?? []).entries()) {
|
|
465
|
+
errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `tasks[${index}].acceptance`));
|
|
466
|
+
}
|
|
467
|
+
for (const [stepIndex, step] of (input.chain ?? []).entries()) {
|
|
468
|
+
errors.push(...validateAcceptanceReportMode(step.acceptance, step.outputSchema, `chain[${stepIndex}].acceptance`));
|
|
469
|
+
if (Array.isArray(step.parallel)) {
|
|
470
|
+
for (const [taskIndex, task] of step.parallel.entries()) {
|
|
471
|
+
errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
|
|
472
|
+
}
|
|
473
|
+
} else if (step.parallel) {
|
|
399
474
|
errors.push(...validateAcceptanceReportMode(step.parallel.acceptance, step.parallel.outputSchema, `chain[${stepIndex}].parallel.acceptance`));
|
|
400
475
|
}
|
|
401
476
|
}
|
|
@@ -408,7 +483,7 @@ function validateAcceptanceReportMode(acceptance: unknown, outputSchema: unknown
|
|
|
408
483
|
acceptance = normalized.value;
|
|
409
484
|
if (!acceptance || typeof acceptance !== "object" || Array.isArray(acceptance)) return [];
|
|
410
485
|
if (!("report" in acceptance)) return [];
|
|
411
|
-
return outputSchema === undefined ? [`${pathLabel}.report requires outputSchema.`] : [];
|
|
486
|
+
return outputSchema === undefined || outputSchema === false ? [`${pathLabel}.report requires outputSchema.`] : [];
|
|
412
487
|
}
|
|
413
488
|
|
|
414
489
|
function normalizeCriteria(criteria: Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }> | undefined, evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
|
|
@@ -453,6 +528,7 @@ export function resolveEffectiveAcceptance(input: {
|
|
|
453
528
|
inferredReason: [],
|
|
454
529
|
criteria,
|
|
455
530
|
evidence,
|
|
531
|
+
preserveStagedIndex: explicit.preserveStagedIndex,
|
|
456
532
|
verify: explicit.verify ?? [],
|
|
457
533
|
review: explicit.review,
|
|
458
534
|
stopRules: explicit.stopRules ?? [],
|
|
@@ -478,6 +554,7 @@ export function resolveEffectiveAcceptance(input: {
|
|
|
478
554
|
inferredReason: inferred.reasons,
|
|
479
555
|
criteria: level === "none" ? [] : criteria,
|
|
480
556
|
evidence: level === "none" ? [] : evidence,
|
|
557
|
+
preserveStagedIndex: explicit.preserveStagedIndex,
|
|
481
558
|
verify: explicit.verify ?? [],
|
|
482
559
|
review,
|
|
483
560
|
stopRules: explicit.stopRules ?? [],
|
|
@@ -489,6 +566,12 @@ function acceptanceRequiresChildReport(acceptance: ResolvedAcceptanceConfig): bo
|
|
|
489
566
|
return acceptance.criteria.length > 0 || acceptance.evidence.length > 0;
|
|
490
567
|
}
|
|
491
568
|
|
|
569
|
+
/** Label a declared review gate the same way in prompts and capability summaries. */
|
|
570
|
+
export function formatReviewGateLabel(review: AcceptanceReviewGate): string {
|
|
571
|
+
const status = review.required === false ? "optional" : "required";
|
|
572
|
+
return review.agent ? `${status} by ${review.agent}` : status;
|
|
573
|
+
}
|
|
574
|
+
|
|
492
575
|
export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, options: { reportOptional?: boolean; structuredOutput?: boolean } = {}): string {
|
|
493
576
|
if (acceptance.level === "none") return "";
|
|
494
577
|
if (options.reportOptional && !acceptanceRequiresChildReport(acceptance)) return "";
|
|
@@ -503,12 +586,15 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
|
|
|
503
586
|
"",
|
|
504
587
|
`Required evidence: ${acceptance.evidence.join(", ") || "none"}`,
|
|
505
588
|
];
|
|
589
|
+
if (acceptance.preserveStagedIndex) {
|
|
590
|
+
lines.push("The host will verify that the staged index is unchanged from launch; report noStagedFiles truthfully even when the preserved index is non-empty.");
|
|
591
|
+
}
|
|
506
592
|
if (acceptance.verify.length > 0) {
|
|
507
593
|
lines.push("", "Runtime verification commands configured by parent:");
|
|
508
594
|
for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
|
|
509
595
|
}
|
|
510
596
|
if (acceptance.review) {
|
|
511
|
-
lines.push("", `Review gate: ${acceptance.review
|
|
597
|
+
lines.push("", `Review gate: ${formatReviewGateLabel(acceptance.review)}.`);
|
|
512
598
|
if (acceptance.review.focus) lines.push(`Review focus: ${acceptance.review.focus}`);
|
|
513
599
|
}
|
|
514
600
|
if (acceptance.stopRules.length > 0) {
|
|
@@ -1055,9 +1141,32 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
|
|
|
1055
1141
|
: { id: "no-staged-files", status: "failed", message: `Staged files present: ${staged.join(", ")}` };
|
|
1056
1142
|
}
|
|
1057
1143
|
|
|
1144
|
+
/** Capture the repository-wide index tree. Throws when Git cannot provide trustworthy evidence. */
|
|
1145
|
+
export function captureStagedIndexBaseline(cwd: string): string {
|
|
1146
|
+
const result = spawnSync("git", ["write-tree"], { cwd, encoding: "utf-8", windowsHide: true });
|
|
1147
|
+
const oid = result.stdout.trim();
|
|
1148
|
+
if (result.status !== 0 || !oid) {
|
|
1149
|
+
const detail = result.stderr.trim();
|
|
1150
|
+
throw new Error(`Unable to capture staged index baseline${detail ? `: ${detail}` : "."}`);
|
|
1151
|
+
}
|
|
1152
|
+
return oid;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function checkStagedIndexUnchanged(cwd: string, baseline: string): AcceptanceRuntimeCheck {
|
|
1156
|
+
try {
|
|
1157
|
+
const terminal = captureStagedIndexBaseline(cwd);
|
|
1158
|
+
return terminal === baseline
|
|
1159
|
+
? { id: "staged-index-unchanged", status: "passed", message: "Staged index matches the launch baseline." }
|
|
1160
|
+
: { id: "staged-index-unchanged", status: "failed", message: "Staged index changed after launch." };
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
return { id: "staged-index-unchanged", status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1058
1166
|
function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
|
|
1059
1167
|
const checks: AcceptanceRuntimeCheck[] = [];
|
|
1060
1168
|
for (const kind of acceptance.evidence) {
|
|
1169
|
+
if (kind === "no-staged-files" && acceptance.preserveStagedIndex) continue;
|
|
1061
1170
|
if (kind === "no-staged-files" && report.noStagedFiles === undefined) continue;
|
|
1062
1171
|
const status = reportEvidenceStatus(report, kind);
|
|
1063
1172
|
checks.push({
|
|
@@ -1070,7 +1179,7 @@ function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: Accep
|
|
|
1070
1179
|
: `${kind} evidence missing from child report.`,
|
|
1071
1180
|
});
|
|
1072
1181
|
}
|
|
1073
|
-
if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
|
|
1182
|
+
if (!acceptance.preserveStagedIndex && acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
|
|
1074
1183
|
return checks;
|
|
1075
1184
|
}
|
|
1076
1185
|
|
|
@@ -1178,6 +1287,43 @@ function isCachedVerifyResult(value: unknown): value is AcceptanceVerifyResult {
|
|
|
1178
1287
|
&& typeof result.durationMs === "number";
|
|
1179
1288
|
}
|
|
1180
1289
|
|
|
1290
|
+
const TYPED_VERIFY_OUTPUT_MAX_BYTES = 12_000;
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* A passing `output: "json"` command must print one JSON document on stdout.
|
|
1294
|
+
* The parsed value becomes the run's structured output; anything else turns
|
|
1295
|
+
* the passing run into a failed one so acceptance rejects rather than
|
|
1296
|
+
* silently dropping the verdict. Stdout is already bounded by trimOutput, so a
|
|
1297
|
+
* truncated document is detected by its marker instead of a parse guess.
|
|
1298
|
+
*/
|
|
1299
|
+
async function applyTypedVerifyOutput(command: AcceptanceVerifyCommand, run: AcceptanceVerifyResult): Promise<void> {
|
|
1300
|
+
if (run.status !== "passed") return;
|
|
1301
|
+
const stdout = run.stdout ?? "";
|
|
1302
|
+
const fail = (message: string): void => {
|
|
1303
|
+
run.status = "failed";
|
|
1304
|
+
run.structuredOutputError = message;
|
|
1305
|
+
};
|
|
1306
|
+
if (!stdout.trim()) return fail("output: \"json\" command printed nothing on stdout.");
|
|
1307
|
+
if (stdout.endsWith("...[truncated]")) return fail(`output: "json" stdout exceeded ${TYPED_VERIFY_OUTPUT_MAX_BYTES} characters and was truncated.`);
|
|
1308
|
+
let value: unknown;
|
|
1309
|
+
try {
|
|
1310
|
+
value = JSON.parse(stdout);
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
return fail(`output: "json" stdout is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1313
|
+
}
|
|
1314
|
+
if (command.schema) {
|
|
1315
|
+
const validation = await validateStructuredOutputValue(command.schema, value);
|
|
1316
|
+
if (validation.status === "invalid") return fail(`output: "json" stdout does not match gate.schema: ${validation.message}`);
|
|
1317
|
+
}
|
|
1318
|
+
run.structuredOutput = value;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** The first typed verify result on a ledger, if any command produced one. */
|
|
1322
|
+
export function typedVerifyOutput(ledger: AcceptanceLedger | undefined): { value: unknown } | undefined {
|
|
1323
|
+
const run = ledger?.verifyRuns?.find((entry) => entry.structuredOutput !== undefined);
|
|
1324
|
+
return run ? { value: run.structuredOutput } : undefined;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1181
1327
|
async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: {
|
|
1182
1328
|
signal?: AbortSignal;
|
|
1183
1329
|
abortMessage?: string;
|
|
@@ -1191,7 +1337,9 @@ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaul
|
|
|
1191
1337
|
} catch {
|
|
1192
1338
|
workspaceState = undefined;
|
|
1193
1339
|
}
|
|
1194
|
-
|
|
1340
|
+
// Typed gates read inputs (a report file, a log) that can change without
|
|
1341
|
+
// the tracked tree changing, so their verdicts are never reused.
|
|
1342
|
+
if (!workspaceState || !options.artifactsDir || !options.runId || command.output === "json") {
|
|
1195
1343
|
return runVerifyCommand(command, defaultCwd, options);
|
|
1196
1344
|
}
|
|
1197
1345
|
const envKeys = Object.keys(command.env ?? {}).sort();
|
|
@@ -1363,6 +1511,8 @@ export async function evaluateAcceptance(input: {
|
|
|
1363
1511
|
acceptance: ResolvedAcceptanceConfig;
|
|
1364
1512
|
output: string;
|
|
1365
1513
|
cwd: string;
|
|
1514
|
+
/** Host-captured launch index tree; required by preserveStagedIndex. */
|
|
1515
|
+
stagedIndexBaseline?: string;
|
|
1366
1516
|
/**
|
|
1367
1517
|
* Content the child sent to its configured output file (from its own write
|
|
1368
1518
|
* tool calls, not from disk, so a concurrent writer to the same path cannot
|
|
@@ -1397,7 +1547,7 @@ export async function evaluateAcceptance(input: {
|
|
|
1397
1547
|
if (input.watchdog) {
|
|
1398
1548
|
const unresolved = unresolvedChildWatchdogBlockers(input.watchdog);
|
|
1399
1549
|
ledger.runtimeChecks.push(unresolved.length
|
|
1400
|
-
? { id: "watchdog-blocker", status: "failed", message:
|
|
1550
|
+
? { id: "watchdog-blocker", status: "failed", message: "Unresolved watchdog blocker (details are available in child watchdog status)." }
|
|
1401
1551
|
: { id: "watchdog-blocker", status: "passed", message: "No unresolved watchdog blockers." });
|
|
1402
1552
|
}
|
|
1403
1553
|
|
|
@@ -1458,30 +1608,36 @@ export async function evaluateAcceptance(input: {
|
|
|
1458
1608
|
ledger.runtimeChecks.push({ id: "verification-config", status: "failed", message: "verified acceptance requires runtime verify commands." });
|
|
1459
1609
|
ledger.status = "rejected";
|
|
1460
1610
|
ledger.evidenceStatus = "rejected";
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
ledger.status
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1611
|
+
} else {
|
|
1612
|
+
ledger.verifyRuns = [];
|
|
1613
|
+
for (const command of acceptance.verify) {
|
|
1614
|
+
const run = await runMemoizedVerifyCommand(command, input.cwd, {
|
|
1615
|
+
signal: input.signal,
|
|
1616
|
+
abortMessage: input.abortMessage,
|
|
1617
|
+
artifactsDir: input.artifactsDir,
|
|
1618
|
+
runId: input.runId,
|
|
1619
|
+
});
|
|
1620
|
+
if (command.output === "json") await applyTypedVerifyOutput(command, run);
|
|
1621
|
+
ledger.verifyRuns.push(run);
|
|
1622
|
+
if (input.signal?.aborted) break;
|
|
1623
|
+
}
|
|
1624
|
+
if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
|
|
1625
|
+
ledger.status = "rejected";
|
|
1626
|
+
ledger.evidenceStatus = "rejected";
|
|
1627
|
+
} else if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
|
|
1628
|
+
ledger.status = "verified";
|
|
1629
|
+
ledger.evidenceStatus = "verified";
|
|
1630
|
+
}
|
|
1481
1631
|
}
|
|
1482
1632
|
}
|
|
1483
1633
|
|
|
1484
|
-
if (
|
|
1634
|
+
if (acceptance.preserveStagedIndex) {
|
|
1635
|
+
ledger.runtimeChecks.push(input.stagedIndexBaseline
|
|
1636
|
+
? checkStagedIndexUnchanged(input.cwd, input.stagedIndexBaseline)
|
|
1637
|
+
: { id: "staged-index-unchanged", status: "failed", message: "Staged index launch baseline is unavailable." });
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (ledger.status === "rejected" || ledger.runtimeChecks.some((check) => check.status === "failed")) {
|
|
1485
1641
|
ledger.status = "rejected";
|
|
1486
1642
|
ledger.evidenceStatus = "rejected";
|
|
1487
1643
|
return ledger;
|
|
@@ -1535,6 +1691,7 @@ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | und
|
|
|
1535
1691
|
const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");
|
|
1536
1692
|
if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
|
|
1537
1693
|
const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
|
|
1694
|
+
if (failedVerify?.structuredOutputError) return `Acceptance verification '${failedVerify.id}' failed: ${failedVerify.structuredOutputError}`;
|
|
1538
1695
|
if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
|
|
1539
1696
|
if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
|
|
1540
1697
|
return "Acceptance rejected.";
|
|
@@ -224,16 +224,25 @@ function appendBoundedChildren(children: AsyncStatusSnapshotNode[], source: read
|
|
|
224
224
|
ctx.omitted.children += Math.max(0, source.length - remaining);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
function stepIdentity(step: AsyncJobStep | NestedStepSummary, index: number, ctx: ProjectionContext) {
|
|
228
|
+
return {
|
|
229
|
+
id: publicText("workflowKey" in step && step.workflowKey ? step.workflowKey : "runId" in step && step.runId ? step.runId : `step:${index}`, `step:${index}`, ctx.caps.maxStringLength),
|
|
230
|
+
label: publicText("label" in step && step.label ? step.label : step.agent, "step", ctx.caps.maxStringLength),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
227
234
|
function projectStep(step: AsyncJobStep | NestedStepSummary, index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNode {
|
|
228
235
|
const state = normalizeState(step.status);
|
|
229
236
|
const startedAt = publicTime(step.startedAt);
|
|
230
|
-
const
|
|
237
|
+
const durationMs = "durationMs" in step ? publicTime(step.durationMs) : undefined;
|
|
238
|
+
const endedAt = publicTime(step.endedAt) ?? (terminalState(state) && startedAt !== undefined && durationMs !== undefined ? publicTime(startedAt + durationMs) : undefined);
|
|
231
239
|
const updatedAt = endedAt ?? publicTime(step.lastActivityAt) ?? startedAt;
|
|
232
240
|
const activity = activityFor(step, ctx);
|
|
241
|
+
const { id, label } = stepIdentity(step, index, ctx);
|
|
233
242
|
const node: AsyncStatusSnapshotNode = {
|
|
234
|
-
id
|
|
243
|
+
id,
|
|
235
244
|
kind: "step",
|
|
236
|
-
label
|
|
245
|
+
label,
|
|
237
246
|
state,
|
|
238
247
|
...(startedAt !== undefined ? { startedAt } : {}),
|
|
239
248
|
...(updatedAt !== undefined ? { updatedAt } : {}),
|
|
@@ -251,16 +260,6 @@ function projectStep(step: AsyncJobStep | NestedStepSummary, index: number, dept
|
|
|
251
260
|
return node;
|
|
252
261
|
}
|
|
253
262
|
|
|
254
|
-
function projectWorkflowGraphNode(node: WorkflowGraphSnapshot["nodes"][number], index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNode {
|
|
255
|
-
const step: AsyncJobStep = {
|
|
256
|
-
agent: node.agent ?? node.label,
|
|
257
|
-
status: workflowGraphStepStatus(node.status),
|
|
258
|
-
workflowKey: node.id,
|
|
259
|
-
label: node.label,
|
|
260
|
-
};
|
|
261
|
-
return projectStep(step, node.flatIndex ?? index, depth, ctx);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
263
|
function projectNestedRun(child: NestedRunSummary, index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNode {
|
|
265
264
|
const state = normalizeState(child.state);
|
|
266
265
|
const startedAt = publicTime(child.startedAt);
|
|
@@ -324,7 +323,86 @@ function projectHostStep(hostStep: HostStepNode, ctx: ProjectionContext): AsyncS
|
|
|
324
323
|
};
|
|
325
324
|
}
|
|
326
325
|
|
|
327
|
-
|
|
326
|
+
type MaterializedChildren = Map<string, AsyncJobState[]>;
|
|
327
|
+
|
|
328
|
+
function asyncJobOrder(left: AsyncJobState, right: AsyncJobState): number {
|
|
329
|
+
const leftRank = left.status === "running" ? 0 : left.status === "queued" ? 1 : 2;
|
|
330
|
+
const rightRank = right.status === "running" ? 0 : right.status === "queued" ? 1 : 2;
|
|
331
|
+
const leftTime = left.updatedAt ?? left.startedAt ?? 0;
|
|
332
|
+
const rightTime = right.updatedAt ?? right.startedAt ?? 0;
|
|
333
|
+
return leftRank - rightRank || rightTime - leftTime || left.asyncId.localeCompare(right.asyncId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hasCyclicWorkflowParent(job: AsyncJobState, parents: ReadonlyMap<string, AsyncJobState>): boolean {
|
|
337
|
+
const seen = new Set([job.asyncId]);
|
|
338
|
+
let parentId = job.parentWorkflowRunId;
|
|
339
|
+
while (parentId) {
|
|
340
|
+
if (seen.has(parentId)) return true;
|
|
341
|
+
seen.add(parentId);
|
|
342
|
+
parentId = parents.get(parentId)?.parentWorkflowRunId;
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function groupMaterializedChildren(jobs: readonly AsyncJobState[]) {
|
|
348
|
+
const parents = new Map(jobs.filter((job) => job.mode === "workflow").map((job) => [job.asyncId, job]));
|
|
349
|
+
const childrenByParent: MaterializedChildren = new Map();
|
|
350
|
+
const liveRoots = new Set<AsyncJobState>();
|
|
351
|
+
const roots: AsyncJobState[] = [];
|
|
352
|
+
for (const job of jobs) {
|
|
353
|
+
const parent = job.parentWorkflowRunId ? parents.get(job.parentWorkflowRunId) : undefined;
|
|
354
|
+
const keepLiveRoot = (job.status === "running" || job.status === "queued") && parent?.status !== "running";
|
|
355
|
+
if (parent && !hasCyclicWorkflowParent(job, parents)) {
|
|
356
|
+
const siblings = childrenByParent.get(parent.asyncId) ?? [];
|
|
357
|
+
siblings.push(job);
|
|
358
|
+
childrenByParent.set(parent.asyncId, siblings);
|
|
359
|
+
if (keepLiveRoot) {
|
|
360
|
+
liveRoots.add(job);
|
|
361
|
+
roots.push(job);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
roots.push(job);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const children of childrenByParent.values()) children.sort(asyncJobOrder);
|
|
368
|
+
return { roots, childrenByParent, liveRoots };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function assignMaterializedChildren(steps: readonly AsyncJobStep[], children: readonly AsyncJobState[]): Map<AsyncJobStep, AsyncJobState> {
|
|
372
|
+
const assigned = new Map<AsyncJobStep, AsyncJobState>();
|
|
373
|
+
const claimed = new Set<AsyncJobState>();
|
|
374
|
+
for (const step of steps) {
|
|
375
|
+
if (!step.runId) continue;
|
|
376
|
+
const child = children.find((candidate) => !claimed.has(candidate) && candidate.asyncId === step.runId);
|
|
377
|
+
if (child) {
|
|
378
|
+
assigned.set(step, child);
|
|
379
|
+
claimed.add(child);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const step of steps) {
|
|
383
|
+
if (assigned.has(step) || !step.workflowKey) continue;
|
|
384
|
+
const child = children.find((candidate) => !claimed.has(candidate) && candidate.workflowKey === step.workflowKey);
|
|
385
|
+
if (child) {
|
|
386
|
+
assigned.set(step, child);
|
|
387
|
+
claimed.add(child);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return assigned;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function projectMaterializedStep(step: AsyncJobStep, index: number, child: AsyncJobState, depth: number, ctx: ProjectionContext, childrenByParent: MaterializedChildren, liveRoots: ReadonlySet<AsyncJobState>): AsyncStatusSnapshotNode {
|
|
394
|
+
const { id, label } = stepIdentity(step, index, ctx);
|
|
395
|
+
const selfStep = child.mode === undefined || child.mode === "single" ? child.steps?.[0] : undefined;
|
|
396
|
+
const live = projectRun(child, ctx, depth, childrenByParent, liveRoots, selfStep !== undefined);
|
|
397
|
+
const laneActivity = activityFor(step, ctx);
|
|
398
|
+
const selfActivity = selfStep ? activityFor(selfStep, ctx) : undefined;
|
|
399
|
+
const activity = laneActivity || selfActivity || live.activity ? { ...laneActivity, ...selfActivity, ...live.activity } : undefined;
|
|
400
|
+
const node: AsyncStatusSnapshotNode = { ...live, id, kind: "step", label };
|
|
401
|
+
if (activity) node.activity = activity;
|
|
402
|
+
return node;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function projectRun(job: AsyncJobState, ctx: ProjectionContext, depth: number, childrenByParent: MaterializedChildren, liveRoots: ReadonlySet<AsyncJobState>, omitSteps = false): AsyncStatusSnapshotNode {
|
|
328
406
|
const state = normalizeState(job.status);
|
|
329
407
|
const startedAt = publicTime(job.startedAt);
|
|
330
408
|
const updatedAt = publicTime(job.updatedAt) ?? startedAt;
|
|
@@ -339,16 +417,32 @@ function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnap
|
|
|
339
417
|
...(terminalState(state) && updatedAt !== undefined ? { endedAt: updatedAt } : {}),
|
|
340
418
|
...(activity ? { activity } : {}),
|
|
341
419
|
};
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
420
|
+
const steps = omitSteps ? [] : job.steps ?? [];
|
|
421
|
+
const materialized = childrenByParent.get(job.asyncId) ?? [];
|
|
422
|
+
const nestedSummaries = (job.nestedChildren ?? []).filter((child) => !materialized.some((live) => live.asyncId === child.id));
|
|
423
|
+
const loadedKeys = new Set(steps.flatMap((step) => step.workflowKey ? [step.workflowKey] : []));
|
|
424
|
+
const graphStages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph).filter((graphNode) => !loadedKeys.has(graphNode.id)) : [];
|
|
425
|
+
const graphSteps = graphStages.map((node, index) => ({
|
|
426
|
+
step: { agent: node.agent ?? node.label, status: workflowGraphStepStatus(node.status), workflowKey: node.id, label: node.label },
|
|
427
|
+
index: node.flatIndex ?? index,
|
|
428
|
+
}));
|
|
429
|
+
const lanes = [...steps, ...graphSteps.map(({ step }) => step)];
|
|
430
|
+
const assigned = assignMaterializedChildren(lanes, materialized);
|
|
431
|
+
const claimed = new Set(assigned.values());
|
|
432
|
+
if (depth < ctx.caps.maxDepth) {
|
|
433
|
+
const childDepth = depth + 1;
|
|
434
|
+
const projectLane = (step: AsyncJobStep, index: number): AsyncStatusSnapshotNode | undefined => {
|
|
435
|
+
const child = assigned.get(step);
|
|
436
|
+
if (!child) return projectStep(step, index, childDepth, ctx);
|
|
437
|
+
if (liveRoots.has(child)) return undefined;
|
|
438
|
+
return projectMaterializedStep(step, index, child, childDepth, ctx, childrenByParent, liveRoots);
|
|
439
|
+
};
|
|
440
|
+
const stepChildren = steps.map((step, index) => projectLane(step, step.index ?? index)).filter((child): child is AsyncStatusSnapshotNode => child !== undefined);
|
|
441
|
+
const graphChildren = graphSteps.map(({ step, index }) => projectLane(step, index)).filter((child): child is AsyncStatusSnapshotNode => child !== undefined);
|
|
442
|
+
const nestedChildren = nestedSummaries.map((child, index) => projectNestedRun(child, index, childDepth, ctx));
|
|
443
|
+
const unclaimedChildren = materialized.filter((child) => !claimed.has(child) && !liveRoots.has(child)).map((child) => projectRun(child, ctx, childDepth, childrenByParent, liveRoots));
|
|
350
444
|
const hostStepChildren = validHostStepList(job.hostSteps).map((hostStep) => projectHostStep(hostStep, ctx));
|
|
351
|
-
const ordinaryChildren = [...stepChildren, ...graphChildren, ...nestedChildren];
|
|
445
|
+
const ordinaryChildren = [...stepChildren, ...graphChildren, ...nestedChildren, ...unclaimedChildren];
|
|
352
446
|
const retainedHostSteps = hostStepChildren.slice(0, ctx.caps.maxChildrenPerNode);
|
|
353
447
|
const retainedOrdinaryChildren = ordinaryChildren.slice(0, ctx.caps.maxChildrenPerNode - retainedHostSteps.length);
|
|
354
448
|
const bounded: AsyncStatusSnapshotNode[] = [];
|
|
@@ -356,10 +450,9 @@ function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnap
|
|
|
356
450
|
ctx.omitted.children += ordinaryChildren.length - retainedOrdinaryChildren.length + hostStepChildren.length - retainedHostSteps.length;
|
|
357
451
|
if (bounded.length) node.children = bounded;
|
|
358
452
|
} else {
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
ctx.omitted.children += (job.steps?.length ?? 0) + graphCount + (job.nestedChildren?.length ?? 0) + validHostStepList(job.hostSteps).length;
|
|
453
|
+
const rootAssignments = [...assigned.values()].filter((child) => liveRoots.has(child)).length;
|
|
454
|
+
const unclaimedChildren = materialized.filter((child) => !claimed.has(child) && !liveRoots.has(child)).length;
|
|
455
|
+
ctx.omitted.children += lanes.length - rootAssignments + nestedSummaries.length + unclaimedChildren + validHostStepList(job.hostSteps).length;
|
|
363
456
|
}
|
|
364
457
|
return node;
|
|
365
458
|
}
|
|
@@ -550,11 +643,8 @@ function projectLoadedWorkflowRow(step: AsyncJobStep, index: number, preflight?:
|
|
|
550
643
|
export function projectAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshot {
|
|
551
644
|
const caps = resolveCaps(options);
|
|
552
645
|
const ctx: ProjectionContext = { caps, omitted: { runs: 0, children: 0, byteLimitExceeded: false } };
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
const rightUpdated = right.updatedAt ?? right.startedAt ?? 0;
|
|
556
|
-
return rightUpdated - leftUpdated || left.asyncId.localeCompare(right.asyncId);
|
|
557
|
-
});
|
|
646
|
+
const { roots, childrenByParent, liveRoots } = groupMaterializedChildren([...jobs]);
|
|
647
|
+
const sorted = roots.sort(asyncJobOrder);
|
|
558
648
|
ctx.omitted.runs += Math.max(0, sorted.length - caps.maxRuns);
|
|
559
649
|
const snapshot: AsyncStatusSnapshot = {
|
|
560
650
|
kind: ASYNC_STATUS_SNAPSHOT_KIND,
|
|
@@ -562,7 +652,7 @@ export function projectAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, option
|
|
|
562
652
|
generatedAt: options.generatedAt ?? Date.now(),
|
|
563
653
|
caps,
|
|
564
654
|
omitted: ctx.omitted,
|
|
565
|
-
runs: sorted.slice(0, caps.maxRuns).map((job) => projectRun(job, ctx)),
|
|
655
|
+
runs: sorted.slice(0, caps.maxRuns).map((job) => projectRun(job, ctx, 0, childrenByParent, liveRoots)),
|
|
566
656
|
};
|
|
567
657
|
enforceByteLimit(snapshot);
|
|
568
658
|
return snapshot;
|