Record maintenance outcomes from workspace runs

This commit is contained in:
matamune 2026-05-16 05:50:00 +00:00
parent 425876b803
commit e5dd461d41
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
17 changed files with 538 additions and 86 deletions

View file

@ -27,6 +27,8 @@ GET /flow-events/:id
POST /flow-events/:id/retry
POST /flow-events/:id/replay
GET /maintenance-attempts
GET /maintenance-attempts/:id
POST /maintenance-attempts/:id/sync
GET /workspace-dispatches
GET /workspace-events
GET /workspace-runs
@ -68,9 +70,9 @@ poll primes `DATA_DIR/feed-state.json`; later polls append upstream activity to
work to `DATA_DIR/feed-jobs.jsonl`. Targets using `mode: "workspace_flow"`
append generic flow events to `DATA_DIR/flow-events.jsonl`, send them to the
configured workspace backend or local adapter, and record dispatch outcomes in
`DATA_DIR/workspace-dispatches.jsonl`. Each dispatch also creates a
`DATA_DIR/workspace-dispatches.jsonl`. Each dispatch also creates or updates a
patch.moi-owned `DATA_DIR/maintenance-attempts.jsonl` entry that links the
upstream update to workspace run ids and future candidate refs.
upstream update to workspace run ids, final flow outcome, and candidate refs.
## Documentation

View file

@ -208,7 +208,11 @@ export async function pollFeedSource(input: {
await input.store.appendWorkspaceDispatch(workspaceDispatch.record);
if (workspaceDispatch.event) {
await input.store.appendMaintenanceAttempt(
maintenanceAttemptForWorkspaceDispatch(workspaceDispatch.event, workspaceDispatch.record),
maintenanceAttemptForWorkspaceDispatch(
workspaceDispatch.event,
workspaceDispatch.record,
workspaceDispatch.result?.runs,
),
);
}
if (workspaceDispatch.record.status === "dispatched") {

View file

@ -1,9 +1,16 @@
import type {
FlowDispatchResult,
FlowReplayResult,
FlowRunView,
} from "@peezy.tech/flow-runtime/client";
import type {
CandidateRefRecord,
FeedWorkspaceFlowTarget,
FeedSignal,
FlowDispatchRecord,
FlowEvent,
MaintenanceAttemptRecord,
MaintenanceAttemptStatus,
} from "./types";
import {
createPatchWorkspaceBackend,
@ -16,6 +23,16 @@ const serviceSource = "patch";
export type FlowDispatchConfig = WorkspaceBackendConfig;
export type WorkspaceDispatchConfig = WorkspaceBackendConfig;
export type WorkspaceDispatchOutcome = {
record: FlowDispatchRecord;
result?: FlowDispatchResult;
};
export type WorkspaceReplayOutcome = {
record: FlowDispatchRecord;
result?: FlowReplayResult;
};
function isWorkspaceFlowTarget(value: unknown): value is FeedWorkspaceFlowTarget {
return (
typeof value === "object" &&
@ -107,39 +124,52 @@ export async function dispatchWorkspaceEvent(
target: Partial<FeedWorkspaceFlowTarget> = {},
config: WorkspaceDispatchConfig = {},
): Promise<FlowDispatchRecord> {
return (await dispatchWorkspaceEventDetailed(event, target, config)).record;
}
export async function dispatchWorkspaceEventDetailed(
event: FlowEvent,
target: Partial<FeedWorkspaceFlowTarget> = {},
config: WorkspaceDispatchConfig = {},
): Promise<WorkspaceDispatchOutcome> {
const workspaceTarget = { mode: "workspace_flow" as const, eventType: event.type, ...target };
const backend = createPatchWorkspaceBackend(workspaceTarget, config);
try {
const result = await backend.client.dispatchEvent(event);
return {
eventId: event.id,
eventType: event.type,
operation: "dispatch",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl,
status: "dispatched",
runIds: result.runIds,
matched: result.matched,
idempotent: result.idempotent,
createdAt: new Date().toISOString(),
result,
record: {
eventId: event.id,
eventType: event.type,
operation: "dispatch",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl,
status: "dispatched",
runIds: result.runIds,
matched: result.matched,
idempotent: result.idempotent,
createdAt: new Date().toISOString(),
},
};
} catch (error) {
const httpStatus = httpStatusFromError(error);
return {
eventId: event.id,
eventType: event.type,
operation: "dispatch",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl,
status: "failed",
...(httpStatus ? { httpStatus } : {}),
error: error instanceof Error ? error.message : String(error),
createdAt: new Date().toISOString(),
record: {
eventId: event.id,
eventType: event.type,
operation: "dispatch",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl,
status: "failed",
...(httpStatus ? { httpStatus } : {}),
error: error instanceof Error ? error.message : String(error),
createdAt: new Date().toISOString(),
},
};
}
}
@ -157,6 +187,14 @@ export async function replayWorkspaceEvent(
target: Partial<FeedWorkspaceFlowTarget> = {},
config: WorkspaceDispatchConfig = {},
): Promise<FlowDispatchRecord> {
return (await replayWorkspaceEventDetailed(event, target, config)).record;
}
export async function replayWorkspaceEventDetailed(
event: FlowEvent,
target: Partial<FeedWorkspaceFlowTarget> = {},
config: WorkspaceDispatchConfig = {},
): Promise<WorkspaceReplayOutcome> {
const env = config.env ?? process.env;
const workspaceTarget = { mode: "workspace_flow" as const, eventType: event.type, ...target };
const backend = createPatchWorkspaceBackend(workspaceTarget, config);
@ -167,33 +205,38 @@ export async function replayWorkspaceEvent(
? await backend.client.replayEvent(event.id, { wait: false })
: await backend.client.dispatchEvent(event);
return {
eventId: event.id,
eventType: event.type,
operation: "replay",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl ? `${backend.eventsUrl}/${encodeURIComponent(event.id)}/replay` : undefined,
status: "dispatched",
runIds: result.runIds,
matched: result.matched,
idempotent: result.idempotent,
createdAt: new Date().toISOString(),
result,
record: {
eventId: event.id,
eventType: event.type,
operation: "replay",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl ? `${backend.eventsUrl}/${encodeURIComponent(event.id)}/replay` : undefined,
status: "dispatched",
runIds: result.runIds,
matched: result.matched,
idempotent: result.idempotent,
createdAt: new Date().toISOString(),
},
};
} catch (error) {
const httpStatus = httpStatusFromError(error);
return {
eventId: event.id,
eventType: event.type,
operation: "replay",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl,
status: "failed",
...(httpStatus ? { httpStatus } : {}),
error: error instanceof Error ? error.message : String(error),
createdAt: new Date().toISOString(),
record: {
eventId: event.id,
eventType: event.type,
operation: "replay",
target: backend.mode === "local" ? "local" : "workspace-backend",
transport: backend.mode,
workspaceBackendUrl: backend.url,
url: backend.eventsUrl,
status: "failed",
...(httpStatus ? { httpStatus } : {}),
error: error instanceof Error ? error.message : String(error),
createdAt: new Date().toISOString(),
},
};
}
}
@ -224,7 +267,7 @@ export async function listWorkspaceEvents(config: WorkspaceDispatchConfig = {},
export async function dispatchWorkspaceEventForFeedSignal(
signal: FeedSignal,
config: WorkspaceDispatchConfig = {},
): Promise<{ event?: FlowEvent<Record<string, unknown>>; record?: FlowDispatchRecord }> {
): Promise<{ event?: FlowEvent<Record<string, unknown>>; record?: FlowDispatchRecord; result?: FlowDispatchResult }> {
if (!isWorkspaceFlowTarget(signal.target)) {
return {};
}
@ -234,10 +277,8 @@ export async function dispatchWorkspaceEventForFeedSignal(
return {};
}
return {
event,
record: await dispatchWorkspaceEvent(event, signal.target, config),
};
const outcome = await dispatchWorkspaceEventDetailed(event, signal.target, config);
return { event, ...outcome };
}
export async function dispatchFlowEventForFeedSignal(
@ -250,13 +291,15 @@ export async function dispatchFlowEventForFeedSignal(
export function maintenanceAttemptForWorkspaceDispatch(
event: FlowEvent,
record: FlowDispatchRecord,
runs: FlowRunView[] = [],
): MaintenanceAttemptRecord {
const payload = typeof event.payload === "object" && event.payload !== null
? event.payload as Record<string, unknown>
: {};
const operation = record.operation ?? "dispatch";
const createdAt = record.createdAt;
return {
return maintenanceAttemptWithWorkspaceRuns({
id: `${event.id}:${operation}:${record.createdAt}`,
eventId: event.id,
eventType: event.type,
@ -270,7 +313,56 @@ export function maintenanceAttemptForWorkspaceDispatch(
workspaceRunIds: record.runIds ?? [],
candidateRefs: [],
error: record.error,
createdAt: record.createdAt,
createdAt,
updatedAt: createdAt,
}, runs, createdAt);
}
export function maintenanceAttemptWithWorkspaceRuns(
attempt: MaintenanceAttemptRecord,
runs: FlowRunView[],
updatedAt = new Date().toISOString(),
): MaintenanceAttemptRecord {
if (runs.length === 0) {
return attempt;
}
const statuses = Object.fromEntries(
runs.map((run) => [run.id, String(run.effectiveStatus ?? run.status ?? "unknown")]),
);
const resultPayloads = runs
.map((run) => flowResultPayload(run.resultPayload))
.filter((payload): payload is Record<string, unknown> => payload !== undefined);
const status = statusFromRuns(runs);
const message = newestString(resultPayloads.map((payload) => payload.message)) ?? attempt.message;
const candidateRefs = uniqueCandidateRefs([
...attempt.candidateRefs,
...resultPayloads.flatMap(candidateRefsFromFlowResult),
]);
const error = newestString([
...runs.map((run) => run.error),
...resultPayloads.map((payload) => payload.message).filter((_value, index) => {
const status = resultPayloads[index]?.status;
return status === "failed" || status === "blocked" || status === "needs_intervention";
}),
]) ?? attempt.error;
const completedAt = status === "started"
? attempt.completedAt
: newestString(runs.map((run) => run.completedAt)) ?? updatedAt;
return {
...attempt,
status,
workspaceRunIds: uniqueStrings([
...attempt.workspaceRunIds,
...runs.map((run) => run.id).filter(Boolean),
]),
workspaceRunStatuses: statuses,
candidateRefs,
...(message ? { message } : {}),
...(error ? { error } : {}),
updatedAt,
...(completedAt ? { completedAt } : {}),
};
}
@ -283,3 +375,99 @@ function httpStatusFromError(error: unknown): number | undefined {
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function flowResultPayload(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const payload = value as Record<string, unknown>;
if (typeof payload.status === "string") {
return payload;
}
const nested = recordValue(payload.result);
return typeof nested.status === "string" ? nested : undefined;
}
function statusFromRuns(runs: FlowRunView[]): MaintenanceAttemptStatus {
const statuses = runs.map((run) => resultStatus(run));
if (statuses.some((status) => status === "needs_intervention")) return "needs_intervention";
if (statuses.some((status) => status === "blocked")) return "blocked";
if (statuses.some((status) => status === "failed")) return "failed";
if (statuses.some((status) => status === "changed")) return "changed";
if (statuses.length > 0 && statuses.every((status) => status === "skipped")) return "skipped";
if (statuses.length > 0 && statuses.every((status) => status === "completed" || status === "skipped")) return "completed";
return "started";
}
function resultStatus(run: FlowRunView): string {
const payload = flowResultPayload(run.resultPayload);
return stringValue(payload?.status) ?? String(run.effectiveStatus ?? run.status ?? "started");
}
function candidateRefsFromFlowResult(result: Record<string, unknown>): CandidateRefRecord[] {
const artifacts = recordValue(result.artifacts);
const candidates = [
...arrayValue(artifacts.candidateRefs),
...arrayValue(artifacts.candidates),
artifacts.candidateRef,
];
return candidates.flatMap(candidateRefValue);
}
function candidateRefValue(value: unknown): CandidateRefRecord[] {
if (typeof value === "string" && value.trim()) {
return [{ kind: "ref", ref: value.trim() }];
}
const record = recordValue(value);
const ref = stringValue(record.ref);
if (!ref) {
return [];
}
return [{
kind: stringValue(record.kind) ?? "ref",
ref,
...(stringValue(record.repo) ? { repo: stringValue(record.repo) } : {}),
...(stringValue(record.remote) ? { remote: stringValue(record.remote) } : {}),
...(stringValue(record.sha) ? { sha: stringValue(record.sha) } : {}),
...(stringValue(record.url) ? { url: stringValue(record.url) } : {}),
...(typeof record.pushed === "boolean" ? { pushed: record.pushed } : {}),
}];
}
function uniqueCandidateRefs(refs: CandidateRefRecord[]): CandidateRefRecord[] {
const seen = new Set<string>();
const result: CandidateRefRecord[] = [];
for (const ref of refs) {
const key = `${ref.kind}:${ref.repo ?? ""}:${ref.remote ?? ""}:${ref.ref}:${ref.sha ?? ""}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(ref);
}
return result;
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}
function newestString(values: unknown[]): string | undefined {
for (let index = values.length - 1; index >= 0; index -= 1) {
const value = stringValue(values[index]);
if (value) {
return value;
}
}
return undefined;
}
function recordValue(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}

View file

@ -118,7 +118,7 @@ export class EventStore {
eventId?: string;
status?: MaintenanceAttemptRecord["status"];
} = {}): Promise<MaintenanceAttemptRecord[]> {
const records = await readJsonLines<MaintenanceAttemptRecord>(this.maintenanceAttemptsPath);
const records = latestRecordsById(await readJsonLines<MaintenanceAttemptRecord>(this.maintenanceAttemptsPath));
return limitNewest(
records.filter((record) =>
(!options.eventId || record.eventId === options.eventId) &&
@ -127,6 +127,30 @@ export class EventStore {
options.limit,
);
}
async getMaintenanceAttempt(id: string): Promise<MaintenanceAttemptRecord | undefined> {
const records = await readJsonLines<MaintenanceAttemptRecord>(this.maintenanceAttemptsPath);
for (let index = records.length - 1; index >= 0; index -= 1) {
if (records[index]?.id === id) {
return records[index];
}
}
return undefined;
}
}
function latestRecordsById<T extends { id: string }>(records: T[]): T[] {
const seen = new Set<string>();
const latest: T[] = [];
for (let index = records.length - 1; index >= 0; index -= 1) {
const record = records[index];
if (!record || seen.has(record.id)) {
continue;
}
seen.add(record.id);
latest.push(record);
}
return latest.reverse();
}
export function jobForFeedSignal(signal: FeedSignal): FeedJob | null {

View file

@ -1,16 +1,18 @@
import { parseDiscordConfig, type DiscordConfig } from "./discord";
import { startFeedPolling } from "./feed";
import {
dispatchWorkspaceEvent,
dispatchWorkspaceEventDetailed,
getWorkspaceEvent,
getWorkspaceRun,
listWorkspaceEvents,
listWorkspaceRuns,
maintenanceAttemptForWorkspaceDispatch,
replayWorkspaceEvent,
maintenanceAttemptWithWorkspaceRuns,
replayWorkspaceEventDetailed,
} from "./flow";
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
import { EventStore } from "./queue";
import type { MaintenanceAttemptRecord } from "./types";
export type ServerConfig = {
dataDir: string;
@ -45,8 +47,16 @@ function dispatchStatus(value: string | null) {
function maintenanceStatus(value: string | null) {
if (!value) return undefined;
if (value === "started" || value === "failed" || value === "skipped") return value;
throw new Error("maintenance attempt status must be started, failed, or skipped");
if (
value === "started" ||
value === "completed" ||
value === "changed" ||
value === "needs_intervention" ||
value === "blocked" ||
value === "failed" ||
value === "skipped"
) return value;
throw new Error("maintenance attempt status is invalid");
}
async function handleFlowEvents(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
@ -78,15 +88,15 @@ async function handleFlowEvents(request: Request, config: ServerConfig, store: E
});
}
if (request.method === "POST" && eventMatch[2] === "retry") {
const record = await dispatchWorkspaceEvent(event, {}, { env: process.env });
const { record, result } = await dispatchWorkspaceEventDetailed(event, {}, { env: process.env });
await store.appendWorkspaceDispatch(record);
await store.appendMaintenanceAttempt(maintenanceAttemptForWorkspaceDispatch(event, record));
await store.appendMaintenanceAttempt(maintenanceAttemptForWorkspaceDispatch(event, record, result?.runs));
return jsonResponse({ event, record }, { status: record.status === "failed" ? 502 : 202 });
}
if (request.method === "POST" && eventMatch[2] === "replay") {
const record = await replayWorkspaceEvent(event, {}, { env: process.env });
const { record, result } = await replayWorkspaceEventDetailed(event, {}, { env: process.env });
await store.appendWorkspaceDispatch(record);
await store.appendMaintenanceAttempt(maintenanceAttemptForWorkspaceDispatch(event, record));
await store.appendMaintenanceAttempt(maintenanceAttemptForWorkspaceDispatch(event, record, result?.runs));
return jsonResponse({ event, record }, { status: record.status === "failed" ? 502 : 202 });
}
return methodNotAllowed();
@ -109,14 +119,68 @@ async function handleFlowDispatches(request: Request, config: ServerConfig, stor
async function handleMaintenanceAttempts(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
const unauthorized = requireAdmin(request, config);
if (unauthorized) return unauthorized;
if (request.method !== "GET") return methodNotAllowed();
const url = new URL(request.url);
return jsonResponse({
attempts: await store.listMaintenanceAttempts({
eventId: url.searchParams.get("eventId") ?? undefined,
status: maintenanceStatus(url.searchParams.get("status")),
limit: numberParam(url.searchParams.get("limit")),
}),
if (request.method === "GET" && url.pathname === "/maintenance-attempts") {
return jsonResponse({
attempts: await store.listMaintenanceAttempts({
eventId: url.searchParams.get("eventId") ?? undefined,
status: maintenanceStatus(url.searchParams.get("status")),
limit: numberParam(url.searchParams.get("limit")),
}),
});
}
const attemptMatch = url.pathname.match(/^\/maintenance-attempts\/([^/]+)(?:\/(sync))?$/);
if (!attemptMatch?.[1]) return jsonResponse({ error: "not_found" }, { status: 404 });
const attemptId = decodeURIComponent(attemptMatch[1]);
const attempt = await store.getMaintenanceAttempt(attemptId);
if (!attempt) {
return jsonResponse({ error: "maintenance_attempt_not_found" }, { status: 404 });
}
if (request.method === "GET" && !attemptMatch[2]) {
return jsonResponse({ attempt });
}
if (request.method === "POST" && attemptMatch[2] === "sync") {
const next = await syncMaintenanceAttempt(store, attempt);
return jsonResponse({ attempt: next }, { status: 202 });
}
return methodNotAllowed();
}
async function syncMaintenanceAttempt(
store: EventStore,
attempt: MaintenanceAttemptRecord,
): Promise<MaintenanceAttemptRecord> {
const runs = attempt.workspaceRunIds.length > 0
? await Promise.all(attempt.workspaceRunIds.map((runId) => getWorkspaceRun(runId, { env: process.env })))
: (await listWorkspaceRuns({ env: process.env }, { eventId: attempt.eventId })).runs;
const next = maintenanceAttemptWithWorkspaceRuns(attempt, runs);
if (maintenanceAttemptChanged(attempt, next)) {
await store.appendMaintenanceAttempt(next);
}
return next;
}
function maintenanceAttemptChanged(
before: MaintenanceAttemptRecord,
after: MaintenanceAttemptRecord,
): boolean {
return JSON.stringify({
status: before.status,
workspaceRunIds: before.workspaceRunIds,
workspaceRunStatuses: before.workspaceRunStatuses,
candidateRefs: before.candidateRefs,
message: before.message,
error: before.error,
completedAt: before.completedAt,
}) !== JSON.stringify({
status: after.status,
workspaceRunIds: after.workspaceRunIds,
workspaceRunStatuses: after.workspaceRunStatuses,
candidateRefs: after.candidateRefs,
message: after.message,
error: after.error,
completedAt: after.completedAt,
});
}
@ -175,7 +239,7 @@ export function createHandler(config: ServerConfig): (request: Request) => Promi
if (url.pathname === "/workspace-dispatches" || url.pathname === "/flow-dispatches") {
return handleFlowDispatches(request, config, store);
}
if (url.pathname === "/maintenance-attempts") {
if (url.pathname === "/maintenance-attempts" || url.pathname.startsWith("/maintenance-attempts/")) {
return handleMaintenanceAttempts(request, config, store);
}
if (url.pathname === "/workspace-runs" || url.pathname.startsWith("/workspace-runs/")) {

View file

@ -97,19 +97,42 @@ export type FlowDispatchRecord = {
export type WorkspaceDispatchRecord = FlowDispatchRecord;
export type CandidateRefRecord = {
kind: string;
ref: string;
repo?: string;
remote?: string;
sha?: string;
url?: string;
pushed?: boolean;
};
export type MaintenanceAttemptStatus =
| "started"
| "completed"
| "changed"
| "needs_intervention"
| "blocked"
| "failed"
| "skipped";
export type MaintenanceAttemptRecord = {
id: string;
eventId: string;
eventType: string;
operation: "dispatch" | "replay";
status: "started" | "failed" | "skipped";
status: MaintenanceAttemptStatus;
upstreamRepo?: string;
upstreamRef?: string;
upstreamSha?: string;
upstreamTag?: string;
workspaceBackendUrl?: string;
workspaceRunIds: string[];
candidateRefs: string[];
workspaceRunStatuses?: Record<string, string>;
candidateRefs: CandidateRefRecord[];
message?: string;
error?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
};

View file

@ -40,6 +40,16 @@ describe("patch.moi harness flow", () => {
expect(result.status).toBe("completed");
expect(result.message).toContain("package checks passed");
expect(result.artifacts?.candidateRefs).toMatchObject([
{
kind: "branch",
repo: "matamune-peezy/patch-moi-harness",
remote: "local",
ref: "refs/heads/main",
sha: afterHead,
pushed: false,
},
]);
expect(afterHead).toBe(beforeHead);
expect(await git(["status", "--porcelain=v1"])).toBe("");
});

View file

@ -203,4 +203,101 @@ describe("server", () => {
}
}
});
test("syncs maintenance attempt outcomes from workspace run results", async () => {
const originalFetch = globalThis.fetch;
const originalWorkspaceUrl = process.env.PATCH_WORKSPACE_BACKEND_URL;
const dataDir = await mkdtemp(join(tmpdir(), "patch-"));
const store = new EventStore(dataDir);
const attempt = {
id: "patch:source:entry:upstream.release:dispatch:2026-05-13T00:00:01.000Z",
eventId: "patch:source:entry:upstream.release",
eventType: "upstream.release",
operation: "dispatch" as const,
status: "started" as const,
upstreamRepo: "openai/codex",
upstreamTag: "v1.2.3",
workspaceBackendUrl: "http://127.0.0.1:3586",
workspaceRunIds: ["run-1"],
candidateRefs: [],
createdAt: "2026-05-13T00:00:01.000Z",
updatedAt: "2026-05-13T00:00:01.000Z",
};
await store.appendMaintenanceAttempt(attempt);
process.env.PATCH_WORKSPACE_BACKEND_URL = "http://127.0.0.1:3586";
globalThis.fetch = (async (url: string | URL | Request) => {
if (String(url).includes("/runs/run-1")) {
return Response.json({
run: {
id: "run-1",
eventId: attempt.eventId,
status: "completed",
completedAt: "2026-05-13T00:00:05.000Z",
resultJson: JSON.stringify({
status: "changed",
message: "candidate branch ready",
artifacts: {
candidateRefs: [{
kind: "branch",
repo: "matamune-peezy/patch-moi-harness",
remote: "origin",
ref: "refs/heads/main",
sha: "abc123",
pushed: true,
}],
},
}),
},
});
}
return Response.json({ error: "not found" }, { status: 404 });
}) as unknown as typeof fetch;
try {
const handler = createHandler({
dataDir,
adminToken: "admin",
});
const sync = await handler(new Request(
`http://localhost/maintenance-attempts/${encodeURIComponent(attempt.id)}/sync`,
{
method: "POST",
headers: { authorization: "Bearer admin" },
},
));
expect(sync.status).toBe(202);
expect(await sync.json()).toMatchObject({
attempt: {
id: attempt.id,
status: "changed",
message: "candidate branch ready",
workspaceRunStatuses: { "run-1": "changed" },
candidateRefs: [{
kind: "branch",
repo: "matamune-peezy/patch-moi-harness",
remote: "origin",
ref: "refs/heads/main",
sha: "abc123",
pushed: true,
}],
},
});
const changed = await handler(new Request("http://localhost/maintenance-attempts?status=changed", {
headers: { authorization: "Bearer admin" },
}));
expect(changed.status).toBe(200);
expect(await changed.json()).toMatchObject({
attempts: [{ id: attempt.id, status: "changed" }],
});
} finally {
globalThis.fetch = originalFetch;
if (originalWorkspaceUrl === undefined) {
delete process.env.PATCH_WORKSPACE_BACKEND_URL;
} else {
process.env.PATCH_WORKSPACE_BACKEND_URL = originalWorkspaceUrl;
}
}
});
});

View file

@ -52,6 +52,13 @@ Each retry or replay writes a maintenance attempt record:
curl http://127.0.0.1:3000/maintenance-attempts?eventId=<event-id>
```
After the workspace run finishes, sync the attempt to record the final
maintenance outcome and any candidate refs reported by the flow:
```bash
curl -X POST http://127.0.0.1:3000/maintenance-attempts/<attempt-id>/sync
```
Use workspace inspection endpoints to read backend-owned run state:
```bash

View file

@ -31,12 +31,19 @@ inspect or modify Git patch stacks directly.
## Maintenance attempts
```text
GET /maintenance-attempts?eventId=<id>&status=started|failed|skipped&limit=<n>
GET /maintenance-attempts?eventId=<id>&status=<status>&limit=<n>
GET /maintenance-attempts/:id
POST /maintenance-attempts/:id/sync
```
`status` can be `started`, `completed`, `changed`, `needs_intervention`,
`blocked`, `failed`, or `skipped`.
Maintenance attempts are patch.moi-owned product records. They link an
upstream update trigger to workspace run ids and candidate refs without copying
workspace backend run state into patch.moi.
workspace backend run state into patch.moi. `sync` reads the configured
workspace backend run results, extracts patch.moi outcome fields such as
candidate refs, and appends the latest attempt state.
## Dispatches

View file

@ -13,13 +13,15 @@ operational state. They are not the patch stack.
| `feed-state.json` | Per-source last seen entry and last checked timestamp. |
| `feed-events.jsonl` | Normalized `FeedSignal` records. |
| `flow-events.jsonl` | Generic `FlowEvent` records emitted by flow targets. |
| `maintenance-attempts.jsonl` | patch.moi-owned attempt records linking update events to workspace run ids and candidate refs. |
| `maintenance-attempts.jsonl` | patch.moi-owned attempt records linking update events to workspace run ids, outcomes, and candidate refs. |
| `workspace-dispatches.jsonl` | Workspace dispatch, retry, replay, and failure records. |
| `flow-dispatches.jsonl` | Legacy dispatch record file read for compatibility. |
Admin endpoints read `flow-events.jsonl`, `maintenance-attempts.jsonl`,
`workspace-dispatches.jsonl`, and the legacy `flow-dispatches.jsonl` file. The
feed poller appends to all relevant files as it accepts new feed entries.
Maintenance attempt sync also appends updated records; admin list endpoints show
the latest record for each attempt id.
If a runner checkout is lost, patch.moi should be able to recreate the
maintenance context from remote Git refs and forge records. JSONL state explains

View file

@ -33,7 +33,8 @@ bun run harness:flow
The fixture event is `v0.1.3`, which the current fork already contains. The
flow should skip rebase work, run `npm test` and `npm run pack:dry-run` in the
fork, and leave the fork checkout unchanged.
fork, report `candidateRefs` for the maintained fork branch, and leave the fork
checkout unchanged.
## 3. Rehearse a real upstream release
@ -58,7 +59,8 @@ bun run harness:flow <event-file>
```
Use an event file whose `payload.tag` is the new upstream tag. The flow rebases
`harness/fork` onto that tag, verifies the fork package, and keeps pushes off.
`harness/fork` onto that tag, verifies the fork package, reports the local
candidate branch, and keeps pushes off.
## 4. Push only after review
@ -69,5 +71,6 @@ CODEX_FLOW_PUSH=1 bun run harness:flow <event-file>
```
That pushes the rebased fork branch to the configured `origin` and `jojo`
remotes with `--force-with-lease`. Public npm publishing remains a separate
trusted-publishing release path.
remotes with `--force-with-lease` and reports those pushed branch refs as
candidate refs. Public npm publishing remains a separate trusted-publishing
release path.

View file

@ -72,7 +72,8 @@ When the feed later contains an unseen release entry, Patch appends:
- `data/feed-events.jsonl` for the normalized signal.
- `data/flow-events.jsonl` for the generic flow event.
- `data/maintenance-attempts.jsonl` for the patch.moi maintenance attempt.
- `data/maintenance-attempts.jsonl` for the patch.moi maintenance attempt and
later candidate refs.
- `data/workspace-dispatches.jsonl` for the workspace dispatch outcome.
If no workspace backend URL is set, Patch uses local flow execution from the

View file

@ -11,6 +11,7 @@ The default behavior is local and reviewable:
- switch to the maintained fork branch
- rebase onto the release tag when the tag is not already an ancestor
- run the configured package checks
- emit candidate branch refs in the `FLOW_RESULT` artifacts
- leave pushes disabled unless `CODEX_FLOW_PUSH=1` is set
Useful overrides:
@ -22,4 +23,4 @@ CODEX_FLOW_PUSH=1 bun run harness:flow
The fixture event is `fixtures/upstream-release-v0.1.3.json`. It should be a
no-op rebase against the current harness fork while still verifying the package
surface.
surface and reporting the local maintained branch as the candidate ref.

View file

@ -32,6 +32,7 @@ const payload = context.flow.event.payload ?? {};
const releaseTag = tagFromPayload(payload);
const repo = stringValue(payload.repo, "payload.repo");
const forkRepo = path.resolve(workspaceRoot, stringConfig("fork_repo", "harness/fork"));
const forkRepoFullName = stringConfig("fork_repo_full_name", "matamune-peezy/patch-moi-harness");
const targetBranch = stringConfig("target_branch", "main");
const upstreamRemote = stringConfig("upstream_remote", "upstream");
const upstreamRepoUrl = stringConfig("upstream_repo_url", "https://github.com/peezy-tech/patch-moi-harness.git");
@ -114,12 +115,15 @@ try {
eventId: context.flow.event.id,
repo,
forkRepo,
forkRepoFullName,
targetBranch,
releaseTag,
releaseSha,
beforeSha,
afterSha,
pushed: enabled("push", false),
checks: verifyCommands.map((command) => ({ name: command, status: "passed" })),
candidateRefs: candidateRefsFor(afterSha),
});
} catch (error) {
finish("failed", error instanceof Error ? error.message : String(error));
@ -246,6 +250,19 @@ function harnessMessage(beforeSha: string, afterSha: string): string {
return `Harness fork rebased onto ${releaseTag}; package checks passed.`;
}
function candidateRefsFor(sha: string): Array<Record<string, unknown>> {
const pushed = enabled("push", false);
const remotes = pushed ? pushRemotes : ["local"];
return remotes.map((remote) => ({
kind: "branch",
repo: forkRepoFullName,
remote,
ref: `refs/heads/${targetBranch}`,
sha,
pushed,
}));
}
function enabled(name: string, fallback: boolean): boolean {
const envValue = process.env[`CODEX_FLOW_${name.toUpperCase()}`];
if (envValue !== undefined) {

View file

@ -4,6 +4,7 @@ description = "Rebase the maintained patch.moi harness fork onto an upstream har
[config]
fork_repo = "harness/fork"
fork_repo_full_name = "matamune-peezy/patch-moi-harness"
target_branch = "main"
upstream_remote = "upstream"
upstream_repo_url = "https://github.com/peezy-tech/patch-moi-harness.git"

View file

@ -115,8 +115,9 @@ the upstream update event, creates a maintenance attempt record, and hands the
same flow event to the configured workspace backend.
The default fixture targets `v0.1.3`, which should verify the current fork
without changing it. For a new upstream tag, run the same command with an event
file whose `payload.tag` names that tag.
without changing it and report `candidateRefs` for the maintained fork branch.
For a new upstream tag, run the same command with an event file whose
`payload.tag` names that tag.
## Scenario: Fork Release