@tranhoangnguyen0310/pi-flow-external 3.1.0-external.0 → 3.1.1-external.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to pi-flow external are documented here.
4
4
 
5
+ ## [3.1.1-external.0] - 2026-10-02
6
+
7
+ ### Fixed
8
+ - `external_runs list` no longer reports live work as `interrupted_or_uncertain` on later pages, or lists a live run twice. Live state now replaces a run's persisted row on whichever page that row falls. Live runs with no persisted row yet (for example, while evidence writes fail) are paged first and count against `limit`, so every page stays within `limit`. The cursor bookmarks the last such run in registry order, so persisting one between pages never skips another; it can only appear twice (once live, once persisted). If the bookmarked run has left the registry, the next page fails with "Run list changed while paging" instead of guessing. A run started after the listing reached its persisted rows appears in the next fresh listing.
9
+ - One unreadable workflow journal (an incompatible version, a corrupt start line, or a read error) no longer fails the whole `list`. It is listed as one `interrupted_or_uncertain` row with the read error; `inspect` of that ID still reports the error.
10
+ - A workflow launched after its session closed no longer throws with its journal left open. The journal is settled as failed, and the call returns an error result naming the closed session.
11
+ - Workflow resume no longer reveals that another project's run exists: it reports the same "run journal not found" message as an unknown run ID.
12
+
5
13
  ## [3.1.0-external.0] - 2026-10-02
6
14
 
7
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "3.1.0-external.0",
3
+ "version": "3.1.1-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -12,7 +12,7 @@ import { renderOutputText } from "./core/subagent-render.ts";
12
12
  import { SPINNER_INTERVAL_MS } from "./core/spinner.ts";
13
13
  import { EXTERNAL_RUNS_PROMPT_SNIPPET } from "./prompts.ts";
14
14
  import type { WorkflowToolDetails } from "./types.ts";
15
- import { getSessionWorkflowDir, listWorkflowJournals, loadWorkflowJournal, type LoadedWorkflowJournal } from "./workflow/journal.ts";
15
+ import { getSessionWorkflowDir, listWorkflowJournals, loadWorkflowJournal, WorkflowJournalReadError, type LoadedWorkflowJournal, type UnreadableWorkflowJournal } from "./workflow/journal.ts";
16
16
 
17
17
  const RUN_ID = /^(?:run|wf)_[A-Za-z0-9_-]{1,128}$/;
18
18
  const WORKFLOW_ID = /^wf_[A-Za-z0-9_-]{1,128}$/;
@@ -219,6 +219,97 @@ function journalSummary(journal: LoadedWorkflowJournal, allChildren = false) {
219
219
  };
220
220
  }
221
221
 
222
+ /** A listed journal that could not be loaded: one uncertain row instead of a failed listing. */
223
+ function unreadableJournalSummary(journal: UnreadableWorkflowJournal) {
224
+ return {
225
+ ...projectWorkflowRun({
226
+ runId: journal.runId,
227
+ live: false,
228
+ status: "interrupted_or_uncertain",
229
+ error: clip(journal.unreadable),
230
+ resultAvailable: false,
231
+ finalAvailable: false,
232
+ }),
233
+ children: [],
234
+ };
235
+ }
236
+
237
+ /** Mirrors the listing's own inclusion rule, so a journal is counted persisted exactly when some page lists it. */
238
+ async function hasWorkflowJournal(dir: string, runId: string, project: string): Promise<boolean> {
239
+ try {
240
+ return (await loadWorkflowJournal(dir, runId))?.project === project;
241
+ } catch (error) {
242
+ const owner = error instanceof WorkflowJournalReadError ? error.project : undefined;
243
+ return owner === undefined || owner === project;
244
+ }
245
+ }
246
+
247
+ function listScope(stream: "runs" | "workflows", sessionId: string, project: string, workflowRunId?: string): string {
248
+ return JSON.stringify({ stream, sessionId, project, workflowRunId });
249
+ }
250
+
251
+ type UnpersistedBookmark = { after: string } | { persisted: true };
252
+
253
+ function encodeUnpersistedCursor(scope: string, bookmark: UnpersistedBookmark): string {
254
+ return Buffer.from(JSON.stringify({ v: 1, kind: "unpersisted-list", scope, ...bookmark })).toString("base64url");
255
+ }
256
+
257
+ /** The bookmark of an unpersisted-stage cursor, or undefined for any other cursor (passed on to the persisted listing). */
258
+ function decodeUnpersistedCursor(value: string, scope: string): UnpersistedBookmark | undefined {
259
+ let parsed: Record<string, unknown> | undefined;
260
+ try {
261
+ parsed = record(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ if (parsed?.kind !== "unpersisted-list") return undefined;
266
+ const keys = Object.keys(parsed).sort().join(",");
267
+ const valid = parsed.v === 1 && ((keys === "after,kind,scope,v" && typeof parsed.after === "string" && RUN_ID.test(parsed.after)) || (keys === "kind,persisted,scope,v" && parsed.persisted === true));
268
+ if (!valid) throw new Error("Invalid list cursor");
269
+ if (parsed.scope !== scope) throw new Error("Cursor belongs to a different list scope");
270
+ return typeof parsed.after === "string" ? { after: parsed.after } : { persisted: true };
271
+ }
272
+
273
+ /**
274
+ * One bounded list page: live runs with no persisted row first, then
275
+ * persisted rows, all within `pageSize`. The unpersisted stage bookmarks the
276
+ * last run it served, in registry order. Persisting a run never removes it
277
+ * from the registry, so a later page always resumes after the same run and
278
+ * none is skipped. If the bookmarked run has left the registry, continuation
279
+ * fails as stale rather than guessing. A run persisted between pages may
280
+ * appear in both stages; a run registered after the listing reached its
281
+ * persisted stage appears in the next fresh listing.
282
+ */
283
+ async function pageUnpersistedFirst<L extends { runId: string }, D>(
284
+ scope: string,
285
+ cursor: string | undefined,
286
+ pageSize: number,
287
+ candidates: L[],
288
+ isUnpersisted: (candidate: L) => Promise<boolean>,
289
+ persisted: (limit: number, cursor: string | undefined) => Promise<{ items: D[]; nextCursor?: string }>,
290
+ ): Promise<{ live: L[]; items: D[]; nextCursor?: string }> {
291
+ const bookmark = cursor === undefined ? undefined : decodeUnpersistedCursor(cursor, scope);
292
+ if (cursor !== undefined && bookmark === undefined) return { live: [], ...await persisted(pageSize, cursor) };
293
+ if (bookmark && "persisted" in bookmark) return { live: [], ...await persisted(pageSize, undefined) };
294
+ let start = 0;
295
+ if (bookmark) {
296
+ const index = candidates.findIndex((candidate) => candidate.runId === bookmark.after);
297
+ if (index < 0) throw new Error("Run list changed while paging; restart the listing without a cursor");
298
+ start = index + 1;
299
+ }
300
+ // Find one more than fits, to know whether the unpersisted stage continues.
301
+ const live: L[] = [];
302
+ for (let index = start; index < candidates.length && live.length <= pageSize; index++) {
303
+ if (await isUnpersisted(candidates[index]!)) live.push(candidates[index]!);
304
+ }
305
+ if (live.length > pageSize) {
306
+ const served = live.slice(0, pageSize);
307
+ return { live: served, items: [], nextCursor: encodeUnpersistedCursor(scope, { after: served.at(-1)!.runId }) };
308
+ }
309
+ if (live.length === pageSize) return { live, items: [], nextCursor: encodeUnpersistedCursor(scope, { persisted: true }) };
310
+ return { live, ...await persisted(pageSize - live.length, undefined) };
311
+ }
312
+
222
313
  function projectionRevision(text: string): string {
223
314
  return createHash("sha256").update(text).digest("hex");
224
315
  }
@@ -685,30 +776,44 @@ export function createExternalRunsTool(
685
776
 
686
777
  if (params.action === "list") {
687
778
  if (params.workflowRunId !== undefined && !WORKFLOW_ID.test(params.workflowRunId)) throw new Error("Invalid workflow run ID");
688
- const page = params.workflowCursor && !params.cursor
689
- ? { items: [] }
690
- : await listRunRecords({
691
- runsDirectory,
692
- sessionId,
693
- project,
694
- workflowRunId: params.workflowRunId,
695
- limit: params.limit,
696
- cursor: params.cursor,
697
- });
698
779
  const workflowDir = getSessionWorkflowDir(ctx);
699
- const historical = !params.workflowRunId && workflowDir
700
- ? await listWorkflowJournals(workflowDir, project, params.limit, params.workflowCursor)
701
- : { items: [] };
702
- const current = params.cursor || params.workflowCursor || params.workflowRunId
703
- ? []
704
- : options.registry.list(sessionId, project).filter((entry) => entry.kind === "workflow").map((entry) => liveSummary(entry));
705
- const currentIds = new Set(current.map((entry) => entry.runId));
706
- const workflows = [...current, ...historical.items.filter((journal) => !currentIds.has(journal.runId)).map((journal) => journalSummary(journal))];
707
- const liveAgents = params.cursor || params.workflowCursor ? [] : options.registry.list(sessionId, project)
708
- .filter((entry) => entry.kind === "agent" && (params.workflowRunId === undefined || entry.workflowRunId === params.workflowRunId))
709
- .map(listedAgent);
710
- const liveIds = new Set(liveAgents.map((entry) => entry.runId));
711
- const runs = [...liveAgents, ...page.items.filter((entry) => !liveIds.has(entry.runId)).map(historicalAgent)];
780
+ // Live state overlays its persisted row on whichever page that row
781
+ // falls, so a live run is never shown as interrupted or listed twice.
782
+ // Live runs with no persisted row yet are paged first, inside the same
783
+ // limit, before the persisted rows begin.
784
+ const registered = new Map(options.registry.list(sessionId, project).map((entry) => [entry.runId, entry]));
785
+ const runScope = listScope("runs", sessionId, project, params.workflowRunId);
786
+ const page = params.workflowCursor && !params.cursor
787
+ ? { live: [], items: [] }
788
+ : await pageUnpersistedFirst(runScope, params.cursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 50))),
789
+ [...registered.values()].filter((entry) => entry.kind === "agent" && (params.workflowRunId === undefined || entry.workflowRunId === params.workflowRunId)),
790
+ async (entry) => {
791
+ const persisted = await getRunRecord(runsDirectory, entry.runId);
792
+ return !persisted || persisted.parentSessionId !== sessionId || persisted.project !== project
793
+ || (params.workflowRunId !== undefined && persisted.workflowRunId !== params.workflowRunId);
794
+ },
795
+ (limit, cursor) => listRunRecords({ runsDirectory, sessionId, project, workflowRunId: params.workflowRunId, limit, cursor }));
796
+ const historical = params.workflowRunId
797
+ ? { live: [], items: [] }
798
+ : await pageUnpersistedFirst(listScope("workflows", sessionId, project), params.workflowCursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 100))),
799
+ [...registered.values()].filter((entry) => entry.kind === "workflow"),
800
+ async (entry) => !workflowDir || !await hasWorkflowJournal(workflowDir, entry.runId, project),
801
+ async (limit, cursor) => workflowDir ? listWorkflowJournals(workflowDir, project, limit, cursor) : { items: [] });
802
+ const workflows = [
803
+ ...historical.live.map((entry) => liveSummary(entry)),
804
+ ...historical.items.map((journal) => {
805
+ const live = registered.get(journal.runId);
806
+ if (live?.kind === "workflow") return liveSummary(live);
807
+ return "unreadable" in journal ? unreadableJournalSummary(journal) : journalSummary(journal);
808
+ }),
809
+ ];
810
+ const runs = [
811
+ ...page.live.map(listedAgent),
812
+ ...page.items.map((item) => {
813
+ const live = registered.get(item.runId);
814
+ return live?.kind === "agent" ? listedAgent(live) : historicalAgent(item);
815
+ }),
816
+ ];
712
817
  const list = { workflows, runs, nextCursor: page.nextCursor, nextWorkflowCursor: historical.nextCursor };
713
818
  return result(JSON.stringify(list), list);
714
819
  }
@@ -41,6 +41,14 @@ export interface LoadedWorkflowJournal {
41
41
  children: Array<{ index: number; runId?: string; label?: string; status: "queued" | "done" | "error" | "aborted"; outcome?: ChildRunOutcome; error?: unknown }>;
42
42
  }
43
43
 
44
+ /** A journal that exists but cannot be loaded; `project` is set when its own run_start line names one. */
45
+ export class WorkflowJournalReadError extends Error {
46
+ constructor(message: string, readonly project?: string) {
47
+ super(message);
48
+ this.name = "WorkflowJournalReadError";
49
+ }
50
+ }
51
+
44
52
  export interface WorkflowJournalWriter {
45
53
  runId: string;
46
54
  path: string;
@@ -128,8 +136,9 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
128
136
  }
129
137
  if (entry.type === "run_start") {
130
138
  if (entry.version !== JOURNAL_VERSION || entry.apiVersion !== WORKFLOW_API_VERSION) {
131
- throw new Error(
139
+ throw new WorkflowJournalReadError(
132
140
  `Workflow journal ${path} uses an incompatible API contract; recompose with meta.apiVersion: ${WORKFLOW_API_VERSION}. No children were launched`,
141
+ typeof entry.project === "string" ? entry.project : undefined,
133
142
  );
134
143
  }
135
144
  seenRunStart = entry.runId === runId;
@@ -190,13 +199,19 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
190
199
  }
191
200
 
192
201
  if (!seenRunStart) {
193
- throw new Error(`Workflow journal ${path} does not match run id ${runId}`);
202
+ throw new WorkflowJournalReadError(`Workflow journal ${path} does not match run id ${runId}`, project);
194
203
  }
195
204
  return { launch, runId, path, agentResults, name, source, project, status, outcome, result, error: terminalError, children: children.filter(Boolean) };
196
205
  }
197
206
 
207
+ /** A listed journal that failed to load: shown as its own row instead of failing the whole listing. */
208
+ export interface UnreadableWorkflowJournal {
209
+ runId: string;
210
+ unreadable: string;
211
+ }
212
+
198
213
  export interface WorkflowJournalPage {
199
- items: LoadedWorkflowJournal[];
214
+ items: Array<LoadedWorkflowJournal | UnreadableWorkflowJournal>;
200
215
  nextCursor?: string;
201
216
  }
202
217
 
@@ -228,12 +243,19 @@ export async function listWorkflowJournals(dir: string, project: string, limit =
228
243
  }
229
244
  const start = workflowListStart(names, cursor);
230
245
  const pageLimit = Math.max(1, Math.min(100, limit));
231
- const journals: LoadedWorkflowJournal[] = [];
246
+ const journals: WorkflowJournalPage["items"] = [];
232
247
  let index = start;
233
248
  for (; index < names.length && index < start + 200; index++) {
234
249
  const name = names[index]!;
235
- const journal = await loadWorkflowJournal(dir, name.slice(4, -6));
236
- if (journal?.project === project) journals.push(journal);
250
+ const runId = name.slice(4, -6);
251
+ try {
252
+ const journal = await loadWorkflowJournal(dir, runId);
253
+ if (journal?.project === project) journals.push(journal);
254
+ } catch (error) {
255
+ // The directory is this session's own; a journal whose project cannot be read is still listed so it is never silently dropped.
256
+ const owner = error instanceof WorkflowJournalReadError ? error.project : undefined;
257
+ if (owner === undefined || owner === project) journals.push({ runId, unreadable: error instanceof Error ? error.message : String(error) });
258
+ }
237
259
  if (journals.length >= pageLimit) {
238
260
  index++;
239
261
  break;
@@ -6,6 +6,7 @@ import {
6
6
  createWorkflowJournalWriter,
7
7
  createWorkflowRunIdentity,
8
8
  getSessionWorkflowDir,
9
+ WorkflowJournalReadError,
9
10
  loadWorkflowJournal,
10
11
  persistWorkflowScript,
11
12
  type WorkflowJournalWriter,
@@ -270,19 +271,23 @@ export async function prepareWorkflowToolSource(
270
271
  try {
271
272
  journal = await loadWorkflowJournal(sessionWorkflowDir, resumeFromRunId);
272
273
  } catch (error) {
273
- const message = `Cannot resume workflow: ${error instanceof Error ? error.message : String(error)}`;
274
- return sourceError(message, {
275
- name: metaName,
276
- error: message,
277
- logs: source.warnings,
278
- source: source.source,
279
- sourcePath: source.sourcePath,
280
- scriptPath,
281
- runId: identity.runId,
282
- resumeFromRunId,
283
- });
274
+ // Another project's unreadable journal is reported exactly like a missing one.
275
+ if (!(error instanceof WorkflowJournalReadError) || error.project === undefined || error.project === project) {
276
+ const message = `Cannot resume workflow: ${error instanceof Error ? error.message : String(error)}`;
277
+ return sourceError(message, {
278
+ name: metaName,
279
+ error: message,
280
+ logs: source.warnings,
281
+ source: source.source,
282
+ sourcePath: source.sourcePath,
283
+ scriptPath,
284
+ runId: identity.runId,
285
+ resumeFromRunId,
286
+ });
287
+ }
284
288
  }
285
- if (!journal) {
289
+ // Another project's run is indistinguishable from an unknown one.
290
+ if (!journal || journal.project !== project) {
286
291
  const message = `Cannot resume workflow: run journal not found for ${resumeFromRunId}.`;
287
292
  return sourceError(message, {
288
293
  name: metaName,
@@ -295,19 +300,6 @@ export async function prepareWorkflowToolSource(
295
300
  resumeFromRunId,
296
301
  });
297
302
  }
298
- if (journal.project !== project) {
299
- const message = `Cannot resume workflow: ${resumeFromRunId} belongs to a different project. No children were launched.`;
300
- return sourceError(message, {
301
- name: metaName,
302
- error: message,
303
- logs: source.warnings,
304
- source: source.source,
305
- sourcePath: source.sourcePath,
306
- scriptPath,
307
- runId: identity.runId,
308
- resumeFromRunId,
309
- });
310
- }
311
303
  resumeAgentResults = journal.agentResults;
312
304
  }
313
305
 
@@ -18,7 +18,8 @@ import { CHILD_EXCLUDED_TOOLS, spawnSubagent } from "../core/spawn.ts";
18
18
  import { createRunRecord } from "../core/run-record.ts";
19
19
  import { runRecordsDirectory } from "../core/retention.ts";
20
20
  import { captureParentContext } from "../core/parent-context.ts";
21
- import { RunRegistry } from "../core/run-registry.ts";
21
+ import { RunRegistry, type RegisteredRunHandle } from "../core/run-registry.ts";
22
+ import { ExpectedFlowError } from "../core/errors.ts";
22
23
  import { OUTPUT_PREVIEW_CHARS } from "../core/progress.ts";
23
24
  import { loadExternalCatalog, resolveExternalProfile, selectorHarness, unknownExternalProfileMessage } from "../profiles.ts";
24
25
  import { resolveExecutionProfile } from '../execution-config.ts';
@@ -585,23 +586,33 @@ export function createWorkflowTool(
585
586
  }
586
587
  };
587
588
 
588
- const registered = options.registry.start({
589
- runId: identity.runId,
590
- kind: "workflow",
591
- sessionId,
592
- sessionVersion,
593
- project,
594
- ...(background ? {} : { signal }),
595
- run: executeWorkflow,
596
- outcome: (result, workflowSignal) => ({
597
- status: result.details.status === "completed" ? "done" : result.details.status === "aborted" ? "aborted" : "error",
598
- outcome: result.details.outcome ?? (result.details.status === "completed" ? "succeeded" : result.details.status === "aborted" ? "cancelled" : "failed"),
599
- ...(result.details.result !== undefined ? { result: result.details.result } : {}),
600
- ...(workflowSignal.aborted && workflowSignal.reason !== undefined
601
- ? { error: workflowSignal.reason instanceof Error ? workflowSignal.reason.message : String(workflowSignal.reason) }
602
- : result.details.error ? { error: result.details.error } : {}),
603
- }),
604
- });
589
+ let registered: RegisteredRunHandle<ReturnType<typeof workflowResult>>;
590
+ try {
591
+ registered = options.registry.start({
592
+ runId: identity.runId,
593
+ kind: "workflow",
594
+ sessionId,
595
+ sessionVersion,
596
+ project,
597
+ ...(background ? {} : { signal }),
598
+ run: executeWorkflow,
599
+ outcome: (result, workflowSignal) => ({
600
+ status: result.details.status === "completed" ? "done" : result.details.status === "aborted" ? "aborted" : "error",
601
+ outcome: result.details.outcome ?? (result.details.status === "completed" ? "succeeded" : result.details.status === "aborted" ? "cancelled" : "failed"),
602
+ ...(result.details.result !== undefined ? { result: result.details.result } : {}),
603
+ ...(workflowSignal.aborted && workflowSignal.reason !== undefined
604
+ ? { error: workflowSignal.reason instanceof Error ? workflowSignal.reason.message : String(workflowSignal.reason) }
605
+ : result.details.error ? { error: result.details.error } : {}),
606
+ }),
607
+ });
608
+ } catch (error) {
609
+ // The run_start journal line is already written; settle it so the
610
+ // never-started run does not read as interrupted forever.
611
+ const message = error instanceof Error ? error.message : String(error);
612
+ await journalWriter?.fail(message).catch(() => undefined);
613
+ if (!(error instanceof ExpectedFlowError)) throw error;
614
+ return workflowError(`Workflow "${metaName}" was not started: ${message}`, { name: metaName, error: message, status: "error", outcome: "failed", runId: identity.runId, journalPath: journalWriter?.path });
615
+ }
605
616
  emit();
606
617
  if (!background) return await registered.result;
607
618
  return workflowResult(