@harms-haus/pi-workflows 1.0.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/LICENSE +21 -0
- package/README.md +113 -0
- package/docs/architecture.md +318 -0
- package/docs/configuration-reference.md +427 -0
- package/docs/contributing.md +132 -0
- package/docs/examples.md +1242 -0
- package/docs/hook-lifecycle.md +380 -0
- package/docs/state-management.md +534 -0
- package/docs/subworkflows.md +428 -0
- package/docs/template-variables.md +383 -0
- package/docs/testing.md +479 -0
- package/package.json +69 -0
- package/skills/workflow-generation/SKILL.md +272 -0
- package/src/TimerManager.ts +67 -0
- package/src/command.ts +199 -0
- package/src/config/index.ts +11 -0
- package/src/config/loading-parse.ts +205 -0
- package/src/config/loading-phases.ts +78 -0
- package/src/config/loading-resolve.ts +82 -0
- package/src/config/loading.ts +202 -0
- package/src/config/templates.ts +25 -0
- package/src/config/validation.ts +258 -0
- package/src/hooks.ts +265 -0
- package/src/index.ts +98 -0
- package/src/prompts.ts +141 -0
- package/src/renderers.ts +46 -0
- package/src/state.ts +426 -0
- package/src/tool.ts +364 -0
- package/src/types.ts +211 -0
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { getBlockedTools, resolveTemplate } from "./config";
|
|
2
|
+
import { phaseEntryName } from "./state";
|
|
3
|
+
import type { ActiveWorkflow, PhaseEntry } from "./types";
|
|
4
|
+
import { isSubworkflowRef } from "./types";
|
|
5
|
+
|
|
6
|
+
// ── Defaults ──
|
|
7
|
+
const DEFAULT_ROLE_INSTRUCTION =
|
|
8
|
+
"You are the ORCHESTRATOR for this workflow. You must NOT use the edit or write tools directly. " +
|
|
9
|
+
"All implementation work must be delegated to subagents via the delegate_to_subagents tool. " +
|
|
10
|
+
"Follow the phase instructions precisely.";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ADVANCE_REMINDER =
|
|
13
|
+
"When you finish this phase, call the workflow_step tool with action='next' to advance to the next phase. If you need to restart the current scope from the beginning, use action='loop'.";
|
|
14
|
+
|
|
15
|
+
// ── Prompt Builder ──
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the context injection prompt for the current phase.
|
|
19
|
+
* This prompt is injected as a hidden message before each agent turn.
|
|
20
|
+
*/
|
|
21
|
+
export function buildContextPrompt(active: ActiveWorkflow): string {
|
|
22
|
+
const { definition, state, currentPhase, nextPhase } = active;
|
|
23
|
+
const profiles = currentPhase.availableProfiles ?? [];
|
|
24
|
+
const allProfileNames = collectAllProfiles(definition);
|
|
25
|
+
const blockedTools = getBlockedTools(currentPhase);
|
|
26
|
+
|
|
27
|
+
// Breadcrumb
|
|
28
|
+
const breadcrumbNames = active.breadcrumb.map((b) => b.name).join(" > ");
|
|
29
|
+
|
|
30
|
+
// Progress
|
|
31
|
+
const innerSegment = state.currentPath[state.currentPath.length - 1];
|
|
32
|
+
if (!innerSegment) return "";
|
|
33
|
+
// Determine inner phase count for the innermost scope
|
|
34
|
+
const innerTotal =
|
|
35
|
+
state.currentPath.length > 1
|
|
36
|
+
? isSubworkflowRef(active.currentPhaseEntry) && active.currentPhaseEntry.resolved
|
|
37
|
+
? active.currentPhaseEntry.resolved.phases.length
|
|
38
|
+
: definition.phases.length
|
|
39
|
+
: definition.phases.length;
|
|
40
|
+
const progress =
|
|
41
|
+
state.currentPath.length === 1
|
|
42
|
+
? `**Progress:** Step ${state.globalStepCount} (${innerSegment.phaseIndex + 1}/${innerTotal} phases)`
|
|
43
|
+
: `**Progress:** Step ${state.globalStepCount} (${innerSegment.phaseIndex + 1}/${innerTotal} in current scope)`;
|
|
44
|
+
|
|
45
|
+
// Next phase name
|
|
46
|
+
const nextPhaseName: string = nextPhase ? phaseEntryName(nextPhase) : "DONE";
|
|
47
|
+
|
|
48
|
+
const vars: Record<string, string> = {
|
|
49
|
+
workflowName: definition.name,
|
|
50
|
+
workflowKey: state.workflowKey,
|
|
51
|
+
description: state.taskDescription,
|
|
52
|
+
taskId: state.taskId,
|
|
53
|
+
phaseId: currentPhase.id,
|
|
54
|
+
phaseName: currentPhase.name,
|
|
55
|
+
previousPhaseName: getPreviousPhaseName(definition, innerSegment.phaseIndex),
|
|
56
|
+
nextPhaseName,
|
|
57
|
+
blockedToolsList: blockedTools.length > 0 ? blockedTools.join(", ") : "(none)",
|
|
58
|
+
toolName: "workflow_step",
|
|
59
|
+
breadcrumbPath: breadcrumbNames,
|
|
60
|
+
globalStepCount: String(state.globalStepCount),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const roleInstruction = resolveTemplate(
|
|
64
|
+
definition.roleInstruction ?? DEFAULT_ROLE_INSTRUCTION,
|
|
65
|
+
vars,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const phaseInstructions = resolveTemplate(currentPhase.instructions, vars);
|
|
69
|
+
|
|
70
|
+
const advanceReminder = resolveTemplate(definition.advanceReminder ?? DEFAULT_ADVANCE_REMINDER, {
|
|
71
|
+
...vars,
|
|
72
|
+
nextPhaseName,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
lines.push(`[Workflow path: ${breadcrumbNames} ▸ ${currentPhase.emoji} ${currentPhase.name}]`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(roleInstruction);
|
|
79
|
+
lines.push("");
|
|
80
|
+
lines.push(`**Task:** ${state.taskDescription}`);
|
|
81
|
+
lines.push(`**Task ID:** ${state.taskId}`);
|
|
82
|
+
lines.push(`**Current Phase:** ${currentPhase.emoji} ${currentPhase.name}`);
|
|
83
|
+
lines.push(progress);
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push("**What to do in this phase:**");
|
|
86
|
+
lines.push(phaseInstructions);
|
|
87
|
+
lines.push("");
|
|
88
|
+
lines.push(`**Available subagent profiles for this phase:** ${profiles.join(", ") || "(none)"}`);
|
|
89
|
+
lines.push(`**All profiles:** ${allProfileNames.join(", ")}`);
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(advanceReminder);
|
|
92
|
+
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Collect all unique profile names across all phases in a workflow,
|
|
98
|
+
* recursively descending into subworkflow references.
|
|
99
|
+
*/
|
|
100
|
+
function collectAllProfiles(definition: { phases: PhaseEntry[] }): string[] {
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
function visit(phases: PhaseEntry[]) {
|
|
103
|
+
for (const phase of phases) {
|
|
104
|
+
if (isSubworkflowRef(phase) && phase.resolved) {
|
|
105
|
+
visit(phase.resolved.phases);
|
|
106
|
+
} else if (!isSubworkflowRef(phase)) {
|
|
107
|
+
if (phase.availableProfiles) {
|
|
108
|
+
for (const p of phase.availableProfiles) seen.add(p);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
visit(definition.phases);
|
|
114
|
+
return Array.from(seen);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the name of the previous phase, or "(start)" if this is the first phase.
|
|
119
|
+
*/
|
|
120
|
+
function getPreviousPhaseName(definition: { phases: PhaseEntry[] }, currentIndex: number): string {
|
|
121
|
+
if (currentIndex <= 0) return "(start)";
|
|
122
|
+
const prev = definition.phases[currentIndex - 1];
|
|
123
|
+
return prev ? phaseEntryName(prev) : "(start)";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Default messages for agent_end hook ──
|
|
127
|
+
export const DEFAULT_NOT_DONE_REMINDER =
|
|
128
|
+
"⚠️ The {workflowName} is still active. Current phase: {phaseEmoji} {phaseName}.\n\n" +
|
|
129
|
+
"You must NOT stop yet. The workflow requires you to complete the current phase " +
|
|
130
|
+
"and call workflow_step to advance.\n\n" +
|
|
131
|
+
"Current phase instructions:\n{phaseInstructions}\n\n" +
|
|
132
|
+
"Continue working on the current phase and call workflow_step when done.";
|
|
133
|
+
|
|
134
|
+
export const DEFAULT_COMPLETION_MESSAGE =
|
|
135
|
+
"✅ **{workflowName} Complete**\n\n" +
|
|
136
|
+
"**Task:** {taskDescription}\n" +
|
|
137
|
+
"**Task ID:** {taskId}\n" +
|
|
138
|
+
"**Phases completed:** {phaseCount}";
|
|
139
|
+
|
|
140
|
+
export const DEFAULT_CANCELLED_MESSAGE =
|
|
141
|
+
"❌ **{workflowName} Cancelled**\n\n" + "**Task:** {taskDescription}\n" + "**Task ID:** {taskId}";
|
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Factory that creates a message renderer callback.
|
|
6
|
+
*
|
|
7
|
+
* @param prefix Optional prefix string (e.g. an emoji) rendered in the accent color.
|
|
8
|
+
* @param colorKey Theme color key used for the main content.
|
|
9
|
+
* @param opts.bold Whether to wrap content in `theme.bold()`.
|
|
10
|
+
* @param opts.staticContent If set, use this fixed string instead of `message.content`.
|
|
11
|
+
*/
|
|
12
|
+
function createTextRenderer(
|
|
13
|
+
prefix: string,
|
|
14
|
+
colorKey: ThemeColor,
|
|
15
|
+
opts: { bold?: boolean; staticContent?: string } = {},
|
|
16
|
+
) {
|
|
17
|
+
return (message: { content: unknown }, _options: unknown, theme: Theme) => {
|
|
18
|
+
const raw = typeof message.content === "string" ? message.content : "";
|
|
19
|
+
const content = opts.staticContent ?? raw;
|
|
20
|
+
const styledContent = opts.bold ? theme.bold(content) : content;
|
|
21
|
+
const parts: string[] = [];
|
|
22
|
+
if (prefix) parts.push(theme.fg("accent", prefix));
|
|
23
|
+
parts.push(theme.fg(colorKey, styledContent));
|
|
24
|
+
return new Text(parts.join(""), 0, 0);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register message renderers for the workflow extension.
|
|
30
|
+
*/
|
|
31
|
+
export function registerRenderers(pi: ExtensionAPI): void {
|
|
32
|
+
// Context injection renderer — shows a minimal dim line for hidden context injections
|
|
33
|
+
pi.registerMessageRenderer(
|
|
34
|
+
"workflow:context",
|
|
35
|
+
createTextRenderer("🔄 ", "dim", { staticContent: "[Workflow Context injected]" }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Completion message renderer — shows the completion/cancellation message in bold
|
|
39
|
+
pi.registerMessageRenderer(
|
|
40
|
+
"workflow:complete",
|
|
41
|
+
createTextRenderer("", "success", { bold: true }),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Countdown shown during the grace period before auto-continue
|
|
45
|
+
pi.registerMessageRenderer("workflow:countdown", createTextRenderer("⏳ ", "dim"));
|
|
46
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
WorkflowState,
|
|
4
|
+
WorkflowDefinition,
|
|
5
|
+
ActiveWorkflow,
|
|
6
|
+
PhaseEntry,
|
|
7
|
+
PhaseDefinition,
|
|
8
|
+
SubworkflowReference,
|
|
9
|
+
} from "./types";
|
|
10
|
+
import { isSubworkflowRef, isPhaseDefinition } from "./types";
|
|
11
|
+
|
|
12
|
+
// ── Constants ──
|
|
13
|
+
const STATE_ENTRY_TYPE = "workflow:state";
|
|
14
|
+
const TASK_ID_PREFIX = "wf-";
|
|
15
|
+
|
|
16
|
+
// ── State Creation ──
|
|
17
|
+
/**
|
|
18
|
+
* Create a fresh workflow state for a new workflow instance.
|
|
19
|
+
*/
|
|
20
|
+
export function createInitialState(workflowKey: string, description: string): WorkflowState {
|
|
21
|
+
return {
|
|
22
|
+
active: true,
|
|
23
|
+
workflowKey,
|
|
24
|
+
currentPath: [{ workflowKey, phaseIndex: 0 }],
|
|
25
|
+
globalStepCount: 0,
|
|
26
|
+
taskId: `${TASK_ID_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
27
|
+
taskDescription: description,
|
|
28
|
+
startedAt: Date.now(),
|
|
29
|
+
completionNotified: false,
|
|
30
|
+
cancelled: false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Helpers ──
|
|
35
|
+
|
|
36
|
+
/** Deep-clone a WorkflowState (copies the mutable currentPath array with new segment objects). */
|
|
37
|
+
export function cloneState(state: WorkflowState): WorkflowState {
|
|
38
|
+
return {
|
|
39
|
+
...state,
|
|
40
|
+
currentPath: state.currentPath.map((s) => ({ ...s })),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get a display name from a PhaseEntry (handles both PhaseDefinition and SubworkflowReference). */
|
|
45
|
+
export function phaseEntryName(entry: PhaseEntry): string {
|
|
46
|
+
return isSubworkflowRef(entry) ? (entry.resolved?.name ?? entry.workflowKey) : entry.name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Auto-enter one or more nested SubworkflowRefs until a concrete PhaseDefinition is reached.
|
|
51
|
+
* Returns a new state with segments pushed onto currentPath and the first concrete phase name,
|
|
52
|
+
* or null if the subworkflow chain cannot be resolved.
|
|
53
|
+
* The original state is not mutated.
|
|
54
|
+
*/
|
|
55
|
+
export function autoEnterSubworkflowRefs(
|
|
56
|
+
state: WorkflowState,
|
|
57
|
+
entry: SubworkflowReference,
|
|
58
|
+
): { phaseName: string | null; newState: WorkflowState } {
|
|
59
|
+
if (!entry.resolved) return { phaseName: null, newState: state };
|
|
60
|
+
|
|
61
|
+
const cloned = cloneState(state);
|
|
62
|
+
cloned.currentPath.push({ workflowKey: entry.workflowKey, phaseIndex: 0 });
|
|
63
|
+
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
65
|
+
const firstEntry = entry.resolved.phases[0]!;
|
|
66
|
+
|
|
67
|
+
if (isSubworkflowRef(firstEntry)) {
|
|
68
|
+
return autoEnterSubworkflowRefs(cloned, firstEntry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { phaseName: firstEntry.name, newState: cloned };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the first concrete PhaseDefinition from a phases array,
|
|
76
|
+
* drilling through any nested SubworkflowReferences.
|
|
77
|
+
* Returns null if the chain cannot be resolved (e.g. empty phases or unresolved refs).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveFirstPhase(phases: PhaseEntry[]): PhaseDefinition | null {
|
|
80
|
+
const first = phases[0];
|
|
81
|
+
if (isPhaseDefinition(first)) return first;
|
|
82
|
+
if (isSubworkflowRef(first)) {
|
|
83
|
+
if (!first.resolved) return null;
|
|
84
|
+
return resolveFirstPhase(first.resolved.phases);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── State Advancement ──
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Advance to the next phase using stack-based navigation.
|
|
93
|
+
* Handles subworkflow entry, normal advancement, subworkflow breakout,
|
|
94
|
+
* and top-level completion.
|
|
95
|
+
* Returns a new state object — the original state is not mutated.
|
|
96
|
+
*/
|
|
97
|
+
export function advancePhase(
|
|
98
|
+
state: WorkflowState,
|
|
99
|
+
definitions: Record<string, WorkflowDefinition>,
|
|
100
|
+
): { advanced: true; from: string; to: string | null; newState: WorkflowState } {
|
|
101
|
+
const s = cloneState(state);
|
|
102
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
103
|
+
const top = s.currentPath[s.currentPath.length - 1]!;
|
|
104
|
+
const topDef = definitions[top.workflowKey]!;
|
|
105
|
+
const currentEntry = topDef.phases[top.phaseIndex]!;
|
|
106
|
+
/* eslint-enable @typescript-eslint/no-non-null-assertion */
|
|
107
|
+
|
|
108
|
+
// Case 1: Entering a subworkflow
|
|
109
|
+
if (isSubworkflowRef(currentEntry)) {
|
|
110
|
+
s.currentPath.push({ workflowKey: currentEntry.workflowKey, phaseIndex: 0 });
|
|
111
|
+
s.globalStepCount++;
|
|
112
|
+
const subDef = definitions[currentEntry.workflowKey];
|
|
113
|
+
if (!subDef) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`[pi-workflows] Missing definition for subworkflow '${currentEntry.workflowKey}' during advance.`,
|
|
116
|
+
);
|
|
117
|
+
s.active = false;
|
|
118
|
+
s.completionNotified = false;
|
|
119
|
+
return { advanced: true, from: phaseEntryName(currentEntry), to: null, newState: s };
|
|
120
|
+
}
|
|
121
|
+
const firstPhaseName = subDef.phases[0]
|
|
122
|
+
? phaseEntryName(subDef.phases[0])
|
|
123
|
+
: currentEntry.workflowKey;
|
|
124
|
+
return { advanced: true, from: phaseEntryName(currentEntry), to: firstPhaseName, newState: s };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Case 2: Normal phase, not the last one — advance within current scope
|
|
128
|
+
if (top.phaseIndex < topDef.phases.length - 1) {
|
|
129
|
+
top.phaseIndex += 1;
|
|
130
|
+
s.globalStepCount++;
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
132
|
+
const nextEntry = topDef.phases[top.phaseIndex]!;
|
|
133
|
+
if (isSubworkflowRef(nextEntry)) {
|
|
134
|
+
const { phaseName: concreteName, newState: entered } = autoEnterSubworkflowRefs(s, nextEntry);
|
|
135
|
+
return {
|
|
136
|
+
advanced: true,
|
|
137
|
+
from: currentEntry.name,
|
|
138
|
+
to: concreteName ?? phaseEntryName(nextEntry),
|
|
139
|
+
newState: entered,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { advanced: true, from: currentEntry.name, to: phaseEntryName(nextEntry), newState: s };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Case 3: Last phase in current scope — top-level done
|
|
146
|
+
if (s.currentPath.length === 1) {
|
|
147
|
+
s.active = false;
|
|
148
|
+
s.completionNotified = false;
|
|
149
|
+
s.globalStepCount++;
|
|
150
|
+
return { advanced: true, from: currentEntry.name, to: null, newState: s };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Case 4: Last phase in subworkflow — breakout to parent
|
|
154
|
+
// The subworkflow was the parent's last phase, so we may need to
|
|
155
|
+
// keep popping until we find a parent with a next phase or reach top-level completion.
|
|
156
|
+
s.currentPath.pop();
|
|
157
|
+
s.globalStepCount++;
|
|
158
|
+
|
|
159
|
+
// Loop: increment the parent's phase index and check if it has a next phase.
|
|
160
|
+
// If the parent is also exhausted, pop again and continue.
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
162
|
+
let newTop = s.currentPath[s.currentPath.length - 1]!;
|
|
163
|
+
let newTopDef: WorkflowDefinition | undefined = definitions[newTop.workflowKey];
|
|
164
|
+
if (!newTopDef) {
|
|
165
|
+
console.warn(`[pi-workflows] Missing definition for '${newTop.workflowKey}' during breakout.`);
|
|
166
|
+
s.active = false;
|
|
167
|
+
s.completionNotified = false;
|
|
168
|
+
return { advanced: true, from: currentEntry.name, to: null, newState: s };
|
|
169
|
+
}
|
|
170
|
+
newTop.phaseIndex += 1;
|
|
171
|
+
|
|
172
|
+
while (newTop.phaseIndex >= newTopDef.phases.length) {
|
|
173
|
+
// Parent has no more phases — check if top-level
|
|
174
|
+
if (s.currentPath.length === 1) {
|
|
175
|
+
s.active = false;
|
|
176
|
+
s.completionNotified = false;
|
|
177
|
+
return { advanced: true, from: currentEntry.name, to: null, newState: s };
|
|
178
|
+
}
|
|
179
|
+
// Nested parent also exhausted — pop again and continue
|
|
180
|
+
s.currentPath.pop();
|
|
181
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
182
|
+
newTop = s.currentPath[s.currentPath.length - 1]!;
|
|
183
|
+
newTopDef = definitions[newTop.workflowKey];
|
|
184
|
+
if (!newTopDef) {
|
|
185
|
+
console.warn(
|
|
186
|
+
`[pi-workflows] Missing definition for '${newTop.workflowKey}' during breakout.`,
|
|
187
|
+
);
|
|
188
|
+
s.active = false;
|
|
189
|
+
s.completionNotified = false;
|
|
190
|
+
return { advanced: true, from: currentEntry.name, to: null, newState: s };
|
|
191
|
+
}
|
|
192
|
+
newTop.phaseIndex += 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
196
|
+
const nextEntry = newTopDef.phases[newTop.phaseIndex]!;
|
|
197
|
+
if (isSubworkflowRef(nextEntry)) {
|
|
198
|
+
const { phaseName: concreteName, newState: entered } = autoEnterSubworkflowRefs(s, nextEntry);
|
|
199
|
+
return {
|
|
200
|
+
advanced: true,
|
|
201
|
+
from: currentEntry.name,
|
|
202
|
+
to: concreteName ?? phaseEntryName(nextEntry),
|
|
203
|
+
newState: entered,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { advanced: true, from: currentEntry.name, to: phaseEntryName(nextEntry), newState: s };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Loop Phase ──
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Loop (restart) the current innermost workflow from phase 0.
|
|
213
|
+
* Respects the workflow's `loopable` setting.
|
|
214
|
+
* Returns a new state object — the original state is not mutated.
|
|
215
|
+
*/
|
|
216
|
+
export function loopPhase(
|
|
217
|
+
state: WorkflowState,
|
|
218
|
+
definitions: Record<string, WorkflowDefinition>,
|
|
219
|
+
): { looped: true; to: string; newState: WorkflowState } | { looped: false; error: string } {
|
|
220
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
221
|
+
const top = state.currentPath[state.currentPath.length - 1]!;
|
|
222
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
223
|
+
const topDef = definitions[top.workflowKey]!;
|
|
224
|
+
|
|
225
|
+
if (topDef.loopable === false) {
|
|
226
|
+
return { looped: false, error: "Looping is disabled for this workflow." };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const s = cloneState(state);
|
|
230
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
231
|
+
s.currentPath[s.currentPath.length - 1] = {
|
|
232
|
+
...s.currentPath[s.currentPath.length - 1]!,
|
|
233
|
+
phaseIndex: 0,
|
|
234
|
+
};
|
|
235
|
+
/* eslint-enable @typescript-eslint/no-non-null-assertion */
|
|
236
|
+
s.globalStepCount++;
|
|
237
|
+
|
|
238
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
239
|
+
const firstEntry = topDef.phases[0]!;
|
|
240
|
+
const firstPhaseName = phaseEntryName(firstEntry);
|
|
241
|
+
return { looped: true, to: firstPhaseName, newState: s };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Active Workflow Resolution ──
|
|
245
|
+
/**
|
|
246
|
+
* Resolve the active workflow from state + definitions.
|
|
247
|
+
* Returns null if state is null/inactive, definition is missing, or phase index is out of bounds.
|
|
248
|
+
*/
|
|
249
|
+
export function resolveActive(
|
|
250
|
+
state: WorkflowState | null,
|
|
251
|
+
definitions: Record<string, WorkflowDefinition>,
|
|
252
|
+
): ActiveWorkflow | null {
|
|
253
|
+
if (!state || !state.active) return null;
|
|
254
|
+
|
|
255
|
+
// Walk the path stack to validate all segments
|
|
256
|
+
for (const segment of state.currentPath) {
|
|
257
|
+
if (!(segment.workflowKey in definitions)) {
|
|
258
|
+
console.warn(
|
|
259
|
+
`[pi-workflows] Path segment references missing workflow '${segment.workflowKey}'`,
|
|
260
|
+
);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Get the innermost scope
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
267
|
+
const top = state.currentPath[state.currentPath.length - 1]!;
|
|
268
|
+
if (!(top.workflowKey in definitions)) return null;
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
270
|
+
const topDef = definitions[top.workflowKey]!;
|
|
271
|
+
|
|
272
|
+
if (top.phaseIndex >= topDef.phases.length) return null;
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
274
|
+
const currentEntry = topDef.phases[top.phaseIndex]!;
|
|
275
|
+
|
|
276
|
+
// Resolve the concrete PhaseDefinition (drill into subworkflow refs)
|
|
277
|
+
let currentPhase: PhaseDefinition;
|
|
278
|
+
if (isSubworkflowRef(currentEntry)) {
|
|
279
|
+
// Drill into the subworkflow's first phase (which may itself be a subworkflow ref)
|
|
280
|
+
if (!currentEntry.resolved) {
|
|
281
|
+
console.warn(
|
|
282
|
+
`[pi-workflows] Unresolved subworkflow reference at phase index ${top.phaseIndex} in "${top.workflowKey}".`,
|
|
283
|
+
);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const firstPhase = resolveFirstPhase(currentEntry.resolved.phases);
|
|
287
|
+
if (!firstPhase) {
|
|
288
|
+
console.warn(
|
|
289
|
+
`[pi-workflows] Could not resolve first phase of subworkflow "${currentEntry.workflowKey}".`,
|
|
290
|
+
);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
currentPhase = firstPhase;
|
|
294
|
+
} else {
|
|
295
|
+
currentPhase = currentEntry;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const nextPhase: PhaseEntry | null = topDef.phases[top.phaseIndex + 1] ?? null;
|
|
299
|
+
|
|
300
|
+
// Build breadcrumb from top-level to innermost
|
|
301
|
+
const breadcrumb = state.currentPath.map((seg, idx) => {
|
|
302
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
303
|
+
const segDef = definitions[seg.workflowKey]!;
|
|
304
|
+
const isInnermost = idx === state.currentPath.length - 1;
|
|
305
|
+
return {
|
|
306
|
+
workflowKey: seg.workflowKey,
|
|
307
|
+
name: segDef.name,
|
|
308
|
+
phaseName: isInnermost ? currentPhase.name : segDef.name,
|
|
309
|
+
emoji: isInnermost ? currentPhase.emoji : "",
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
315
|
+
definition: definitions[state.currentPath[0]!.workflowKey]!,
|
|
316
|
+
state,
|
|
317
|
+
currentPhase,
|
|
318
|
+
currentPhaseEntry: currentEntry,
|
|
319
|
+
nextPhase,
|
|
320
|
+
breadcrumb,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── State Persistence ──
|
|
325
|
+
/**
|
|
326
|
+
* Persist state to session via pi.appendEntry.
|
|
327
|
+
*/
|
|
328
|
+
export function persistState(pi: ExtensionAPI, state: WorkflowState): void {
|
|
329
|
+
pi.appendEntry(STATE_ENTRY_TYPE, { ...state });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── State Reconstruction ──
|
|
333
|
+
interface SessionEntry {
|
|
334
|
+
type: string;
|
|
335
|
+
customType?: string;
|
|
336
|
+
data?: unknown;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Migrate old state format (currentPhaseIndex) to new format (currentPath).
|
|
341
|
+
*/
|
|
342
|
+
function migrateStateData(data: Record<string, unknown>): void {
|
|
343
|
+
if (data.currentPhaseIndex !== undefined && !data.currentPath) {
|
|
344
|
+
data.currentPath = [
|
|
345
|
+
{ workflowKey: data.workflowKey as string, phaseIndex: data.currentPhaseIndex as number },
|
|
346
|
+
];
|
|
347
|
+
delete data.currentPhaseIndex;
|
|
348
|
+
}
|
|
349
|
+
if (data.currentPath && data.globalStepCount === undefined) {
|
|
350
|
+
data.globalStepCount = (data.currentPath as Array<{ phaseIndex: number }>)[0]?.phaseIndex ?? 0;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Validate that each segment in currentPath has the correct shape. */
|
|
355
|
+
function isValidPath(currentPath: unknown): currentPath is WorkflowState["currentPath"] {
|
|
356
|
+
if (!Array.isArray(currentPath) || currentPath.length === 0) {
|
|
357
|
+
console.warn("[pi-workflows] Invalid persisted state: empty currentPath. Discarding.");
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
for (const seg of currentPath) {
|
|
361
|
+
if (
|
|
362
|
+
typeof seg !== "object" ||
|
|
363
|
+
seg === null ||
|
|
364
|
+
typeof (seg as Record<string, unknown>).workflowKey !== "string" ||
|
|
365
|
+
typeof (seg as Record<string, unknown>).phaseIndex !== "number"
|
|
366
|
+
) {
|
|
367
|
+
console.warn("[pi-workflows] Invalid persisted state: malformed path segment. Discarding.");
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Validate required and optional scalar fields on reconstructed state. */
|
|
375
|
+
function isValidFields(d: Record<string, unknown>): boolean {
|
|
376
|
+
if (typeof d.active !== "boolean") return false;
|
|
377
|
+
if (typeof d.workflowKey !== "string") return false;
|
|
378
|
+
if (typeof d.globalStepCount !== "number") return false;
|
|
379
|
+
if (typeof d.startedAt !== "number") return false;
|
|
380
|
+
if (d.taskId !== undefined && typeof d.taskId !== "string") return false;
|
|
381
|
+
if (d.completionNotified !== undefined && typeof d.completionNotified !== "boolean") return false;
|
|
382
|
+
if (d.cancelled !== undefined && typeof d.cancelled !== "boolean") return false;
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Validate that reconstructed state has all required fields with correct types.
|
|
388
|
+
* Acts as a type guard so the caller can safely use the value as WorkflowState.
|
|
389
|
+
* Returns true if valid, false if the state should be discarded.
|
|
390
|
+
*/
|
|
391
|
+
function validateReconstructedState(data: unknown): data is WorkflowState {
|
|
392
|
+
if (typeof data !== "object" || data === null) return false;
|
|
393
|
+
const d = data as Record<string, unknown>;
|
|
394
|
+
return isValidPath(d.currentPath) && isValidFields(d);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Reconstruct workflow state from the session branch.
|
|
399
|
+
* Scans entries in reverse order and finds the most recent state entry.
|
|
400
|
+
*/
|
|
401
|
+
export function reconstructState(ctx: {
|
|
402
|
+
sessionManager: { getBranch: () => SessionEntry[] };
|
|
403
|
+
}): WorkflowState | null {
|
|
404
|
+
const branch = ctx.sessionManager.getBranch();
|
|
405
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
406
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
407
|
+
const entry = branch[i]!;
|
|
408
|
+
if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
|
|
409
|
+
const rawData = entry.data as Record<string, unknown> | undefined;
|
|
410
|
+
if (rawData?.workflowKey == null) continue;
|
|
411
|
+
const data = { ...rawData };
|
|
412
|
+
migrateStateData(data);
|
|
413
|
+
if (!validateReconstructedState(data)) return null;
|
|
414
|
+
return data;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── isActive Check ──
|
|
421
|
+
/**
|
|
422
|
+
* Check if the workflow state is active.
|
|
423
|
+
*/
|
|
424
|
+
export function isActive(state: WorkflowState | null): state is WorkflowState {
|
|
425
|
+
return state?.active === true;
|
|
426
|
+
}
|