@agwab/pi-workflow 0.3.0 → 0.4.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 +3 -1
- package/dist/artifact-graph-runtime.d.ts +1 -1
- package/dist/artifact-graph-runtime.js +10 -5
- package/dist/artifact-graph-schema.js +127 -5
- package/dist/compiler.js +46 -11
- package/dist/dynamic-decision.d.ts +1 -0
- package/dist/dynamic-decision.js +7 -0
- package/dist/dynamic-generated-task-runtime.js +3 -1
- package/dist/dynamic-profiles.d.ts +1 -0
- package/dist/dynamic-profiles.js +3 -0
- package/dist/engine-run-graph.d.ts +2 -0
- package/dist/engine-run-graph.js +55 -5
- package/dist/engine.js +278 -15
- package/dist/extension.js +3 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/prompt-json.d.ts +7 -0
- package/dist/prompt-json.js +13 -0
- package/dist/roles.d.ts +1 -1
- package/dist/roles.js +5 -8
- package/dist/store.d.ts +20 -1
- package/dist/store.js +89 -29
- package/dist/strings.d.ts +11 -0
- package/dist/strings.js +24 -0
- package/dist/subagent-backend.js +557 -13
- package/dist/types.d.ts +101 -1
- package/dist/verification-ontology.d.ts +31 -0
- package/dist/verification-ontology.js +66 -0
- package/dist/workflow-artifact-tool.js +5 -6
- package/dist/workflow-artifacts.d.ts +7 -0
- package/dist/workflow-artifacts.js +55 -4
- package/dist/workflow-fetch-cache-extension.d.ts +1 -0
- package/dist/workflow-fetch-cache-extension.js +57 -9
- package/dist/workflow-metrics.d.ts +113 -0
- package/dist/workflow-metrics.js +272 -0
- package/dist/workflow-output-artifacts.js +5 -3
- package/dist/workflow-partial-output.d.ts +45 -0
- package/dist/workflow-partial-output.js +205 -0
- package/dist/workflow-progress-health.js +42 -10
- package/dist/workflow-web-source-extension.js +27 -4
- package/dist/workflow-web-source.js +26 -12
- package/docs/usage.md +76 -29
- package/node_modules/@agwab/pi-subagent/package.json +1 -1
- package/node_modules/@agwab/pi-subagent/src/index.ts +53 -5
- package/node_modules/@agwab/pi-subagent/src/panel.ts +7 -3
- package/package.json +2 -2
- package/skills/workflow-guide/SKILL.md +1 -0
- package/src/artifact-graph-runtime.ts +19 -13
- package/src/artifact-graph-schema.ts +143 -3
- package/src/cli.mjs +52 -0
- package/src/compiler.ts +49 -9
- package/src/dynamic-decision.ts +11 -0
- package/src/dynamic-generated-task-runtime.ts +3 -1
- package/src/dynamic-profiles.ts +4 -0
- package/src/engine-run-graph.ts +63 -4
- package/src/engine.ts +400 -14
- package/src/extension.ts +3 -2
- package/src/index.ts +49 -0
- package/src/prompt-json.ts +13 -0
- package/src/roles.ts +6 -9
- package/src/store.ts +123 -34
- package/src/strings.ts +38 -0
- package/src/subagent-backend.ts +727 -41
- package/src/types.ts +110 -2
- package/src/verification-ontology.ts +88 -0
- package/src/workflow-artifact-tool.ts +5 -7
- package/src/workflow-artifacts.ts +83 -3
- package/src/workflow-fetch-cache-extension.ts +78 -13
- package/src/workflow-metrics.ts +478 -0
- package/src/workflow-output-artifacts.ts +5 -3
- package/src/workflow-partial-output.ts +299 -0
- package/src/workflow-progress-health.ts +47 -15
- package/src/workflow-web-source-extension.ts +33 -4
- package/src/workflow-web-source.ts +36 -12
- package/workflows/README.md +7 -25
- package/workflows/deep-research/batched-verification.spec.json +253 -0
- package/workflows/deep-research/helpers/batch-verification-candidates.mjs +136 -0
- package/workflows/deep-research/helpers/claim-evidence-gate.mjs +173 -20
- package/workflows/deep-research/helpers/normalize-input-packet.mjs +80 -1
- package/workflows/deep-research/helpers/render-executive.mjs +32 -5
- package/workflows/deep-research/helpers/shadow-select-verification.mjs +229 -0
- package/workflows/deep-research/helpers/verification-ontology.mjs +77 -0
- package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +3 -2
- package/workflows/deep-research/schemas/deep-research-research-questions-control.schema.json +38 -0
- package/workflows/deep-research/schemas/deep-research-sanitize-claims-control.schema.json +63 -0
- package/workflows/deep-research/schemas/deep-research-verify-claims-batch-control.schema.json +47 -0
- package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +10 -3
- package/workflows/deep-research/spec.json +32 -12
- package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stderr +0 -0
- package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stdout +0 -13
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
export const WORKFLOW_METRICS_SCHEMA_VERSION = 1;
|
|
2
|
+
export const WORKFLOW_METRICS_PRICING_MODEL_VERSION = "provider-reported-v1";
|
|
3
|
+
const USAGE_METRIC_KEYS = [
|
|
4
|
+
"inputTokens",
|
|
5
|
+
"outputTokens",
|
|
6
|
+
"totalTokens",
|
|
7
|
+
"cachedInputTokens",
|
|
8
|
+
"cacheCreationInputTokens",
|
|
9
|
+
"cacheReadInputTokens",
|
|
10
|
+
"reasoningTokens",
|
|
11
|
+
"costUsd",
|
|
12
|
+
];
|
|
13
|
+
const TIMING_METRIC_KEYS = [
|
|
14
|
+
"launchWaitMs",
|
|
15
|
+
"launchDurationMs",
|
|
16
|
+
"executionMs",
|
|
17
|
+
"totalMs",
|
|
18
|
+
"launchSlotReleaseDelayMs",
|
|
19
|
+
];
|
|
20
|
+
function hasOwnValue(record, key) {
|
|
21
|
+
return Object.hasOwn(record, key);
|
|
22
|
+
}
|
|
23
|
+
function metricValue(record, key) {
|
|
24
|
+
if (!record || !hasOwnValue(record, key))
|
|
25
|
+
return null;
|
|
26
|
+
const value = record[key];
|
|
27
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
28
|
+
}
|
|
29
|
+
function metricString(value) {
|
|
30
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
31
|
+
}
|
|
32
|
+
function sumMetricValues(values) {
|
|
33
|
+
if (values.length === 0)
|
|
34
|
+
return { value: null, incomplete: true };
|
|
35
|
+
let total = 0;
|
|
36
|
+
for (const value of values) {
|
|
37
|
+
if (value === null)
|
|
38
|
+
return { value: null, incomplete: true };
|
|
39
|
+
total += value;
|
|
40
|
+
}
|
|
41
|
+
return { value: total, incomplete: false };
|
|
42
|
+
}
|
|
43
|
+
function usageAttempts(task) {
|
|
44
|
+
return task.usage?.aggregate?.attempts ?? task.usage?.attempts?.length ?? 0;
|
|
45
|
+
}
|
|
46
|
+
function timingAttempts(task) {
|
|
47
|
+
return task.timing?.aggregate?.attempts ?? task.timing?.attempts?.length ?? 0;
|
|
48
|
+
}
|
|
49
|
+
function taskUsageMetrics(task) {
|
|
50
|
+
const usage = task.usage;
|
|
51
|
+
const source = usage?.aggregate ?? usage;
|
|
52
|
+
const unavailable = usage === undefined ||
|
|
53
|
+
usage.attempts?.some((attempt) => attempt.unavailable) === true;
|
|
54
|
+
const metrics = Object.fromEntries(USAGE_METRIC_KEYS.map((key) => [key, metricValue(source, key)]));
|
|
55
|
+
const incomplete = unavailable ||
|
|
56
|
+
usage?.incomplete === true ||
|
|
57
|
+
usage?.aggregate?.incomplete === true ||
|
|
58
|
+
USAGE_METRIC_KEYS.some((key) => metrics[key] === null);
|
|
59
|
+
return {
|
|
60
|
+
inputTokens: metrics.inputTokens,
|
|
61
|
+
outputTokens: metrics.outputTokens,
|
|
62
|
+
totalTokens: metrics.totalTokens,
|
|
63
|
+
cachedInputTokens: metrics.cachedInputTokens,
|
|
64
|
+
cacheCreationInputTokens: metrics.cacheCreationInputTokens,
|
|
65
|
+
cacheReadInputTokens: metrics.cacheReadInputTokens,
|
|
66
|
+
reasoningTokens: metrics.reasoningTokens,
|
|
67
|
+
costUsd: metrics.costUsd,
|
|
68
|
+
attempts: usageAttempts(task),
|
|
69
|
+
unavailable,
|
|
70
|
+
incomplete,
|
|
71
|
+
unavailableTaskIds: unavailable ? [task.taskId] : [],
|
|
72
|
+
incompleteTaskIds: incomplete ? [task.taskId] : [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function taskLaunchTimingMetrics(task) {
|
|
76
|
+
const timing = task.timing;
|
|
77
|
+
const aggregateSource = timing?.aggregate ?? timing;
|
|
78
|
+
const unavailable = timing === undefined;
|
|
79
|
+
const metrics = Object.fromEntries(TIMING_METRIC_KEYS.map((key) => [
|
|
80
|
+
key,
|
|
81
|
+
metricValue(key === "launchSlotReleaseDelayMs" ? timing : aggregateSource, key),
|
|
82
|
+
]));
|
|
83
|
+
const incomplete = unavailable ||
|
|
84
|
+
timing?.aggregate?.incomplete === true ||
|
|
85
|
+
TIMING_METRIC_KEYS.some((key) => metrics[key] === null);
|
|
86
|
+
return {
|
|
87
|
+
launchWaitMs: metrics.launchWaitMs,
|
|
88
|
+
launchDurationMs: metrics.launchDurationMs,
|
|
89
|
+
executionMs: metrics.executionMs,
|
|
90
|
+
totalMs: metrics.totalMs,
|
|
91
|
+
launchSlotReleaseDelayMs: metrics.launchSlotReleaseDelayMs,
|
|
92
|
+
attempts: timingAttempts(task),
|
|
93
|
+
unavailable,
|
|
94
|
+
incomplete,
|
|
95
|
+
unavailableTaskIds: unavailable ? [task.taskId] : [],
|
|
96
|
+
incompleteTaskIds: incomplete ? [task.taskId] : [],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function sumResumeRetryAttempts(task, key) {
|
|
100
|
+
return (task.resumeEvents ?? []).reduce((total, event) => {
|
|
101
|
+
const attempts = event[key];
|
|
102
|
+
return typeof attempts === "number" && Number.isFinite(attempts)
|
|
103
|
+
? total + attempts
|
|
104
|
+
: total;
|
|
105
|
+
}, 0);
|
|
106
|
+
}
|
|
107
|
+
function taskRetryMetrics(task) {
|
|
108
|
+
const launchRetries = (task.launchRetry?.attempts ?? 0) +
|
|
109
|
+
sumResumeRetryAttempts(task, "launchRetryAttempts");
|
|
110
|
+
const outputRetries = (task.outputRetry?.attempts ?? 0) +
|
|
111
|
+
sumResumeRetryAttempts(task, "outputRetryAttempts");
|
|
112
|
+
const resumeEvents = task.resumeEvents?.length ?? 0;
|
|
113
|
+
const totalRetryEvents = launchRetries + outputRetries + resumeEvents;
|
|
114
|
+
return {
|
|
115
|
+
launchRetries,
|
|
116
|
+
outputRetries,
|
|
117
|
+
resumeEvents,
|
|
118
|
+
totalRetryEvents,
|
|
119
|
+
tasksWithRetries: totalRetryEvents > 0 ? 1 : 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function emptyStatusCounts() {
|
|
123
|
+
return {
|
|
124
|
+
pending: 0,
|
|
125
|
+
running: 0,
|
|
126
|
+
blocked: 0,
|
|
127
|
+
completed: 0,
|
|
128
|
+
failed: 0,
|
|
129
|
+
skipped: 0,
|
|
130
|
+
interrupted: 0,
|
|
131
|
+
total: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function rollupUsage(tasks) {
|
|
135
|
+
const rollup = Object.fromEntries(USAGE_METRIC_KEYS.map((key) => [
|
|
136
|
+
key,
|
|
137
|
+
sumMetricValues(tasks.map((task) => task.usage[key])),
|
|
138
|
+
]));
|
|
139
|
+
const unavailableTaskIds = tasks.flatMap((task) => task.usage.unavailableTaskIds);
|
|
140
|
+
const incompleteTaskIds = tasks.flatMap((task) => task.usage.incompleteTaskIds);
|
|
141
|
+
return {
|
|
142
|
+
inputTokens: rollup.inputTokens.value,
|
|
143
|
+
outputTokens: rollup.outputTokens.value,
|
|
144
|
+
totalTokens: rollup.totalTokens.value,
|
|
145
|
+
cachedInputTokens: rollup.cachedInputTokens.value,
|
|
146
|
+
cacheCreationInputTokens: rollup.cacheCreationInputTokens.value,
|
|
147
|
+
cacheReadInputTokens: rollup.cacheReadInputTokens.value,
|
|
148
|
+
reasoningTokens: rollup.reasoningTokens.value,
|
|
149
|
+
costUsd: rollup.costUsd.value,
|
|
150
|
+
attempts: tasks.reduce((total, task) => total + task.usage.attempts, 0),
|
|
151
|
+
unavailable: unavailableTaskIds.length > 0,
|
|
152
|
+
incomplete: incompleteTaskIds.length > 0 ||
|
|
153
|
+
USAGE_METRIC_KEYS.some((key) => rollup[key].incomplete),
|
|
154
|
+
unavailableTaskIds,
|
|
155
|
+
incompleteTaskIds,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function rollupLaunchTiming(tasks) {
|
|
159
|
+
const rollup = Object.fromEntries(TIMING_METRIC_KEYS.map((key) => [
|
|
160
|
+
key,
|
|
161
|
+
sumMetricValues(tasks.map((task) => task.launchTiming[key])),
|
|
162
|
+
]));
|
|
163
|
+
const unavailableTaskIds = tasks.flatMap((task) => task.launchTiming.unavailableTaskIds);
|
|
164
|
+
const incompleteTaskIds = tasks.flatMap((task) => task.launchTiming.incompleteTaskIds);
|
|
165
|
+
return {
|
|
166
|
+
launchWaitMs: rollup.launchWaitMs.value,
|
|
167
|
+
launchDurationMs: rollup.launchDurationMs.value,
|
|
168
|
+
executionMs: rollup.executionMs.value,
|
|
169
|
+
totalMs: rollup.totalMs.value,
|
|
170
|
+
launchSlotReleaseDelayMs: rollup.launchSlotReleaseDelayMs.value,
|
|
171
|
+
attempts: tasks.reduce((total, task) => total + task.launchTiming.attempts, 0),
|
|
172
|
+
unavailable: unavailableTaskIds.length > 0,
|
|
173
|
+
incomplete: incompleteTaskIds.length > 0 ||
|
|
174
|
+
TIMING_METRIC_KEYS.some((key) => rollup[key].incomplete),
|
|
175
|
+
unavailableTaskIds,
|
|
176
|
+
incompleteTaskIds,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function rollupRetries(tasks) {
|
|
180
|
+
const launchRetries = tasks.reduce((total, task) => total + task.retries.launchRetries, 0);
|
|
181
|
+
const outputRetries = tasks.reduce((total, task) => total + task.retries.outputRetries, 0);
|
|
182
|
+
const resumeEvents = tasks.reduce((total, task) => total + task.retries.resumeEvents, 0);
|
|
183
|
+
return {
|
|
184
|
+
launchRetries,
|
|
185
|
+
outputRetries,
|
|
186
|
+
resumeEvents,
|
|
187
|
+
totalRetryEvents: launchRetries + outputRetries + resumeEvents,
|
|
188
|
+
tasksWithRetries: tasks.reduce((total, task) => total + task.retries.tasksWithRetries, 0),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function statusCounts(tasks) {
|
|
192
|
+
const counts = emptyStatusCounts();
|
|
193
|
+
for (const task of tasks) {
|
|
194
|
+
counts[task.status] += 1;
|
|
195
|
+
counts.total += 1;
|
|
196
|
+
}
|
|
197
|
+
return counts;
|
|
198
|
+
}
|
|
199
|
+
function rollupTasks(tasks) {
|
|
200
|
+
return {
|
|
201
|
+
taskCount: tasks.length,
|
|
202
|
+
statusCounts: statusCounts(tasks),
|
|
203
|
+
usage: rollupUsage(tasks),
|
|
204
|
+
launchTiming: rollupLaunchTiming(tasks),
|
|
205
|
+
retries: rollupRetries(tasks),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function stageMetrics(tasks) {
|
|
209
|
+
const stageIds = [];
|
|
210
|
+
for (const task of tasks) {
|
|
211
|
+
if (!stageIds.includes(task.stageId))
|
|
212
|
+
stageIds.push(task.stageId);
|
|
213
|
+
}
|
|
214
|
+
return stageIds.map((stageId) => ({
|
|
215
|
+
stageId,
|
|
216
|
+
...rollupTasks(tasks.filter((task) => task.stageId === stageId)),
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
function taskMetrics(task) {
|
|
220
|
+
return {
|
|
221
|
+
taskId: task.taskId,
|
|
222
|
+
specId: task.specId,
|
|
223
|
+
displayName: task.displayName,
|
|
224
|
+
agent: task.agent,
|
|
225
|
+
status: task.status,
|
|
226
|
+
statusDetail: task.statusDetail,
|
|
227
|
+
stageId: task.stageId ?? null,
|
|
228
|
+
kind: task.kind ?? null,
|
|
229
|
+
provider: metricString(task.usage?.provider),
|
|
230
|
+
model: metricString(task.usage?.model ?? task.runtime.model),
|
|
231
|
+
thinking: metricString(task.usage?.thinking ?? task.runtime.thinking),
|
|
232
|
+
usage: taskUsageMetrics(task),
|
|
233
|
+
launchTiming: taskLaunchTimingMetrics(task),
|
|
234
|
+
retries: taskRetryMetrics(task),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Build a deterministic, JSON-serializable metrics export from a persisted
|
|
239
|
+
* workflow run record. The helper is intentionally pure: it reads only the
|
|
240
|
+
* supplied record, performs no pricing inference, and does not mutate the run.
|
|
241
|
+
*/
|
|
242
|
+
export function buildWorkflowRunMetrics(run) {
|
|
243
|
+
const byTask = run.tasks.map((task) => taskMetrics(task));
|
|
244
|
+
const totals = rollupTasks(byTask);
|
|
245
|
+
return {
|
|
246
|
+
schemaVersion: WORKFLOW_METRICS_SCHEMA_VERSION,
|
|
247
|
+
pricingModelVersion: WORKFLOW_METRICS_PRICING_MODEL_VERSION,
|
|
248
|
+
pricingSource: "provider-reported",
|
|
249
|
+
costsAreProviderReported: true,
|
|
250
|
+
run: {
|
|
251
|
+
runId: run.runId,
|
|
252
|
+
...(run.name === undefined ? {} : { name: run.name }),
|
|
253
|
+
type: run.type,
|
|
254
|
+
status: run.status,
|
|
255
|
+
createdAt: run.createdAt,
|
|
256
|
+
updatedAt: run.updatedAt,
|
|
257
|
+
},
|
|
258
|
+
totals,
|
|
259
|
+
byStage: stageMetrics(byTask),
|
|
260
|
+
byTask,
|
|
261
|
+
metadata: {
|
|
262
|
+
usageUnavailableTaskIds: [...totals.usage.unavailableTaskIds],
|
|
263
|
+
usageIncompleteTaskIds: [...totals.usage.incompleteTaskIds],
|
|
264
|
+
launchTimingUnavailableTaskIds: [
|
|
265
|
+
...totals.launchTiming.unavailableTaskIds,
|
|
266
|
+
],
|
|
267
|
+
launchTimingIncompleteTaskIds: [...totals.launchTiming.incompleteTaskIds],
|
|
268
|
+
incomplete: totals.usage.incomplete || totals.launchTiming.incomplete,
|
|
269
|
+
unavailable: totals.usage.unavailable || totals.launchTiming.unavailable,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { validateStructuredContract, } from "./workflow-artifacts.js";
|
|
5
|
+
import { stripWorkflowPartialOutputSections } from "./workflow-partial-output.js";
|
|
5
6
|
import { validateJsonSchema, } from "./json-schema.js";
|
|
6
7
|
export const VNEXT_OUTPUT_PROTOCOL = "workflow-output-sections-v1";
|
|
7
8
|
export const VNEXT_TASK_RESULT_SCHEMA = "workflow-task-result-v1";
|
|
@@ -17,16 +18,17 @@ const DEFAULT_MAX_DIGEST_CHARS = 1000;
|
|
|
17
18
|
const DEFAULT_REFS_URL_VALIDATION_TIMEOUT_MS = 8_000;
|
|
18
19
|
const DEFAULT_REFS_URL_VALIDATION_MAX_URLS = 25;
|
|
19
20
|
export function parseWorkflowOutput(raw, options = {}) {
|
|
21
|
+
const protocolRaw = stripWorkflowPartialOutputSections(raw);
|
|
20
22
|
const issues = [];
|
|
21
23
|
const requirements = sectionRequirements(options);
|
|
22
|
-
const sections = collectSections(
|
|
23
|
-
validateSectionLayout(
|
|
24
|
+
const sections = collectSections(protocolRaw, requirements);
|
|
25
|
+
validateSectionLayout(protocolRaw, sections, issues, requirements);
|
|
24
26
|
const control = parseControlSection(sectionText(sections, SECTION_CONTROL), issues, options);
|
|
25
27
|
const analysis = parseAnalysisSection(sectionText(sections, SECTION_ANALYSIS), issues, requirements);
|
|
26
28
|
const refs = parseRefsSection(sectionText(sections, SECTION_REFS), issues, requirements);
|
|
27
29
|
validateControlContract(control, issues, options.controlContract);
|
|
28
30
|
validateControlJsonSchema(control, issues, options.controlJsonSchema);
|
|
29
|
-
return buildParsedOutput(
|
|
31
|
+
return buildParsedOutput(protocolRaw, issues, { control, analysis, refs }, requirements);
|
|
30
32
|
}
|
|
31
33
|
export function parseWorkflowOutputForBundle(raw, options = {}) {
|
|
32
34
|
const parsed = parseWorkflowOutput(raw, options);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export declare const WORKFLOW_PARTIAL_OUTPUT_PROTOCOL: "workflow-partial-output-v1";
|
|
2
|
+
export declare const WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA: "workflow-partial-output-ledger-v1";
|
|
3
|
+
export declare const WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE = "partial-control.json";
|
|
4
|
+
export type WorkflowPartialOutputIssueCode = "invalid_json" | "invalid_type" | "invalid_schema" | "invalid_path" | "disallowed_path" | "missing_items" | "missing_item_id" | "duplicate_item_id";
|
|
5
|
+
export interface WorkflowPartialOutputIssue {
|
|
6
|
+
code: WorkflowPartialOutputIssueCode;
|
|
7
|
+
message: string;
|
|
8
|
+
sectionIndex?: number;
|
|
9
|
+
path?: string;
|
|
10
|
+
itemId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WorkflowPartialOutputItem {
|
|
13
|
+
path: string;
|
|
14
|
+
itemId: string;
|
|
15
|
+
itemHash: string;
|
|
16
|
+
item: unknown;
|
|
17
|
+
ordinal: number;
|
|
18
|
+
sectionIndex: number;
|
|
19
|
+
sectionItemIndex: number;
|
|
20
|
+
itemRef: string;
|
|
21
|
+
}
|
|
22
|
+
export interface WorkflowPartialOutputLedger {
|
|
23
|
+
schema: typeof WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA;
|
|
24
|
+
protocol: typeof WORKFLOW_PARTIAL_OUTPUT_PROTOCOL;
|
|
25
|
+
items: WorkflowPartialOutputItem[];
|
|
26
|
+
issues: WorkflowPartialOutputIssue[];
|
|
27
|
+
}
|
|
28
|
+
export interface ParseWorkflowPartialOutputOptions {
|
|
29
|
+
allowedPaths?: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
export declare function partialOutputLedgerPath(taskDir: string): string;
|
|
32
|
+
export declare function readWorkflowPartialOutputLedger(taskDir: string): Promise<WorkflowPartialOutputLedger | undefined>;
|
|
33
|
+
export declare function writeWorkflowPartialOutputLedger(options: {
|
|
34
|
+
taskDir: string;
|
|
35
|
+
rawOutput: string;
|
|
36
|
+
allowedPaths?: readonly string[];
|
|
37
|
+
}): Promise<WorkflowPartialOutputLedger>;
|
|
38
|
+
export declare function writeWorkflowPartialOutputLedgerFromFile(options: {
|
|
39
|
+
taskDir: string;
|
|
40
|
+
outputFile: string;
|
|
41
|
+
allowedPaths?: readonly string[];
|
|
42
|
+
}): Promise<WorkflowPartialOutputLedger | undefined>;
|
|
43
|
+
export declare function stripWorkflowPartialOutputSections(raw: string): string;
|
|
44
|
+
export declare function parseWorkflowPartialOutput(raw: string, options?: ParseWorkflowPartialOutputOptions): WorkflowPartialOutputLedger;
|
|
45
|
+
export declare function hasFatalPartialOutputIssue(ledger: Pick<WorkflowPartialOutputLedger, "issues"> | undefined): WorkflowPartialOutputIssue | undefined;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hashDynamicRequest } from "./dynamic-events.js";
|
|
4
|
+
import { readJson, writeJsonAtomic } from "./store.js";
|
|
5
|
+
export const WORKFLOW_PARTIAL_OUTPUT_PROTOCOL = "workflow-partial-output-v1";
|
|
6
|
+
export const WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA = "workflow-partial-output-ledger-v1";
|
|
7
|
+
export const WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE = "partial-control.json";
|
|
8
|
+
const PARTIAL_CONTROL_OPEN = "partial-control";
|
|
9
|
+
export function partialOutputLedgerPath(taskDir) {
|
|
10
|
+
return join(taskDir, WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE);
|
|
11
|
+
}
|
|
12
|
+
export async function readWorkflowPartialOutputLedger(taskDir) {
|
|
13
|
+
return await readJson(partialOutputLedgerPath(taskDir));
|
|
14
|
+
}
|
|
15
|
+
export async function writeWorkflowPartialOutputLedger(options) {
|
|
16
|
+
const ledger = parseWorkflowPartialOutput(options.rawOutput, {
|
|
17
|
+
allowedPaths: options.allowedPaths,
|
|
18
|
+
});
|
|
19
|
+
await writeJsonAtomic(partialOutputLedgerPath(options.taskDir), ledger);
|
|
20
|
+
return ledger;
|
|
21
|
+
}
|
|
22
|
+
export async function writeWorkflowPartialOutputLedgerFromFile(options) {
|
|
23
|
+
const rawOutput = await readFile(options.outputFile, "utf8").catch(() => undefined);
|
|
24
|
+
if (rawOutput === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
return await writeWorkflowPartialOutputLedger({
|
|
27
|
+
taskDir: options.taskDir,
|
|
28
|
+
rawOutput,
|
|
29
|
+
allowedPaths: options.allowedPaths,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function stripWorkflowPartialOutputSections(raw) {
|
|
33
|
+
if (!raw.includes(PARTIAL_CONTROL_OPEN))
|
|
34
|
+
return raw;
|
|
35
|
+
return raw.replace(partialControlSectionRegExp(), "");
|
|
36
|
+
}
|
|
37
|
+
export function parseWorkflowPartialOutput(raw, options = {}) {
|
|
38
|
+
const allowedPaths = options.allowedPaths
|
|
39
|
+
? new Set(options.allowedPaths)
|
|
40
|
+
: undefined;
|
|
41
|
+
const items = [];
|
|
42
|
+
const issues = [];
|
|
43
|
+
const byPathAndId = new Map();
|
|
44
|
+
for (const section of collectPartialControlSections(raw)) {
|
|
45
|
+
const parsed = parsePartialSectionJson(section, issues);
|
|
46
|
+
if (!parsed)
|
|
47
|
+
continue;
|
|
48
|
+
const path = parsePartialSectionPath(parsed, section, allowedPaths, issues);
|
|
49
|
+
if (!path)
|
|
50
|
+
continue;
|
|
51
|
+
const rawItems = parsePartialSectionItems(parsed, section, path, issues);
|
|
52
|
+
if (!rawItems)
|
|
53
|
+
continue;
|
|
54
|
+
for (const [sectionItemIndex, item] of rawItems.entries()) {
|
|
55
|
+
const itemId = stablePartialItemId(item);
|
|
56
|
+
if (!itemId) {
|
|
57
|
+
issues.push({
|
|
58
|
+
code: "missing_item_id",
|
|
59
|
+
sectionIndex: section.index,
|
|
60
|
+
path,
|
|
61
|
+
message: "partial output items must be objects with a stable non-empty string id",
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const itemHash = hashDynamicRequest(item);
|
|
66
|
+
const key = `${path}\0${itemId}`;
|
|
67
|
+
const existing = byPathAndId.get(key);
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (existing.itemHash !== itemHash) {
|
|
70
|
+
issues.push({
|
|
71
|
+
code: "duplicate_item_id",
|
|
72
|
+
sectionIndex: section.index,
|
|
73
|
+
path,
|
|
74
|
+
itemId,
|
|
75
|
+
message: `partial output item ${itemId} at ${path} changed after it was published`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const ordinal = items.length;
|
|
81
|
+
const partialItem = {
|
|
82
|
+
path,
|
|
83
|
+
itemId,
|
|
84
|
+
itemHash,
|
|
85
|
+
item,
|
|
86
|
+
ordinal,
|
|
87
|
+
sectionIndex: section.index,
|
|
88
|
+
sectionItemIndex,
|
|
89
|
+
itemRef: `${WORKFLOW_PARTIAL_OUTPUT_LEDGER_FILE}#/items/${ordinal}`,
|
|
90
|
+
};
|
|
91
|
+
items.push(partialItem);
|
|
92
|
+
byPathAndId.set(key, partialItem);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
schema: WORKFLOW_PARTIAL_OUTPUT_LEDGER_SCHEMA,
|
|
97
|
+
protocol: WORKFLOW_PARTIAL_OUTPUT_PROTOCOL,
|
|
98
|
+
items,
|
|
99
|
+
issues,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function hasFatalPartialOutputIssue(ledger) {
|
|
103
|
+
return ledger?.issues.find((issue) => issue.code === "duplicate_item_id");
|
|
104
|
+
}
|
|
105
|
+
function collectPartialControlSections(raw) {
|
|
106
|
+
if (!raw.includes(PARTIAL_CONTROL_OPEN))
|
|
107
|
+
return [];
|
|
108
|
+
const matches = [];
|
|
109
|
+
const re = partialControlSectionRegExp();
|
|
110
|
+
let match;
|
|
111
|
+
while ((match = re.exec(raw)) !== null) {
|
|
112
|
+
matches.push({
|
|
113
|
+
content: match[1] ?? "",
|
|
114
|
+
start: match.index,
|
|
115
|
+
end: re.lastIndex,
|
|
116
|
+
index: matches.length,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return matches;
|
|
120
|
+
}
|
|
121
|
+
function partialControlSectionRegExp() {
|
|
122
|
+
return /[ \t]*<partial-control\s*>([\s\S]*?)<\/partial-control>[ \t]*(?:\r?\n)?/gi;
|
|
123
|
+
}
|
|
124
|
+
function parsePartialSectionJson(section, issues) {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(section.content.trim());
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
issues.push({
|
|
131
|
+
code: "invalid_json",
|
|
132
|
+
sectionIndex: section.index,
|
|
133
|
+
message: error instanceof Error ? error.message : String(error),
|
|
134
|
+
});
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
if (!isRecord(parsed)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
code: "invalid_type",
|
|
140
|
+
sectionIndex: section.index,
|
|
141
|
+
message: "partial-control section must contain a JSON object",
|
|
142
|
+
});
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (parsed.schema !== WORKFLOW_PARTIAL_OUTPUT_PROTOCOL) {
|
|
146
|
+
issues.push({
|
|
147
|
+
code: "invalid_schema",
|
|
148
|
+
sectionIndex: section.index,
|
|
149
|
+
message: `partial-control schema must be ${WORKFLOW_PARTIAL_OUTPUT_PROTOCOL}`,
|
|
150
|
+
});
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
function parsePartialSectionPath(section, match, allowedPaths, issues) {
|
|
156
|
+
const path = section.path;
|
|
157
|
+
if (typeof path !== "string" || !path.startsWith("$.")) {
|
|
158
|
+
issues.push({
|
|
159
|
+
code: "invalid_path",
|
|
160
|
+
sectionIndex: match.index,
|
|
161
|
+
message: "partial-control path must be a control JSONPath starting with $.",
|
|
162
|
+
});
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
if (allowedPaths && !allowedPaths.has(path)) {
|
|
166
|
+
issues.push({
|
|
167
|
+
code: "disallowed_path",
|
|
168
|
+
sectionIndex: match.index,
|
|
169
|
+
path,
|
|
170
|
+
message: `partial-control path ${path} is not declared for this stage`,
|
|
171
|
+
});
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return path;
|
|
175
|
+
}
|
|
176
|
+
function parsePartialSectionItems(section, match, path, issues) {
|
|
177
|
+
const items = section.items;
|
|
178
|
+
if (!Array.isArray(items)) {
|
|
179
|
+
issues.push({
|
|
180
|
+
code: "missing_items",
|
|
181
|
+
sectionIndex: match.index,
|
|
182
|
+
path,
|
|
183
|
+
message: "partial-control items must be an array",
|
|
184
|
+
});
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return items;
|
|
188
|
+
}
|
|
189
|
+
function stablePartialItemId(item) {
|
|
190
|
+
if (!isRecord(item) || typeof item.id !== "string")
|
|
191
|
+
return undefined;
|
|
192
|
+
const sanitized = sanitizePartialItemId(item.id);
|
|
193
|
+
return sanitized || undefined;
|
|
194
|
+
}
|
|
195
|
+
function sanitizePartialItemId(value) {
|
|
196
|
+
return value
|
|
197
|
+
.trim()
|
|
198
|
+
.toLowerCase()
|
|
199
|
+
.replace(/[^a-z0-9_.-]+/g, "-")
|
|
200
|
+
.replace(/^-+|-+$/g, "")
|
|
201
|
+
.slice(0, 64);
|
|
202
|
+
}
|
|
203
|
+
function isRecord(value) {
|
|
204
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
205
|
+
}
|
|
@@ -12,9 +12,6 @@ const STUCK_BY_DURATION = {
|
|
|
12
12
|
};
|
|
13
13
|
export function diagnoseWorkflowRunHealth(run, options = {}) {
|
|
14
14
|
const nowMs = options.nowMs ?? Date.now();
|
|
15
|
-
const runningTask = currentRunningTask(run.tasks ?? []);
|
|
16
|
-
if (runningTask)
|
|
17
|
-
return diagnoseWorkflowTaskHealth(runningTask, run, { nowMs });
|
|
18
15
|
const problem = (run.tasks ?? []).find((task) => isProblemStatus(task.status));
|
|
19
16
|
if (problem)
|
|
20
17
|
return problemRunHealth(problem, nowMs);
|
|
@@ -22,6 +19,9 @@ export function diagnoseWorkflowRunHealth(run, options = {}) {
|
|
|
22
19
|
return problemWorkflowHealth(run.status);
|
|
23
20
|
if (run.status === "completed")
|
|
24
21
|
return completedWorkflowHealth();
|
|
22
|
+
const runningTask = currentRunningTask(run.tasks ?? []);
|
|
23
|
+
if (runningTask)
|
|
24
|
+
return diagnoseWorkflowTaskHealth(runningTask, run, { nowMs });
|
|
25
25
|
return waitingWorkflowHealth(run, nowMs);
|
|
26
26
|
}
|
|
27
27
|
export function diagnoseWorkflowTaskHealth(task, run, options = {}) {
|
|
@@ -41,10 +41,6 @@ export function classifyWorkflowTaskDuration(task) {
|
|
|
41
41
|
.filter(Boolean)
|
|
42
42
|
.join(" ")
|
|
43
43
|
.toLowerCase();
|
|
44
|
-
if (/\b(render|helper|support|schema|partition|gate)\b/.test(text))
|
|
45
|
-
return "short";
|
|
46
|
-
if (/\b(research|audit|synthesis|review|verify|verifier|normalize|plan|impact|spec)\b/.test(text))
|
|
47
|
-
return "long";
|
|
48
44
|
const maxRuntimeMs = task.runtime?.maxRuntimeMs;
|
|
49
45
|
if (maxRuntimeMs !== undefined && Number.isFinite(maxRuntimeMs)) {
|
|
50
46
|
if (maxRuntimeMs <= 5 * 60_000)
|
|
@@ -52,6 +48,12 @@ export function classifyWorkflowTaskDuration(task) {
|
|
|
52
48
|
if (maxRuntimeMs >= 60 * 60_000)
|
|
53
49
|
return "long";
|
|
54
50
|
}
|
|
51
|
+
if (task.kind === "support")
|
|
52
|
+
return "short";
|
|
53
|
+
if (/\b(research|audit|synthesi[sz]e?r?|review(?:er|ers|ing|s)?|verif(?:y|ier|iers|ication)|normaliz(?:e|er|ing|ation)?|plan(?:ning)?|impact|spec)\b/.test(text))
|
|
54
|
+
return "long";
|
|
55
|
+
if (/\b(render|helper|support|schema|partition|gate)\b/.test(text))
|
|
56
|
+
return "short";
|
|
55
57
|
return "medium";
|
|
56
58
|
}
|
|
57
59
|
function currentRunningTask(tasks) {
|
|
@@ -93,6 +95,33 @@ function problemRunHealth(task, nowMs) {
|
|
|
93
95
|
}
|
|
94
96
|
function waitingWorkflowHealth(run, nowMs) {
|
|
95
97
|
const hasPending = run.taskSummary.pending > 0;
|
|
98
|
+
const lastActivityAgeMs = ageMs(run.updatedAt, nowMs);
|
|
99
|
+
if (hasPending && lastActivityAgeMs !== undefined) {
|
|
100
|
+
if (lastActivityAgeMs >= STUCK_BY_DURATION.medium) {
|
|
101
|
+
return {
|
|
102
|
+
state: "likely-stuck",
|
|
103
|
+
label: "scheduler stuck",
|
|
104
|
+
summary: "pending tasks have not scheduled",
|
|
105
|
+
tone: "error",
|
|
106
|
+
suggestion: "resume",
|
|
107
|
+
reason: "no task is running and run activity is stale",
|
|
108
|
+
lastActivityAt: run.updatedAt,
|
|
109
|
+
lastActivityAgeMs,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (lastActivityAgeMs >= STALL_BY_DURATION.medium) {
|
|
113
|
+
return {
|
|
114
|
+
state: "stalled",
|
|
115
|
+
label: "scheduler quiet",
|
|
116
|
+
summary: "pending tasks are waiting without recent activity",
|
|
117
|
+
tone: "warning",
|
|
118
|
+
suggestion: "inspect",
|
|
119
|
+
reason: "no task is running and run activity is stale",
|
|
120
|
+
lastActivityAt: run.updatedAt,
|
|
121
|
+
lastActivityAgeMs,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
96
125
|
return {
|
|
97
126
|
state: hasPending ? "pending" : "active",
|
|
98
127
|
label: hasPending ? "pending" : "active",
|
|
@@ -105,7 +134,7 @@ function waitingWorkflowHealth(run, nowMs) {
|
|
|
105
134
|
? "no task is currently running"
|
|
106
135
|
: "workflow is still in progress",
|
|
107
136
|
lastActivityAt: run.updatedAt,
|
|
108
|
-
lastActivityAgeMs
|
|
137
|
+
lastActivityAgeMs,
|
|
109
138
|
};
|
|
110
139
|
}
|
|
111
140
|
function runningTaskHealth(context) {
|
|
@@ -189,8 +218,11 @@ function runningContext(task, run, nowMs) {
|
|
|
189
218
|
const durationClass = classifyWorkflowTaskDuration(task);
|
|
190
219
|
const startedAtMs = parseTime(task.startedAt);
|
|
191
220
|
const heartbeatAt = parseHeartbeatAt(task.lastMessage);
|
|
221
|
+
const heartbeatAgeMs = ageMs(heartbeatAt, nowMs);
|
|
192
222
|
const activityAt = latestIso([heartbeatAt, run?.updatedAt, task.startedAt]);
|
|
193
223
|
const lastActivityAgeMs = ageMs(activityAt, nowMs);
|
|
224
|
+
const hasFreshHeartbeat = heartbeatAgeMs !== undefined &&
|
|
225
|
+
heartbeatAgeMs <= STALL_BY_DURATION[durationClass];
|
|
194
226
|
return {
|
|
195
227
|
task,
|
|
196
228
|
nowMs,
|
|
@@ -199,8 +231,8 @@ function runningContext(task, run, nowMs) {
|
|
|
199
231
|
activityAt,
|
|
200
232
|
lastActivityAgeMs,
|
|
201
233
|
heartbeatAt,
|
|
202
|
-
heartbeatAgeMs
|
|
203
|
-
hasBackendSignal: Boolean(task.
|
|
234
|
+
heartbeatAgeMs,
|
|
235
|
+
hasBackendSignal: Boolean(task.pid || hasFreshHeartbeat),
|
|
204
236
|
staleMs: lastActivityAgeMs ?? Number.POSITIVE_INFINITY,
|
|
205
237
|
};
|
|
206
238
|
}
|