@agwab/pi-workflow 0.1.1 → 0.2.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/README.md +20 -15
- package/agents/researcher.md +17 -7
- package/dist/artifact-graph-runtime.js +1 -0
- package/dist/compiler.d.ts +2 -0
- package/dist/compiler.js +29 -4
- package/dist/dynamic-generated-task-runtime.js +4 -3
- package/dist/dynamic-runtime-bundle.js +3 -2
- package/dist/engine.d.ts +2 -0
- package/dist/engine.js +3 -2
- package/dist/extension.js +240 -16
- package/dist/store.js +1 -0
- package/dist/subagent-backend.js +82 -27
- package/dist/tool-metadata.d.ts +1 -0
- package/dist/tool-metadata.js +13 -1
- package/dist/types.d.ts +3 -0
- package/dist/workflow-artifact-extension.js +3 -2
- package/dist/workflow-artifact-tool.js +84 -4
- package/dist/workflow-progress-health.d.ts +37 -0
- package/dist/workflow-progress-health.js +296 -0
- package/dist/workflow-runtime.d.ts +6 -0
- package/dist/workflow-runtime.js +33 -10
- package/dist/workflow-view.d.ts +2 -0
- package/dist/workflow-view.js +97 -18
- package/dist/workflow-web-source-extension.d.ts +43 -0
- package/dist/workflow-web-source-extension.js +1194 -0
- package/dist/workflow-web-source.d.ts +171 -0
- package/dist/workflow-web-source.js +915 -0
- package/docs/usage.md +32 -18
- package/node_modules/@agwab/pi-subagent/package.json +1 -1
- package/node_modules/@agwab/pi-subagent/src/api.ts +245 -132
- package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +243 -163
- package/node_modules/@agwab/pi-subagent/src/core/constants.ts +117 -90
- package/node_modules/@agwab/pi-subagent/src/core/validation.ts +728 -475
- package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +305 -209
- package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +750 -439
- package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +422 -268
- package/package.json +7 -7
- package/skills/workflow-guide/scaffolds/object-tool-fallback/schemas/fetch-control.schema.json +1 -1
- package/skills/workflow-guide/scaffolds/object-tool-fallback/spec.json +4 -3
- package/src/artifact-graph-runtime.ts +1 -0
- package/src/compiler.ts +43 -3
- package/src/dynamic-generated-task-runtime.ts +4 -2
- package/src/dynamic-runtime-bundle.ts +3 -2
- package/src/engine.ts +7 -16
- package/src/extension.ts +299 -22
- package/src/store.ts +1 -0
- package/src/subagent-backend.ts +121 -37
- package/src/tool-metadata.ts +22 -1
- package/src/types.ts +4 -0
- package/src/workflow-artifact-extension.ts +3 -2
- package/src/workflow-artifact-tool.ts +96 -4
- package/src/workflow-progress-health.ts +461 -0
- package/src/workflow-runtime.ts +50 -13
- package/src/workflow-view.ts +186 -41
- package/src/workflow-web-source-extension.ts +1411 -0
- package/src/workflow-web-source.ts +1294 -0
- package/workflows/README.md +1 -1
- package/workflows/deep-research/helpers/claim-evidence-gate.mjs +552 -44
- package/workflows/deep-research/helpers/final-audit-packet.mjs +396 -0
- package/workflows/deep-research/helpers/normalize-input-packet.mjs +545 -0
- package/workflows/deep-research/helpers/render-executive.mjs +1199 -192
- package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +624 -0
- package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +37 -8
- package/workflows/deep-research/schemas/deep-research-final-synthesis-control.schema.json +110 -0
- package/workflows/deep-research/schemas/deep-research-normalize-claims-control.schema.json +45 -4
- package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +0 -2
- package/workflows/deep-research/spec.json +71 -26
- package/workflows/deep-review/helpers/render-review-report.mjs +502 -0
- package/workflows/deep-review/schemas/deep-review-render-control.schema.json +50 -0
- package/workflows/deep-review/spec.json +22 -1
|
@@ -41,7 +41,7 @@ const workflowArtifactParameters = {
|
|
|
41
41
|
path: {
|
|
42
42
|
type: "string",
|
|
43
43
|
description:
|
|
44
|
-
"Optional simple JSON path for projected reads, for example $.claims or $.claimIndex.items. Supported only for JSON artifacts.",
|
|
44
|
+
"Optional simple JSON path for projected reads, for example $.claims or $.claimIndex.items. Required when maxItems or maxChars is provided. Supported only for JSON artifacts.",
|
|
45
45
|
},
|
|
46
46
|
maxItems: {
|
|
47
47
|
type: "integer",
|
|
@@ -53,7 +53,7 @@ const workflowArtifactParameters = {
|
|
|
53
53
|
type: "integer",
|
|
54
54
|
minimum: 0,
|
|
55
55
|
description:
|
|
56
|
-
"Optional character limit for the projected JSON value after maxItems is applied.",
|
|
56
|
+
"Optional character limit for the projected JSON value after maxItems is applied. Requires path; omit maxChars for whole-artifact reads.",
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
required: ["action"],
|
|
@@ -73,6 +73,7 @@ export function registerWorkflowArtifactTool(
|
|
|
73
73
|
promptGuidelines: [
|
|
74
74
|
"Use workflow_artifact to inspect upstream workflow artifacts when the workflow prompt lists available sources or required reads.",
|
|
75
75
|
"Call workflow_artifact with action=list to see visible source names before reading an artifact if unsure.",
|
|
76
|
+
"When using maxItems or maxChars, include a JSON path such as $.claims; for whole-artifact reads, omit maxItems/maxChars.",
|
|
76
77
|
"Do not use repository read for workflow artifacts; workflow_artifact records required-read evidence.",
|
|
77
78
|
],
|
|
78
79
|
parameters: workflowArtifactParameters as any,
|
|
@@ -147,7 +147,25 @@ const WORKFLOW_ARTIFACT_KIND_SET = new Set<string>(WORKFLOW_ARTIFACT_KINDS);
|
|
|
147
147
|
const DEFAULT_MAX_BYTES = 50 * 1024;
|
|
148
148
|
const DEFAULT_MAX_LINES = 2000;
|
|
149
149
|
const SOURCE_NAME_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
150
|
-
const SIMPLE_JSON_PATH_PATTERN =
|
|
150
|
+
const SIMPLE_JSON_PATH_PATTERN =
|
|
151
|
+
/^(\$|\$(\.[A-Za-z0-9_-]+(\[(\*|\d+|\d*:\d*)\])?)+)$/;
|
|
152
|
+
const JSON_PATH_SEGMENT_ALIASES: Record<string, string> = {
|
|
153
|
+
axes: "researchAxes",
|
|
154
|
+
claimVerdicts: "claimVerdictLedger",
|
|
155
|
+
factSlot: "factSlots",
|
|
156
|
+
gaps: "remainingGaps",
|
|
157
|
+
primarySources: "sourcePolicy",
|
|
158
|
+
priorities: "verificationPriorities",
|
|
159
|
+
questions: "researchQuestions",
|
|
160
|
+
requiredSources: "sourcePolicy",
|
|
161
|
+
scope: "researchScope",
|
|
162
|
+
slots: "factSlots",
|
|
163
|
+
sourceQualityRules: "sourcePolicy",
|
|
164
|
+
sourceRequirements: "sourcePolicy",
|
|
165
|
+
verification: "verificationPriorities",
|
|
166
|
+
verificationPriority: "verificationPriorities",
|
|
167
|
+
verdicts: "claimVerdictLedger",
|
|
168
|
+
};
|
|
151
169
|
|
|
152
170
|
export async function loadWorkflowSourceManifest(
|
|
153
171
|
manifestPath: string,
|
|
@@ -427,18 +445,33 @@ async function readProjectedWorkflowArtifact(options: {
|
|
|
427
445
|
maxChars?: number;
|
|
428
446
|
}): Promise<WorkflowArtifactReadResult> {
|
|
429
447
|
const parsed = JSON.parse(await readFile(options.artifactPath, "utf8"));
|
|
430
|
-
|
|
448
|
+
let effectivePath = options.path;
|
|
449
|
+
let resolved: unknown;
|
|
450
|
+
for (const candidatePath of projectionPathCandidates(
|
|
451
|
+
options.path,
|
|
452
|
+
options.source,
|
|
453
|
+
options.artifact,
|
|
454
|
+
)) {
|
|
455
|
+
resolved = readSimpleJsonPath(parsed, candidatePath);
|
|
456
|
+
if (resolved !== undefined) {
|
|
457
|
+
effectivePath = candidatePath;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
431
461
|
if (resolved === undefined) {
|
|
432
462
|
throw new Error(`workflow_artifact path did not resolve: ${options.path}`);
|
|
433
463
|
}
|
|
434
|
-
const sliced = applyProjectionItemLimit(resolved,
|
|
464
|
+
const sliced = applyProjectionItemLimit(resolved, {
|
|
465
|
+
...options,
|
|
466
|
+
path: effectivePath,
|
|
467
|
+
});
|
|
435
468
|
const serialized = JSON.stringify(sliced.value, null, 2);
|
|
436
469
|
const preview =
|
|
437
470
|
options.maxChars !== undefined && serialized.length > options.maxChars
|
|
438
471
|
? serialized.slice(0, options.maxChars)
|
|
439
472
|
: serialized;
|
|
440
473
|
const projection: WorkflowArtifactProjectionMetadata = {
|
|
441
|
-
path:
|
|
474
|
+
path: effectivePath,
|
|
442
475
|
valueType: jsonValueType(resolved),
|
|
443
476
|
...(options.maxItems === undefined ? {} : { maxItems: options.maxItems }),
|
|
444
477
|
...(options.maxChars === undefined ? {} : { maxChars: options.maxChars }),
|
|
@@ -471,6 +504,65 @@ async function readProjectedWorkflowArtifact(options: {
|
|
|
471
504
|
};
|
|
472
505
|
}
|
|
473
506
|
|
|
507
|
+
function projectionPathCandidates(
|
|
508
|
+
path: string,
|
|
509
|
+
source: string,
|
|
510
|
+
artifact: WorkflowArtifactKind,
|
|
511
|
+
): string[] {
|
|
512
|
+
const candidates: string[] = [];
|
|
513
|
+
const seen = new Set<string>();
|
|
514
|
+
const queue = [path];
|
|
515
|
+
for (let index = 0; index < queue.length && index < 32; index += 1) {
|
|
516
|
+
const candidate = queue[index];
|
|
517
|
+
if (seen.has(candidate)) continue;
|
|
518
|
+
seen.add(candidate);
|
|
519
|
+
candidates.push(candidate);
|
|
520
|
+
for (const next of [
|
|
521
|
+
stripArraySelector(candidate),
|
|
522
|
+
stripSourcePathPrefix(candidate, source),
|
|
523
|
+
stripArtifactPathPrefix(candidate, artifact),
|
|
524
|
+
applyJsonPathSegmentAliases(candidate),
|
|
525
|
+
]) {
|
|
526
|
+
if (next !== candidate && !seen.has(next)) queue.push(next);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return candidates;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function stripArraySelector(path: string): string {
|
|
533
|
+
return path.replace(/\[(\*|\d+|\d*:\d*)\]/gu, "");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function stripSourcePathPrefix(path: string, source: string): string {
|
|
537
|
+
const sourcePrefix = `$.${source}.`;
|
|
538
|
+
if (!path.startsWith(sourcePrefix)) return path;
|
|
539
|
+
return `$.${path.slice(sourcePrefix.length)}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function stripArtifactPathPrefix(
|
|
543
|
+
path: string,
|
|
544
|
+
artifact: WorkflowArtifactKind,
|
|
545
|
+
): string {
|
|
546
|
+
const artifactPath = `$.${artifact}`;
|
|
547
|
+
if (path === artifactPath) return "$";
|
|
548
|
+
const artifactPrefix = `${artifactPath}.`;
|
|
549
|
+
if (!path.startsWith(artifactPrefix)) return path;
|
|
550
|
+
return `$.${path.slice(artifactPrefix.length)}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function applyJsonPathSegmentAliases(path: string): string {
|
|
554
|
+
if (path === "$") return path;
|
|
555
|
+
const segments = path
|
|
556
|
+
.slice(2)
|
|
557
|
+
.split(".")
|
|
558
|
+
.map((segment) => segment.replace(/\[(\*|\d+|\d*:\d*)\]$/u, ""));
|
|
559
|
+
const aliased = segments.map(
|
|
560
|
+
(segment) => JSON_PATH_SEGMENT_ALIASES[segment] ?? segment,
|
|
561
|
+
);
|
|
562
|
+
if (aliased.every((segment, index) => segment === segments[index])) return path;
|
|
563
|
+
return `$.${aliased.join(".")}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
474
566
|
function applyProjectionItemLimit(
|
|
475
567
|
value: unknown,
|
|
476
568
|
options: { maxItems?: number; path: string },
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TaskRunStatus,
|
|
3
|
+
WorkflowRunRecord,
|
|
4
|
+
WorkflowRunStatus,
|
|
5
|
+
WorkflowTaskRunRecord,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
export type WorkflowHealthState =
|
|
9
|
+
| "completed"
|
|
10
|
+
| "pending"
|
|
11
|
+
| "active"
|
|
12
|
+
| "long-tail"
|
|
13
|
+
| "stalled"
|
|
14
|
+
| "likely-stuck"
|
|
15
|
+
| "needs-action";
|
|
16
|
+
|
|
17
|
+
export type WorkflowHealthTone =
|
|
18
|
+
| "success"
|
|
19
|
+
| "accent"
|
|
20
|
+
| "warning"
|
|
21
|
+
| "error"
|
|
22
|
+
| "dim";
|
|
23
|
+
export type WorkflowDurationClass = "short" | "medium" | "long";
|
|
24
|
+
export type WorkflowHealthSuggestion = "wait" | "inspect" | "resume" | "review";
|
|
25
|
+
|
|
26
|
+
export interface WorkflowHealthTaskSummary {
|
|
27
|
+
taskId?: string;
|
|
28
|
+
displayName?: string;
|
|
29
|
+
stageId?: string;
|
|
30
|
+
status?: TaskRunStatus;
|
|
31
|
+
elapsedMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkflowProgressHealth {
|
|
35
|
+
state: WorkflowHealthState;
|
|
36
|
+
label: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
tone: WorkflowHealthTone;
|
|
39
|
+
suggestion: WorkflowHealthSuggestion;
|
|
40
|
+
reason: string;
|
|
41
|
+
durationClass?: WorkflowDurationClass;
|
|
42
|
+
currentTask?: WorkflowHealthTaskSummary;
|
|
43
|
+
lastActivityAt?: string;
|
|
44
|
+
lastActivityAgeMs?: number;
|
|
45
|
+
heartbeatAt?: string;
|
|
46
|
+
heartbeatAgeMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type TaskHealthInput = Pick<
|
|
50
|
+
WorkflowTaskRunRecord,
|
|
51
|
+
| "taskId"
|
|
52
|
+
| "specId"
|
|
53
|
+
| "displayName"
|
|
54
|
+
| "status"
|
|
55
|
+
| "statusDetail"
|
|
56
|
+
| "stageId"
|
|
57
|
+
| "kind"
|
|
58
|
+
| "startedAt"
|
|
59
|
+
| "lastMessage"
|
|
60
|
+
| "runtime"
|
|
61
|
+
| "backendHandle"
|
|
62
|
+
| "pid"
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
type RunHealthInput = Pick<
|
|
66
|
+
WorkflowRunRecord,
|
|
67
|
+
"status" | "taskSummary" | "createdAt" | "updatedAt"
|
|
68
|
+
> & {
|
|
69
|
+
tasks?: TaskHealthInput[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type RunningTaskContext = {
|
|
73
|
+
task: TaskHealthInput;
|
|
74
|
+
nowMs: number;
|
|
75
|
+
durationClass: WorkflowDurationClass;
|
|
76
|
+
elapsedMs?: number;
|
|
77
|
+
activityAt?: string;
|
|
78
|
+
lastActivityAgeMs?: number;
|
|
79
|
+
heartbeatAt?: string;
|
|
80
|
+
heartbeatAgeMs?: number;
|
|
81
|
+
hasBackendSignal: boolean;
|
|
82
|
+
staleMs: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface WorkflowHealthOptions {
|
|
86
|
+
nowMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ACTIVE_ACTIVITY_MS = 2 * 60_000;
|
|
90
|
+
const LONG_TAIL_ELAPSED_MS = 8 * 60_000;
|
|
91
|
+
const STALL_BY_DURATION: Record<WorkflowDurationClass, number> = {
|
|
92
|
+
short: 5 * 60_000,
|
|
93
|
+
medium: 10 * 60_000,
|
|
94
|
+
long: 20 * 60_000,
|
|
95
|
+
};
|
|
96
|
+
const STUCK_BY_DURATION: Record<WorkflowDurationClass, number> = {
|
|
97
|
+
short: 15 * 60_000,
|
|
98
|
+
medium: 30 * 60_000,
|
|
99
|
+
long: 60 * 60_000,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function diagnoseWorkflowRunHealth(
|
|
103
|
+
run: RunHealthInput,
|
|
104
|
+
options: WorkflowHealthOptions = {},
|
|
105
|
+
): WorkflowProgressHealth {
|
|
106
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
107
|
+
const runningTask = currentRunningTask(run.tasks ?? []);
|
|
108
|
+
if (runningTask)
|
|
109
|
+
return diagnoseWorkflowTaskHealth(runningTask, run, { nowMs });
|
|
110
|
+
|
|
111
|
+
const problem = (run.tasks ?? []).find((task) =>
|
|
112
|
+
isProblemStatus(task.status),
|
|
113
|
+
);
|
|
114
|
+
if (problem) return problemRunHealth(problem, nowMs);
|
|
115
|
+
if (isProblemStatus(run.status)) return problemWorkflowHealth(run.status);
|
|
116
|
+
if (run.status === "completed") return completedWorkflowHealth();
|
|
117
|
+
return waitingWorkflowHealth(run, nowMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function diagnoseWorkflowTaskHealth(
|
|
121
|
+
task: TaskHealthInput,
|
|
122
|
+
run?: Pick<WorkflowRunRecord, "updatedAt">,
|
|
123
|
+
options: WorkflowHealthOptions = {},
|
|
124
|
+
): WorkflowProgressHealth {
|
|
125
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
126
|
+
if (task.status !== "running") return terminalTaskHealth(task, nowMs);
|
|
127
|
+
return runningTaskHealth(runningContext(task, run, nowMs));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function classifyWorkflowTaskDuration(
|
|
131
|
+
task: Pick<
|
|
132
|
+
WorkflowTaskRunRecord,
|
|
133
|
+
"stageId" | "displayName" | "specId" | "kind" | "statusDetail" | "runtime"
|
|
134
|
+
>,
|
|
135
|
+
): WorkflowDurationClass {
|
|
136
|
+
const text = [
|
|
137
|
+
task.stageId,
|
|
138
|
+
task.displayName,
|
|
139
|
+
task.specId,
|
|
140
|
+
task.kind,
|
|
141
|
+
task.statusDetail,
|
|
142
|
+
]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(" ")
|
|
145
|
+
.toLowerCase();
|
|
146
|
+
if (/\b(render|helper|support|schema|partition|gate)\b/.test(text))
|
|
147
|
+
return "short";
|
|
148
|
+
if (
|
|
149
|
+
/\b(research|audit|synthesis|review|verify|verifier|normalize|plan|impact|spec)\b/.test(
|
|
150
|
+
text,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return "long";
|
|
154
|
+
const maxRuntimeMs = task.runtime?.maxRuntimeMs;
|
|
155
|
+
if (maxRuntimeMs !== undefined && Number.isFinite(maxRuntimeMs)) {
|
|
156
|
+
if (maxRuntimeMs <= 5 * 60_000) return "short";
|
|
157
|
+
if (maxRuntimeMs >= 60 * 60_000) return "long";
|
|
158
|
+
}
|
|
159
|
+
return "medium";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function currentRunningTask(
|
|
163
|
+
tasks: TaskHealthInput[],
|
|
164
|
+
): TaskHealthInput | undefined {
|
|
165
|
+
return tasks
|
|
166
|
+
.filter((task) => task.status === "running")
|
|
167
|
+
.sort(
|
|
168
|
+
(left, right) =>
|
|
169
|
+
(parseTime(left.startedAt) ?? Number.POSITIVE_INFINITY) -
|
|
170
|
+
(parseTime(right.startedAt) ?? Number.POSITIVE_INFINITY),
|
|
171
|
+
)[0];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function completedWorkflowHealth(): WorkflowProgressHealth {
|
|
175
|
+
return {
|
|
176
|
+
state: "completed",
|
|
177
|
+
label: "completed",
|
|
178
|
+
summary: "run completed",
|
|
179
|
+
tone: "success",
|
|
180
|
+
suggestion: "review",
|
|
181
|
+
reason: "all tasks reached a terminal successful state",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function problemWorkflowHealth(
|
|
186
|
+
status: WorkflowRunStatus,
|
|
187
|
+
): WorkflowProgressHealth {
|
|
188
|
+
return {
|
|
189
|
+
state: "needs-action",
|
|
190
|
+
label: "needs action",
|
|
191
|
+
summary: `run ${status}`,
|
|
192
|
+
tone: "error",
|
|
193
|
+
suggestion: "inspect",
|
|
194
|
+
reason: `workflow status is ${status}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function problemRunHealth(
|
|
199
|
+
task: TaskHealthInput,
|
|
200
|
+
nowMs: number,
|
|
201
|
+
): WorkflowProgressHealth {
|
|
202
|
+
return {
|
|
203
|
+
state: "needs-action",
|
|
204
|
+
label: "needs action",
|
|
205
|
+
summary: `${task.displayName ?? task.taskId ?? "task"} needs attention`,
|
|
206
|
+
tone: "error",
|
|
207
|
+
suggestion: "inspect",
|
|
208
|
+
reason: task.lastMessage ?? task.statusDetail ?? "task did not complete",
|
|
209
|
+
currentTask: taskSummary(task, nowMs),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function waitingWorkflowHealth(
|
|
214
|
+
run: RunHealthInput,
|
|
215
|
+
nowMs: number,
|
|
216
|
+
): WorkflowProgressHealth {
|
|
217
|
+
const hasPending = run.taskSummary.pending > 0;
|
|
218
|
+
return {
|
|
219
|
+
state: hasPending ? "pending" : "active",
|
|
220
|
+
label: hasPending ? "pending" : "active",
|
|
221
|
+
summary: hasPending
|
|
222
|
+
? "waiting for the next schedulable task"
|
|
223
|
+
: "run is active",
|
|
224
|
+
tone: hasPending ? "dim" : "accent",
|
|
225
|
+
suggestion: "wait",
|
|
226
|
+
reason: hasPending
|
|
227
|
+
? "no task is currently running"
|
|
228
|
+
: "workflow is still in progress",
|
|
229
|
+
lastActivityAt: run.updatedAt,
|
|
230
|
+
lastActivityAgeMs: ageMs(run.updatedAt, nowMs),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runningTaskHealth(
|
|
235
|
+
context: RunningTaskContext,
|
|
236
|
+
): WorkflowProgressHealth {
|
|
237
|
+
return (
|
|
238
|
+
runtimeExceededHealth(context) ??
|
|
239
|
+
staleRunningHealth(context) ??
|
|
240
|
+
longTailHealth(context) ??
|
|
241
|
+
activeRunningHealth(context)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function runtimeExceededHealth(
|
|
246
|
+
context: RunningTaskContext,
|
|
247
|
+
): WorkflowProgressHealth | undefined {
|
|
248
|
+
const maxRuntimeMs = context.task.runtime?.maxRuntimeMs;
|
|
249
|
+
const hasElapsed = context.elapsedMs !== undefined;
|
|
250
|
+
const hasBudget = maxRuntimeMs !== undefined && Number.isFinite(maxRuntimeMs);
|
|
251
|
+
if (!hasElapsed || !hasBudget) return undefined;
|
|
252
|
+
if (maxRuntimeMs <= 0 || context.elapsedMs! <= maxRuntimeMs) return undefined;
|
|
253
|
+
return runningHealth(context, {
|
|
254
|
+
state: "likely-stuck",
|
|
255
|
+
label: "runtime exceeded",
|
|
256
|
+
summary: "task exceeded its runtime budget",
|
|
257
|
+
tone: "error",
|
|
258
|
+
suggestion: "resume",
|
|
259
|
+
reason: "elapsed time is past runtime.maxRuntimeMs",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function staleRunningHealth(
|
|
264
|
+
context: RunningTaskContext,
|
|
265
|
+
): WorkflowProgressHealth | undefined {
|
|
266
|
+
if (
|
|
267
|
+
context.staleMs >= STUCK_BY_DURATION[context.durationClass] &&
|
|
268
|
+
!context.hasBackendSignal
|
|
269
|
+
) {
|
|
270
|
+
return runningHealth(context, {
|
|
271
|
+
state: "likely-stuck",
|
|
272
|
+
label: "likely stuck",
|
|
273
|
+
summary: "no fresh backend or activity signal",
|
|
274
|
+
tone: "error",
|
|
275
|
+
suggestion: "resume",
|
|
276
|
+
reason: "running task has no backend signal and activity is stale",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (context.staleMs < STALL_BY_DURATION[context.durationClass])
|
|
280
|
+
return undefined;
|
|
281
|
+
return runningHealth(context, {
|
|
282
|
+
state: "stalled",
|
|
283
|
+
label: "possibly stalled",
|
|
284
|
+
summary: "no recent visible progress",
|
|
285
|
+
tone: "warning",
|
|
286
|
+
suggestion: "inspect",
|
|
287
|
+
reason: context.hasBackendSignal
|
|
288
|
+
? "backend signal exists, but activity is stale"
|
|
289
|
+
: "activity is stale",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function longTailHealth(
|
|
294
|
+
context: RunningTaskContext,
|
|
295
|
+
): WorkflowProgressHealth | undefined {
|
|
296
|
+
const isLongTail =
|
|
297
|
+
context.durationClass === "long" &&
|
|
298
|
+
context.elapsedMs !== undefined &&
|
|
299
|
+
context.elapsedMs >= LONG_TAIL_ELAPSED_MS;
|
|
300
|
+
if (!isLongTail) return undefined;
|
|
301
|
+
return runningHealth(context, {
|
|
302
|
+
state: "long-tail",
|
|
303
|
+
label: "long-tail active",
|
|
304
|
+
summary: "slow task with fresh liveness signals",
|
|
305
|
+
tone: "accent",
|
|
306
|
+
suggestion: "wait",
|
|
307
|
+
reason:
|
|
308
|
+
context.staleMs <= ACTIVE_ACTIVITY_MS
|
|
309
|
+
? "liveness signal is fresh"
|
|
310
|
+
: "long-running stage is still within the stale threshold",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function activeRunningHealth(
|
|
315
|
+
context: RunningTaskContext,
|
|
316
|
+
): WorkflowProgressHealth {
|
|
317
|
+
return runningHealth(context, {
|
|
318
|
+
state: "active",
|
|
319
|
+
label: "active",
|
|
320
|
+
summary: "task is running",
|
|
321
|
+
tone: "accent",
|
|
322
|
+
suggestion: "wait",
|
|
323
|
+
reason:
|
|
324
|
+
context.staleMs <= ACTIVE_ACTIVITY_MS
|
|
325
|
+
? "liveness signal is fresh"
|
|
326
|
+
: "activity remains within the expected window",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function runningContext(
|
|
331
|
+
task: TaskHealthInput,
|
|
332
|
+
run: Pick<WorkflowRunRecord, "updatedAt"> | undefined,
|
|
333
|
+
nowMs: number,
|
|
334
|
+
): RunningTaskContext {
|
|
335
|
+
const durationClass = classifyWorkflowTaskDuration(task);
|
|
336
|
+
const startedAtMs = parseTime(task.startedAt);
|
|
337
|
+
const heartbeatAt = parseHeartbeatAt(task.lastMessage);
|
|
338
|
+
const activityAt = latestIso([heartbeatAt, run?.updatedAt, task.startedAt]);
|
|
339
|
+
const lastActivityAgeMs = ageMs(activityAt, nowMs);
|
|
340
|
+
return {
|
|
341
|
+
task,
|
|
342
|
+
nowMs,
|
|
343
|
+
durationClass,
|
|
344
|
+
elapsedMs:
|
|
345
|
+
startedAtMs === undefined ? undefined : Math.max(0, nowMs - startedAtMs),
|
|
346
|
+
activityAt,
|
|
347
|
+
lastActivityAgeMs,
|
|
348
|
+
heartbeatAt,
|
|
349
|
+
heartbeatAgeMs: ageMs(heartbeatAt, nowMs),
|
|
350
|
+
hasBackendSignal: Boolean(task.backendHandle || task.pid || heartbeatAt),
|
|
351
|
+
staleMs: lastActivityAgeMs ?? Number.POSITIVE_INFINITY,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function terminalTaskHealth(
|
|
356
|
+
task: TaskHealthInput,
|
|
357
|
+
nowMs: number,
|
|
358
|
+
): WorkflowProgressHealth {
|
|
359
|
+
if (task.status === "completed" || task.status === "skipped") {
|
|
360
|
+
return {
|
|
361
|
+
state: "completed",
|
|
362
|
+
label: task.status === "skipped" ? "skipped" : "completed",
|
|
363
|
+
summary: task.status === "skipped" ? "task skipped" : "task completed",
|
|
364
|
+
tone: "success",
|
|
365
|
+
suggestion: "review",
|
|
366
|
+
reason: task.statusDetail,
|
|
367
|
+
currentTask: taskSummary(task, nowMs),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (task.status === "pending") {
|
|
371
|
+
return {
|
|
372
|
+
state: "pending",
|
|
373
|
+
label: "pending",
|
|
374
|
+
summary: "waiting for dependencies or scheduler",
|
|
375
|
+
tone: "dim",
|
|
376
|
+
suggestion: "wait",
|
|
377
|
+
reason: task.statusDetail,
|
|
378
|
+
currentTask: taskSummary(task, nowMs),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
state: "needs-action",
|
|
383
|
+
label: "needs action",
|
|
384
|
+
summary: `${task.status} task needs attention`,
|
|
385
|
+
tone: "error",
|
|
386
|
+
suggestion: "inspect",
|
|
387
|
+
reason: task.lastMessage ?? task.statusDetail,
|
|
388
|
+
currentTask: taskSummary(task, nowMs),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function runningHealth(
|
|
393
|
+
context: RunningTaskContext,
|
|
394
|
+
health: Pick<
|
|
395
|
+
WorkflowProgressHealth,
|
|
396
|
+
"state" | "label" | "summary" | "tone" | "suggestion" | "reason"
|
|
397
|
+
>,
|
|
398
|
+
): WorkflowProgressHealth {
|
|
399
|
+
return {
|
|
400
|
+
...health,
|
|
401
|
+
durationClass: context.durationClass,
|
|
402
|
+
currentTask: taskSummary(context.task, context.nowMs),
|
|
403
|
+
lastActivityAt: context.activityAt,
|
|
404
|
+
lastActivityAgeMs: context.lastActivityAgeMs,
|
|
405
|
+
heartbeatAt: context.heartbeatAt,
|
|
406
|
+
heartbeatAgeMs: context.heartbeatAgeMs,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function taskSummary(
|
|
411
|
+
task: TaskHealthInput,
|
|
412
|
+
nowMs: number,
|
|
413
|
+
): WorkflowHealthTaskSummary {
|
|
414
|
+
const startedAtMs = parseTime(task.startedAt);
|
|
415
|
+
return {
|
|
416
|
+
taskId: task.taskId,
|
|
417
|
+
displayName: task.displayName,
|
|
418
|
+
stageId: task.stageId,
|
|
419
|
+
status: task.status,
|
|
420
|
+
...(startedAtMs === undefined
|
|
421
|
+
? {}
|
|
422
|
+
: { elapsedMs: Math.max(0, nowMs - startedAtMs) }),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isProblemStatus(status: TaskRunStatus | WorkflowRunStatus): boolean {
|
|
427
|
+
return (
|
|
428
|
+
status === "failed" || status === "blocked" || status === "interrupted"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function parseHeartbeatAt(message: string | undefined): string | undefined {
|
|
433
|
+
if (!message) return undefined;
|
|
434
|
+
const match = /heartbeat\s+(\d{4}-\d{2}-\d{2}T\S+?Z)/i.exec(message);
|
|
435
|
+
if (!match) return undefined;
|
|
436
|
+
const value = match[1];
|
|
437
|
+
return parseTime(value) === undefined ? undefined : value;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function latestIso(values: Array<string | undefined>): string | undefined {
|
|
441
|
+
let latest: string | undefined;
|
|
442
|
+
let latestMs = Number.NEGATIVE_INFINITY;
|
|
443
|
+
for (const value of values) {
|
|
444
|
+
const time = parseTime(value);
|
|
445
|
+
if (time === undefined || time <= latestMs) continue;
|
|
446
|
+
latest = value;
|
|
447
|
+
latestMs = time;
|
|
448
|
+
}
|
|
449
|
+
return latest;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function ageMs(value: string | undefined, nowMs: number): number | undefined {
|
|
453
|
+
const time = parseTime(value);
|
|
454
|
+
return time === undefined ? undefined : Math.max(0, nowMs - time);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseTime(value: string | undefined): number | undefined {
|
|
458
|
+
if (!value) return undefined;
|
|
459
|
+
const parsed = Date.parse(value);
|
|
460
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
461
|
+
}
|