@osolmaz/pi-workflows 0.10.0 → 0.11.1
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 +32 -14
- 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 +212 -11
- package/dist/builtins/autoimplement.workflow.js +431 -22
- 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/herdr/setup.d.ts +13 -1
- package/dist/herdr/setup.js +349 -36
- package/dist/herdr/setup.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/cli.d.ts +1 -0
- package/dist/viewer/cli.js +21 -10
- package/dist/viewer/cli.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/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +138 -0
- package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +104 -0
- package/docs/run-bundles.md +23 -2
- package/docs/workflows.md +47 -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 +493 -23
- 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/herdr/setup.ts +429 -39
- package/src/host/rpc-bridge.ts +7 -30
- package/src/render/graph-render.ts +40 -3
- package/src/viewer/cli.ts +22 -10
- 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
package/dist/extension/index.js
CHANGED
|
@@ -3,19 +3,23 @@ import path from "node:path";
|
|
|
3
3
|
import { isDeepStrictEqual } from "node:util";
|
|
4
4
|
import { builtinWorkflowCatalog } from "../builtins/catalog.js";
|
|
5
5
|
import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
|
|
6
|
+
import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
|
|
6
7
|
import { WorkflowEngine } from "../workflows/engine.js";
|
|
7
8
|
import { ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
|
|
9
|
+
import { HumanDecisionStore, createHumanDecisionAttemptId, validateHumanDecisionResponse, } from "../workflows/human-decision.js";
|
|
8
10
|
import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
|
|
9
11
|
import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
|
|
10
12
|
import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/progress.js";
|
|
11
13
|
import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
|
|
12
14
|
import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
|
|
15
|
+
import { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
|
|
13
16
|
import { ConversationStepExecutor } from "./executor.js";
|
|
14
17
|
import { HerdrWorkflowViewer, parseViewerPlacement, PIW_SHORTCUT, PIW_SHORTCUT_HINT, VIEWER_PLACEMENTS, } from "./herdr-viewer.js";
|
|
15
18
|
import { SessionRecorder } from "./recorder.js";
|
|
16
19
|
import { registerWorkflowAgentStepMessageRenderer, WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA, WORKFLOW_AGENT_STEP_MESSAGE_TYPE, } from "./step-message.js";
|
|
17
20
|
import { buildWidgetView } from "./widget.js";
|
|
18
|
-
import { WorkflowToolParameters } from "./workflow-tool.js";
|
|
21
|
+
import { parseWorkflowToolInput, WorkflowToolParameters } from "./workflow-tool.js";
|
|
22
|
+
export { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, decisionConfigDir, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
|
|
19
23
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
20
24
|
const RUN_CLAIM_RENEW_MS = 10_000;
|
|
21
25
|
const RUN_SYNC_POLL_MS = 3_000;
|
|
@@ -42,6 +46,16 @@ class PresentationSupersededError extends Error {
|
|
|
42
46
|
}
|
|
43
47
|
class PresentationTimeoutError extends Error {
|
|
44
48
|
}
|
|
49
|
+
function humanDecisionRequest(value) {
|
|
50
|
+
const schema = value !== null && typeof value === "object"
|
|
51
|
+
? value.schema
|
|
52
|
+
: undefined;
|
|
53
|
+
if (schema !== "pi-workflows.human-decision-request.v1" &&
|
|
54
|
+
schema !== "pi-workflows.human-decision-request.v2") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
45
59
|
/** Parse `/workflow` arguments. Exported for tests. */
|
|
46
60
|
export function parseWorkflowArgs(args) {
|
|
47
61
|
const trimmed = args.trim();
|
|
@@ -145,6 +159,16 @@ export default function piWorkflows(pi) {
|
|
|
145
159
|
// One runner identity per session; it names this session in run claims.
|
|
146
160
|
const runnerId = randomUUID();
|
|
147
161
|
let runQueueStore = null;
|
|
162
|
+
let decisionChannelConfig = null;
|
|
163
|
+
let telegramDecisionChannels = new Map();
|
|
164
|
+
const activePiDecisionChannels = new Map();
|
|
165
|
+
const settleHumanDecisionChannels = async (decision) => {
|
|
166
|
+
const piChannel = activePiDecisionChannels.get(decision.decisionId);
|
|
167
|
+
await Promise.allSettled([
|
|
168
|
+
...(piChannel === undefined ? [] : [piChannel.settle(decision)]),
|
|
169
|
+
...[...telegramDecisionChannels.values()].map(async (channel) => channel.settle(decision)),
|
|
170
|
+
]);
|
|
171
|
+
};
|
|
148
172
|
const migrationBlockedRuns = new Set();
|
|
149
173
|
const ensureRunQueueStore = (cwd) => {
|
|
150
174
|
runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
|
|
@@ -154,6 +178,8 @@ export default function piWorkflows(pi) {
|
|
|
154
178
|
// Run events remain an audit feed and never enter a conversation.
|
|
155
179
|
let syncArmed = false;
|
|
156
180
|
let runSyncTimer = null;
|
|
181
|
+
let decisionRecoveryTimer = null;
|
|
182
|
+
let decisionRecoveryActive = false;
|
|
157
183
|
const recordRunEvent = (event) => {
|
|
158
184
|
try {
|
|
159
185
|
runQueueStore?.recordRunEvent({ ...event, runnerId });
|
|
@@ -548,8 +574,11 @@ export default function piWorkflows(pi) {
|
|
|
548
574
|
if (state.status === "waiting" && run.childKey === undefined) {
|
|
549
575
|
lastWaitingRunId = run.runId;
|
|
550
576
|
}
|
|
577
|
+
const pendingDecision = humanDecisionRequest(state.finalOutput);
|
|
551
578
|
const summary = state.status === "waiting" && state.waitingOn
|
|
552
|
-
?
|
|
579
|
+
? pendingDecision === null
|
|
580
|
+
? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
|
|
581
|
+
: `Workflow ${state.workflowName} is waiting for a verified human decision: ${pendingDecision.title} (run ${state.runId})`
|
|
553
582
|
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""}`;
|
|
554
583
|
notify(ctx, summary, state.status === "completed" ? "info" : "warning");
|
|
555
584
|
try {
|
|
@@ -568,6 +597,24 @@ export default function piWorkflows(pi) {
|
|
|
568
597
|
notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
|
|
569
598
|
}
|
|
570
599
|
void presentRun(ctx, run, state);
|
|
600
|
+
if (pendingDecision !== null && state.status === "waiting") {
|
|
601
|
+
const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
|
|
602
|
+
let available = false;
|
|
603
|
+
if (ctx.mode === "tui" && channels.includes("pi")) {
|
|
604
|
+
available = true;
|
|
605
|
+
queueMicrotask(() => void promptHumanDecision(ctx, pendingDecision));
|
|
606
|
+
}
|
|
607
|
+
for (const channelId of channels) {
|
|
608
|
+
const channel = telegramDecisionChannels.get(channelId);
|
|
609
|
+
if (channel !== undefined) {
|
|
610
|
+
available = true;
|
|
611
|
+
queueMicrotask(() => void channel.deliver(humanDecisionChannelRequest(pendingDecision)));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (!available) {
|
|
615
|
+
notify(ctx, `Human decision ${pendingDecision.title} remains waiting because its audience has no available channel.`, "warning");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
571
618
|
};
|
|
572
619
|
const startRun = async (ctx, ref, input, options = {}) => {
|
|
573
620
|
if (options.signal?.aborted) {
|
|
@@ -784,6 +831,9 @@ export default function piWorkflows(pi) {
|
|
|
784
831
|
: engine.continueRun(workflow, options.parentRunId, input, {
|
|
785
832
|
workflowSource,
|
|
786
833
|
runId,
|
|
834
|
+
...(options.humanDecision !== undefined
|
|
835
|
+
? { humanDecision: options.humanDecision }
|
|
836
|
+
: {}),
|
|
787
837
|
}))
|
|
788
838
|
.then((result) => finishRun(ctx, run, result))
|
|
789
839
|
.catch(async (error) => {
|
|
@@ -1025,7 +1075,35 @@ export default function piWorkflows(pi) {
|
|
|
1025
1075
|
const result = await listWorkflowControl(ctx);
|
|
1026
1076
|
notify(ctx, result.message, result.level);
|
|
1027
1077
|
};
|
|
1028
|
-
const
|
|
1078
|
+
const cancelHumanDecision = async (request) => {
|
|
1079
|
+
const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
|
|
1080
|
+
await store.cancel(request, "cancelled");
|
|
1081
|
+
const cancellation = await store.readCancellation(request.decisionId);
|
|
1082
|
+
if (cancellation !== null)
|
|
1083
|
+
await settleHumanDecisionChannels(cancellation);
|
|
1084
|
+
lastWaitingRunId = null;
|
|
1085
|
+
};
|
|
1086
|
+
const findOwnedWaitingHumanDecision = async (ctx) => {
|
|
1087
|
+
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
1088
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1089
|
+
const owned = new Set(rows
|
|
1090
|
+
.filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
|
|
1091
|
+
.map((row) => row.runId));
|
|
1092
|
+
const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
|
|
1093
|
+
const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
|
|
1094
|
+
for (const bundle of bundles) {
|
|
1095
|
+
if (bundle.state.status !== "waiting" ||
|
|
1096
|
+
!owned.has(bundle.state.runId) ||
|
|
1097
|
+
continued.has(bundle.state.runId)) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const request = humanDecisionRequest(bundle.state.finalOutput);
|
|
1101
|
+
if (request !== null)
|
|
1102
|
+
return { state: bundle.state, request };
|
|
1103
|
+
}
|
|
1104
|
+
return null;
|
|
1105
|
+
};
|
|
1106
|
+
const cancelWorkflowControl = async (ctx) => {
|
|
1029
1107
|
if (activeRun) {
|
|
1030
1108
|
const workflowName = activeRun.workflowName;
|
|
1031
1109
|
const runId = activeRun.runId;
|
|
@@ -1045,6 +1123,16 @@ export default function piWorkflows(pi) {
|
|
|
1045
1123
|
}
|
|
1046
1124
|
if (widgetSource) {
|
|
1047
1125
|
const { state } = widgetSource;
|
|
1126
|
+
const request = humanDecisionRequest(state.finalOutput);
|
|
1127
|
+
if (state.status === "waiting" && request !== null) {
|
|
1128
|
+
await cancelHumanDecision(request);
|
|
1129
|
+
clearWidgetTimer();
|
|
1130
|
+
clearWidget(ctx);
|
|
1131
|
+
return {
|
|
1132
|
+
message: `Cancelled the pending human decision for workflow ${state.workflowName}.`,
|
|
1133
|
+
details: { action: "cancel", workflowName: state.workflowName, runId: state.runId },
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1048
1136
|
clearWidgetTimer();
|
|
1049
1137
|
clearWidget(ctx);
|
|
1050
1138
|
const detail = state.status === "waiting" && state.waitingOn
|
|
@@ -1055,6 +1143,18 @@ export default function piWorkflows(pi) {
|
|
|
1055
1143
|
details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
|
|
1056
1144
|
};
|
|
1057
1145
|
}
|
|
1146
|
+
const recovered = await findOwnedWaitingHumanDecision(ctx);
|
|
1147
|
+
if (recovered !== null) {
|
|
1148
|
+
await cancelHumanDecision(recovered.request);
|
|
1149
|
+
return {
|
|
1150
|
+
message: `Cancelled the pending human decision for workflow ${recovered.state.workflowName}.`,
|
|
1151
|
+
details: {
|
|
1152
|
+
action: "cancel",
|
|
1153
|
+
workflowName: recovered.state.workflowName,
|
|
1154
|
+
runId: recovered.state.runId,
|
|
1155
|
+
},
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1058
1158
|
return {
|
|
1059
1159
|
message: "No workflow is running.",
|
|
1060
1160
|
details: { action: "cancel", active: false },
|
|
@@ -1156,7 +1256,7 @@ export default function piWorkflows(pi) {
|
|
|
1156
1256
|
details: workflowStateSummary(state),
|
|
1157
1257
|
};
|
|
1158
1258
|
};
|
|
1159
|
-
const resolveWaitingWorkflow = async (ctx, requestedRunId) => {
|
|
1259
|
+
const resolveWaitingWorkflow = async (ctx, requestedRunId, allowOtherSession = false) => {
|
|
1160
1260
|
let parentRunId = requestedRunId ?? lastWaitingRunId;
|
|
1161
1261
|
if (parentRunId === null) {
|
|
1162
1262
|
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
@@ -1175,7 +1275,8 @@ export default function piWorkflows(pi) {
|
|
|
1175
1275
|
throw new Error("No workflow is waiting for an answer.");
|
|
1176
1276
|
}
|
|
1177
1277
|
const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
|
|
1178
|
-
if (
|
|
1278
|
+
if (!allowOtherSession &&
|
|
1279
|
+
queueRecord?.originSessionId !== null &&
|
|
1179
1280
|
queueRecord?.originSessionId !== undefined &&
|
|
1180
1281
|
queueRecord.originSessionId !== ctx.sessionManager.getSessionId()) {
|
|
1181
1282
|
throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
|
|
@@ -1196,14 +1297,98 @@ export default function piWorkflows(pi) {
|
|
|
1196
1297
|
: parent.state.workflowSource.path,
|
|
1197
1298
|
};
|
|
1198
1299
|
};
|
|
1199
|
-
const answerWorkflowControl = async (ctx, input, requestedRunId) => {
|
|
1200
|
-
const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
|
|
1201
|
-
const
|
|
1300
|
+
const answerWorkflowControl = async (ctx, input, requestedRunId, verified) => {
|
|
1301
|
+
const waiting = await resolveWaitingWorkflow(ctx, requestedRunId, verified !== undefined);
|
|
1302
|
+
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(waiting.parentRunId));
|
|
1303
|
+
if (parent === null)
|
|
1304
|
+
throw new Error(`Workflow run ${waiting.parentRunId} is unreadable.`);
|
|
1305
|
+
const request = humanDecisionRequest(parent.state.finalOutput);
|
|
1306
|
+
let accepted;
|
|
1307
|
+
let continuationInput = input;
|
|
1308
|
+
let continuationRunId;
|
|
1309
|
+
if (request !== null) {
|
|
1310
|
+
if (verified !== undefined &&
|
|
1311
|
+
(verified.request.decisionId !== request.decisionId ||
|
|
1312
|
+
verified.request.requestDigest !== request.requestDigest)) {
|
|
1313
|
+
throw new Error("Verified human answer does not match the waiting decision.");
|
|
1314
|
+
}
|
|
1315
|
+
const response = validateHumanDecisionResponse(request, verified?.response ?? input);
|
|
1316
|
+
const submission = {
|
|
1317
|
+
decisionId: request.decisionId,
|
|
1318
|
+
requestDigest: request.requestDigest,
|
|
1319
|
+
...response,
|
|
1320
|
+
source: verified?.source ?? {
|
|
1321
|
+
channel: "pi",
|
|
1322
|
+
actorId: ctx.sessionManager.getSessionId(),
|
|
1323
|
+
eventId: createHumanDecisionAttemptId(),
|
|
1324
|
+
},
|
|
1325
|
+
idempotencyKey: verified?.idempotencyKey ?? createHumanDecisionAttemptId(),
|
|
1326
|
+
};
|
|
1327
|
+
const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
|
|
1328
|
+
const acceptance = await store.accept(request, submission);
|
|
1329
|
+
if (acceptance.status === "conflict") {
|
|
1330
|
+
throw new Error("That human decision was already answered differently.");
|
|
1331
|
+
}
|
|
1332
|
+
accepted = acceptance.decision;
|
|
1333
|
+
continuationInput = parent.state.input;
|
|
1334
|
+
continuationRunId = `continuation-${request.decisionId.slice("decision-".length)}`;
|
|
1335
|
+
}
|
|
1336
|
+
if (request !== null && accepted !== undefined && verified !== undefined) {
|
|
1337
|
+
const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(waiting.parentRunId);
|
|
1338
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1339
|
+
if (queueRecord === undefined ||
|
|
1340
|
+
(queueRecord.originSessionId !== null && queueRecord.originSessionId !== currentSessionId)) {
|
|
1341
|
+
await settleHumanDecisionChannels(accepted);
|
|
1342
|
+
return {
|
|
1343
|
+
message: "Human decision accepted; its owning Pi session will continue the workflow.",
|
|
1344
|
+
details: {
|
|
1345
|
+
action: "answer",
|
|
1346
|
+
parentRunId: waiting.parentRunId,
|
|
1347
|
+
accepted: true,
|
|
1348
|
+
continuationPending: true,
|
|
1349
|
+
},
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const decisionStore = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
|
|
1354
|
+
if (request !== null && continuationRunId !== undefined && accepted !== undefined) {
|
|
1355
|
+
await decisionStore.recordContinuation(request.decisionId, {
|
|
1356
|
+
schema: "pi-workflows.human-decision-continuation.v1",
|
|
1357
|
+
decisionId: request.decisionId,
|
|
1358
|
+
requestDigest: request.requestDigest,
|
|
1359
|
+
parentRunId: waiting.parentRunId,
|
|
1360
|
+
runId: continuationRunId,
|
|
1361
|
+
createdAt: accepted.acceptedAt,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
if (continuationRunId !== undefined) {
|
|
1365
|
+
const existingContinuation = await readRunBundle(new WorkflowRunStore().runDirFor(continuationRunId));
|
|
1366
|
+
if (existingContinuation !== null) {
|
|
1367
|
+
if (accepted !== undefined)
|
|
1368
|
+
await settleHumanDecisionChannels(accepted);
|
|
1369
|
+
lastWaitingRunId = null;
|
|
1370
|
+
return {
|
|
1371
|
+
message: `Human decision already continued as ${continuationRunId}.`,
|
|
1372
|
+
details: {
|
|
1373
|
+
action: "answer",
|
|
1374
|
+
parentRunId: waiting.parentRunId,
|
|
1375
|
+
runId: continuationRunId,
|
|
1376
|
+
adopted: true,
|
|
1377
|
+
},
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
const continued = await startRun(ctx, waiting.workflowRef, continuationInput, {
|
|
1202
1382
|
parentRunId: waiting.parentRunId,
|
|
1383
|
+
...(accepted !== undefined ? { humanDecision: accepted } : {}),
|
|
1384
|
+
...(continuationRunId !== undefined ? { runId: continuationRunId } : {}),
|
|
1203
1385
|
});
|
|
1204
1386
|
if (continued === undefined) {
|
|
1205
1387
|
throw new Error("Could not start the checkpoint continuation.");
|
|
1206
1388
|
}
|
|
1389
|
+
if (request !== null && accepted !== undefined) {
|
|
1390
|
+
await settleHumanDecisionChannels(accepted);
|
|
1391
|
+
}
|
|
1207
1392
|
lastWaitingRunId = null;
|
|
1208
1393
|
return {
|
|
1209
1394
|
message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
|
|
@@ -1214,6 +1399,148 @@ export default function piWorkflows(pi) {
|
|
|
1214
1399
|
},
|
|
1215
1400
|
};
|
|
1216
1401
|
};
|
|
1402
|
+
async function promptHumanDecision(ctx, request) {
|
|
1403
|
+
if (activePiDecisionChannels.has(request.decisionId))
|
|
1404
|
+
return;
|
|
1405
|
+
const channel = new PiDecisionChannel({
|
|
1406
|
+
actorId: ctx.sessionManager.getSessionId(),
|
|
1407
|
+
ui: ctx.ui,
|
|
1408
|
+
store: new HumanDecisionStore(new WorkflowRunStore().outputRoot),
|
|
1409
|
+
onAnswer: async (answer) => {
|
|
1410
|
+
const result = await answerWorkflowControl(ctx, answer.response, request.runId, answer);
|
|
1411
|
+
notify(ctx, result.message, result.level);
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
activePiDecisionChannels.set(request.decisionId, channel);
|
|
1415
|
+
try {
|
|
1416
|
+
await channel.deliver(humanDecisionChannelRequest(request));
|
|
1417
|
+
}
|
|
1418
|
+
catch (error) {
|
|
1419
|
+
notify(ctx, `Could not answer human decision: ${errorMessage(error)}`, "error");
|
|
1420
|
+
}
|
|
1421
|
+
finally {
|
|
1422
|
+
if (activePiDecisionChannels.get(request.decisionId) === channel) {
|
|
1423
|
+
activePiDecisionChannels.delete(request.decisionId);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const stopDecisionChannels = async () => {
|
|
1428
|
+
await Promise.allSettled([
|
|
1429
|
+
...[...activePiDecisionChannels.values()].map(async (channel) => channel.stop()),
|
|
1430
|
+
...[...telegramDecisionChannels.values()].map(async (channel) => channel.stop()),
|
|
1431
|
+
]);
|
|
1432
|
+
activePiDecisionChannels.clear();
|
|
1433
|
+
telegramDecisionChannels.clear();
|
|
1434
|
+
decisionChannelConfig = null;
|
|
1435
|
+
};
|
|
1436
|
+
const reloadDecisionChannels = async (ctx) => {
|
|
1437
|
+
await stopDecisionChannels();
|
|
1438
|
+
const loaded = await loadDecisionChannelConfig();
|
|
1439
|
+
if (loaded === null)
|
|
1440
|
+
return;
|
|
1441
|
+
decisionChannelConfig = loaded.channels;
|
|
1442
|
+
telegramDecisionChannels = createTelegramChannels({
|
|
1443
|
+
config: loaded.channels,
|
|
1444
|
+
credentials: loaded.credentials,
|
|
1445
|
+
configDir: loaded.configDir,
|
|
1446
|
+
store: new HumanDecisionStore(new WorkflowRunStore().outputRoot),
|
|
1447
|
+
onAnswer: async (answer) => {
|
|
1448
|
+
await answerWorkflowControl(ctx, answer.response, answer.request.runId, answer);
|
|
1449
|
+
},
|
|
1450
|
+
});
|
|
1451
|
+
await Promise.all([...telegramDecisionChannels.values()].map(async (channel) => channel.start()));
|
|
1452
|
+
};
|
|
1453
|
+
const recoverHumanDecisions = async (ctx, deliverPending = true) => {
|
|
1454
|
+
const runStore = new WorkflowRunStore();
|
|
1455
|
+
const store = new HumanDecisionStore(runStore.outputRoot);
|
|
1456
|
+
const requests = (await store.listRequests()).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
1457
|
+
for (const request of requests) {
|
|
1458
|
+
const parent = await readRunBundle(runStore.runDirFor(request.runId));
|
|
1459
|
+
if (parent === null || parent.state.status !== "waiting")
|
|
1460
|
+
continue;
|
|
1461
|
+
const currentRequest = humanDecisionRequest(parent.state.finalOutput);
|
|
1462
|
+
if (currentRequest === null ||
|
|
1463
|
+
currentRequest.decisionId !== request.decisionId ||
|
|
1464
|
+
currentRequest.requestDigest !== request.requestDigest) {
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(request.runId);
|
|
1468
|
+
const ownedBySession = queueRecord !== undefined &&
|
|
1469
|
+
(queueRecord.originSessionId === null ||
|
|
1470
|
+
queueRecord.originSessionId === ctx.sessionManager.getSessionId());
|
|
1471
|
+
const accepted = await store.readAccepted(request.decisionId);
|
|
1472
|
+
if (accepted === null) {
|
|
1473
|
+
let cancellation = await store.readCancellation(request.decisionId);
|
|
1474
|
+
if (cancellation === null &&
|
|
1475
|
+
request.expiresAt !== undefined &&
|
|
1476
|
+
Date.parse(request.expiresAt) <= Date.now()) {
|
|
1477
|
+
await store.cancel(request, "expired");
|
|
1478
|
+
cancellation = await store.readCancellation(request.decisionId);
|
|
1479
|
+
}
|
|
1480
|
+
if (cancellation !== null) {
|
|
1481
|
+
await settleHumanDecisionChannels(cancellation);
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (!deliverPending || !ownedBySession)
|
|
1485
|
+
continue;
|
|
1486
|
+
const channels = audienceChannels(decisionChannelConfig, request.audience);
|
|
1487
|
+
for (const channelId of channels) {
|
|
1488
|
+
const channel = telegramDecisionChannels.get(channelId);
|
|
1489
|
+
if (channel !== undefined)
|
|
1490
|
+
await channel.deliver(humanDecisionChannelRequest(request));
|
|
1491
|
+
}
|
|
1492
|
+
if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
|
|
1493
|
+
lastWaitingRunId = request.runId;
|
|
1494
|
+
queueMicrotask(() => void promptHumanDecision(ctx, request));
|
|
1495
|
+
}
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (!ownedBySession)
|
|
1499
|
+
continue;
|
|
1500
|
+
const runId = `continuation-${request.decisionId.slice("decision-".length)}`;
|
|
1501
|
+
const continuation = (await store.readContinuation(request.decisionId)) ?? {
|
|
1502
|
+
schema: "pi-workflows.human-decision-continuation.v1",
|
|
1503
|
+
decisionId: request.decisionId,
|
|
1504
|
+
requestDigest: request.requestDigest,
|
|
1505
|
+
parentRunId: request.runId,
|
|
1506
|
+
runId,
|
|
1507
|
+
createdAt: accepted.acceptedAt,
|
|
1508
|
+
};
|
|
1509
|
+
await store.recordContinuation(request.decisionId, continuation);
|
|
1510
|
+
const existing = await readRunBundle(runStore.runDirFor(continuation.runId));
|
|
1511
|
+
if (existing === null && activeRun === null) {
|
|
1512
|
+
if (parent.state.workflowSource === undefined)
|
|
1513
|
+
continue;
|
|
1514
|
+
const workflowRef = parent.state.workflowSource.kind === "builtin"
|
|
1515
|
+
? `builtin:${parent.state.workflowSource.id}`
|
|
1516
|
+
: parent.state.workflowSource.path;
|
|
1517
|
+
await startRun(ctx, workflowRef, parent.state.input, {
|
|
1518
|
+
parentRunId: request.runId,
|
|
1519
|
+
humanDecision: accepted,
|
|
1520
|
+
runId: continuation.runId,
|
|
1521
|
+
quiet: true,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
await settleHumanDecisionChannels(accepted);
|
|
1525
|
+
if (activeRun !== null)
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
const startDecisionRecovery = (ctx) => {
|
|
1530
|
+
if (decisionRecoveryTimer !== null)
|
|
1531
|
+
return;
|
|
1532
|
+
decisionRecoveryTimer = setInterval(() => {
|
|
1533
|
+
if (decisionRecoveryActive || sessionClosed)
|
|
1534
|
+
return;
|
|
1535
|
+
decisionRecoveryActive = true;
|
|
1536
|
+
void recoverHumanDecisions(ctx, false)
|
|
1537
|
+
.catch(() => undefined)
|
|
1538
|
+
.finally(() => {
|
|
1539
|
+
decisionRecoveryActive = false;
|
|
1540
|
+
});
|
|
1541
|
+
}, 1_000);
|
|
1542
|
+
decisionRecoveryTimer.unref?.();
|
|
1543
|
+
};
|
|
1217
1544
|
const startWorkflowControl = async (ctx, ref, input) => {
|
|
1218
1545
|
if (activeRun !== null) {
|
|
1219
1546
|
throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
|
|
@@ -1312,7 +1639,7 @@ export default function piWorkflows(pi) {
|
|
|
1312
1639
|
return;
|
|
1313
1640
|
}
|
|
1314
1641
|
if (parsed.kind === "cancel") {
|
|
1315
|
-
const result = cancelWorkflowControl(ctx);
|
|
1642
|
+
const result = await cancelWorkflowControl(ctx);
|
|
1316
1643
|
notify(ctx, result.message, result.level);
|
|
1317
1644
|
return;
|
|
1318
1645
|
}
|
|
@@ -1358,6 +1685,69 @@ export default function piWorkflows(pi) {
|
|
|
1358
1685
|
}
|
|
1359
1686
|
},
|
|
1360
1687
|
});
|
|
1688
|
+
pi.registerCommand("workflow-channel", {
|
|
1689
|
+
description: "Configure or reload private human decision channels",
|
|
1690
|
+
handler: async (args, ctx) => {
|
|
1691
|
+
const action = args.trim() || "status";
|
|
1692
|
+
try {
|
|
1693
|
+
if (action === "reload") {
|
|
1694
|
+
await reloadDecisionChannels(ctx);
|
|
1695
|
+
notify(ctx, "Human decision channels reloaded.");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (action === "status") {
|
|
1699
|
+
notify(ctx, decisionChannelConfig === null
|
|
1700
|
+
? "Human decisions use the Pi channel only."
|
|
1701
|
+
: `Human decision channels are configured for ${Object.keys(decisionChannelConfig.audiences).length} audience(s).`);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (action !== "setup") {
|
|
1705
|
+
throw new Error("Use /workflow-channel status, setup, or reload.");
|
|
1706
|
+
}
|
|
1707
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
1708
|
+
throw new Error("Channel setup requires interactive Pi TUI mode.");
|
|
1709
|
+
}
|
|
1710
|
+
const tokenFile = await ctx.ui.input("Absolute path to the mode-0600 Telegram token file", "");
|
|
1711
|
+
if (tokenFile === undefined)
|
|
1712
|
+
return;
|
|
1713
|
+
const audience = await ctx.ui.input("Logical audience", "operator");
|
|
1714
|
+
if (audience === undefined)
|
|
1715
|
+
return;
|
|
1716
|
+
const profile = await ctx.ui.input("Private Telegram profile name", "default");
|
|
1717
|
+
if (profile === undefined)
|
|
1718
|
+
return;
|
|
1719
|
+
const credential = await ctx.ui.input("Private credential reference name", "telegram");
|
|
1720
|
+
if (credential === undefined)
|
|
1721
|
+
return;
|
|
1722
|
+
const users = await ctx.ui.input("Allowed numeric Telegram user IDs, comma separated", "");
|
|
1723
|
+
if (users === undefined)
|
|
1724
|
+
return;
|
|
1725
|
+
const chats = await ctx.ui.input("Allowed numeric Telegram chat IDs, comma separated", "");
|
|
1726
|
+
if (chats === undefined)
|
|
1727
|
+
return;
|
|
1728
|
+
await verifyTelegramTokenFile(tokenFile.trim());
|
|
1729
|
+
await writeDecisionChannelProfile({
|
|
1730
|
+
audience: audience.trim(),
|
|
1731
|
+
profile: profile.trim(),
|
|
1732
|
+
credential: credential.trim(),
|
|
1733
|
+
tokenFile: tokenFile.trim(),
|
|
1734
|
+
allowedUserIds: users
|
|
1735
|
+
.split(",")
|
|
1736
|
+
.map((value) => value.trim())
|
|
1737
|
+
.filter(Boolean),
|
|
1738
|
+
allowedChatIds: chats
|
|
1739
|
+
.split(",")
|
|
1740
|
+
.map((value) => value.trim())
|
|
1741
|
+
.filter(Boolean),
|
|
1742
|
+
});
|
|
1743
|
+
await reloadDecisionChannels(ctx);
|
|
1744
|
+
notify(ctx, "The private human decision channel profile was verified and installed.");
|
|
1745
|
+
}
|
|
1746
|
+
catch (error) {
|
|
1747
|
+
notify(ctx, `Human decision channel setup failed: ${errorMessage(error)}`, "error");
|
|
1748
|
+
}
|
|
1749
|
+
},
|
|
1750
|
+
});
|
|
1361
1751
|
pi.registerCommand("controller", {
|
|
1362
1752
|
description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
|
|
1363
1753
|
getArgumentCompletions: async (prefix) => {
|
|
@@ -1431,7 +1821,8 @@ export default function piWorkflows(pi) {
|
|
|
1431
1821
|
"Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
|
|
1432
1822
|
].join(" "),
|
|
1433
1823
|
parameters: WorkflowToolParameters,
|
|
1434
|
-
async execute(toolCallId,
|
|
1824
|
+
async execute(toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
1825
|
+
const params = parseWorkflowToolInput(rawParams);
|
|
1435
1826
|
let control;
|
|
1436
1827
|
switch (params.action) {
|
|
1437
1828
|
case "list":
|
|
@@ -1450,10 +1841,14 @@ export default function piWorkflows(pi) {
|
|
|
1450
1841
|
control = resumeWorkflowControl(ctx);
|
|
1451
1842
|
break;
|
|
1452
1843
|
case "cancel":
|
|
1453
|
-
control = cancelWorkflowControl(ctx);
|
|
1844
|
+
control = await cancelWorkflowControl(ctx);
|
|
1454
1845
|
break;
|
|
1455
1846
|
case "answer": {
|
|
1456
1847
|
const waiting = await resolveWaitingWorkflow(ctx, params.runId);
|
|
1848
|
+
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(waiting.parentRunId));
|
|
1849
|
+
if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
|
|
1850
|
+
throw new Error("This checkpoint requires a verified human answer from Pi UI or a configured decision channel.");
|
|
1851
|
+
}
|
|
1457
1852
|
control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
|
|
1458
1853
|
parentRunId: waiting.parentRunId,
|
|
1459
1854
|
});
|
|
@@ -1534,6 +1929,13 @@ export default function piWorkflows(pi) {
|
|
|
1534
1929
|
catch (error) {
|
|
1535
1930
|
notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
|
|
1536
1931
|
}
|
|
1932
|
+
try {
|
|
1933
|
+
await reloadDecisionChannels(ctx);
|
|
1934
|
+
}
|
|
1935
|
+
catch {
|
|
1936
|
+
await stopDecisionChannels();
|
|
1937
|
+
notify(ctx, "Could not start human decision channels because the private channel configuration is invalid or unavailable.", "warning");
|
|
1938
|
+
}
|
|
1537
1939
|
try {
|
|
1538
1940
|
syncArmed = true;
|
|
1539
1941
|
startRunSync(ctx);
|
|
@@ -1541,6 +1943,13 @@ export default function piWorkflows(pi) {
|
|
|
1541
1943
|
catch {
|
|
1542
1944
|
// Session sync is best-effort; runs themselves never depend on it.
|
|
1543
1945
|
}
|
|
1946
|
+
try {
|
|
1947
|
+
await recoverHumanDecisions(ctx);
|
|
1948
|
+
startDecisionRecovery(ctx);
|
|
1949
|
+
}
|
|
1950
|
+
catch (error) {
|
|
1951
|
+
notify(ctx, `Could not recover human decisions: ${errorMessage(error)}`, "warning");
|
|
1952
|
+
}
|
|
1544
1953
|
try {
|
|
1545
1954
|
await resumeParkedRun(ctx);
|
|
1546
1955
|
}
|
|
@@ -1673,7 +2082,13 @@ export default function piWorkflows(pi) {
|
|
|
1673
2082
|
clearInterval(runSyncTimer);
|
|
1674
2083
|
runSyncTimer = null;
|
|
1675
2084
|
}
|
|
2085
|
+
if (decisionRecoveryTimer !== null) {
|
|
2086
|
+
clearInterval(decisionRecoveryTimer);
|
|
2087
|
+
decisionRecoveryTimer = null;
|
|
2088
|
+
}
|
|
2089
|
+
decisionRecoveryActive = false;
|
|
1676
2090
|
syncArmed = false;
|
|
2091
|
+
await stopDecisionChannels();
|
|
1677
2092
|
await controllerHost?.close().catch(() => undefined);
|
|
1678
2093
|
controllerHost = undefined;
|
|
1679
2094
|
controllerContext = null;
|