@osolmaz/pi-workflows 0.1.0 → 0.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.
Files changed (62) hide show
  1. package/README.md +36 -21
  2. package/dist/extension/executor.d.ts +14 -1
  3. package/dist/extension/executor.js +11 -1
  4. package/dist/extension/executor.js.map +1 -1
  5. package/dist/extension/index.js +79 -7
  6. package/dist/extension/index.js.map +1 -1
  7. package/dist/extension/recorder.d.ts +85 -0
  8. package/dist/extension/recorder.js +525 -0
  9. package/dist/extension/recorder.js.map +1 -0
  10. package/dist/extension/session-events.d.ts +134 -0
  11. package/dist/extension/session-events.js +60 -0
  12. package/dist/extension/session-events.js.map +1 -0
  13. package/dist/extension/widget.js +25 -24
  14. package/dist/extension/widget.js.map +1 -1
  15. package/dist/render/canvas.d.ts +1 -1
  16. package/dist/render/canvas.js +5 -0
  17. package/dist/render/canvas.js.map +1 -1
  18. package/dist/render/graph-render.d.ts +5 -0
  19. package/dist/render/graph-render.js +211 -48
  20. package/dist/render/graph-render.js.map +1 -1
  21. package/dist/viewer/render.js +19 -3
  22. package/dist/viewer/render.js.map +1 -1
  23. package/dist/viewer/session-reducer.d.ts +45 -0
  24. package/dist/viewer/session-reducer.js +266 -0
  25. package/dist/viewer/session-reducer.js.map +1 -0
  26. package/dist/workflows/artifacts.d.ts +40 -0
  27. package/dist/workflows/artifacts.js +155 -0
  28. package/dist/workflows/artifacts.js.map +1 -0
  29. package/dist/workflows/engine.d.ts +2 -0
  30. package/dist/workflows/engine.js +38 -7
  31. package/dist/workflows/engine.js.map +1 -1
  32. package/dist/workflows/index.d.ts +3 -2
  33. package/dist/workflows/index.js +2 -1
  34. package/dist/workflows/index.js.map +1 -1
  35. package/dist/workflows/store.d.ts +53 -9
  36. package/dist/workflows/store.js +523 -43
  37. package/dist/workflows/store.js.map +1 -1
  38. package/dist/workflows/types.d.ts +126 -3
  39. package/docs/development.md +43 -19
  40. package/docs/live-replay-protocol.md +155 -0
  41. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  42. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  43. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  44. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  45. package/docs/run-bundles.md +320 -55
  46. package/docs/session-event-journal.md +470 -0
  47. package/docs/tui-viewer.md +218 -0
  48. package/package.json +2 -1
  49. package/src/extension/executor.ts +28 -1
  50. package/src/extension/index.ts +87 -7
  51. package/src/extension/recorder.ts +633 -0
  52. package/src/extension/session-events.ts +119 -0
  53. package/src/extension/widget.ts +26 -24
  54. package/src/render/canvas.ts +19 -1
  55. package/src/render/graph-render.ts +277 -44
  56. package/src/viewer/render.ts +21 -3
  57. package/src/viewer/session-reducer.ts +347 -0
  58. package/src/workflows/artifacts.ts +188 -0
  59. package/src/workflows/engine.ts +39 -7
  60. package/src/workflows/index.ts +15 -0
  61. package/src/workflows/store.ts +649 -49
  62. package/src/workflows/types.ts +141 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osolmaz/pi-workflows",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Workflow engine, JSON control-flow tool, and live terminal viewer for the pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -42,6 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.build.json",
44
44
  "check": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test:coverage",
45
+ "fixtures": "node scripts/export-layout-fixtures.mjs",
45
46
  "format": "oxfmt --write",
46
47
  "format:check": "oxfmt --check",
47
48
  "lint": "oxlint --deny-warnings src test examples",
@@ -2,6 +2,7 @@ import type {
2
2
  AgentStepExecutor,
3
3
  AgentStepRequest,
4
4
  AgentStepSubmission,
5
+ ConversationRange,
5
6
  } from "../workflows/types.js";
6
7
 
7
8
  export type SubmissionResult =
@@ -14,11 +15,24 @@ export type PromptDelivery = {
14
15
  streaming: boolean;
15
16
  };
16
17
 
18
+ /**
19
+ * Bracketing hooks for conversation linkage: a mark is taken when the step
20
+ * prompt is first delivered, and the recorded entry range since that mark is
21
+ * attached to the accepted submission.
22
+ */
23
+ export type ConversationHooks = {
24
+ beginAttempt?: (contract: AgentStepRequest["contract"]) => void;
25
+ mark: () => number;
26
+ rangeSince: (mark: number) => ConversationRange | undefined;
27
+ };
28
+
17
29
  export type ConversationStepExecutorOptions = {
18
30
  /** Deliver a prompt into the pi conversation. */
19
31
  sendPrompt: (delivery: PromptDelivery) => void;
20
32
  /** Reminders sent when the agent settles without submitting. Default 2. */
21
33
  maxNudges?: number;
34
+ /** Conversation linkage hooks, wired to the session recorder. */
35
+ conversation?: ConversationHooks;
22
36
  };
23
37
 
24
38
  type PendingStep = {
@@ -26,6 +40,8 @@ type PendingStep = {
26
40
  resolve: (submission: AgentStepSubmission) => void;
27
41
  reject: (error: unknown) => void;
28
42
  nudgesSent: number;
43
+ /** Conversation mark taken when the prompt was first delivered. */
44
+ mark: number | null;
29
45
  cleanup: () => void;
30
46
  /** Resolves when this step stops being the pending step. */
31
47
  cleared: Promise<void>;
@@ -44,6 +60,7 @@ const DEFAULT_MAX_NUDGES = 2;
44
60
  export class ConversationStepExecutor implements AgentStepExecutor {
45
61
  private readonly sendPrompt: (delivery: PromptDelivery) => void;
46
62
  private readonly maxNudges: number;
63
+ private readonly conversation: ConversationHooks | undefined;
47
64
  private pending: PendingStep | null = null;
48
65
  private streaming = false;
49
66
  private heldByUser = false;
@@ -51,6 +68,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
51
68
  constructor(options: ConversationStepExecutorOptions) {
52
69
  this.sendPrompt = options.sendPrompt;
53
70
  this.maxNudges = options.maxNudges ?? DEFAULT_MAX_NUDGES;
71
+ this.conversation = options.conversation;
54
72
  }
55
73
 
56
74
  /** Track agent streaming state (wire to agent_start / agent_settled). */
@@ -88,6 +106,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
88
106
  return;
89
107
  }
90
108
  try {
109
+ this.conversation?.beginAttempt?.(pending.request.contract);
91
110
  this.sendPrompt({ prompt: pending.request.prompt, streaming: this.streaming });
92
111
  } catch (error) {
93
112
  this.clearPending();
@@ -110,11 +129,13 @@ export class ConversationStepExecutor implements AgentStepExecutor {
110
129
  const cleared = new Promise<void>((resolveCleared) => {
111
130
  markCleared = resolveCleared;
112
131
  });
132
+ this.conversation?.beginAttempt?.(request.contract);
113
133
  this.pending = {
114
134
  request,
115
135
  resolve,
116
136
  reject,
117
137
  nudgesSent: 0,
138
+ mark: this.conversation?.mark() ?? null,
118
139
  cleanup: () => signal.removeEventListener("abort", onAbort),
119
140
  cleared,
120
141
  markCleared,
@@ -185,7 +206,12 @@ export class ConversationStepExecutor implements AgentStepExecutor {
185
206
  };
186
207
  }
187
208
  this.clearPending();
188
- pending.resolve({ output: result.value });
209
+ const conversation =
210
+ pending.mark !== null ? this.conversation?.rangeSince(pending.mark) : undefined;
211
+ pending.resolve({
212
+ output: result.value,
213
+ ...(conversation !== undefined ? { conversation } : {}),
214
+ });
189
215
  return {
190
216
  accepted: true,
191
217
  message: [
@@ -224,6 +250,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
224
250
  pending.nudgesSent += 1;
225
251
  const { nodeId, attemptId } = pending.request.contract;
226
252
  try {
253
+ this.conversation?.beginAttempt?.(pending.request.contract);
227
254
  this.sendPrompt({
228
255
  prompt: [
229
256
  `Reminder: workflow step ${JSON.stringify(nodeId)} is still awaiting your output.`,
@@ -7,7 +7,7 @@ import { Type } from "typebox";
7
7
  import { WorkflowEngine } from "../workflows/engine.js";
8
8
  import { errorMessage } from "../workflows/errors.js";
9
9
  import { discoverWorkflows, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
10
- import { createDefinitionSnapshot } from "../workflows/store.js";
10
+ import { WorkflowRunStore, createDefinitionSnapshot } from "../workflows/store.js";
11
11
  import type {
12
12
  WorkflowDefinition,
13
13
  WorkflowDefinitionSnapshot,
@@ -15,6 +15,7 @@ import type {
15
15
  WorkflowRunState,
16
16
  } from "../workflows/types.js";
17
17
  import { ConversationStepExecutor } from "./executor.js";
18
+ import { SessionRecorder } from "./recorder.js";
18
19
  import { buildWidgetView } from "./widget.js";
19
20
 
20
21
  const WIDGET_KEY = "pi-workflows";
@@ -37,6 +38,7 @@ type ActiveRun = {
37
38
  workflowName: string;
38
39
  engine: WorkflowEngine;
39
40
  executor: ConversationStepExecutor;
41
+ recorder: SessionRecorder | null;
40
42
  snapshot: WorkflowDefinitionSnapshot;
41
43
  presentationPrompt: WorkflowDefinition["presentationPrompt"];
42
44
  generation: number;
@@ -294,6 +296,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
294
296
  if (activeRun === run) {
295
297
  activeRun = null;
296
298
  }
299
+ // Normally already stopped via onRunFinishing; this covers observers of
300
+ // runs that ended without reaching that hook.
301
+ void run.recorder?.stop();
297
302
  stopWidgetTicker();
298
303
  const { state } = result;
299
304
  updateWidget(ctx, state, run.snapshot);
@@ -335,9 +340,36 @@ export default function piWorkflows(pi: ExtensionAPI) {
335
340
  sendPrompt: ({ prompt, streaming }) => {
336
341
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
337
342
  },
343
+ conversation: {
344
+ beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
345
+ mark: () => run.recorder?.mark() ?? 0,
346
+ rangeSince: (mark) => run.recorder?.rangeSince(mark),
347
+ },
338
348
  });
349
+ // The store is shared between the engine and the session recorder so the
350
+ // trace sequence stays single-writer (see docs/run-bundles.md).
351
+ const store = new WorkflowRunStore();
339
352
  const engine = new WorkflowEngine({
340
353
  executor,
354
+ store,
355
+ // Awaited by the engine after run_started is persisted, so the session
356
+ // binding and its trace event always precede node and terminal events.
357
+ onRunStarted: async (runDir, state) => {
358
+ const recorder = new SessionRecorder(store, runDir, state.runId);
359
+ try {
360
+ await recorder.bind(ctx);
361
+ run.recorder = recorder;
362
+ } catch {
363
+ // Binding is best-effort: a session without UI access or an
364
+ // ephemeral context must not fail the run.
365
+ }
366
+ },
367
+ // Awaited by the engine before the terminal snapshot. If completion was
368
+ // submitted from the workflow tool, capture stays open through Pi's
369
+ // final tool, message, and turn hooks before it drains.
370
+ onRunFinishing: async () => {
371
+ await run.recorder?.finish();
372
+ },
341
373
  onEvent: (_event, state: WorkflowRunState) => {
342
374
  if (run.runId === null) {
343
375
  run.runId = state.runId;
@@ -351,6 +383,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
351
383
  workflowName: workflow.name,
352
384
  engine,
353
385
  executor,
386
+ recorder: null,
354
387
  snapshot,
355
388
  presentationPrompt: workflow.presentationPrompt,
356
389
  generation,
@@ -368,6 +401,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
368
401
  if (activeRun === run) {
369
402
  activeRun = null;
370
403
  }
404
+ void run.recorder?.stop();
371
405
  stopWidgetTicker();
372
406
  clearWidget(ctx);
373
407
  notify(ctx, `Workflow ${workflow.name} crashed: ${errorMessage(error)}`, "error");
@@ -488,12 +522,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
488
522
  attempt: Type.String({ description: "The attempt id from the workflow step contract" }),
489
523
  output: Type.Unknown({ description: "The step output, matching the expected output shape" }),
490
524
  }),
491
- async execute(_toolCallId, params) {
525
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
492
526
  if (!activeRun) {
493
527
  throw new Error(
494
528
  "No workflow is running. Do not call the workflow tool outside a workflow.",
495
529
  );
496
530
  }
531
+ // Flush the conversation into the bundle before accepting, so the
532
+ // attempt's recorded range includes the assistant message that carries
533
+ // this submission. Pi guarantees ctx.sessionManager is synchronized
534
+ // through the current assistant tool-calling message before tool_call
535
+ // dispatch, which precedes tool execution (docs/extensions.md, "Tool
536
+ // Events"). The tool result of this call itself lands after the range
537
+ // by design: it is the submission receipt, not the submission.
538
+ await activeRun.recorder?.record(ctx).catch(() => undefined);
539
+ await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
497
540
  const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
498
541
  if (!result.accepted) {
499
542
  throw new Error(result.message);
@@ -552,19 +595,56 @@ export default function piWorkflows(pi: ExtensionAPI) {
552
595
  );
553
596
  });
554
597
 
555
- pi.on("agent_settled", () => {
556
- if (!activeRun) {
598
+ pi.on("turn_start", (event) => {
599
+ activeRun?.recorder?.handleTurnStart(event);
600
+ });
601
+
602
+ pi.on("turn_end", async (event, ctx) => {
603
+ await activeRun?.recorder?.handleTurnEnd(event, ctx).catch(() => undefined);
604
+ });
605
+
606
+ pi.on("message_start", async (event, ctx) => {
607
+ await activeRun?.recorder?.handleMessageStart(event, ctx).catch(() => undefined);
608
+ });
609
+
610
+ pi.on("message_update", (event) => {
611
+ activeRun?.recorder?.handleMessageUpdate(event);
612
+ });
613
+
614
+ pi.on("message_end", (event) => {
615
+ activeRun?.recorder?.handleMessageEnd(event);
616
+ });
617
+
618
+ pi.on("tool_execution_start", (event) => {
619
+ activeRun?.recorder?.handleToolStart(event);
620
+ });
621
+
622
+ pi.on("tool_execution_update", (event) => {
623
+ activeRun?.recorder?.handleToolUpdate(event);
624
+ });
625
+
626
+ pi.on("tool_execution_end", (event) => {
627
+ activeRun?.recorder?.handleToolEnd(event);
628
+ });
629
+
630
+ pi.on("agent_settled", async (_event, ctx) => {
631
+ const run = activeRun;
632
+ if (!run) {
557
633
  presentationPending = null;
558
634
  return;
559
635
  }
560
- activeRun.executor.setStreaming(false);
561
- activeRun.executor.handleAgentSettled();
636
+ await run.recorder?.synchronize(ctx).catch(() => undefined);
637
+ run.recorder?.settleAttempt();
638
+ run.executor.setStreaming(false);
639
+ run.executor.handleAgentSettled();
562
640
  });
563
641
 
564
642
  pi.on("session_shutdown", () => {
565
643
  sessionClosed = true;
566
644
  supersedePresentation();
567
- activeRun?.engine.cancel();
645
+ const run = activeRun;
646
+ run?.engine.cancel();
647
+ void run?.recorder?.stop();
568
648
  activeRun = null;
569
649
  presentationPending = null;
570
650
  clearWidgetTimer();