@wichayutdew/pi-workflows 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -983
- package/dist/index.js +995 -756
- package/examples/starter-kit/mr-review.workflow.yaml +3 -1
- package/examples/starter-kit/steps/mr-review/publish.md +9 -2
- package/examples/starter-kit/steps/mr-review/verify.md +16 -5
- package/package.json +1 -1
- package/schemas/workflow.schema.json +1 -0
- package/src/command-names.ts +1 -0
- package/src/commands.ts +14 -0
- package/src/config/validation/prompt.ts +2 -0
- package/src/engine/create-run.ts +3 -0
- package/src/engine/run-advance.ts +16 -0
- package/src/engine/run-lifecycle.ts +69 -0
- package/src/engine/run-validation.ts +16 -0
- package/src/engine/state-types.ts +7 -0
- package/src/engine/transitions.ts +1 -0
- package/src/harness/action-context.ts +6 -0
- package/src/harness/core-actions.ts +2 -0
- package/src/harness/dependencies.ts +4 -0
- package/src/harness/lifecycle-actions.ts +14 -1
- package/src/harness/session-persistence.ts +66 -0
- package/src/harness/start-actions.ts +188 -1
- package/src/harness.ts +20 -0
- package/src/prompt/step-sections.ts +14 -0
- package/src/prompt/step-task.ts +3 -0
- package/src/prompt/template.ts +2 -0
- package/src/workflow-status/render-summary.ts +3 -0
|
@@ -105,7 +105,7 @@ steps:
|
|
|
105
105
|
blocked: $pause
|
|
106
106
|
|
|
107
107
|
verify:
|
|
108
|
-
title: Verify the review
|
|
108
|
+
title: Verify or return the review for publication repair
|
|
109
109
|
prompt:
|
|
110
110
|
file: steps/mr-review/verify.md
|
|
111
111
|
subagent:
|
|
@@ -127,4 +127,6 @@ steps:
|
|
|
127
127
|
tools: [bash]
|
|
128
128
|
transitions:
|
|
129
129
|
verified: $done
|
|
130
|
+
failed: publish
|
|
131
|
+
retry: verify
|
|
130
132
|
blocked: $pause
|
|
@@ -10,6 +10,9 @@ Approved review:
|
|
|
10
10
|
Approval feedback:
|
|
11
11
|
{{reviewed.feedback}}
|
|
12
12
|
|
|
13
|
+
Previous step handoff:
|
|
14
|
+
{{last.summary}}
|
|
15
|
+
|
|
13
16
|
Parse the approved Publication contract. Refresh the same review and head SHA
|
|
14
17
|
through configured read-only MCP/CLI/cURL calls. For every action, first query
|
|
15
18
|
the public review collections and skip it only when its exact marker, body,
|
|
@@ -26,5 +29,9 @@ After a mutation-capable call is attempted, ambiguity is `blocked`; do not
|
|
|
26
29
|
blindly replay it. Call `structured_output` alone with outcome `published` only
|
|
27
30
|
after every approved effect succeeded now or was proven already present.
|
|
28
31
|
Summarize the URL/head, per-action pre-state, attempted/skipped result, exact
|
|
29
|
-
correlation, and remaining work.
|
|
30
|
-
|
|
32
|
+
correlation, and remaining work. When the previous handoff is an actionable
|
|
33
|
+
verification finding, treat it as a corrective publication handoff: re-check
|
|
34
|
+
the exact approved effect and retry only when its absence is conclusive. Never
|
|
35
|
+
use it to alter the approved content, target, or action list. Use `blocked`
|
|
36
|
+
with the same ledger when freshness, execution, or correlation is ambiguous or
|
|
37
|
+
unsafe.
|
|
@@ -17,8 +17,19 @@ review collections and prove every exact marker, body, head, effect kind,
|
|
|
17
17
|
optional path/line anchor, and remote identifier. The publication ledger alone
|
|
18
18
|
is not proof.
|
|
19
19
|
|
|
20
|
-
Call `structured_output` alone with
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`
|
|
20
|
+
Call `structured_output` alone with:
|
|
21
|
+
|
|
22
|
+
- `verified` only when all approved effects are observable exactly once or in
|
|
23
|
+
the explicitly idempotent form described by the contract;
|
|
24
|
+
- `failed` for an actionable, unambiguous missing or mismatched approved
|
|
25
|
+
effect. Its self-contained summary becomes the next publication worker's
|
|
26
|
+
corrective handoff, so include the expected and observed state, exact
|
|
27
|
+
evidence, and the smallest safe repair;
|
|
28
|
+
- `retry` for a transient read-only verification failure after safe equivalent
|
|
29
|
+
checks were attempted;
|
|
30
|
+
- `blocked` only when the review, head, target, or remote result is stale,
|
|
31
|
+
ambiguous, or unsafe to repair automatically.
|
|
32
|
+
|
|
33
|
+
For `verified` and `failed`, summarize the canonical URL, current head,
|
|
34
|
+
verified or missing remote identifiers/URLs and anchors, action count, and
|
|
35
|
+
final verdict. Do not mutate state yourself.
|
package/package.json
CHANGED
package/src/command-names.ts
CHANGED
package/src/commands.ts
CHANGED
|
@@ -23,6 +23,11 @@ export type WorkflowCommandController = {
|
|
|
23
23
|
input: string,
|
|
24
24
|
context: ExtensionCommandContext,
|
|
25
25
|
) => Promise<void>;
|
|
26
|
+
/** Restarts a completed workflow in its existing worktree. */
|
|
27
|
+
readonly restart: (
|
|
28
|
+
input: string,
|
|
29
|
+
context: ExtensionCommandContext,
|
|
30
|
+
) => Promise<void>;
|
|
26
31
|
/** Pauses the active workflow. */
|
|
27
32
|
readonly pause: (
|
|
28
33
|
reason: string,
|
|
@@ -126,6 +131,15 @@ export function createHarnessCommands(
|
|
|
126
131
|
},
|
|
127
132
|
},
|
|
128
133
|
createStartCommand(controller),
|
|
134
|
+
{
|
|
135
|
+
name: 'workflow-restart',
|
|
136
|
+
options: {
|
|
137
|
+
description:
|
|
138
|
+
'Restart the completed workflow in its worktree: /workflow-restart [input]',
|
|
139
|
+
handler: async (input, context) =>
|
|
140
|
+
controller.restart(input.trim(), context),
|
|
141
|
+
},
|
|
142
|
+
},
|
|
129
143
|
{
|
|
130
144
|
name: 'workflow-pause',
|
|
131
145
|
options: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const PROMPT_VARIABLES = new Set([
|
|
2
2
|
'workflow.input',
|
|
3
3
|
'workflow.id',
|
|
4
|
+
'workflow.iteration',
|
|
4
5
|
'run.id',
|
|
5
6
|
'step.id',
|
|
6
7
|
'step.title',
|
|
@@ -10,6 +11,7 @@ const PROMPT_VARIABLES = new Set([
|
|
|
10
11
|
'gate.artifact',
|
|
11
12
|
'gate.feedback',
|
|
12
13
|
'resume.input',
|
|
14
|
+
'restart.workspace',
|
|
13
15
|
]);
|
|
14
16
|
|
|
15
17
|
/** Validate template variables embedded in resolved prompt text. */
|
package/src/engine/create-run.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { WorkflowRun } from './state-types.ts';
|
|
|
11
11
|
* @param runId - Stable run identifier.
|
|
12
12
|
* @param now - Creation timestamp.
|
|
13
13
|
* @param cwd - Canonical working directory captured at workflow start.
|
|
14
|
+
* @param iteration - One-based restart iteration within this worktree lineage.
|
|
14
15
|
* @returns A new running workflow state.
|
|
15
16
|
*/
|
|
16
17
|
export const createRun = (
|
|
@@ -20,10 +21,12 @@ export const createRun = (
|
|
|
20
21
|
runId: string,
|
|
21
22
|
now: number,
|
|
22
23
|
cwd?: string,
|
|
24
|
+
iteration = 1,
|
|
23
25
|
): WorkflowRun => {
|
|
24
26
|
const startStepId = workflow.definition.start;
|
|
25
27
|
return {
|
|
26
28
|
stateVersion: RUN_STATE_VERSION,
|
|
29
|
+
iteration,
|
|
27
30
|
runId,
|
|
28
31
|
workflowId: workflow.definition.id,
|
|
29
32
|
workflowDigest: workflow.digest,
|
|
@@ -88,6 +88,15 @@ export const advanceRun = (
|
|
|
88
88
|
: `outcome "${outcome}" cannot bind a workspace`,
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
|
+
if (
|
|
92
|
+
shouldBindWorkspace &&
|
|
93
|
+
run.restartWorkspaceCwd !== undefined &&
|
|
94
|
+
effects.workspaceCwd !== run.restartWorkspaceCwd
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`restarted workflow must rebind workspace "${run.restartWorkspaceCwd}"`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
91
100
|
if (target === '$pause') {
|
|
92
101
|
return withRunUpdate(
|
|
93
102
|
run,
|
|
@@ -105,6 +114,11 @@ export const advanceRun = (
|
|
|
105
114
|
const completed = completedStep(run, outcome, summary, now, effects);
|
|
106
115
|
const cwd = effects.workspaceCwd ?? run.cwd;
|
|
107
116
|
if (target === '$done') {
|
|
117
|
+
if (run.restartWorkspaceCwd !== undefined) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`restarted workflow completed before rebinding workspace "${run.restartWorkspaceCwd}"`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
108
122
|
return withRunUpdate(
|
|
109
123
|
run,
|
|
110
124
|
{
|
|
@@ -113,6 +127,7 @@ export const advanceRun = (
|
|
|
113
127
|
currentStepAttempts: undefined,
|
|
114
128
|
currentStepOmittedAttempts: undefined,
|
|
115
129
|
...(cwd ? { cwd } : {}),
|
|
130
|
+
...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
|
|
116
131
|
stepHandoff: summary,
|
|
117
132
|
lastSummary: summary,
|
|
118
133
|
gateArtifact: '',
|
|
@@ -161,6 +176,7 @@ export const advanceRun = (
|
|
|
161
176
|
currentStepAttempts: undefined,
|
|
162
177
|
currentStepOmittedAttempts: undefined,
|
|
163
178
|
...(cwd ? { cwd } : {}),
|
|
179
|
+
...(effects.workspaceCwd ? { restartWorkspaceCwd: undefined } : {}),
|
|
164
180
|
stepHandoff: preservesGateRevisionContext ? run.stepHandoff : summary,
|
|
165
181
|
lastSummary: summary,
|
|
166
182
|
gateArtifact: preservesGateRevisionContext ? run.gateArtifact : '',
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { LoadedWorkflow } from '../config/types.ts';
|
|
2
|
+
import { createRun } from './create-run.ts';
|
|
2
3
|
import type { WorkflowRun } from './state-types.ts';
|
|
3
4
|
import { currentStep, withRunUpdate } from './transition-helpers.ts';
|
|
4
5
|
|
|
6
|
+
const completedWorkspaceCwd = (run: WorkflowRun): string | undefined => {
|
|
7
|
+
for (let index = run.history.length - 1; index >= 0; index -= 1) {
|
|
8
|
+
const workspaceCwd = run.history[index]?.workspaceCwd;
|
|
9
|
+
if (workspaceCwd) return workspaceCwd;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
5
14
|
/**
|
|
6
15
|
* Lists outcomes the active step may submit directly.
|
|
7
16
|
*
|
|
@@ -133,3 +142,63 @@ export const abortRun = (
|
|
|
133
142
|
},
|
|
134
143
|
now,
|
|
135
144
|
);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Starts another iteration of a completed workflow while retaining its stable
|
|
148
|
+
* run/worktree identity. The next workspace-binding result must reaffirm the
|
|
149
|
+
* previously selected workspace before the iteration can complete.
|
|
150
|
+
*
|
|
151
|
+
* @param workflow - Current loaded workflow definition.
|
|
152
|
+
* @param run - Completed iteration to restart.
|
|
153
|
+
* @param input - New request, or the prior request when no replacement was supplied.
|
|
154
|
+
* @param baselineTools - Tools available before the new iteration is isolated.
|
|
155
|
+
* @param now - Restart timestamp.
|
|
156
|
+
* @returns A fresh running state for the next iteration.
|
|
157
|
+
*/
|
|
158
|
+
export const restartRun = (
|
|
159
|
+
workflow: LoadedWorkflow,
|
|
160
|
+
run: WorkflowRun,
|
|
161
|
+
input: string,
|
|
162
|
+
baselineTools: ReadonlyArray<string>,
|
|
163
|
+
now: number,
|
|
164
|
+
): WorkflowRun => {
|
|
165
|
+
if (run.status !== 'completed') {
|
|
166
|
+
throw new Error('only a completed workflow can be restarted');
|
|
167
|
+
}
|
|
168
|
+
if (!run.startCwd) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
'the completed workflow has no captured start directory; start a new workflow instead',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const previousIteration = run.iteration ?? 1;
|
|
175
|
+
if (!Number.isSafeInteger(previousIteration) || previousIteration < 1) {
|
|
176
|
+
throw new Error('the completed workflow has an invalid iteration number');
|
|
177
|
+
}
|
|
178
|
+
if (previousIteration >= Number.MAX_SAFE_INTEGER) {
|
|
179
|
+
throw new Error('the workflow iteration limit has been reached');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workspaceCwd = completedWorkspaceCwd(run);
|
|
183
|
+
if (workspaceCwd && run.cwd !== workspaceCwd) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
'the completed workflow workspace does not match its recorded binding',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const restarted = createRun(
|
|
190
|
+
workflow,
|
|
191
|
+
input,
|
|
192
|
+
baselineTools,
|
|
193
|
+
run.runId,
|
|
194
|
+
now,
|
|
195
|
+
run.startCwd,
|
|
196
|
+
previousIteration + 1,
|
|
197
|
+
);
|
|
198
|
+
return {
|
|
199
|
+
...restarted,
|
|
200
|
+
stepHandoff: run.lastSummary,
|
|
201
|
+
lastSummary: run.lastSummary,
|
|
202
|
+
...(workspaceCwd ? { restartWorkspaceCwd: workspaceCwd } : {}),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
@@ -253,6 +253,10 @@ const isOptionalResumeInput = (value: unknown): value is string | undefined =>
|
|
|
253
253
|
value === undefined ||
|
|
254
254
|
(typeof value === 'string' && value.length <= MAX_RESUME_INPUT_CHARS);
|
|
255
255
|
|
|
256
|
+
const isOptionalIteration = (value: unknown): value is number | undefined =>
|
|
257
|
+
value === undefined ||
|
|
258
|
+
(Number.isSafeInteger(value) && (value as number) >= 1);
|
|
259
|
+
|
|
256
260
|
const isWorkflowRunStatus = (value: unknown): value is WorkflowRunStatus =>
|
|
257
261
|
value === 'running' ||
|
|
258
262
|
value === 'paused' ||
|
|
@@ -341,12 +345,15 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
|
|
|
341
345
|
if (!hasValidRequiredFields) return false;
|
|
342
346
|
|
|
343
347
|
const hasValidOptionalFields =
|
|
348
|
+
isOptionalIteration(value.iteration) &&
|
|
344
349
|
isOptionalString(value.reviewedArtifact) &&
|
|
345
350
|
isOptionalString(value.reviewedFeedback) &&
|
|
346
351
|
(typeof value.reviewedFeedback !== 'string' ||
|
|
347
352
|
value.reviewedFeedback.length <= MAX_GATE_FEEDBACK_CHARS) &&
|
|
348
353
|
isOptionalString(value.stepHandoff) &&
|
|
349
354
|
isOptionalString(value.gateArtifact) &&
|
|
355
|
+
(value.restartWorkspaceCwd === undefined ||
|
|
356
|
+
isAbsoluteCwd(value.restartWorkspaceCwd)) &&
|
|
350
357
|
isOptionalResumeInput(value.resumeInput) &&
|
|
351
358
|
isOptionalString(value.pauseReason) &&
|
|
352
359
|
isOptionalString(value.failedStepId) &&
|
|
@@ -358,6 +365,15 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
|
|
|
358
365
|
const pendingGate = value.pendingGate;
|
|
359
366
|
if (pendingGate !== undefined && !isPendingGate(pendingGate)) return false;
|
|
360
367
|
|
|
368
|
+
if (
|
|
369
|
+
value.restartWorkspaceCwd !== undefined &&
|
|
370
|
+
(value.history as ReadonlyArray<StepHistoryEntry>).some(
|
|
371
|
+
(entry) => entry.workspaceCwd !== undefined,
|
|
372
|
+
)
|
|
373
|
+
) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
361
377
|
return (
|
|
362
378
|
workflowTraceChars(value as WorkflowRun) <= MAX_WORKFLOW_TRACE_CHARS &&
|
|
363
379
|
hasValidWorkspaceState(
|
|
@@ -125,6 +125,8 @@ export type PendingGate = {
|
|
|
125
125
|
|
|
126
126
|
export type WorkflowRun = {
|
|
127
127
|
readonly stateVersion: typeof RUN_STATE_VERSION;
|
|
128
|
+
/** One-based attempt number within a restartable workflow/worktree lineage. */
|
|
129
|
+
readonly iteration?: number | undefined;
|
|
128
130
|
readonly runId: string;
|
|
129
131
|
readonly workflowId: string;
|
|
130
132
|
readonly workflowDigest: string;
|
|
@@ -153,6 +155,11 @@ export type WorkflowRun = {
|
|
|
153
155
|
readonly startCwd?: string | undefined;
|
|
154
156
|
/** Current canonical execution directory for delegated workflow steps. */
|
|
155
157
|
readonly cwd?: string | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* Exact workspace that a restarted iteration must bind before it can finish.
|
|
160
|
+
* This prevents a missing prior worktree from being silently replaced.
|
|
161
|
+
*/
|
|
162
|
+
readonly restartWorkspaceCwd?: string | undefined;
|
|
156
163
|
/**
|
|
157
164
|
* Input inherited from the previous completed step. Unlike `lastSummary`,
|
|
158
165
|
* this survives a paused attempt of the current step.
|
|
@@ -77,6 +77,12 @@ export type HarnessActionContext = {
|
|
|
77
77
|
startContext: WorkflowStartContext,
|
|
78
78
|
sessionEpoch: number,
|
|
79
79
|
) => Promise<void>;
|
|
80
|
+
restart: (input: string, context: ExtensionCommandContext) => Promise<void>;
|
|
81
|
+
restartNow: (
|
|
82
|
+
input: string,
|
|
83
|
+
startContext: WorkflowStartContext,
|
|
84
|
+
sessionEpoch: number,
|
|
85
|
+
) => Promise<void>;
|
|
80
86
|
pause: (reason: string, context: ExtensionCommandContext) => Promise<void>;
|
|
81
87
|
pauseNow: (reason: string, context: ExtensionCommandContext) => Promise<void>;
|
|
82
88
|
resume: (input: string, context: ExtensionCommandContext) => Promise<void>;
|
|
@@ -190,6 +190,8 @@ function enqueueMutation(
|
|
|
190
190
|
function persist(this: HarnessActionContext): void {
|
|
191
191
|
if (this.run) {
|
|
192
192
|
this.pi.appendEntry(STATE_ENTRY_TYPE, structuredClone(this.run));
|
|
193
|
+
const session = this.latestContext?.sessionManager;
|
|
194
|
+
if (session) this.dependencies.flushUnwrittenSession(session);
|
|
193
195
|
}
|
|
194
196
|
}
|
|
195
197
|
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
resolveWorkspaceDirectory,
|
|
34
34
|
type ResolveWorkspaceDirectoryOptions,
|
|
35
35
|
} from './workspace-directory.ts';
|
|
36
|
+
import { flushUnwrittenSession } from './session-persistence.ts';
|
|
36
37
|
|
|
37
38
|
const MAX_DELEGATED_RESULT_BYTES = 1024 * 1024;
|
|
38
39
|
|
|
@@ -71,6 +72,8 @@ export type WorkflowHarnessDependencies = {
|
|
|
71
72
|
pi: ExtensionAPI,
|
|
72
73
|
) => MainStepRuntimeController;
|
|
73
74
|
readonly createMutationQueue: () => SerialTaskQueueController;
|
|
75
|
+
/** Makes a new Pi session durable before its first assistant message. */
|
|
76
|
+
readonly flushUnwrittenSession: typeof flushUnwrittenSession;
|
|
74
77
|
readonly scheduleInterval: (
|
|
75
78
|
operation: () => void,
|
|
76
79
|
intervalMs: number,
|
|
@@ -154,6 +157,7 @@ const DEFAULT_DEPENDENCIES: WorkflowHarnessDependencies = {
|
|
|
154
157
|
createSubagentClient: (pi) => createSubagentDelegationClient(pi.events),
|
|
155
158
|
createMainStepRuntime: (pi) => createMainStepRuntime({ pi }),
|
|
156
159
|
createMutationQueue: createSerialTaskQueue,
|
|
160
|
+
flushUnwrittenSession,
|
|
157
161
|
scheduleInterval: (operation, intervalMs) =>
|
|
158
162
|
setInterval(operation, intervalMs),
|
|
159
163
|
cancelInterval: (timer) => {
|
|
@@ -107,9 +107,22 @@ function registerLifecycle(this: HarnessActionContext): void {
|
|
|
107
107
|
this.isSessionActive = true;
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
this.pi.on('session_shutdown', async () => {
|
|
110
|
+
this.pi.on('session_shutdown', async (_event, context) => {
|
|
111
111
|
this.sessionEpoch += 1;
|
|
112
112
|
this.isSessionActive = false;
|
|
113
|
+
if (this.run) {
|
|
114
|
+
this.latestContext = context;
|
|
115
|
+
try {
|
|
116
|
+
this.persist();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
context.ui.notify(
|
|
119
|
+
`Workflow checkpoint could not be saved before shutdown: ${
|
|
120
|
+
error instanceof Error ? error.message : String(error)
|
|
121
|
+
}`,
|
|
122
|
+
'error',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
113
126
|
this.cancelPromptReview();
|
|
114
127
|
this.mainSteps.deactivate();
|
|
115
128
|
await this.cancelActiveDelegation('Pi session shut down');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
SessionManager,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
|
|
7
|
+
type SessionSnapshot = Pick<
|
|
8
|
+
ExtensionContext['sessionManager'],
|
|
9
|
+
'getEntries' | 'getHeader' | 'getSessionFile'
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
type AdoptableSessionSnapshot = SessionSnapshot &
|
|
13
|
+
Pick<SessionManager, 'setSessionFile'>;
|
|
14
|
+
|
|
15
|
+
const isAlreadyPersisted = (error: unknown): boolean =>
|
|
16
|
+
error instanceof Error && 'code' in error && error.code === 'EEXIST';
|
|
17
|
+
|
|
18
|
+
function getAdoptableSession(
|
|
19
|
+
session: SessionSnapshot,
|
|
20
|
+
): AdoptableSessionSnapshot {
|
|
21
|
+
const adoptable = session as Partial<AdoptableSessionSnapshot>;
|
|
22
|
+
if (typeof adoptable.setSessionFile !== 'function') {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'This Pi runtime cannot adopt a materialized workflow session file',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return adoptable as AdoptableSessionSnapshot;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Materializes a Pi session before its first regular assistant message.
|
|
32
|
+
*
|
|
33
|
+
* Pi defers creating a new session file until it records a regular assistant
|
|
34
|
+
* message. A workflow made entirely of delegated steps can therefore have
|
|
35
|
+
* checkpoint entries in memory but no file to reopen. After creating a public
|
|
36
|
+
* session snapshot, this re-adopts the same file through Pi's public
|
|
37
|
+
* `SessionManager#setSessionFile` method. That marks the running manager as
|
|
38
|
+
* flushed, allowing Pi to append future custom and assistant entries normally.
|
|
39
|
+
*
|
|
40
|
+
* @param session - Public snapshot view supplied by Pi's extension context.
|
|
41
|
+
* @returns `true` when this call created the session file.
|
|
42
|
+
*/
|
|
43
|
+
export function flushUnwrittenSession(session: SessionSnapshot): boolean {
|
|
44
|
+
const sessionFile = session.getSessionFile();
|
|
45
|
+
const header = session.getHeader();
|
|
46
|
+
if (!sessionFile || !header) return false;
|
|
47
|
+
if (existsSync(sessionFile)) return false;
|
|
48
|
+
const adoptable = getAdoptableSession(session);
|
|
49
|
+
|
|
50
|
+
const serialized = [header, ...session.getEntries()]
|
|
51
|
+
.map((entry) => JSON.stringify(entry))
|
|
52
|
+
.join('\n');
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
writeFileSync(sessionFile, `${serialized}\n`, {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
flag: 'wx',
|
|
58
|
+
mode: 0o600,
|
|
59
|
+
});
|
|
60
|
+
adoptable.setSessionFile(sessionFile);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (isAlreadyPersisted(error)) return false;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|