210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
ExtensionAPI,
|
|
ExtensionContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import type { WorkflowRun } from "./domain.ts";
|
|
import type { ContextTransitionRuntime } from "./context-transition.ts";
|
|
import { deriveRunLabel } from "./run-label.ts";
|
|
|
|
const NOTIFICATION_TYPE = "workflow-notification";
|
|
const DELIVERY_OWNER_TOKEN = `${process.pid}:${randomUUID()}`;
|
|
const ACTIVE_DELIVERIES = new Map<
|
|
string,
|
|
{ scheduledAt: number; runId: string }
|
|
>();
|
|
const DELIVERY_RETRY_MS = 30_000;
|
|
|
|
function receiptKeys(ctx: ExtensionContext) {
|
|
const keys = new Set<string>();
|
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
if (entry.type !== "message") continue;
|
|
const message = entry.message as unknown as Record<string, unknown>;
|
|
if (message.role !== "custom" || message.customType !== NOTIFICATION_TYPE) {
|
|
continue;
|
|
}
|
|
const details = message.details;
|
|
if (!details || typeof details !== "object") continue;
|
|
const key = (details as Record<string, unknown>).deliveryKey;
|
|
if (typeof key === "string") keys.add(key);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
function deliveryIsCurrent(run: WorkflowRun, key: string) {
|
|
if (key.startsWith("gate:")) {
|
|
const gate = [...run.gates]
|
|
.reverse()
|
|
.find((candidate) => key.includes(`:${candidate.id}:`));
|
|
const activeGate = [...run.gates]
|
|
.reverse()
|
|
.find((candidate) => candidate.status === "open");
|
|
return Boolean(gate && gate.status === "open" && activeGate?.id === gate.id);
|
|
}
|
|
if (key.startsWith("terminal:")) {
|
|
return key.includes(`:${run.status}:`) &&
|
|
(run.status === "completed" ||
|
|
run.status === "failed" ||
|
|
run.status === "aborted");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function deliveryText(run: WorkflowRun, key: string) {
|
|
const label = deriveRunLabel(run);
|
|
if (key.startsWith("gate:")) {
|
|
const gate = [...run.gates]
|
|
.reverse()
|
|
.find((candidate) => key.includes(`:${candidate.id}:`));
|
|
return gate
|
|
? `workflow ${label} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.`
|
|
: `workflow ${label} is waiting for a Gate Decision.`;
|
|
}
|
|
const report = run.reportArtifact?.path
|
|
? ` Report: ${run.reportArtifact.path}`
|
|
: "";
|
|
return `workflow ${label} finished with status ${run.status}.${report}`;
|
|
}
|
|
|
|
function belongsToSession(run: WorkflowRun, sessionId: string) {
|
|
const activeSession =
|
|
run.lease?.activeParentSessionId ?? run.parentSessionIds.at(-1);
|
|
return activeSession === sessionId;
|
|
}
|
|
|
|
export async function reconcileWorkflowDeliveries(
|
|
pi: Pick<ExtensionAPI, "sendMessage">,
|
|
runtime: ContextTransitionRuntime,
|
|
ctx: ExtensionContext,
|
|
) {
|
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
if (!sessionId) return;
|
|
let receipts = receiptKeys(ctx);
|
|
for (const run of await runtime.store.listRuns()) {
|
|
if (!belongsToSession(run, sessionId)) continue;
|
|
if (
|
|
(run.status === "completed" ||
|
|
run.status === "failed" ||
|
|
run.status === "aborted") &&
|
|
run.reportArtifact
|
|
) {
|
|
await runtime.store.ensureResult(run);
|
|
}
|
|
for (const delivery of run.deliveries) {
|
|
if (delivery.state === "sent") continue;
|
|
if (!deliveryIsCurrent(run, delivery.key)) {
|
|
ACTIVE_DELIVERIES.delete(delivery.key);
|
|
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
|
|
await runtime.store.markDeliverySent(
|
|
run.runId,
|
|
delivery.key,
|
|
Date.now(),
|
|
);
|
|
continue;
|
|
}
|
|
if (receipts.has(delivery.key)) {
|
|
ACTIVE_DELIVERIES.delete(delivery.key);
|
|
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
|
|
await runtime.store.markDeliverySent(
|
|
run.runId,
|
|
delivery.key,
|
|
Date.now(),
|
|
);
|
|
continue;
|
|
}
|
|
const active = ACTIVE_DELIVERIES.get(delivery.key);
|
|
if (
|
|
active &&
|
|
Date.now() - active.scheduledAt < DELIVERY_RETRY_MS
|
|
) {
|
|
continue;
|
|
}
|
|
const claimedAt = Date.now();
|
|
const claimed = await runtime.store.claimDelivery(
|
|
run.runId,
|
|
delivery.key,
|
|
DELIVERY_OWNER_TOKEN,
|
|
claimedAt,
|
|
);
|
|
if (!claimed) continue;
|
|
ACTIVE_DELIVERIES.set(delivery.key, {
|
|
scheduledAt: claimedAt,
|
|
runId: run.runId,
|
|
});
|
|
try {
|
|
pi.sendMessage(
|
|
{
|
|
customType: NOTIFICATION_TYPE,
|
|
content: deliveryText(run, delivery.key),
|
|
display: true,
|
|
details: {
|
|
deliveryKey: delivery.key,
|
|
runId: run.runId,
|
|
revision: run.revision,
|
|
status: run.status,
|
|
reportPath: run.reportArtifact?.path,
|
|
},
|
|
},
|
|
run.background
|
|
? { deliverAs: "followUp", triggerTurn: true }
|
|
: { deliverAs: "steer" },
|
|
);
|
|
receipts = receiptKeys(ctx);
|
|
if (receipts.has(delivery.key)) {
|
|
ACTIVE_DELIVERIES.delete(delivery.key);
|
|
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
|
|
await runtime.store.markDeliverySent(
|
|
run.runId,
|
|
delivery.key,
|
|
Date.now(),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
ACTIVE_DELIVERIES.delete(delivery.key);
|
|
await runtime.store.releaseDeliveryClaim(
|
|
run.runId,
|
|
delivery.key,
|
|
DELIVERY_OWNER_TOKEN,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function registerWorkflowNotifications(
|
|
pi: ExtensionAPI,
|
|
runtime: ContextTransitionRuntime,
|
|
) {
|
|
const reconcile = async (ctx: ExtensionContext) => {
|
|
try {
|
|
await reconcileWorkflowDeliveries(pi, runtime, ctx);
|
|
} catch (error) {
|
|
ctx.ui.notify(
|
|
`workflow delivery recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
}
|
|
};
|
|
pi.on("session_start", async (_event, ctx) => reconcile(ctx));
|
|
pi.on("agent_settled", async (_event, ctx) => reconcile(ctx));
|
|
pi.on("tool_result", async (event, ctx) => {
|
|
if (event.toolName === "workflow") await reconcile(ctx);
|
|
});
|
|
pi.on("session_shutdown", async () => {
|
|
const active = [...ACTIVE_DELIVERIES.entries()];
|
|
ACTIVE_DELIVERIES.clear();
|
|
await Promise.all(
|
|
active.map(([key, delivery]) =>
|
|
runtime.store.releaseDeliveryClaim(
|
|
delivery.runId,
|
|
key,
|
|
DELIVERY_OWNER_TOKEN,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
export const workflowNotificationType = NOTIFICATION_TYPE;
|