@osolmaz/pi-workflows 0.10.0 → 0.11.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 +23 -12
- package/dist/builtins/autodoc.workflow.d.ts +58 -0
- package/dist/builtins/autodoc.workflow.js +266 -0
- package/dist/builtins/autodoc.workflow.js.map +1 -0
- package/dist/builtins/autoimplement.workflow.d.ts +210 -11
- package/dist/builtins/autoimplement.workflow.js +245 -13
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/{autodevise.workflow.d.ts → autoplan.workflow.d.ts} +9 -9
- package/dist/builtins/{autodevise.workflow.js → autoplan.workflow.js} +20 -20
- package/dist/builtins/autoplan.workflow.js.map +1 -0
- package/dist/builtins/catalog.js +8 -4
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +6 -3
- package/dist/builtins/index.js +5 -2
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +4 -0
- package/dist/builtins/monitor.workflow.js +134 -9
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/plan-approval.workflow.d.ts +83 -0
- package/dist/builtins/plan-approval.workflow.js +148 -0
- package/dist/builtins/plan-approval.workflow.js.map +1 -0
- package/dist/builtins/plan-presentation.d.ts +7 -0
- package/dist/builtins/plan-presentation.js +44 -0
- package/dist/builtins/plan-presentation.js.map +1 -0
- package/dist/extension/decision-channels.d.ts +132 -0
- package/dist/extension/decision-channels.js +1082 -0
- package/dist/extension/decision-channels.js.map +1 -0
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +426 -11
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.js +26 -2
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +1 -37
- package/dist/extension/workflow-tool.js +1 -42
- package/dist/extension/workflow-tool.js.map +1 -1
- package/dist/host/rpc-bridge.js +4 -21
- package/dist/host/rpc-bridge.js.map +1 -1
- package/dist/render/graph-render.js +33 -8
- package/dist/render/graph-render.js.map +1 -1
- package/dist/viewer/render.js +36 -2
- package/dist/viewer/render.js.map +1 -1
- package/dist/workflows/composition.js +15 -1
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/decision-presentation.d.ts +18 -0
- package/dist/workflows/decision-presentation.js +417 -0
- package/dist/workflows/decision-presentation.js.map +1 -0
- package/dist/workflows/engine.d.ts +2 -1
- package/dist/workflows/engine.js +86 -4
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +87 -0
- package/dist/workflows/human-decision.js +583 -0
- package/dist/workflows/human-decision.js.map +1 -0
- package/dist/workflows/index.d.ts +3 -1
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +15 -0
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +7 -0
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +72 -0
- package/dist/workflows/tool-input.js +141 -0
- package/dist/workflows/tool-input.js.map +1 -0
- package/dist/workflows/types.d.ts +223 -0
- package/docs/HUMAN_DECISIONS.md +371 -0
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
- package/docs/MONITOR.md +17 -6
- package/docs/WORKFLOW_COMPOSITION.md +13 -6
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
- package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
- package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
- package/docs/plans/2026-08-19-workflow-composition-plan.md +25 -17
- package/docs/run-bundles.md +23 -2
- package/docs/workflows.md +43 -5
- package/examples/workflows/approved-plan.workflow.ts +58 -0
- package/examples/workflows/autoplan.workflow.ts +1 -0
- package/examples/workflows/human-decision.workflow.ts +62 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +2 -1
- package/schemas/decision-presentation-v1.schema.json +83 -0
- package/schemas/human-decision-accepted-v1.schema.json +44 -0
- package/schemas/human-decision-accepted-v2.schema.json +50 -0
- package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
- package/schemas/human-decision-cancellation-v1.schema.json +15 -0
- package/schemas/human-decision-continuation-v1.schema.json +16 -0
- package/schemas/human-decision-delivery-v1.schema.json +28 -0
- package/schemas/human-decision-delivery-v2.schema.json +36 -0
- package/schemas/human-decision-receipt-v1.schema.json +33 -0
- package/schemas/human-decision-receipt-v2.schema.json +39 -0
- package/schemas/human-decision-request-v1.schema.json +59 -0
- package/schemas/human-decision-request-v2.schema.json +69 -0
- package/schemas/human-decision-resolution-v1.schema.json +27 -0
- package/schemas/human-decision-resolution-v2.schema.json +27 -0
- package/schemas/human-decision-settlement-v1.schema.json +28 -0
- package/skills/autodoc/SKILL.md +43 -0
- package/skills/autoimplement/SKILL.md +58 -0
- package/skills/autoimplement/agents/openai.yaml +4 -0
- package/skills/autoplan/SKILL.md +25 -0
- package/skills/monitor/SKILL.md +3 -1
- package/skills/pi-workflows/SKILL.md +3 -3
- package/src/builtins/autodoc.workflow.ts +325 -0
- package/src/builtins/autoimplement.workflow.ts +281 -14
- package/src/builtins/{autodevise.workflow.ts → autoplan.workflow.ts} +32 -32
- package/src/builtins/catalog.ts +8 -4
- package/src/builtins/index.ts +25 -6
- package/src/builtins/monitor.workflow.ts +162 -8
- package/src/builtins/plan-approval.workflow.ts +190 -0
- package/src/builtins/plan-presentation.ts +57 -0
- package/src/extension/decision-channels.ts +1533 -0
- package/src/extension/index.ts +499 -9
- package/src/extension/widget.ts +39 -2
- package/src/extension/workflow-tool.ts +5 -81
- package/src/host/rpc-bridge.ts +7 -30
- package/src/render/graph-render.ts +40 -3
- package/src/viewer/render.ts +56 -3
- package/src/workflows/composition.ts +16 -1
- package/src/workflows/decision-presentation.ts +501 -0
- package/src/workflows/engine.ts +108 -4
- package/src/workflows/human-decision.ts +819 -0
- package/src/workflows/index.ts +69 -0
- package/src/workflows/schema.ts +17 -0
- package/src/workflows/store.ts +8 -0
- package/src/workflows/tool-input.ts +202 -0
- package/src/workflows/types.ts +265 -0
- package/dist/builtins/autodevise.workflow.js.map +0 -1
- package/examples/workflows/autodevise.workflow.ts +0 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
DecisionPresentation,
|
|
4
|
+
DecisionPresentationBlock,
|
|
5
|
+
HumanDecisionChannelRequest,
|
|
6
|
+
HumanDecisionRequest,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
export const MAX_PRESENTATION_CODE_UNITS = 64_000;
|
|
10
|
+
export const MAX_PRESENTATION_BLOCKS = 256;
|
|
11
|
+
export const MAX_PRESENTATION_ITEMS = 256;
|
|
12
|
+
export const MAX_PRESENTATION_STRING_CODE_UNITS = 16_000;
|
|
13
|
+
export const MAX_PRESENTATION_TRANSPORT_PARTS = 20;
|
|
14
|
+
|
|
15
|
+
export type DecisionDocumentSegment = {
|
|
16
|
+
kind: "title" | "summary" | DecisionPresentationBlock["kind"] | "choices";
|
|
17
|
+
text: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function normalizeDecisionPresentation(value: unknown): DecisionPresentation {
|
|
21
|
+
const presentation = requireRecord(value, "Decision presentation");
|
|
22
|
+
requireExactKeys(presentation, ["schema", "summary", "blocks"], "Decision presentation");
|
|
23
|
+
if (presentation.schema !== "pi-workflows.decision-presentation.v1") {
|
|
24
|
+
throw new Error("Decision presentation schema must be pi-workflows.decision-presentation.v1");
|
|
25
|
+
}
|
|
26
|
+
const summary = normalizeDisplayString(presentation.summary, "Decision presentation summary");
|
|
27
|
+
if (!Array.isArray(presentation.blocks)) {
|
|
28
|
+
throw new Error("Decision presentation blocks must be an array");
|
|
29
|
+
}
|
|
30
|
+
if (presentation.blocks.length > MAX_PRESENTATION_BLOCKS) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Decision presentation has ${presentation.blocks.length} blocks; limit is ${MAX_PRESENTATION_BLOCKS}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const blocks = presentation.blocks.map((block, index) =>
|
|
36
|
+
normalizeBlock(block, `Decision presentation block ${index + 1}`),
|
|
37
|
+
);
|
|
38
|
+
const normalized: DecisionPresentation = {
|
|
39
|
+
schema: "pi-workflows.decision-presentation.v1",
|
|
40
|
+
summary,
|
|
41
|
+
blocks,
|
|
42
|
+
};
|
|
43
|
+
const codeUnits = presentationCodeUnits(normalized);
|
|
44
|
+
if (codeUnits > MAX_PRESENTATION_CODE_UNITS) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Decision presentation has ${codeUnits} UTF-16 code units; limit is ${MAX_PRESENTATION_CODE_UNITS}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function decisionPresentationDigest(presentation: DecisionPresentation): string {
|
|
53
|
+
return digestCanonical(normalizeDecisionPresentation(presentation));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateHumanDecisionRequestIntegrity(
|
|
57
|
+
request: HumanDecisionRequest,
|
|
58
|
+
): HumanDecisionRequest {
|
|
59
|
+
if (request.schema === "pi-workflows.human-decision-request.v1") return request;
|
|
60
|
+
const presentation = normalizeDecisionPresentation(request.presentation);
|
|
61
|
+
const subjectDigest = digestCanonical(request.subject);
|
|
62
|
+
const presentationDigest = digestCanonical(presentation);
|
|
63
|
+
const requestDigest = digestCanonical({
|
|
64
|
+
schema: request.schema,
|
|
65
|
+
runId: request.runId,
|
|
66
|
+
workflowName: request.workflowName,
|
|
67
|
+
nodeId: request.nodeId,
|
|
68
|
+
attemptId: request.attemptId,
|
|
69
|
+
audience: request.audience,
|
|
70
|
+
title: request.title,
|
|
71
|
+
choices: request.choices,
|
|
72
|
+
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
|
|
73
|
+
subject: request.subject,
|
|
74
|
+
presentation,
|
|
75
|
+
revision: request.revision,
|
|
76
|
+
});
|
|
77
|
+
if (subjectDigest !== request.subjectDigest) {
|
|
78
|
+
throw new Error("Human decision subject digest does not match its durable request");
|
|
79
|
+
}
|
|
80
|
+
if (presentationDigest !== request.presentationDigest) {
|
|
81
|
+
throw new Error("Human decision presentation digest does not match its durable request");
|
|
82
|
+
}
|
|
83
|
+
if (requestDigest !== request.requestDigest) {
|
|
84
|
+
throw new Error("Human decision request digest does not match its durable request");
|
|
85
|
+
}
|
|
86
|
+
return request;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function humanDecisionChannelRequest(
|
|
90
|
+
request: HumanDecisionRequest,
|
|
91
|
+
): HumanDecisionChannelRequest {
|
|
92
|
+
validateHumanDecisionRequestIntegrity(request);
|
|
93
|
+
const presentation =
|
|
94
|
+
request.schema === "pi-workflows.human-decision-request.v2"
|
|
95
|
+
? normalizeDecisionPresentation(request.presentation)
|
|
96
|
+
: legacyDecisionPresentation(request.body);
|
|
97
|
+
const presentationDigest = decisionPresentationDigest(presentation);
|
|
98
|
+
return {
|
|
99
|
+
schema: "pi-workflows.human-decision-channel-request.v1",
|
|
100
|
+
sourceSchema: request.schema,
|
|
101
|
+
decisionId: request.decisionId,
|
|
102
|
+
requestDigest: request.requestDigest,
|
|
103
|
+
runId: request.runId,
|
|
104
|
+
workflowName: request.workflowName,
|
|
105
|
+
nodeId: request.nodeId,
|
|
106
|
+
attemptId: request.attemptId,
|
|
107
|
+
audience: request.audience,
|
|
108
|
+
title: request.title,
|
|
109
|
+
presentation,
|
|
110
|
+
presentationDigest,
|
|
111
|
+
revision: request.schema === "pi-workflows.human-decision-request.v2" ? request.revision : 1,
|
|
112
|
+
choices: request.choices,
|
|
113
|
+
createdAt: request.createdAt,
|
|
114
|
+
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function legacyDecisionPresentation(body: unknown): DecisionPresentation {
|
|
119
|
+
const blocks: DecisionPresentationBlock[] = [];
|
|
120
|
+
if (typeof body === "string") {
|
|
121
|
+
appendLegacyText(blocks, body, "Decision details");
|
|
122
|
+
} else {
|
|
123
|
+
appendLegacyValue(blocks, body, undefined);
|
|
124
|
+
}
|
|
125
|
+
const summary =
|
|
126
|
+
typeof body === "string"
|
|
127
|
+
? "Review the decision message below."
|
|
128
|
+
: "Review the decision details below.";
|
|
129
|
+
return normalizeDecisionPresentation(boundLegacyPresentation(summary, blocks, body));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decisionDocumentSegments(
|
|
133
|
+
request: HumanDecisionChannelRequest,
|
|
134
|
+
): DecisionDocumentSegment[] {
|
|
135
|
+
const segments: DecisionDocumentSegment[] = [
|
|
136
|
+
{ kind: "title", text: request.title },
|
|
137
|
+
{ kind: "summary", text: request.presentation.summary },
|
|
138
|
+
];
|
|
139
|
+
for (const block of request.presentation.blocks) {
|
|
140
|
+
if (block.kind === "paragraph") segments.push({ kind: block.kind, text: block.text });
|
|
141
|
+
else if (block.kind === "section") segments.push({ kind: block.kind, text: block.title });
|
|
142
|
+
else if (block.kind === "bullets") {
|
|
143
|
+
segments.push({ kind: block.kind, text: block.items.map((item) => `• ${item}`).join("\n") });
|
|
144
|
+
} else if (block.kind === "fields") {
|
|
145
|
+
segments.push({
|
|
146
|
+
kind: block.kind,
|
|
147
|
+
text: block.items.map(({ label, value }) => `${label}: ${value}`).join("\n"),
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
segments.push({ kind: block.kind, text: block.text });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const choices = Object.values(request.choices).map((choice) =>
|
|
154
|
+
choice.input === undefined
|
|
155
|
+
? `• ${choice.label}`
|
|
156
|
+
: `• ${choice.label}\n ${choice.input.prompt}`,
|
|
157
|
+
);
|
|
158
|
+
segments.push({ kind: "choices", text: `Choices\n${choices.join("\n")}` });
|
|
159
|
+
return segments;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function decisionPresentationFingerprint(request: HumanDecisionChannelRequest): string {
|
|
163
|
+
return request.presentationDigest.slice("sha256:".length, "sha256:".length + 12);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function digestCanonical(value: unknown): string {
|
|
167
|
+
return `sha256:${createHash("sha256")
|
|
168
|
+
.update(`${canonicalJson(value)}\n`)
|
|
169
|
+
.digest("hex")}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeBlock(value: unknown, label: string): DecisionPresentationBlock {
|
|
173
|
+
const block = requireRecord(value, label);
|
|
174
|
+
if (block.kind === "paragraph") {
|
|
175
|
+
requireExactKeys(block, ["kind", "text"], label);
|
|
176
|
+
return { kind: "paragraph", text: normalizeMultilineString(block.text, `${label} text`) };
|
|
177
|
+
}
|
|
178
|
+
if (block.kind === "section") {
|
|
179
|
+
requireExactKeys(block, ["kind", "title"], label);
|
|
180
|
+
return { kind: "section", title: normalizeDisplayString(block.title, `${label} title`) };
|
|
181
|
+
}
|
|
182
|
+
if (block.kind === "bullets") {
|
|
183
|
+
requireExactKeys(block, ["kind", "items"], label);
|
|
184
|
+
return {
|
|
185
|
+
kind: "bullets",
|
|
186
|
+
items: normalizeItems(block.items, `${label} items`, (item, itemLabel) =>
|
|
187
|
+
normalizeDisplayString(item, itemLabel),
|
|
188
|
+
),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (block.kind === "fields") {
|
|
192
|
+
requireExactKeys(block, ["kind", "items"], label);
|
|
193
|
+
return {
|
|
194
|
+
kind: "fields",
|
|
195
|
+
items: normalizeItems(block.items, `${label} items`, (item, itemLabel) => {
|
|
196
|
+
const field = requireRecord(item, itemLabel);
|
|
197
|
+
requireExactKeys(field, ["label", "value"], itemLabel);
|
|
198
|
+
return {
|
|
199
|
+
label: normalizeDisplayString(field.label, `${itemLabel} label`),
|
|
200
|
+
value: normalizeDisplayString(field.value, `${itemLabel} value`),
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (block.kind === "preformatted") {
|
|
206
|
+
requireExactKeys(block, ["kind", "text"], label);
|
|
207
|
+
return { kind: "preformatted", text: normalizeMultilineString(block.text, `${label} text`) };
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`${label} kind is not supported`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizeItems<T>(
|
|
213
|
+
value: unknown,
|
|
214
|
+
label: string,
|
|
215
|
+
normalize: (item: unknown, label: string) => T,
|
|
216
|
+
): T[] {
|
|
217
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
218
|
+
throw new Error(`${label} must be a non-empty array`);
|
|
219
|
+
}
|
|
220
|
+
if (value.length > MAX_PRESENTATION_ITEMS) {
|
|
221
|
+
throw new Error(`${label} has ${value.length} items; limit is ${MAX_PRESENTATION_ITEMS}`);
|
|
222
|
+
}
|
|
223
|
+
return value.map((item, index) => normalize(item, `${label} item ${index + 1}`));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeDisplayString(value: unknown, label: string): string {
|
|
227
|
+
const text = normalizeString(value, label);
|
|
228
|
+
if (hasControlCharacter(text, false)) {
|
|
229
|
+
throw new Error(`${label} contains a control character`);
|
|
230
|
+
}
|
|
231
|
+
return text;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeMultilineString(value: unknown, label: string): string {
|
|
235
|
+
const text = normalizeString(value, label);
|
|
236
|
+
if (hasControlCharacter(text, true)) {
|
|
237
|
+
throw new Error(`${label} contains a terminal control character`);
|
|
238
|
+
}
|
|
239
|
+
return text;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeString(value: unknown, label: string): string {
|
|
243
|
+
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
|
244
|
+
const text = value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").normalize("NFC");
|
|
245
|
+
if (text.trim().length === 0) throw new Error(`${label} must not be empty`);
|
|
246
|
+
if (text.length > MAX_PRESENTATION_STRING_CODE_UNITS) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`${label} has ${text.length} UTF-16 code units; limit is ${MAX_PRESENTATION_STRING_CODE_UNITS}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function boundLegacyPresentation(
|
|
255
|
+
summary: string,
|
|
256
|
+
blocks: DecisionPresentationBlock[],
|
|
257
|
+
body: unknown,
|
|
258
|
+
): DecisionPresentation {
|
|
259
|
+
const candidate: DecisionPresentation = {
|
|
260
|
+
schema: "pi-workflows.decision-presentation.v1",
|
|
261
|
+
summary,
|
|
262
|
+
blocks,
|
|
263
|
+
};
|
|
264
|
+
if (
|
|
265
|
+
blocks.length <= MAX_PRESENTATION_BLOCKS &&
|
|
266
|
+
blocks.every(legacyBlockFitsSchema) &&
|
|
267
|
+
presentationCodeUnits(candidate) <= MAX_PRESENTATION_CODE_UNITS
|
|
268
|
+
) {
|
|
269
|
+
return candidate;
|
|
270
|
+
}
|
|
271
|
+
const originalCodeUnits = canonicalJson(body).length;
|
|
272
|
+
const omission = `Some legacy decision content is not shown because it exceeds the readable presentation limit. Inspect the durable v1 request before answering. Body digest: ${digestCanonical(body)}. Original body: ${originalCodeUnits} UTF-16 code units. Generated blocks: ${blocks.length}.`;
|
|
273
|
+
const budget = MAX_PRESENTATION_CODE_UNITS - summary.length - omission.length;
|
|
274
|
+
const kept: DecisionPresentationBlock[] = [];
|
|
275
|
+
let used = 0;
|
|
276
|
+
let omitted = false;
|
|
277
|
+
for (const block of blocks) {
|
|
278
|
+
const units = presentationBlockCodeUnits(block);
|
|
279
|
+
if (
|
|
280
|
+
kept.length >= MAX_PRESENTATION_BLOCKS ||
|
|
281
|
+
!legacyBlockFitsSchema(block) ||
|
|
282
|
+
used + units > budget
|
|
283
|
+
) {
|
|
284
|
+
omitted = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
kept.push(block);
|
|
288
|
+
used += units;
|
|
289
|
+
}
|
|
290
|
+
if (omitted) {
|
|
291
|
+
if (kept.length >= MAX_PRESENTATION_BLOCKS) kept.pop();
|
|
292
|
+
kept.push({ kind: "paragraph", text: omission });
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
schema: "pi-workflows.decision-presentation.v1",
|
|
296
|
+
summary,
|
|
297
|
+
blocks: kept,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function legacyBlockFitsSchema(block: DecisionPresentationBlock): boolean {
|
|
302
|
+
if (block.kind === "paragraph" || block.kind === "preformatted") {
|
|
303
|
+
return block.text.length <= MAX_PRESENTATION_STRING_CODE_UNITS;
|
|
304
|
+
}
|
|
305
|
+
if (block.kind === "section") {
|
|
306
|
+
return block.title.length <= MAX_PRESENTATION_STRING_CODE_UNITS;
|
|
307
|
+
}
|
|
308
|
+
if (block.kind === "bullets") {
|
|
309
|
+
return block.items.every((item) => item.length <= MAX_PRESENTATION_STRING_CODE_UNITS);
|
|
310
|
+
}
|
|
311
|
+
return block.items.every(
|
|
312
|
+
(item) =>
|
|
313
|
+
item.label.length <= MAX_PRESENTATION_STRING_CODE_UNITS &&
|
|
314
|
+
item.value.length <= MAX_PRESENTATION_STRING_CODE_UNITS,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function presentationBlockCodeUnits(block: DecisionPresentationBlock): number {
|
|
319
|
+
if (block.kind === "paragraph" || block.kind === "preformatted") return block.text.length;
|
|
320
|
+
if (block.kind === "section") return block.title.length;
|
|
321
|
+
if (block.kind === "bullets") {
|
|
322
|
+
return block.items.reduce((sum, item) => sum + item.length, 0);
|
|
323
|
+
}
|
|
324
|
+
return block.items.reduce((sum, item) => sum + item.label.length + item.value.length, 0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function presentationCodeUnits(presentation: DecisionPresentation): number {
|
|
328
|
+
let total = presentation.summary.length;
|
|
329
|
+
for (const block of presentation.blocks) {
|
|
330
|
+
total += presentationBlockCodeUnits(block);
|
|
331
|
+
}
|
|
332
|
+
return total;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function appendLegacyValue(
|
|
336
|
+
blocks: DecisionPresentationBlock[],
|
|
337
|
+
value: unknown,
|
|
338
|
+
label: string | undefined,
|
|
339
|
+
): void {
|
|
340
|
+
if (isScalar(value)) {
|
|
341
|
+
const scalar = legacyScalar(value);
|
|
342
|
+
if (label === undefined || scalar.length > MAX_PRESENTATION_STRING_CODE_UNITS) {
|
|
343
|
+
appendLegacyText(
|
|
344
|
+
blocks,
|
|
345
|
+
scalar,
|
|
346
|
+
label === undefined ? "Decision details" : legacyLabel(label),
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
blocks.push({ kind: "fields", items: [{ label: legacyLabel(label), value: scalar }] });
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (Array.isArray(value)) {
|
|
354
|
+
if (label !== undefined) blocks.push({ kind: "section", title: legacyLabel(label) });
|
|
355
|
+
if (value.length === 0) {
|
|
356
|
+
blocks.push({ kind: "paragraph", text: "None" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
value.every(isScalar) &&
|
|
361
|
+
value.every((item) => legacyScalar(item).length <= MAX_PRESENTATION_STRING_CODE_UNITS)
|
|
362
|
+
) {
|
|
363
|
+
const items = value.map(legacyScalar);
|
|
364
|
+
for (let index = 0; index < items.length; index += MAX_PRESENTATION_ITEMS) {
|
|
365
|
+
blocks.push({ kind: "bullets", items: items.slice(index, index + MAX_PRESENTATION_ITEMS) });
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
value.forEach((item, index) => appendLegacyValue(blocks, item, `Item ${index + 1}`));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
|
|
373
|
+
compareKeys(left, right),
|
|
374
|
+
);
|
|
375
|
+
if (label !== undefined) blocks.push({ kind: "section", title: legacyLabel(label) });
|
|
376
|
+
if (entries.length === 0) {
|
|
377
|
+
blocks.push({ kind: "paragraph", text: "None" });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const fields = entries
|
|
381
|
+
.filter(
|
|
382
|
+
([, child]) =>
|
|
383
|
+
isScalar(child) && legacyScalar(child).length <= MAX_PRESENTATION_STRING_CODE_UNITS,
|
|
384
|
+
)
|
|
385
|
+
.map(([key, child]) => ({ label: legacyLabel(key), value: legacyScalar(child) }));
|
|
386
|
+
for (let index = 0; index < fields.length; index += MAX_PRESENTATION_ITEMS) {
|
|
387
|
+
blocks.push({ kind: "fields", items: fields.slice(index, index + MAX_PRESENTATION_ITEMS) });
|
|
388
|
+
}
|
|
389
|
+
for (const [key, child] of entries.filter(
|
|
390
|
+
([, child]) =>
|
|
391
|
+
!isScalar(child) || legacyScalar(child).length > MAX_PRESENTATION_STRING_CODE_UNITS,
|
|
392
|
+
)) {
|
|
393
|
+
appendLegacyValue(blocks, child, key);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function appendLegacyText(
|
|
398
|
+
blocks: DecisionPresentationBlock[],
|
|
399
|
+
value: string,
|
|
400
|
+
section: string,
|
|
401
|
+
): void {
|
|
402
|
+
const text = sanitizeLegacyText(value);
|
|
403
|
+
blocks.push({ kind: "section", title: section });
|
|
404
|
+
for (const part of splitByCodeUnits(text, MAX_PRESENTATION_STRING_CODE_UNITS)) {
|
|
405
|
+
blocks.push({ kind: "paragraph", text: part.trim().length === 0 ? "None" : part });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function legacyLabel(value: string): string {
|
|
410
|
+
const sanitized = sanitizeLegacyText(value)
|
|
411
|
+
.replaceAll(/[_-]+/gu, " ")
|
|
412
|
+
.replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2")
|
|
413
|
+
.replaceAll(/\s+/gu, " ")
|
|
414
|
+
.trim();
|
|
415
|
+
if (sanitized.length === 0) return "Value";
|
|
416
|
+
return `${sanitized[0]!.toUpperCase()}${sanitized.slice(1)}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function legacyScalar(value: unknown): string {
|
|
420
|
+
if (value === null) return "None";
|
|
421
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
422
|
+
return sanitizeLegacyText(String(value));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function sanitizeLegacyText(value: string): string {
|
|
426
|
+
return value
|
|
427
|
+
.replaceAll("\r\n", "\n")
|
|
428
|
+
.replaceAll("\r", "\n")
|
|
429
|
+
.normalize("NFC")
|
|
430
|
+
.split("")
|
|
431
|
+
.map((character) => (isControlCode(character.charCodeAt(0), true) ? "�" : character))
|
|
432
|
+
.join("");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function hasControlCharacter(value: string, allowLineAndTab: boolean): boolean {
|
|
436
|
+
return [...value].some((character) =>
|
|
437
|
+
isControlCode(character.codePointAt(0) ?? 0, allowLineAndTab),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isControlCode(code: number, allowLineAndTab: boolean): boolean {
|
|
442
|
+
if (allowLineAndTab && (code === 9 || code === 10)) return false;
|
|
443
|
+
return code <= 31 || code === 127;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function splitByCodeUnits(value: string, limit: number): string[] {
|
|
447
|
+
if (value.length <= limit) return [value];
|
|
448
|
+
const parts: string[] = [];
|
|
449
|
+
let remaining = value;
|
|
450
|
+
while (remaining.length > 0) {
|
|
451
|
+
let end = Math.min(limit, remaining.length);
|
|
452
|
+
const last = remaining.charCodeAt(end - 1);
|
|
453
|
+
if (last >= 0xd800 && last <= 0xdbff && end < remaining.length) end -= 1;
|
|
454
|
+
parts.push(remaining.slice(0, end));
|
|
455
|
+
remaining = remaining.slice(end);
|
|
456
|
+
}
|
|
457
|
+
return parts;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isScalar(value: unknown): value is null | string | number | boolean {
|
|
461
|
+
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
465
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
466
|
+
throw new Error(`${label} must be an object`);
|
|
467
|
+
}
|
|
468
|
+
return value as Record<string, unknown>;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function requireExactKeys(
|
|
472
|
+
value: Record<string, unknown>,
|
|
473
|
+
allowed: readonly string[],
|
|
474
|
+
label: string,
|
|
475
|
+
): void {
|
|
476
|
+
const keys = Object.keys(value);
|
|
477
|
+
const extras = keys.filter((key) => !allowed.includes(key));
|
|
478
|
+
const missing = allowed.filter((key) => !Object.hasOwn(value, key));
|
|
479
|
+
if (missing.length > 0) throw new Error(`${label} is missing ${missing.join(", ")}`);
|
|
480
|
+
if (extras.length > 0) throw new Error(`${label} has unknown field ${extras.join(", ")}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function canonicalJson(value: unknown): string {
|
|
484
|
+
return JSON.stringify(sortJson(value));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function compareKeys(left: string, right: string): number {
|
|
488
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function sortJson(value: unknown): unknown {
|
|
492
|
+
if (Array.isArray(value)) return value.map(sortJson);
|
|
493
|
+
if (value !== null && typeof value === "object") {
|
|
494
|
+
return Object.fromEntries(
|
|
495
|
+
Object.entries(value as Record<string, unknown>)
|
|
496
|
+
.sort(([left], [right]) => compareKeys(left, right))
|
|
497
|
+
.map(([key, child]) => [key, sortJson(child)]),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
}
|
package/src/workflows/engine.ts
CHANGED
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
WorkflowSourceChangedError,
|
|
18
18
|
} from "./errors.js";
|
|
19
19
|
import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
|
|
20
|
+
import {
|
|
21
|
+
HumanDecisionStore,
|
|
22
|
+
createHumanDecisionRequest,
|
|
23
|
+
validateHumanDecisionResponse,
|
|
24
|
+
} from "./human-decision.js";
|
|
20
25
|
import { extractJsonValue } from "./json.js";
|
|
21
26
|
import { runShellAction, shellResultFromError } from "./shell.js";
|
|
22
27
|
import {
|
|
@@ -27,6 +32,7 @@ import {
|
|
|
27
32
|
readRunBundle,
|
|
28
33
|
} from "./store.js";
|
|
29
34
|
import type {
|
|
35
|
+
AcceptedHumanDecision,
|
|
30
36
|
AgentNodeDefinition,
|
|
31
37
|
AgentStepExecutor,
|
|
32
38
|
ActionNodeDefinition,
|
|
@@ -43,6 +49,7 @@ import type {
|
|
|
43
49
|
WorkflowNodeOutcome,
|
|
44
50
|
WorkflowNodeResult,
|
|
45
51
|
WorkflowNotificationSink,
|
|
52
|
+
HumanDecisionRequest,
|
|
46
53
|
WorkflowRunResult,
|
|
47
54
|
WorkflowRunState,
|
|
48
55
|
WorkflowSource,
|
|
@@ -366,7 +373,12 @@ export class WorkflowEngine {
|
|
|
366
373
|
workflow: WorkflowDefinition,
|
|
367
374
|
parentRunId: string,
|
|
368
375
|
input: unknown,
|
|
369
|
-
options: {
|
|
376
|
+
options: {
|
|
377
|
+
workflowSource?: WorkflowSource;
|
|
378
|
+
runId?: string;
|
|
379
|
+
force?: boolean;
|
|
380
|
+
humanDecision?: AcceptedHumanDecision;
|
|
381
|
+
} = {},
|
|
370
382
|
): Promise<WorkflowRunResult> {
|
|
371
383
|
workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
|
|
372
384
|
validateWorkflowDefinition(workflow);
|
|
@@ -387,8 +399,39 @@ export class WorkflowEngine {
|
|
|
387
399
|
throw new WorkflowSourceChangedError(parentRunId);
|
|
388
400
|
}
|
|
389
401
|
|
|
390
|
-
const
|
|
391
|
-
const
|
|
402
|
+
const waitingNodeId = parent.state.waitingOn;
|
|
403
|
+
const waitingNode = workflow.nodes[waitingNodeId];
|
|
404
|
+
const humanContract =
|
|
405
|
+
waitingNode?.nodeType === "checkpoint" ? waitingNode.humanDecision : undefined;
|
|
406
|
+
let acceptedResponse: unknown;
|
|
407
|
+
let acceptedNodeId: string | undefined;
|
|
408
|
+
let normalizedInput: unknown;
|
|
409
|
+
if (humanContract !== undefined) {
|
|
410
|
+
if (options.humanDecision === undefined) {
|
|
411
|
+
throw new Error(`Checkpoint ${waitingNodeId} requires an accepted verified human decision`);
|
|
412
|
+
}
|
|
413
|
+
const request = parent.state.finalOutput as HumanDecisionRequest;
|
|
414
|
+
if (
|
|
415
|
+
(request?.schema !== "pi-workflows.human-decision-request.v1" &&
|
|
416
|
+
request?.schema !== "pi-workflows.human-decision-request.v2") ||
|
|
417
|
+
request.decisionId !== options.humanDecision.decisionId ||
|
|
418
|
+
request.requestDigest !== options.humanDecision.requestDigest
|
|
419
|
+
) {
|
|
420
|
+
throw new Error("Accepted human decision does not match the waiting request");
|
|
421
|
+
}
|
|
422
|
+
const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readAccepted(
|
|
423
|
+
request.decisionId,
|
|
424
|
+
);
|
|
425
|
+
if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
|
|
426
|
+
throw new Error("Accepted human decision does not match the durable decision record");
|
|
427
|
+
}
|
|
428
|
+
acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
|
|
429
|
+
acceptedNodeId = request.nodeId;
|
|
430
|
+
normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
|
|
431
|
+
} else {
|
|
432
|
+
const suppliedInput = input === undefined ? null : input;
|
|
433
|
+
normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
|
|
434
|
+
}
|
|
392
435
|
assertJsonSerializable(normalizedInput, "Workflow run input");
|
|
393
436
|
if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
|
|
394
437
|
throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
|
|
@@ -415,6 +458,39 @@ export class WorkflowEngine {
|
|
|
415
458
|
parent.state.steps,
|
|
416
459
|
parent.runDir,
|
|
417
460
|
)) as WorkflowRunState["steps"];
|
|
461
|
+
if (humanContract !== undefined && options.humanDecision !== undefined) {
|
|
462
|
+
const receipt = {
|
|
463
|
+
decisionId: options.humanDecision.decisionId,
|
|
464
|
+
requestDigest: options.humanDecision.requestDigest,
|
|
465
|
+
nodeId: acceptedNodeId ?? waitingNodeId,
|
|
466
|
+
response: options.humanDecision.response,
|
|
467
|
+
acceptedAt: options.humanDecision.acceptedAt,
|
|
468
|
+
answerDigest: options.humanDecision.answerDigest,
|
|
469
|
+
};
|
|
470
|
+
state.humanDecision =
|
|
471
|
+
options.humanDecision.schema === "pi-workflows.human-decision-accepted.v2"
|
|
472
|
+
? {
|
|
473
|
+
schema: "pi-workflows.human-decision-receipt.v2",
|
|
474
|
+
...receipt,
|
|
475
|
+
subjectDigest: options.humanDecision.subjectDigest,
|
|
476
|
+
presentationDigest: options.humanDecision.presentationDigest,
|
|
477
|
+
revision: options.humanDecision.revision,
|
|
478
|
+
}
|
|
479
|
+
: { schema: "pi-workflows.human-decision-receipt.v1", ...receipt };
|
|
480
|
+
state.outputs[waitingNodeId] = acceptedResponse;
|
|
481
|
+
const priorResult = state.results[waitingNodeId];
|
|
482
|
+
if (priorResult === undefined) {
|
|
483
|
+
throw new Error(`Waiting human decision result is missing for ${waitingNodeId}`);
|
|
484
|
+
}
|
|
485
|
+
state.results[waitingNodeId] = { ...priorResult, output: acceptedResponse };
|
|
486
|
+
const stepIndex = state.steps.findLastIndex((step) => step.nodeId === waitingNodeId);
|
|
487
|
+
if (stepIndex < 0)
|
|
488
|
+
throw new Error(`Waiting human decision step is missing for ${waitingNodeId}`);
|
|
489
|
+
const priorStep = state.steps[stepIndex];
|
|
490
|
+
if (priorStep === undefined)
|
|
491
|
+
throw new Error("Waiting human decision step became unavailable");
|
|
492
|
+
state.steps[stepIndex] = { ...priorStep, output: acceptedResponse };
|
|
493
|
+
}
|
|
418
494
|
state.carriedStepCount = state.steps.length;
|
|
419
495
|
|
|
420
496
|
const runDir = await this.store.initializeRunBundle(workflow, state);
|
|
@@ -1043,7 +1119,12 @@ export class WorkflowEngine {
|
|
|
1043
1119
|
case "action":
|
|
1044
1120
|
return await this.runActionNode(node, context, nodeId, attemptId, signal, meta);
|
|
1045
1121
|
case "checkpoint":
|
|
1046
|
-
return await runCheckpointNode(node, context
|
|
1122
|
+
return await runCheckpointNode(node, context, {
|
|
1123
|
+
store: this.store,
|
|
1124
|
+
workflowName: workflow.name,
|
|
1125
|
+
nodeId,
|
|
1126
|
+
attemptId,
|
|
1127
|
+
});
|
|
1047
1128
|
}
|
|
1048
1129
|
}
|
|
1049
1130
|
|
|
@@ -1220,7 +1301,30 @@ export class WorkflowEngine {
|
|
|
1220
1301
|
async function runCheckpointNode(
|
|
1221
1302
|
node: CheckpointNodeDefinition,
|
|
1222
1303
|
context: WorkflowNodeContext,
|
|
1304
|
+
execution: {
|
|
1305
|
+
store: WorkflowRunStore;
|
|
1306
|
+
workflowName: string;
|
|
1307
|
+
nodeId: string;
|
|
1308
|
+
attemptId: string;
|
|
1309
|
+
},
|
|
1223
1310
|
): Promise<NodeExecution> {
|
|
1311
|
+
if (node.humanDecision !== undefined) {
|
|
1312
|
+
const prompt = await node.humanDecision.request(context);
|
|
1313
|
+
const audience =
|
|
1314
|
+
typeof node.humanDecision.audience === "function"
|
|
1315
|
+
? await node.humanDecision.audience(context)
|
|
1316
|
+
: node.humanDecision.audience;
|
|
1317
|
+
const request = createHumanDecisionRequest({
|
|
1318
|
+
runId: context.state.runId,
|
|
1319
|
+
workflowName: execution.workflowName,
|
|
1320
|
+
nodeId: execution.nodeId,
|
|
1321
|
+
attemptId: execution.attemptId,
|
|
1322
|
+
contract: { audience, choices: node.humanDecision.choices },
|
|
1323
|
+
prompt,
|
|
1324
|
+
});
|
|
1325
|
+
await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
|
|
1326
|
+
return { output: request, promptText: null };
|
|
1327
|
+
}
|
|
1224
1328
|
const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
|
|
1225
1329
|
return { output, promptText: null };
|
|
1226
1330
|
}
|