pi-crew 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/AGENTS.md +57 -32
- package/CHANGELOG.md +466 -448
- package/LICENSE +21 -21
- package/NOTICE.md +16 -16
- package/README.md +323 -323
- package/docs/FEATURE_INTAKE.md +126 -0
- package/docs/HARNESS.md +86 -0
- package/docs/HARNESS_BACKLOG.md +41 -0
- package/docs/TEST_MATRIX.md +49 -0
- package/docs/actions-reference.md +595 -595
- package/docs/architecture.md +180 -180
- package/docs/code-review-2026-05-11.md +592 -592
- package/docs/commands-reference.md +347 -347
- package/docs/comparison-pi-subagents-vs-pi-crew.md +303 -0
- package/docs/decisions/0001-durable-state.md +41 -0
- package/docs/decisions/0002-child-process-for-async.md +42 -0
- package/docs/decisions/0003-depth-guard.md +36 -0
- package/docs/decisions/0004-execfile-over-exec.md +34 -0
- package/docs/decisions/0005-no-parameter-properties.md +49 -0
- package/docs/decisions/0006-publish-bundled-esm.md +63 -0
- package/docs/decisions/0007-active-run-binary-index.md +54 -0
- package/docs/decisions/0008-child-pi-warm-pool.md +61 -0
- package/docs/decisions/README.md +23 -0
- package/docs/followup-review-round4-2026-05-13.md +107 -0
- package/docs/implementation-plan-top3.md +333 -0
- package/docs/live-mailbox-runtime.md +36 -36
- package/docs/next-upgrade-roadmap.md +808 -808
- package/docs/oh-my-pi-research.md +509 -0
- package/docs/perf/baseline-2026-05.md +113 -0
- package/docs/perf/final-report-2026-05.md +206 -0
- package/docs/perf/sprint-1-report.md +71 -0
- package/docs/perf/sprint-2-report.md +81 -0
- package/docs/perf/sprint-2.5-report.md +53 -0
- package/docs/perf/sprint-3-report.md +36 -0
- package/docs/perf/sprint-4-report.md +47 -0
- package/docs/perf/sprint-5-report.md +51 -0
- package/docs/perf/sprint-6-report.md +94 -0
- package/docs/perf/sprint-7-report.md +74 -0
- package/docs/perf/upgrade-plan-2026-05.md +147 -0
- package/docs/pi-subagents3-deep-analysis.md +508 -0
- package/docs/product/README.md +31 -0
- package/docs/product/platform.md +27 -0
- package/docs/product/runtime-safety.md +37 -0
- package/docs/product/team-run.md +39 -0
- package/docs/product/team-tool.md +37 -0
- package/docs/publishing.md +65 -65
- package/docs/resource-formats.md +134 -134
- package/docs/runtime-analysis-child-vs-live.md +171 -0
- package/docs/runtime-flow.md +148 -148
- package/docs/runtime-migration-in-process-analysis.md +250 -0
- package/docs/stories/README.md +30 -0
- package/docs/stories/backlog.md +36 -0
- package/docs/templates/decision.md +27 -0
- package/docs/templates/story.md +44 -0
- package/docs/templates/validation-report.md +32 -0
- package/docs/usage.md +238 -238
- package/index.ts +7 -6
- package/install.mjs +65 -65
- package/package.json +107 -100
- package/schema.json +222 -222
- package/skills/child-pi-spawning/SKILL.md +213 -0
- package/skills/context-artifact-hygiene/SKILL.md +32 -0
- package/skills/event-log-tracing/SKILL.md +299 -0
- package/skills/git-master/SKILL.md +225 -24
- package/skills/live-agent-lifecycle/SKILL.md +192 -0
- package/skills/mailbox-interactive/SKILL.md +300 -19
- package/skills/model-routing-context/SKILL.md +94 -0
- package/skills/multi-perspective-review/SKILL.md +88 -0
- package/skills/read-only-explorer/SKILL.md +250 -26
- package/skills/safe-bash/SKILL.md +307 -21
- package/skills/verification-before-done/SKILL.md +11 -2
- package/skills/widget-rendering/SKILL.md +258 -0
- package/skills/workspace-isolation/SKILL.md +202 -0
- package/skills/worktree-isolation/SKILL.md +202 -18
- package/src/adapters/claude-adapter.ts +25 -25
- package/src/adapters/codex-adapter.ts +21 -21
- package/src/adapters/cursor-adapter.ts +17 -17
- package/src/adapters/export-util.ts +137 -137
- package/src/adapters/index.ts +15 -15
- package/src/adapters/registry.ts +18 -18
- package/src/adapters/types.ts +23 -23
- package/src/agents/agent-config.ts +38 -38
- package/src/agents/agent-serializer.ts +38 -38
- package/src/agents/discover-agents.ts +121 -118
- package/src/config/config.ts +740 -858
- package/src/config/defaults.ts +96 -96
- package/src/config/drift-detector.ts +211 -211
- package/src/config/markers.ts +327 -327
- package/src/config/resilient-parser.ts +109 -108
- package/src/config/suggestions.ts +74 -74
- package/src/config/types.ts +199 -0
- package/src/extension/async-notifier.ts +123 -89
- package/src/extension/autonomous-policy.ts +169 -169
- package/src/extension/cross-extension-rpc.ts +104 -104
- package/src/extension/help.ts +47 -47
- package/src/extension/import-index.ts +69 -69
- package/src/extension/management.ts +395 -382
- package/src/extension/notification-router.ts +116 -116
- package/src/extension/notification-sink.ts +51 -51
- package/src/extension/project-init.ts +168 -168
- package/src/extension/register.ts +859 -668
- package/src/extension/registration/artifact-cleanup.ts +15 -15
- package/src/extension/registration/command-utils.ts +54 -54
- package/src/extension/registration/commands.ts +559 -452
- package/src/extension/registration/compaction-guard.ts +125 -125
- package/src/extension/registration/subagent-helpers.ts +102 -102
- package/src/extension/registration/subagent-tools.ts +220 -159
- package/src/extension/registration/team-tool.ts +159 -99
- package/src/extension/registration/viewers.ts +29 -0
- package/src/extension/result-watcher.ts +128 -128
- package/src/extension/run-bundle-schema.ts +89 -89
- package/src/extension/run-export.ts +73 -73
- package/src/extension/run-import.ts +84 -84
- package/src/extension/run-index.ts +94 -94
- package/src/extension/run-maintenance.ts +142 -142
- package/src/extension/session-summary.ts +8 -8
- package/src/extension/team-manager-command.ts +96 -96
- package/src/extension/team-recommendation.ts +188 -188
- package/src/extension/team-tool/api.ts +5 -2
- package/src/extension/team-tool/cancel.ts +224 -209
- package/src/extension/team-tool/config-patch.ts +36 -36
- package/src/extension/team-tool/context.ts +60 -60
- package/src/extension/team-tool/doctor.ts +242 -242
- package/src/extension/team-tool/handle-settings.ts +421 -195
- package/src/extension/team-tool/inspect.ts +41 -41
- package/src/extension/team-tool/lifecycle-actions.ts +139 -139
- package/src/extension/team-tool/parallel-dispatch.ts +156 -156
- package/src/extension/team-tool/plan.ts +19 -19
- package/src/extension/team-tool/respond.ts +112 -111
- package/src/extension/team-tool/run.ts +246 -229
- package/src/extension/team-tool/status.ts +110 -110
- package/src/extension/team-tool-types.ts +13 -13
- package/src/extension/team-tool.ts +344 -344
- package/src/extension/tool-result.ts +16 -16
- package/src/extension/validate-resources.ts +77 -77
- package/src/hooks/registry.ts +61 -61
- package/src/hooks/types.ts +40 -40
- package/src/i18n.ts +184 -184
- package/src/observability/correlation.ts +35 -35
- package/src/observability/event-to-metric.ts +68 -68
- package/src/observability/exporters/adapter.ts +30 -30
- package/src/observability/exporters/otlp-exporter.ts +106 -92
- package/src/observability/exporters/prometheus-exporter.ts +54 -54
- package/src/observability/metric-registry.ts +87 -87
- package/src/observability/metric-retention.ts +54 -54
- package/src/observability/metric-sink.ts +81 -56
- package/src/observability/metrics-primitives.ts +167 -167
- package/src/prompt/prompt-runtime.ts +72 -72
- package/src/runtime/adaptive-plan.ts +338 -0
- package/src/runtime/agent-control.ts +169 -169
- package/src/runtime/agent-memory.ts +72 -72
- package/src/runtime/agent-observability.ts +114 -114
- package/src/runtime/async-marker.ts +26 -26
- package/src/runtime/async-runner.ts +153 -153
- package/src/runtime/attention-events.ts +28 -28
- package/src/runtime/auto-resume.ts +100 -100
- package/src/runtime/background-runner.ts +122 -89
- package/src/runtime/cancellation.ts +61 -61
- package/src/runtime/capability-inventory.ts +116 -116
- package/src/runtime/child-pi-pool.ts +68 -0
- package/src/runtime/child-pi.ts +541 -461
- package/src/runtime/code-summary.ts +247 -247
- package/src/runtime/compaction-summary.ts +271 -271
- package/src/runtime/concurrency.ts +58 -58
- package/src/runtime/crash-recovery.ts +317 -301
- package/src/runtime/crew-agent-records.ts +379 -281
- package/src/runtime/crew-agent-runtime.ts +60 -60
- package/src/runtime/cross-extension-rpc.ts +72 -0
- package/src/runtime/custom-tools/irc-tool.ts +201 -201
- package/src/runtime/custom-tools/submit-result-tool.ts +90 -90
- package/src/runtime/deadletter.ts +47 -47
- package/src/runtime/delivery-coordinator.ts +176 -176
- package/src/runtime/delta-conflict.ts +360 -360
- package/src/runtime/diagnostic-export.ts +102 -102
- package/src/runtime/direct-run.ts +35 -35
- package/src/runtime/effectiveness.ts +82 -81
- package/src/runtime/errors/crew-errors.ts +166 -0
- package/src/runtime/event-stream-bridge.ts +92 -92
- package/src/runtime/foreground-control.ts +82 -82
- package/src/runtime/green-contract.ts +46 -46
- package/src/runtime/group-join.ts +234 -106
- package/src/runtime/heartbeat-watcher.ts +145 -124
- package/src/runtime/iteration-hooks.ts +267 -267
- package/src/runtime/live-agent-control.ts +88 -88
- package/src/runtime/live-agent-manager.ts +377 -179
- package/src/runtime/live-control-realtime.ts +36 -36
- package/src/runtime/live-session-runtime.ts +676 -600
- package/src/runtime/loop-gates.ts +129 -129
- package/src/runtime/manifest-cache.ts +263 -263
- package/src/runtime/mcp-proxy.ts +113 -113
- package/src/runtime/metric-parser.ts +40 -40
- package/src/runtime/model-fallback.ts +282 -274
- package/src/runtime/model-resolver.ts +118 -0
- package/src/runtime/output-validator.ts +187 -187
- package/src/runtime/overflow-recovery.ts +175 -175
- package/src/runtime/parallel-research.ts +44 -44
- package/src/runtime/parallel-utils.ts +156 -156
- package/src/runtime/parent-guard.ts +80 -80
- package/src/runtime/phase-progress.ts +217 -217
- package/src/runtime/pi-args.ts +165 -165
- package/src/runtime/pi-json-output.ts +111 -111
- package/src/runtime/pi-spawn.ts +167 -167
- package/src/runtime/policy-engine.ts +79 -79
- package/src/runtime/post-checks.ts +125 -125
- package/src/runtime/post-exit-stdio-guard.ts +86 -86
- package/src/runtime/process-status.ts +97 -73
- package/src/runtime/progress-event-coalescer.ts +43 -43
- package/src/runtime/recovery-recipes.ts +74 -74
- package/src/runtime/retry-executor.ts +81 -81
- package/src/runtime/role-permission.ts +39 -39
- package/src/runtime/run-tracker.ts +99 -0
- package/src/runtime/runtime-policy.ts +21 -0
- package/src/runtime/runtime-resolver.ts +94 -91
- package/src/runtime/scheduler.ts +294 -0
- package/src/runtime/semaphore.ts +131 -131
- package/src/runtime/sensitive-paths.ts +92 -92
- package/src/runtime/session-usage.ts +79 -79
- package/src/runtime/settings-store.ts +103 -0
- package/src/runtime/sidechain-output.ts +29 -29
- package/src/runtime/skill-instructions.ts +222 -222
- package/src/runtime/stale-reconciler.ts +198 -189
- package/src/runtime/streaming-output.ts +47 -0
- package/src/runtime/subagent-manager.ts +404 -400
- package/src/runtime/subprocess-tool-registry.ts +67 -67
- package/src/runtime/task-display.ts +38 -38
- package/src/runtime/task-graph-scheduler.ts +122 -122
- package/src/runtime/task-graph.ts +207 -207
- package/src/runtime/task-output-context.ts +177 -177
- package/src/runtime/task-packet.ts +93 -93
- package/src/runtime/task-quality.ts +207 -207
- package/src/runtime/task-runner/capabilities.ts +78 -78
- package/src/runtime/task-runner/live-executor.ts +131 -113
- package/src/runtime/task-runner/progress.ts +119 -119
- package/src/runtime/task-runner/prompt-builder.ts +139 -139
- package/src/runtime/task-runner/prompt-pipeline.ts +64 -64
- package/src/runtime/task-runner/result-utils.ts +14 -14
- package/src/runtime/task-runner/run-projection.ts +103 -103
- package/src/runtime/task-runner/state-helpers.ts +22 -22
- package/src/runtime/task-runner.ts +469 -459
- package/src/runtime/team-runner.ts +693 -945
- package/src/runtime/usage-tracker.ts +71 -0
- package/src/runtime/worker-heartbeat.ts +21 -21
- package/src/runtime/worker-startup.ts +57 -57
- package/src/runtime/workflow-state.ts +187 -187
- package/src/runtime/yield-handler.ts +190 -190
- package/src/schema/config-schema.ts +172 -168
- package/src/schema/team-tool-schema.ts +126 -126
- package/src/schema/validation-types.ts +151 -148
- package/src/skills/discover-skills.ts +67 -67
- package/src/skills/skill-templates.ts +374 -374
- package/src/state/active-run-registry.ts +227 -191
- package/src/state/artifact-store.ts +130 -129
- package/src/state/atomic-write.ts +262 -195
- package/src/state/blob-store.ts +116 -116
- package/src/state/contracts.ts +111 -111
- package/src/state/event-log-rotation.ts +161 -158
- package/src/state/event-log.ts +383 -303
- package/src/state/event-reconstructor.ts +217 -217
- package/src/state/jsonl-writer.ts +82 -82
- package/src/state/locks.ts +146 -146
- package/src/state/mailbox.ts +446 -405
- package/src/state/state-store.ts +364 -351
- package/src/state/task-claims.ts +44 -44
- package/src/state/types.ts +285 -285
- package/src/state/usage.ts +29 -29
- package/src/subagents/async-entry.ts +1 -1
- package/src/subagents/index.ts +3 -3
- package/src/subagents/live/control.ts +1 -1
- package/src/subagents/live/manager.ts +1 -1
- package/src/subagents/live/realtime.ts +1 -1
- package/src/subagents/live/session-runtime.ts +1 -1
- package/src/subagents/manager.ts +1 -1
- package/src/subagents/spawn.ts +1 -1
- package/src/teams/discover-teams.ts +116 -116
- package/src/teams/team-config.ts +27 -27
- package/src/teams/team-serializer.ts +38 -38
- package/src/types/diff.d.ts +18 -18
- package/src/ui/agent-management-overlay.ts +144 -144
- package/src/ui/crew-widget.ts +487 -370
- package/src/ui/dashboard-panes/agents-pane.ts +109 -28
- package/src/ui/dashboard-panes/cancellation-pane.ts +42 -42
- package/src/ui/dashboard-panes/capability-pane.ts +59 -59
- package/src/ui/dashboard-panes/health-pane.ts +30 -30
- package/src/ui/dashboard-panes/mailbox-pane.ts +35 -35
- package/src/ui/dashboard-panes/progress-pane.ts +30 -30
- package/src/ui/dashboard-panes/transcript-pane.ts +10 -10
- package/src/ui/heartbeat-aggregator.ts +63 -63
- package/src/ui/keybinding-map.ts +97 -94
- package/src/ui/live-conversation-overlay.ts +152 -0
- package/src/ui/live-run-sidebar.ts +180 -180
- package/src/ui/mascot.ts +442 -442
- package/src/ui/overlays/agent-picker-overlay.ts +57 -57
- package/src/ui/overlays/confirm-overlay.ts +58 -58
- package/src/ui/overlays/mailbox-compose-overlay.ts +144 -144
- package/src/ui/overlays/mailbox-compose-preview.ts +63 -63
- package/src/ui/overlays/mailbox-detail-overlay.ts +122 -122
- package/src/ui/pi-ui-compat.ts +57 -57
- package/src/ui/powerbar-publisher.ts +221 -197
- package/src/ui/render-scheduler.ts +216 -143
- package/src/ui/run-action-dispatcher.ts +118 -118
- package/src/ui/run-dashboard.ts +526 -464
- package/src/ui/run-event-bus.ts +208 -208
- package/src/ui/run-snapshot-cache.ts +826 -777
- package/src/ui/settings-overlay.ts +721 -0
- package/src/ui/snapshot-types.ts +86 -70
- package/src/ui/theme-adapter.ts +190 -190
- package/src/ui/tool-progress-formatter.ts +89 -0
- package/src/ui/transcript-cache.ts +94 -94
- package/src/ui/transcript-viewer.ts +335 -335
- package/src/utils/conflict-detect.ts +662 -0
- package/src/utils/file-coalescer.ts +86 -86
- package/src/utils/frontmatter.ts +68 -68
- package/src/utils/fs-watch.ts +88 -31
- package/src/utils/gh-protocol.ts +479 -0
- package/src/utils/ids.ts +17 -17
- package/src/utils/incremental-reader.ts +104 -104
- package/src/utils/internal-error.ts +6 -6
- package/src/utils/names.ts +27 -27
- package/src/utils/paths.ts +102 -63
- package/src/utils/redaction.ts +44 -44
- package/src/utils/safe-paths.ts +47 -47
- package/src/utils/scan-cache.ts +136 -136
- package/src/utils/sse-parser.ts +134 -134
- package/src/utils/task-name-generator.ts +337 -337
- package/src/utils/timings.ts +33 -33
- package/src/utils/visual.ts +243 -198
- package/src/workflows/discover-workflows.ts +139 -139
- package/src/workflows/validate-workflow.ts +40 -40
- package/src/workflows/workflow-config.ts +26 -26
- package/src/workflows/workflow-serializer.ts +32 -32
- package/src/worktree/branch-freshness.ts +45 -45
- package/src/worktree/cleanup.ts +75 -75
- package/src/worktree/worktree-manager.ts +188 -188
- package/teams/default.team.md +12 -12
- package/teams/fast-fix.team.md +11 -11
- package/teams/implementation.team.md +18 -18
- package/teams/parallel-research.team.md +14 -14
- package/teams/research.team.md +11 -11
- package/teams/review.team.md +12 -12
- package/tsconfig.json +19 -19
- package/workflows/default.workflow.md +30 -30
- package/workflows/fast-fix.workflow.md +23 -23
- package/workflows/implementation.workflow.md +43 -43
- package/workflows/parallel-research.workflow.md +46 -46
- package/workflows/research.workflow.md +22 -22
- package/workflows/review.workflow.md +30 -30
- package/skills/task-packet/SKILL.md +0 -28
- package/skills/verify-evidence/SKILL.md +0 -27
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
2
|
+
|
|
3
|
+
export interface LifetimeUsage {
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cacheWrite: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function emptyLifetimeUsage(): LifetimeUsage {
|
|
10
|
+
return { input: 0, output: 0, cacheWrite: 0 };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function addUsage(into: LifetimeUsage, delta: { input?: number; output?: number; cacheWrite?: number }): void {
|
|
14
|
+
if (typeof delta.input === "number") into.input += delta.input;
|
|
15
|
+
if (typeof delta.output === "number") into.output += delta.output;
|
|
16
|
+
if (typeof delta.cacheWrite === "number") into.cacheWrite += delta.cacheWrite;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
|
|
20
|
+
if (!state) return emptyLifetimeUsage();
|
|
21
|
+
return {
|
|
22
|
+
input: state.input ?? 0,
|
|
23
|
+
output: state.output ?? 0,
|
|
24
|
+
cacheWrite: state.cacheWrite ?? 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function usageStateFromLifetime(lifetime: LifetimeUsage): UsageState {
|
|
29
|
+
return {
|
|
30
|
+
input: lifetime.input,
|
|
31
|
+
output: lifetime.output,
|
|
32
|
+
cacheWrite: lifetime.cacheWrite,
|
|
33
|
+
cacheRead: 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const taskUsageMap = new Map<string, LifetimeUsage>();
|
|
38
|
+
|
|
39
|
+
export function trackTaskUsage(taskId: string, delta: { input?: number; output?: number; cacheWrite?: number }): void {
|
|
40
|
+
const existing = taskUsageMap.get(taskId) ?? emptyLifetimeUsage();
|
|
41
|
+
addUsage(existing, delta);
|
|
42
|
+
taskUsageMap.set(taskId, existing);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getTrackedTaskUsage(taskId: string): LifetimeUsage {
|
|
46
|
+
return taskUsageMap.get(taskId) ?? emptyLifetimeUsage();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearTrackedTaskUsage(taskId: string): void {
|
|
50
|
+
taskUsageMap.delete(taskId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearAllTrackedTaskUsage(): void {
|
|
54
|
+
taskUsageMap.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Aliases for public API
|
|
58
|
+
export const getTaskUsage = getTrackedTaskUsage;
|
|
59
|
+
export const getRunUsage = getTrackedTaskUsage;
|
|
60
|
+
export const clearAllTaskUsage = clearAllTrackedTaskUsage;
|
|
61
|
+
|
|
62
|
+
export function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
|
|
63
|
+
const total = emptyLifetimeUsage();
|
|
64
|
+
for (const task of tasks) {
|
|
65
|
+
const tracked = getTrackedTaskUsage(task.id);
|
|
66
|
+
addUsage(total, tracked);
|
|
67
|
+
// Also add any usage already stored on the task
|
|
68
|
+
if (task.usage) addUsage(total, task.usage);
|
|
69
|
+
}
|
|
70
|
+
return usageStateFromLifetime(total);
|
|
71
|
+
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
export interface WorkerHeartbeatState {
|
|
2
|
-
workerId: string;
|
|
3
|
-
pid?: number;
|
|
4
|
-
lastSeenAt: string;
|
|
5
|
-
lastStdoutAt?: string;
|
|
6
|
-
lastEventAt?: string;
|
|
7
|
-
turnCount?: number;
|
|
8
|
-
alive?: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function createWorkerHeartbeat(workerId: string, pid?: number, now = new Date()): WorkerHeartbeatState {
|
|
12
|
-
return { workerId, pid, lastSeenAt: now.toISOString(), alive: true };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function touchWorkerHeartbeat(heartbeat: WorkerHeartbeatState, updates: Partial<Omit<WorkerHeartbeatState, "workerId">> = {}, now = new Date()): WorkerHeartbeatState {
|
|
16
|
-
return { ...heartbeat, ...updates, lastSeenAt: now.toISOString() };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function isWorkerHeartbeatStale(heartbeat: WorkerHeartbeatState, staleMs: number, now = new Date()): boolean {
|
|
20
|
-
return now.getTime() - Date.parse(heartbeat.lastSeenAt) > staleMs;
|
|
21
|
-
}
|
|
1
|
+
export interface WorkerHeartbeatState {
|
|
2
|
+
workerId: string;
|
|
3
|
+
pid?: number;
|
|
4
|
+
lastSeenAt: string;
|
|
5
|
+
lastStdoutAt?: string;
|
|
6
|
+
lastEventAt?: string;
|
|
7
|
+
turnCount?: number;
|
|
8
|
+
alive?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createWorkerHeartbeat(workerId: string, pid?: number, now = new Date()): WorkerHeartbeatState {
|
|
12
|
+
return { workerId, pid, lastSeenAt: now.toISOString(), alive: true };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function touchWorkerHeartbeat(heartbeat: WorkerHeartbeatState, updates: Partial<Omit<WorkerHeartbeatState, "workerId">> = {}, now = new Date()): WorkerHeartbeatState {
|
|
16
|
+
return { ...heartbeat, ...updates, lastSeenAt: now.toISOString() };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isWorkerHeartbeatStale(heartbeat: WorkerHeartbeatState, staleMs: number, now = new Date()): boolean {
|
|
20
|
+
return now.getTime() - Date.parse(heartbeat.lastSeenAt) > staleMs;
|
|
21
|
+
}
|
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
export type WorkerLifecycleState = "spawning" | "trust_required" | "ready_for_prompt" | "running" | "finished" | "failed";
|
|
2
|
-
export type StartupFailureClassification = "trust_required" | "prompt_misdelivery" | "prompt_acceptance_timeout" | "transport_dead" | "worker_crashed" | "unknown";
|
|
3
|
-
|
|
4
|
-
export interface WorkerStartupEvidence {
|
|
5
|
-
lastLifecycleState: WorkerLifecycleState;
|
|
6
|
-
command: string;
|
|
7
|
-
promptSentAt?: string;
|
|
8
|
-
promptAccepted: boolean;
|
|
9
|
-
trustPromptDetected: boolean;
|
|
10
|
-
transportHealthy: boolean;
|
|
11
|
-
childProcessAlive: boolean;
|
|
12
|
-
elapsedMs: number;
|
|
13
|
-
classification: StartupFailureClassification;
|
|
14
|
-
stderrPreview?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function detectTrustPrompt(text: string): boolean {
|
|
18
|
-
const lowered = text.toLowerCase();
|
|
19
|
-
return lowered.includes("do you trust") || lowered.includes("trust this") || lowered.includes("untrusted") || lowered.includes("workspace trust") || lowered.includes("allow this folder");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function classifyStartupFailure(evidence: Omit<WorkerStartupEvidence, "classification">): StartupFailureClassification {
|
|
23
|
-
if (!evidence.transportHealthy) return "transport_dead";
|
|
24
|
-
if (evidence.trustPromptDetected || evidence.lastLifecycleState === "trust_required") return "trust_required";
|
|
25
|
-
if (evidence.promptSentAt && !evidence.promptAccepted && evidence.childProcessAlive) return "prompt_acceptance_timeout";
|
|
26
|
-
if (evidence.promptSentAt && !evidence.promptAccepted && !evidence.childProcessAlive) return "worker_crashed";
|
|
27
|
-
if (evidence.stderrPreview?.toLowerCase().includes("command not found") || evidence.stderrPreview?.toLowerCase().includes("not recognized")) return "prompt_misdelivery";
|
|
28
|
-
if (!evidence.childProcessAlive && evidence.lastLifecycleState !== "finished") return "worker_crashed";
|
|
29
|
-
return "unknown";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function createStartupEvidence(input: {
|
|
33
|
-
command: string;
|
|
34
|
-
startedAt: Date;
|
|
35
|
-
finishedAt?: Date;
|
|
36
|
-
promptSentAt?: Date;
|
|
37
|
-
promptAccepted?: boolean;
|
|
38
|
-
stderr?: string;
|
|
39
|
-
error?: string;
|
|
40
|
-
exitCode?: number | null;
|
|
41
|
-
}): WorkerStartupEvidence {
|
|
42
|
-
const stderrPreview = (input.error || input.stderr || "").slice(0, 500) || undefined;
|
|
43
|
-
const trustPromptDetected = detectTrustPrompt(stderrPreview ?? "");
|
|
44
|
-
const childProcessAlive = input.exitCode === undefined || input.exitCode === null ? !input.finishedAt : false;
|
|
45
|
-
const base: Omit<WorkerStartupEvidence, "classification"> = {
|
|
46
|
-
lastLifecycleState: input.error || (input.exitCode !== undefined && input.exitCode !== null && input.exitCode !== 0) ? "failed" : input.finishedAt ? "finished" : "running",
|
|
47
|
-
command: input.command,
|
|
48
|
-
promptSentAt: input.promptSentAt?.toISOString(),
|
|
49
|
-
promptAccepted: input.promptAccepted ?? !input.error,
|
|
50
|
-
trustPromptDetected,
|
|
51
|
-
transportHealthy: !input.error || !/enoent|spawn|transport/i.test(input.error),
|
|
52
|
-
childProcessAlive,
|
|
53
|
-
elapsedMs: Math.max(0, (input.finishedAt ?? new Date()).getTime() - input.startedAt.getTime()),
|
|
54
|
-
stderrPreview,
|
|
55
|
-
};
|
|
56
|
-
return { ...base, classification: classifyStartupFailure(base) };
|
|
57
|
-
}
|
|
1
|
+
export type WorkerLifecycleState = "spawning" | "trust_required" | "ready_for_prompt" | "running" | "finished" | "failed";
|
|
2
|
+
export type StartupFailureClassification = "trust_required" | "prompt_misdelivery" | "prompt_acceptance_timeout" | "transport_dead" | "worker_crashed" | "unknown";
|
|
3
|
+
|
|
4
|
+
export interface WorkerStartupEvidence {
|
|
5
|
+
lastLifecycleState: WorkerLifecycleState;
|
|
6
|
+
command: string;
|
|
7
|
+
promptSentAt?: string;
|
|
8
|
+
promptAccepted: boolean;
|
|
9
|
+
trustPromptDetected: boolean;
|
|
10
|
+
transportHealthy: boolean;
|
|
11
|
+
childProcessAlive: boolean;
|
|
12
|
+
elapsedMs: number;
|
|
13
|
+
classification: StartupFailureClassification;
|
|
14
|
+
stderrPreview?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function detectTrustPrompt(text: string): boolean {
|
|
18
|
+
const lowered = text.toLowerCase();
|
|
19
|
+
return lowered.includes("do you trust") || lowered.includes("trust this") || lowered.includes("untrusted") || lowered.includes("workspace trust") || lowered.includes("allow this folder");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function classifyStartupFailure(evidence: Omit<WorkerStartupEvidence, "classification">): StartupFailureClassification {
|
|
23
|
+
if (!evidence.transportHealthy) return "transport_dead";
|
|
24
|
+
if (evidence.trustPromptDetected || evidence.lastLifecycleState === "trust_required") return "trust_required";
|
|
25
|
+
if (evidence.promptSentAt && !evidence.promptAccepted && evidence.childProcessAlive) return "prompt_acceptance_timeout";
|
|
26
|
+
if (evidence.promptSentAt && !evidence.promptAccepted && !evidence.childProcessAlive) return "worker_crashed";
|
|
27
|
+
if (evidence.stderrPreview?.toLowerCase().includes("command not found") || evidence.stderrPreview?.toLowerCase().includes("not recognized")) return "prompt_misdelivery";
|
|
28
|
+
if (!evidence.childProcessAlive && evidence.lastLifecycleState !== "finished") return "worker_crashed";
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createStartupEvidence(input: {
|
|
33
|
+
command: string;
|
|
34
|
+
startedAt: Date;
|
|
35
|
+
finishedAt?: Date;
|
|
36
|
+
promptSentAt?: Date;
|
|
37
|
+
promptAccepted?: boolean;
|
|
38
|
+
stderr?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
exitCode?: number | null;
|
|
41
|
+
}): WorkerStartupEvidence {
|
|
42
|
+
const stderrPreview = (input.error || input.stderr || "").slice(0, 500) || undefined;
|
|
43
|
+
const trustPromptDetected = detectTrustPrompt(stderrPreview ?? "");
|
|
44
|
+
const childProcessAlive = input.exitCode === undefined || input.exitCode === null ? !input.finishedAt : false;
|
|
45
|
+
const base: Omit<WorkerStartupEvidence, "classification"> = {
|
|
46
|
+
lastLifecycleState: input.error || (input.exitCode !== undefined && input.exitCode !== null && input.exitCode !== 0) ? "failed" : input.finishedAt ? "finished" : "running",
|
|
47
|
+
command: input.command,
|
|
48
|
+
promptSentAt: input.promptSentAt?.toISOString(),
|
|
49
|
+
promptAccepted: input.promptAccepted ?? !input.error,
|
|
50
|
+
trustPromptDetected,
|
|
51
|
+
transportHealthy: !input.error || !/enoent|spawn|transport/i.test(input.error),
|
|
52
|
+
childProcessAlive,
|
|
53
|
+
elapsedMs: Math.max(0, (input.finishedAt ?? new Date()).getTime() - input.startedAt.getTime()),
|
|
54
|
+
stderrPreview,
|
|
55
|
+
};
|
|
56
|
+
return { ...base, classification: classifyStartupFailure(base) };
|
|
57
|
+
}
|
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workflow Phase State Machine
|
|
3
|
-
*
|
|
4
|
-
* Tracks phase-level progression through a workflow, validating that each
|
|
5
|
-
* phase's declared inputs (required artifacts) are satisfied before it can
|
|
6
|
-
* start. All transition functions are immutable — they return new machine
|
|
7
|
-
* state rather than mutating in place.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
// ── Public types ──────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
export type PhaseStatus = "pending" | "running" | "completed" | "failed" | "skipped";
|
|
13
|
-
|
|
14
|
-
export interface PhaseGuardContext {
|
|
15
|
-
/** Artifact paths that currently exist on disk. */
|
|
16
|
-
completedArtifacts: string[];
|
|
17
|
-
/** Status of the phase immediately preceding the target phase. */
|
|
18
|
-
previousPhaseStatus: PhaseStatus;
|
|
19
|
-
/** Results from tasks that have already completed. */
|
|
20
|
-
taskResults: Array<{ taskId: string; status: string; outputPath?: string }>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface GuardResult {
|
|
24
|
-
allowed: boolean;
|
|
25
|
-
reason?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface PhaseTransition {
|
|
29
|
-
from: PhaseStatus;
|
|
30
|
-
to: PhaseStatus;
|
|
31
|
-
guard?: (context: PhaseGuardContext) => GuardResult;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface PhaseState {
|
|
35
|
-
name: string;
|
|
36
|
-
status: PhaseStatus;
|
|
37
|
-
/** Artifact names required before this phase can start. */
|
|
38
|
-
inputs: string[];
|
|
39
|
-
/** Artifact names this phase produces. */
|
|
40
|
-
outputs: string[];
|
|
41
|
-
startedAt?: string;
|
|
42
|
-
finishedAt?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface WorkflowStateMachine {
|
|
46
|
-
phases: PhaseState[];
|
|
47
|
-
currentPhaseIndex: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Create a new workflow state machine from an initial set of phases.
|
|
54
|
-
* All phases start as "pending" and the current index is set to 0.
|
|
55
|
-
*/
|
|
56
|
-
export function createWorkflowStateMachine(phases: PhaseState[]): WorkflowStateMachine {
|
|
57
|
-
return {
|
|
58
|
-
phases: phases.map((phase) => ({
|
|
59
|
-
...phase,
|
|
60
|
-
status: phase.status ?? "pending",
|
|
61
|
-
})),
|
|
62
|
-
currentPhaseIndex: 0,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── Queries ───────────────────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Return the declared inputs for a given phase index.
|
|
70
|
-
* Returns an empty array for out-of-bounds indices.
|
|
71
|
-
*/
|
|
72
|
-
export function getPhaseInputs(machine: WorkflowStateMachine, phaseIndex: number): string[] {
|
|
73
|
-
const phase = machine.phases[phaseIndex];
|
|
74
|
-
if (!phase) return [];
|
|
75
|
-
return phase.inputs;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Check whether a transition to a specific phase is allowed.
|
|
80
|
-
*
|
|
81
|
-
* Rules (evaluated in order):
|
|
82
|
-
* 1. `phaseIndex` must be in bounds.
|
|
83
|
-
* 2. If this is not the first phase, the previous phase must be "completed" or "skipped".
|
|
84
|
-
* 3. All declared `inputs` for the target phase must appear in `completedArtifacts`.
|
|
85
|
-
* 4. If a custom `guard` is supplied on a transition, it must also approve.
|
|
86
|
-
*/
|
|
87
|
-
export function canTransitionToPhase(
|
|
88
|
-
machine: WorkflowStateMachine,
|
|
89
|
-
phaseIndex: number,
|
|
90
|
-
context: PhaseGuardContext,
|
|
91
|
-
): GuardResult {
|
|
92
|
-
if (phaseIndex < 0 || phaseIndex >= machine.phases.length) {
|
|
93
|
-
return { allowed: false, reason: `Phase index ${phaseIndex} is out of bounds (0..${machine.phases.length - 1}).` };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Previous phase must have completed (or been skipped).
|
|
97
|
-
if (phaseIndex > 0) {
|
|
98
|
-
const prevStatus = context.previousPhaseStatus;
|
|
99
|
-
if (prevStatus !== "completed" && prevStatus !== "skipped") {
|
|
100
|
-
return { allowed: false, reason: `Previous phase status is '${prevStatus}'; expected 'completed' or 'skipped'.` };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// All declared inputs must be satisfied.
|
|
105
|
-
const phase = machine.phases[phaseIndex]!;
|
|
106
|
-
if (phase.inputs.length > 0) {
|
|
107
|
-
const artifactSet = new Set(context.completedArtifacts);
|
|
108
|
-
const missing = phase.inputs.filter((input) => !artifactSet.has(input));
|
|
109
|
-
if (missing.length > 0) {
|
|
110
|
-
return { allowed: false, reason: `Missing required artifacts: ${missing.join(", ")}.` };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return { allowed: true };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Validate that all preconditions for the *current* phase are met.
|
|
119
|
-
* Returns the subset of declared inputs that are not yet present in `completedArtifacts`.
|
|
120
|
-
*/
|
|
121
|
-
export function validatePhasePreconditions(
|
|
122
|
-
machine: WorkflowStateMachine,
|
|
123
|
-
context: PhaseGuardContext,
|
|
124
|
-
): { ready: boolean; blocking: string[] } {
|
|
125
|
-
const phase = machine.phases[machine.currentPhaseIndex];
|
|
126
|
-
if (!phase) {
|
|
127
|
-
return { ready: false, blocking: [`No phase at index ${machine.currentPhaseIndex}.`] };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const artifactSet = new Set(context.completedArtifacts);
|
|
131
|
-
const missing = phase.inputs.filter((input) => !artifactSet.has(input));
|
|
132
|
-
return { ready: missing.length === 0, blocking: missing };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── Transitions ───────────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
/** Result of a phase transition: the (potentially unchanged) machine and an optional guard result. */
|
|
138
|
-
export interface TransitionResult {
|
|
139
|
-
machine: WorkflowStateMachine;
|
|
140
|
-
guardResult?: GuardResult;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Transition a phase to a new status. Returns a `TransitionResult` containing
|
|
145
|
-
* the new `WorkflowStateMachine` and an optional `guardResult` when the guard
|
|
146
|
-
* was checked.
|
|
147
|
-
*
|
|
148
|
-
* If `context` is provided and the guard fails, the machine is returned
|
|
149
|
-
* **UNCHANGED** (the phase is NOT auto-failed) along with the `guardResult`
|
|
150
|
-
* so the caller can decide what to do.
|
|
151
|
-
*
|
|
152
|
-
* For the "pending → running" transition the `startedAt` timestamp is set.
|
|
153
|
-
* For terminal transitions ("completed", "failed", "skipped") the `finishedAt` timestamp is set.
|
|
154
|
-
* On success the `currentPhaseIndex` advances to `phaseIndex` (or stays if already there).
|
|
155
|
-
*/
|
|
156
|
-
export function transitionPhase(
|
|
157
|
-
machine: WorkflowStateMachine,
|
|
158
|
-
phaseIndex: number,
|
|
159
|
-
status: PhaseStatus,
|
|
160
|
-
context?: PhaseGuardContext,
|
|
161
|
-
): TransitionResult {
|
|
162
|
-
if (context) {
|
|
163
|
-
const check = canTransitionToPhase(machine, phaseIndex, context);
|
|
164
|
-
if (!check.allowed) {
|
|
165
|
-
// Return machine UNCHANGED with guard result — caller decides what to do
|
|
166
|
-
return { machine, guardResult: check };
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const now = new Date().toISOString();
|
|
171
|
-
const phases = machine.phases.map((phase, index) => {
|
|
172
|
-
if (index !== phaseIndex) return phase;
|
|
173
|
-
return {
|
|
174
|
-
...phase,
|
|
175
|
-
status,
|
|
176
|
-
...(status === "running" && !phase.startedAt ? { startedAt: now } : {}),
|
|
177
|
-
...((status === "completed" || status === "failed" || status === "skipped") && !phase.finishedAt ? { finishedAt: now } : {}),
|
|
178
|
-
};
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
machine: {
|
|
183
|
-
phases,
|
|
184
|
-
currentPhaseIndex: Math.max(machine.currentPhaseIndex, phaseIndex),
|
|
185
|
-
},
|
|
186
|
-
};
|
|
187
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Phase State Machine
|
|
3
|
+
*
|
|
4
|
+
* Tracks phase-level progression through a workflow, validating that each
|
|
5
|
+
* phase's declared inputs (required artifacts) are satisfied before it can
|
|
6
|
+
* start. All transition functions are immutable — they return new machine
|
|
7
|
+
* state rather than mutating in place.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Public types ──────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type PhaseStatus = "pending" | "running" | "completed" | "failed" | "skipped";
|
|
13
|
+
|
|
14
|
+
export interface PhaseGuardContext {
|
|
15
|
+
/** Artifact paths that currently exist on disk. */
|
|
16
|
+
completedArtifacts: string[];
|
|
17
|
+
/** Status of the phase immediately preceding the target phase. */
|
|
18
|
+
previousPhaseStatus: PhaseStatus;
|
|
19
|
+
/** Results from tasks that have already completed. */
|
|
20
|
+
taskResults: Array<{ taskId: string; status: string; outputPath?: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GuardResult {
|
|
24
|
+
allowed: boolean;
|
|
25
|
+
reason?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PhaseTransition {
|
|
29
|
+
from: PhaseStatus;
|
|
30
|
+
to: PhaseStatus;
|
|
31
|
+
guard?: (context: PhaseGuardContext) => GuardResult;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PhaseState {
|
|
35
|
+
name: string;
|
|
36
|
+
status: PhaseStatus;
|
|
37
|
+
/** Artifact names required before this phase can start. */
|
|
38
|
+
inputs: string[];
|
|
39
|
+
/** Artifact names this phase produces. */
|
|
40
|
+
outputs: string[];
|
|
41
|
+
startedAt?: string;
|
|
42
|
+
finishedAt?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorkflowStateMachine {
|
|
46
|
+
phases: PhaseState[];
|
|
47
|
+
currentPhaseIndex: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a new workflow state machine from an initial set of phases.
|
|
54
|
+
* All phases start as "pending" and the current index is set to 0.
|
|
55
|
+
*/
|
|
56
|
+
export function createWorkflowStateMachine(phases: PhaseState[]): WorkflowStateMachine {
|
|
57
|
+
return {
|
|
58
|
+
phases: phases.map((phase) => ({
|
|
59
|
+
...phase,
|
|
60
|
+
status: phase.status ?? "pending",
|
|
61
|
+
})),
|
|
62
|
+
currentPhaseIndex: 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Queries ───────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Return the declared inputs for a given phase index.
|
|
70
|
+
* Returns an empty array for out-of-bounds indices.
|
|
71
|
+
*/
|
|
72
|
+
export function getPhaseInputs(machine: WorkflowStateMachine, phaseIndex: number): string[] {
|
|
73
|
+
const phase = machine.phases[phaseIndex];
|
|
74
|
+
if (!phase) return [];
|
|
75
|
+
return phase.inputs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check whether a transition to a specific phase is allowed.
|
|
80
|
+
*
|
|
81
|
+
* Rules (evaluated in order):
|
|
82
|
+
* 1. `phaseIndex` must be in bounds.
|
|
83
|
+
* 2. If this is not the first phase, the previous phase must be "completed" or "skipped".
|
|
84
|
+
* 3. All declared `inputs` for the target phase must appear in `completedArtifacts`.
|
|
85
|
+
* 4. If a custom `guard` is supplied on a transition, it must also approve.
|
|
86
|
+
*/
|
|
87
|
+
export function canTransitionToPhase(
|
|
88
|
+
machine: WorkflowStateMachine,
|
|
89
|
+
phaseIndex: number,
|
|
90
|
+
context: PhaseGuardContext,
|
|
91
|
+
): GuardResult {
|
|
92
|
+
if (phaseIndex < 0 || phaseIndex >= machine.phases.length) {
|
|
93
|
+
return { allowed: false, reason: `Phase index ${phaseIndex} is out of bounds (0..${machine.phases.length - 1}).` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Previous phase must have completed (or been skipped).
|
|
97
|
+
if (phaseIndex > 0) {
|
|
98
|
+
const prevStatus = context.previousPhaseStatus;
|
|
99
|
+
if (prevStatus !== "completed" && prevStatus !== "skipped") {
|
|
100
|
+
return { allowed: false, reason: `Previous phase status is '${prevStatus}'; expected 'completed' or 'skipped'.` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// All declared inputs must be satisfied.
|
|
105
|
+
const phase = machine.phases[phaseIndex]!;
|
|
106
|
+
if (phase.inputs.length > 0) {
|
|
107
|
+
const artifactSet = new Set(context.completedArtifacts);
|
|
108
|
+
const missing = phase.inputs.filter((input) => !artifactSet.has(input));
|
|
109
|
+
if (missing.length > 0) {
|
|
110
|
+
return { allowed: false, reason: `Missing required artifacts: ${missing.join(", ")}.` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { allowed: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate that all preconditions for the *current* phase are met.
|
|
119
|
+
* Returns the subset of declared inputs that are not yet present in `completedArtifacts`.
|
|
120
|
+
*/
|
|
121
|
+
export function validatePhasePreconditions(
|
|
122
|
+
machine: WorkflowStateMachine,
|
|
123
|
+
context: PhaseGuardContext,
|
|
124
|
+
): { ready: boolean; blocking: string[] } {
|
|
125
|
+
const phase = machine.phases[machine.currentPhaseIndex];
|
|
126
|
+
if (!phase) {
|
|
127
|
+
return { ready: false, blocking: [`No phase at index ${machine.currentPhaseIndex}.`] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const artifactSet = new Set(context.completedArtifacts);
|
|
131
|
+
const missing = phase.inputs.filter((input) => !artifactSet.has(input));
|
|
132
|
+
return { ready: missing.length === 0, blocking: missing };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Transitions ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** Result of a phase transition: the (potentially unchanged) machine and an optional guard result. */
|
|
138
|
+
export interface TransitionResult {
|
|
139
|
+
machine: WorkflowStateMachine;
|
|
140
|
+
guardResult?: GuardResult;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Transition a phase to a new status. Returns a `TransitionResult` containing
|
|
145
|
+
* the new `WorkflowStateMachine` and an optional `guardResult` when the guard
|
|
146
|
+
* was checked.
|
|
147
|
+
*
|
|
148
|
+
* If `context` is provided and the guard fails, the machine is returned
|
|
149
|
+
* **UNCHANGED** (the phase is NOT auto-failed) along with the `guardResult`
|
|
150
|
+
* so the caller can decide what to do.
|
|
151
|
+
*
|
|
152
|
+
* For the "pending → running" transition the `startedAt` timestamp is set.
|
|
153
|
+
* For terminal transitions ("completed", "failed", "skipped") the `finishedAt` timestamp is set.
|
|
154
|
+
* On success the `currentPhaseIndex` advances to `phaseIndex` (or stays if already there).
|
|
155
|
+
*/
|
|
156
|
+
export function transitionPhase(
|
|
157
|
+
machine: WorkflowStateMachine,
|
|
158
|
+
phaseIndex: number,
|
|
159
|
+
status: PhaseStatus,
|
|
160
|
+
context?: PhaseGuardContext,
|
|
161
|
+
): TransitionResult {
|
|
162
|
+
if (context) {
|
|
163
|
+
const check = canTransitionToPhase(machine, phaseIndex, context);
|
|
164
|
+
if (!check.allowed) {
|
|
165
|
+
// Return machine UNCHANGED with guard result — caller decides what to do
|
|
166
|
+
return { machine, guardResult: check };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const now = new Date().toISOString();
|
|
171
|
+
const phases = machine.phases.map((phase, index) => {
|
|
172
|
+
if (index !== phaseIndex) return phase;
|
|
173
|
+
return {
|
|
174
|
+
...phase,
|
|
175
|
+
status,
|
|
176
|
+
...(status === "running" && !phase.startedAt ? { startedAt: now } : {}),
|
|
177
|
+
...((status === "completed" || status === "failed" || status === "skipped") && !phase.finishedAt ? { finishedAt: now } : {}),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
machine: {
|
|
183
|
+
phases,
|
|
184
|
+
currentPhaseIndex: Math.max(machine.currentPhaseIndex, phaseIndex),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|