mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
159 lines
15 KiB
TypeScript
159 lines
15 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import { canonicalJson } from "../../packages/shared/src/portability-hash.js";
|
|
import { validatePrpStructuredRunResult } from "../../packages/paperclip-runner/src/protocol/replay-contract.js";
|
|
import { pollUntil } from "./api.js";
|
|
import { hasAcpxNativeOrigin } from "./acpx-native-origin.js";
|
|
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
|
|
|
|
type Row = Record<string, any>;
|
|
type State = { issue: Row; runs: Row[]; interactions: Row[] };
|
|
type Check = { id: string; passed: boolean; detail: string };
|
|
const rec = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
|
|
const id = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(value) && !value.includes("[REDACTED]");
|
|
const digest = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
const closures = new Set(["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"]);
|
|
const terminals = new Set(["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]);
|
|
function requireProof(value: unknown, reason: string): asserts value { if (!value) throw new Error(`Pi native restart: ${reason}`); }
|
|
|
|
export interface PiRestartPending {
|
|
companyId: string; issueId: string; runId: string; interactionId: string; requestId: string;
|
|
turnId: string; itemId: string; nativeSessionId: string; sourceInstanceId: string; questionId: string;
|
|
createdSourceSeq: number; createdRowSha256: string; interactionBindingSha256: string;
|
|
}
|
|
|
|
function interactionBinding(card: Row) {
|
|
return { id: card.id, companyId: card.companyId, issueId: card.issueId, kind: card.kind,
|
|
sourceRunId: card.sourceRunId, idempotencyKey: card.idempotencyKey,
|
|
continuationPolicy: card.continuationPolicy, resolverPolicy: card.resolverPolicy, payload: card.payload };
|
|
}
|
|
|
|
function inspect(state: State, events: readonly Row[], companyId: string) {
|
|
requireProof(id(companyId) && id(state.issue.id) && state.issue.companyId === companyId && state.runs.length === 1 && state.interactions.length === 1,
|
|
"one company-scoped task, run and question required");
|
|
const run = state.runs[0]!, card = state.interactions[0]!;
|
|
requireProof(id(run.id) && run.companyId === companyId && run.nativeIssueId === state.issue.id && run.runtimeMode === "native"
|
|
&& id(run.nativeSessionId) && id(run.runnerInstanceId), "native run/session identity missing");
|
|
requireProof(id(card.id) && card.companyId === companyId && card.issueId === state.issue.id && card.kind === "ask_user_questions"
|
|
&& card.sourceRunId === run.id && card.continuationPolicy === "none" && card.resolverPolicy === "human_only", "question is not the native callback");
|
|
requireProof(Array.isArray(events) && events.length > 0 && events.length <= 20_000, "bounded durable events required");
|
|
const rows = events.filter(row => rec(row.payload).prpEvent !== undefined).map(row => ({ row, event: rec(rec(row.payload).prpEvent) })).sort((a, b) => a.row.seq - b.row.seq);
|
|
const seenRows = new Set<number>(), seenSources = new Set<string>(), cursors = new Map<string, number>();
|
|
for (const { row, event } of rows) {
|
|
requireProof(row.companyId === companyId && row.runId === run.id && isValidNativePrpEnvelope(event, row.protocolSchemaVersion)
|
|
&& event.runId === run.id && event.eventType === row.eventType && ["runner", "control_plane"].includes(event.sourceKind)
|
|
&& Number.isSafeInteger(row.seq) && row.seq > 0 && !seenRows.has(row.seq)
|
|
&& id(event.sourceInstanceId) && Number.isSafeInteger(event.sourceSeq) && event.sourceSeq > (cursors.get(event.sourceInstanceId) ?? 0)
|
|
&& event.sourceEventId === `${event.sourceInstanceId}:${run.id}:${event.sourceSeq}` && !seenSources.has(event.sourceEventId),
|
|
"foreign, duplicate or reordered durable event");
|
|
// The production finalizer owns this separate, stable two-event stream.
|
|
// It cannot impersonate a native request/turn, nor can a runner accept itself.
|
|
if (event.sourceKind === "control_plane") {
|
|
requireProof(event.sourceInstanceId === `${run.runnerInstanceId}:control` && event.normalizedSessionId === run.nativeSessionId
|
|
&& row.sourceInstanceId === event.sourceInstanceId && row.sourceSeq === event.sourceSeq && row.sourceEventId === event.sourceEventId
|
|
&& ((event.eventType === "run.result.accepted" && event.sourceSeq === 1) || (event.eventType === "run.terminal" && event.sourceSeq === 2)),
|
|
"foreign or unexpected control-plane completion receipt");
|
|
} else requireProof(!["run.result.accepted", "run.terminal"].includes(event.eventType), "runner cannot claim control-plane completion");
|
|
seenRows.add(row.seq); seenSources.add(event.sourceEventId); cursors.set(event.sourceInstanceId, event.sourceSeq);
|
|
}
|
|
const created = rows.filter(x => x.event.eventType === "runtime_request.created");
|
|
requireProof(created.length === 1, "exactly one native request creation required");
|
|
const opening = created[0]!, request = rec(opening.event.payload?.request), questionSet = rec(request.input);
|
|
const question = Array.isArray(questionSet.questions) && questionSet.questions.length === 1 ? rec(questionSet.questions[0]) : {};
|
|
requireProof(request.schema === "paperclip.runtime_request.v2" && request.requestKind === "runtime" && request.type === "input" && request.status === "pending"
|
|
&& id(request.requestId) && id(request.itemId) && id(request.turnId) && request.turnId === opening.event.turnId && request.itemId === opening.event.itemId
|
|
&& hasAcpxNativeOrigin(request.origin, "pi", "elicitation/create") && questionSet.schema === "paperclip.question_set.v1"
|
|
&& id(question.id) && question.answerMode === "text" && question.header === "Pi native restart"
|
|
&& card.payload?.runtimeRequestId === request.requestId && digest(card.payload.questionSet) === digest(questionSet)
|
|
&& card.idempotencyKey === `paperclip-runner-question:${run.id}:${request.requestId}`, "canonical Pi input request identity missing");
|
|
requireProof(rows.filter(x => x.event.turnId != null || x.event.eventType.startsWith("turn.") || x.event.eventType.startsWith("runtime_request.")).every(({ event }) => event.turnId === request.turnId
|
|
&& event.normalizedSessionId === run.nativeSessionId
|
|
&& event.sourceInstanceId === (event.sourceKind === "runner" ? run.runnerInstanceId : `${run.runnerInstanceId}:control`)), "turn, native session or producer was replaced");
|
|
requireProof(rows.filter(x => x.event.sourceKind === "control_plane").every(x => x.event.turnId === request.turnId), "control-plane result belongs to another turn");
|
|
const binding: PiRestartPending = { companyId, issueId: state.issue.id, runId: run.id, interactionId: card.id, requestId: request.requestId,
|
|
turnId: request.turnId, itemId: request.itemId, nativeSessionId: run.nativeSessionId, sourceInstanceId: run.runnerInstanceId, questionId: question.id,
|
|
createdSourceSeq: opening.event.sourceSeq, createdRowSha256: digest(opening.row), interactionBindingSha256: digest(interactionBinding(card)) };
|
|
return { run, card, rows, binding };
|
|
}
|
|
|
|
/** Grade public state and PRP receipts; assistant prose is never restart proof. */
|
|
export function observePiRestartPending(state: State, events: readonly Row[], companyId: string): PiRestartPending {
|
|
const { run, card, rows, binding } = inspect(state, events, companyId);
|
|
requireProof(state.issue.status === "in_progress" && run.status === "running" && card.status === "pending" && card.result == null
|
|
&& run.resultJson?.nativeCancellation == null && run.resultJson?.startupCancellation == null
|
|
&& !rows.some(x => closures.has(x.event.eventType) || terminals.has(x.event.eventType) || x.event.sourceKind === "control_plane"), "question is no longer unanswered in a live run");
|
|
return binding;
|
|
}
|
|
|
|
export function gradePiRestartCompletion(state: State, events: readonly Row[], pending: PiRestartPending, answer: string, proof: unknown): boolean {
|
|
const { run, card, rows, binding } = inspect(state, events, pending.companyId);
|
|
requireProof(digest(binding) === digest(pending), "original request/run/source identity changed");
|
|
const resolved = rows.filter(x => closures.has(x.event.eventType)), terminal = rows.filter(x => terminals.has(x.event.eventType));
|
|
requireProof(resolved.length === 1 && resolved[0]!.event.eventType === "runtime_request.resolved", "exactly one durable native resolution required");
|
|
const delivered = resolved[0]!.event, response = rec(delivered.payload);
|
|
requireProof(response.requestId === pending.requestId && response.turnId === pending.turnId && response.itemId === pending.itemId
|
|
&& response.action === "submit" && delivered.itemId === pending.itemId && delivered.sourceSeq > pending.createdSourceSeq
|
|
&& digest(response.response) === digest({ schema: "paperclip.question_response.v1", answers: { [pending.questionId]: { text: answer } } }),
|
|
"hidden browser answer was not delivered to the original native request");
|
|
requireProof(terminal.length === 1 && terminal[0]!.event.eventType === "turn.completed" && terminal[0]!.event.payload?.status === "completed"
|
|
&& terminal[0]!.event.payload?.error == null && terminal[0]!.event.sourceSeq > delivered.sourceSeq
|
|
&& state.issue.status === "done" && run.status === "succeeded" && run.resultJson?.nativeCancellation == null && run.resultJson?.startupCancellation == null,
|
|
"original native turn did not finish exactly once");
|
|
const proposed = rows.filter(x => x.event.eventType === "run.result.proposed"), accepted = rows.filter(x => x.event.eventType === "run.result.accepted"), runTerminal = rows.filter(x => x.event.eventType === "run.terminal");
|
|
requireProof(proposed.length === 1 && accepted.length === 1 && runTerminal.length === 1, "unique proposed and control-plane accepted completion required");
|
|
const result = rec(accepted[0]!.event.payload?.result), validation = validatePrpStructuredRunResult(result);
|
|
requireProof(validation.ok && digest(validation.result) === digest(result) && result.reportedWorkDisposition === "done"
|
|
&& digest(proposed[0]!.event.payload) === digest(result)
|
|
&& proposed[0]!.row.seq > resolved[0]!.row.seq && proposed[0]!.row.seq < terminal[0]!.row.seq
|
|
&& accepted[0]!.row.seq > terminal[0]!.row.seq && runTerminal[0]!.row.seq > accepted[0]!.row.seq
|
|
&& digest(runTerminal[0]!.event.payload) === digest({ schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" }),
|
|
"control-plane completion does not accept the original successful native result");
|
|
requireProof(card.status === "answered" && Array.isArray(card.result?.answers) && card.result.answers.length === 1
|
|
&& card.result.answers[0].questionId === pending.questionId && card.result.answers[0].otherText === answer, "durable interaction answer missing or wrong");
|
|
requireProof(digest(proof) === digest({ status: "answered", value: answer }), "independent workspace proof is missing or wrong");
|
|
return true;
|
|
}
|
|
|
|
export async function runPiPendingControllerRestart(input: {
|
|
page: Page; companyId: string; deadlineAt: number; load(): Promise<State>; events(runId: string): Promise<Row[]>;
|
|
restart(): Promise<void>; settle(): Promise<State>; readProof(): Promise<unknown>;
|
|
capture(id: string, label: string, file: string): Promise<void>; evidence(name: string, data: unknown): Promise<void>;
|
|
}): Promise<Check[]> {
|
|
const checks: Check[] = [], answer = `PI-RESTART-${randomBytes(16).toString("hex")}`;
|
|
const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); requireProof(passed, detail); };
|
|
const snapshot = async (name: string) => {
|
|
const state = await input.load(), events = state.runs.length === 1 ? await input.events(state.runs[0]!.id) : [];
|
|
await input.evidence(name, { ...state, events }); return { state, events };
|
|
};
|
|
try {
|
|
await pollUntil({ label: "Pi pending native restart question", deadlineAt: input.deadlineAt, load: input.load,
|
|
accept: state => state.interactions.some(card => card.status === "pending"),
|
|
reject: state => state.runs.length > 1 || state.runs.some(run => ["failed", "cancelled", "timed_out", "succeeded"].includes(run.status)) ? "Pi ended or created another run before the pending restart boundary" : undefined });
|
|
const before = await snapshot("pi-native-restart-before.json"), pending = observePiRestartPending(before.state, before.events, input.companyId);
|
|
check("restart-pending-native-identity", true, "One unanswered native Pi callback belongs to the original running turn and session");
|
|
const composer = () => input.page.getByTestId("question-text-answer-composer").filter({ visible: true });
|
|
await expect(composer()).toHaveCount(1);
|
|
await input.capture("pi-restart-pending", "Pi native question before controller restart", "pi-restart-pending.png");
|
|
await input.restart();
|
|
await input.page.reload();
|
|
const after = await snapshot("pi-native-restart-after.json"), resumed = observePiRestartPending(after.state, after.events, input.companyId);
|
|
check("restart-same-pending-native-request", digest(resumed) === digest(pending), "Controller restart retained the same unanswered request, run, turn, native session and producer");
|
|
await expect(composer()).toHaveCount(1);
|
|
await input.capture("pi-restart-reconnected", "Same Pi question after controller restart", "pi-restart-reconnected.png");
|
|
await composer().locator('[contenteditable="true"],textarea').first().fill(answer);
|
|
const route = `/api/issues/${pending.issueId}/interactions/${pending.interactionId}/respond`;
|
|
const submitted = input.page.waitForRequest(request => new URL(request.url()).pathname === route && request.method() === "POST",
|
|
{ timeout: Math.max(1, input.deadlineAt - Date.now()) });
|
|
const button = input.page.getByRole("button", { name: after.state.interactions[0]!.payload.questionSet.submitLabel ?? "Submit answers", exact: true }).filter({ visible: true });
|
|
await expect(button).toHaveCount(1); await button.click();
|
|
const posted = (await submitted).postDataJSON();
|
|
check("restart-exact-browser-answer", Array.isArray(posted?.answers) && posted.answers.length === 1
|
|
&& posted.answers[0].questionId === pending.questionId && posted.answers[0].otherText === answer, "Actual browser POST answered the retained question with previously undisclosed text");
|
|
const final = await input.settle(), events = await input.events(pending.runId), proof = await input.readProof();
|
|
await input.evidence("pi-native-restart-final.json", { ...final, events, pending, posted, proof });
|
|
check("restart-native-answer-delivered-once", gradePiRestartCompletion(final, events, pending, answer, proof),
|
|
"One durable answer and one successful original native turn delivered the exact typed result after restart");
|
|
return checks;
|
|
} finally { await input.evidence("pi-native-restart-checks.json", checks); }
|
|
}
|