Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions extensions/workflows/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
refreshWorkflowGraph,
type TranscriptEntry,
type WorkflowDelivery,
type WorkflowDetails,
} from "./model.ts";
import {
Expand Down Expand Up @@ -214,6 +215,25 @@ export function persistWorkflowJson(
);
}

/**
* Update only the durable delivery receipt. Completion delivery may retain a
* compact memory projection after the run has been evicted, so rewriting the
* whole details object here would risk replacing exact result artifacts with
* that projection.
*/
export function persistWorkflowDeliveryState(
runDir: string,
delivery: WorkflowDelivery,
) {
const file = path.join(runDir, "workflow.json");
const raw: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`Invalid persisted workflow details: ${file}`);
}
const next = { ...(raw as Record<string, unknown>), delivery };
writeFileAtomic(file, JSON.stringify(next));
}

/** Coalesce live checkpoints while keeping final persistence synchronous. */
export function createWorkflowPersistence(
runDir: string,
Expand Down
21 changes: 18 additions & 3 deletions extensions/workflows/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,22 +569,31 @@ export function loadRunEntries(
referencedRunIds: ReadonlySet<string>,
/** Hide runs untouched by the current request; live runs always show. */
startedSince = 0,
/** Bounded settled projections used only if canonical disk state is unreadable. */
retained: ReadonlyMap<string, WorkflowDetails> = new Map(),
): RunEntry[] {
const entries: RunEntry[] = [];
for (const runId of listPersistedRunIds()) {
const runIds = new Set([...listPersistedRunIds(), ...retained.keys()]);
for (const runId of runIds) {
const live = active.get(runId);
if (live) {
entries.push({ runId, details: live, live: true });
continue;
}
const details = readPersistedWorkflowDetails(runId, {
const persisted = readPersistedWorkflowDetails(runId, {
hydrateArtifacts: true,
});
const retainedDetails = retained.get(runId);
const details = persisted ?? retainedDetails;
if (!details) continue;
const fromRetention =
persisted === undefined && retainedDetails !== undefined;
const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
if (
touchedAt < startedSince ||
(details.sessionId !== sessionId && !referencedRunIds.has(runId))
(!fromRetention &&
details.sessionId !== sessionId &&
!referencedRunIds.has(runId))
) {
continue;
}
Expand Down Expand Up @@ -716,6 +725,7 @@ export class WorkflowDashboard {
private theme: Theme;
private keybindings: KeybindingsManager;
private getActive: () => Map<string, WorkflowDetails>;
private getRetained: () => ReadonlyMap<string, WorkflowDetails>;
private sessionId: string;
private referencedRunIds: ReadonlySet<string>;
private startedSince: number;
Expand All @@ -734,12 +744,14 @@ export class WorkflowDashboard {
close: () => void,
initialRunId?: string,
onAbort?: (runId: string) => boolean,
getRetained: () => ReadonlyMap<string, WorkflowDetails> = () => new Map(),
initialToolsExpanded = false,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.getActive = getActive;
this.getRetained = getRetained;
this.sessionId = sessionId;
this.referencedRunIds = referencedRunIds;
this.startedSince = startedSince;
Expand Down Expand Up @@ -811,6 +823,7 @@ export class WorkflowDashboard {
this.sessionId,
this.referencedRunIds,
this.startedSince,
this.getRetained(),
);
if (selected) {
const index = this.entries.findIndex((e) => e.runId === selected);
Expand Down Expand Up @@ -1412,6 +1425,7 @@ export async function showWorkflowDashboard(
initialRunId?: string,
startedSince = 0,
onAbort?: (runId: string) => boolean,
getRetained?: () => ReadonlyMap<string, WorkflowDetails>,
) {
await ctx.ui.custom<void>(
(tui, theme, keybindings, done) => {
Expand All @@ -1429,6 +1443,7 @@ export async function showWorkflowDashboard(
},
initialRunId,
onAbort,
getRetained,
ctx.ui.getToolsExpanded(),
);
return dashboard;
Expand Down
131 changes: 92 additions & 39 deletions extensions/workflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
createWorkflowPersistence,
loadJournal,
persistWorkflowAgentResult,
persistWorkflowDeliveryState,
persistWorkflowJson,
persistWorkflowTerminalState,
} from "./artifacts.ts";
Expand Down Expand Up @@ -199,6 +200,11 @@ import {
type WorkflowAgentSessionFactory,
type WorkflowModel,
} from "./runner.ts";
import {
createWorkflowSettledRunRetention,
projectWorkflowDetails,
type WorkflowSettledRunRetentionOptions,
} from "./retention.ts";
import { runWorkflowSandbox } from "./sandbox.ts";
import { writeFileAtomic } from "./serialization.ts";
import {
Expand Down Expand Up @@ -782,20 +788,30 @@ function runDetailText(
return `Run ${run.runId} — ${run.status}`;
}

export default function workflows(pi: ExtensionAPI) {
export interface WorkflowExtensionOptions {
/** Test/configuration seam for the settled session-memory projection. */
readonly settledRetention?: WorkflowSettledRunRetentionOptions;
}

const WORKFLOW_DELIVERY_DETAILS_MAX_BYTES = 128 * 1024;

export default function workflows(
pi: ExtensionAPI,
options: WorkflowExtensionOptions = {},
) {
/** Live background runs, for /workflows and shutdown cleanup. */
const activeRuns = new Map<string, ActiveWorkflowRunLifecycle>();
const activeDetails = () =>
new Map(
[...activeRuns].map(([runId, run]) => [runId, run.details] as const),
);
// This is an ephemeral UI projection. Persistence and result delivery own
// the durable history and pending completion independently.
const settledRuns = new Map<string, WorkflowDetails>();
let settledRunsEvicted = 0;
/** Keep current-session settled records live for their ephemeral UI renderer. */
const dashboardDetails = () =>
new Map<string, WorkflowDetails>([...settledRuns, ...activeDetails()]);
const settledRuns = createWorkflowSettledRunRetention(
options.settledRetention,
);
/** Disk remains canonical; retained projections cover transient read failures. */
const dashboardDetails = () => activeDetails();
const dashboardRetainedDetails = () =>
new Map<string, WorkflowDetails>(settledRuns.entriesArray());
const registerStableToolFamily = () =>
patchOwnedTools(pi, "workflows", {
enable: OPENPI_TOOL_SURFACE.workflows.entry,
Expand All @@ -814,21 +830,40 @@ export default function workflows(pi: ExtensionAPI) {
): WorkflowCompletionEnvelope => {
const deliveryId = details.delivery?.id;
if (!deliveryId) throw new Error("Workflow delivery identity is missing");
const projection = projectWorkflowDetails(
details,
WORKFLOW_DELIVERY_DETAILS_MAX_BYTES,
);
if (!projection) {
throw new Error(
`Workflow ${details.runId} cannot create a bounded completion projection`,
);
}
return {
deliveryId,
runId: details.runId,
details,
details: projection,
};
};
const resultDelivery = createWorkflowResultDelivery({
isIdle: () => lastContext?.isIdle() ?? false,
persist: (details) =>
persistWorkflowJson(
persist: (details) => {
if (!details.delivery)
throw new Error("Workflow delivery identity is missing");
persistWorkflowDeliveryState(
path.join(getAgentDir(), "workflows", details.runId),
details,
),
details.delivery,
);
},
deliver: async (envelopes, wake) => {
const sourceEntries = envelopes.map((envelope) => ({
const hydrated = envelopes.map((envelope) => ({
...envelope,
details:
readPersistedWorkflowDetails(envelope.runId, {
hydrateArtifacts: true,
}) ?? envelope.details,
}));
const sourceEntries = hydrated.map((envelope) => ({
deliveryId: envelope.deliveryId,
details: envelope.details,
runDir: path.join(getAgentDir(), "workflows", envelope.runId),
Expand Down Expand Up @@ -890,7 +925,7 @@ export default function workflows(pi: ExtensionAPI) {
const running = newestEntry(
[...activeRuns].map(([runId, run]) => [runId, run.details] as const),
);
return running ?? newestEntry(settledRuns);
return running ?? newestEntry(settledRuns.entriesArray());
};

const updateWorkflowWidget = () => {
Expand Down Expand Up @@ -954,8 +989,7 @@ export default function workflows(pi: ExtensionAPI) {
};

const recordSettledRun = (details: WorkflowDetails) => {
settledRuns.set(details.runId, details);
settledRunsEvicted += evictOldestSettledRuns(settledRuns).length;
settledRuns.set(details);
if (details.status === "completed") completedRuns += 1;
else failedRuns += 1;
};
Expand All @@ -982,6 +1016,7 @@ export default function workflows(pi: ExtensionAPI) {
initialRunId,
startedSince,
stopRun,
dashboardRetainedDetails,
);
acknowledgeSettledRuns();
} finally {
Expand Down Expand Up @@ -1025,8 +1060,7 @@ export default function workflows(pi: ExtensionAPI) {
turnStartedAt = 0;
completedRuns = 0;
failedRuns = 0;
settledRunsEvicted = 0;
settledRuns.clear();
settledRuns.resetSession();
installWorkflowNavigation(ctx);
updateIndicator();

Expand Down Expand Up @@ -2350,24 +2384,25 @@ export default function workflows(pi: ExtensionAPI) {

const active = activeRuns.get(resolution.runId);
if (active) return { ok: true, details: active.details } as const;
const settled = settledRuns.get(resolution.runId);
if (settled) return { ok: true, details: settled } as const;

const details = readPersistedWorkflowDetails(resolution.runId, {
hydrateArtifacts: true,
});
if (!details) {
if (details) {
// A run absent from activeRuns cannot still be running this session; a
// persisted "running" is a run that was hard-killed or missed the
// shutdown settle deadline.
return {
ok: false,
error: `Workflow run ${resolution.runId} could not be read.`,
ok: true,
details: recoverStaleWorkflowDetails(details),
} as const;
}
// A run absent from activeRuns cannot still be running this session; a
// persisted "running" is a run that was hard-killed or missed the
// shutdown settle deadline.
// Keep the bounded projection as a diagnostic fallback when an artifact is
// temporarily unreadable. An explicit id still resolves to a known run.
const settled = settledRuns.get(resolution.runId);
if (settled) return { ok: true, details: settled } as const;
return {
ok: true,
details: recoverStaleWorkflowDetails(details),
ok: false,
error: `Workflow run ${resolution.runId} could not be read.`,
} as const;
};

Expand Down Expand Up @@ -2440,39 +2475,57 @@ export default function workflows(pi: ExtensionAPI) {
if (!resolution.ok) throw new Error(resolution.error);
const details = resolution.details;
const runDir = path.join(getAgentDir(), "workflows", details.runId);
const retention = settledRuns.stats;
return Promise.resolve({
content: [
{ type: "text", text: buildWorkflowStatusSummary(details, runDir) },
],
details: { runs: [summarize(details)], settledRunsEvicted },
details: {
runs: [summarize(details)],
retention,
settledRunsEvicted: retention.settledRunsEvicted,
},
});
}
const retentionNote =
settledRunsEvicted > 0
? `\n${settledRunsEvicted} settled workflow detail${settledRunsEvicted === 1 ? "" : "s"} evicted from memory; persisted artifacts retained.`
: "";
const runs = [
...[...activeRuns.values()].map((run) => run.details),
...settledRuns.values(),
];
const retention = settledRuns.stats;
if (runs.length === 0) {
return Promise.resolve({
content: [
{
type: "text",
text: `No active or recently finished workflows.${retentionNote}`,
text:
retention.evictedRuns > 0
? `No active or retained workflows. ${retention.evictedRuns} settled run(s) omitted from memory in the current session; canonical artifacts remain available on disk.`
: "No active or recently finished workflows.",
},
],
details: { runs: [], settledRunsEvicted },
details: {
runs: [],
retention,
settledRunsEvicted: retention.settledRunsEvicted,
},
});
}
const lines = runs.map((d) => {
const { done, failed, uncertain } = countStates(d);
return `${d.runId}${d.name ? ` "${d.name}"` : ""} — ${statusWord(d.status)} · ${done + failed}/${d.agents.length} agents${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""}`;
});
if (retention.evictedRuns > 0) {
lines.push(
`Retention (current session): ${retention.retainedRuns} settled projection(s) retained; ${retention.evictedRuns} evicted/omitted (${retention.evictedBytes} UTF-8 bytes). Canonical artifacts remain available on disk.`,
);
}
return Promise.resolve({
content: [{ type: "text", text: lines.join("\n") + retentionNote }],
details: { runs: runs.map(summarize), settledRunsEvicted },
content: [{ type: "text", text: lines.join("\n") }],
details: {
runs: runs.map(summarize),
retention,
settledRunsEvicted: retention.settledRunsEvicted,
},
});
},
});
Expand Down
17 changes: 17 additions & 0 deletions extensions/workflows/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ export interface WorkflowLogEntry {
kind?: "pipeline-drop";
}

/** Metadata attached only to the bounded in-memory settled-run projection. */
export interface WorkflowMemoryProjection {
readonly kind: "settled";
readonly maxBytes: number;
readonly bytes: number;
readonly truncated: boolean;
readonly omitted: {
readonly agents: number;
readonly logs: number;
readonly transcriptEntries: number;
readonly result: boolean;
readonly graph: boolean;
};
}

export interface WorkflowDetails {
runId: string;
/** Pi session that launched this run. */
Expand Down Expand Up @@ -174,6 +189,8 @@ export interface WorkflowDetails {
/** Read-only lineage projection; never execution or admission authority. */
graph?: WorkflowGraphProjection;
error?: string;
/** Present only on the session-memory projection, never canonical history. */
memoryProjection?: WorkflowMemoryProjection;
}

/** Bounded tool-result projection; authoritative details remain in run artifacts. */
Expand Down
Loading
Loading