@a-t-h-i/bot-lobby 0.6.18 → 0.6.20
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/README.md +19 -1
- package/package.json +1 -1
- package/src/ask/relay.ts +3 -1
- package/src/ask/tool.ts +18 -3
- package/src/ask/types.ts +12 -0
- package/src/ask/web.ts +2 -1
- package/src/delivery/checks.ts +37 -0
- package/src/delivery/lock.ts +78 -0
- package/src/delivery/merge.ts +44 -0
- package/src/delivery/operations.ts +134 -0
- package/src/delivery/pr.ts +40 -0
- package/src/delivery/review.ts +90 -0
- package/src/delivery/rules.ts +46 -0
- package/src/delivery/source.ts +39 -0
- package/src/delivery/transport.ts +36 -0
- package/src/delivery/types.ts +60 -0
- package/src/execution/agent-runner.ts +1 -23
- package/src/index.ts +1 -1
- package/src/lobby/feed.ts +0 -6
- package/src/lobby/host.ts +3 -0
- package/src/lobby/planner.ts +1 -7
- package/src/lobby/pr-review.ts +1 -7
- package/src/lobby/prompt-hub.ts +7 -1
- package/src/lobby/quickfix.ts +3 -16
- package/src/lobby/service.ts +73 -3
- package/src/lobby/sessions.ts +12 -0
- package/src/lobby/task-rows.ts +5 -0
- package/src/master/master.ts +1 -1
- package/src/master/research.ts +0 -1
- package/src/pi/events.ts +0 -2
- package/src/pi/model-settings.ts +7 -26
- package/src/pi/model-support.ts +4 -19
- package/src/schemas/configuration.ts +8 -38
- package/src/schemas/findings.ts +0 -2
- package/src/schemas/task.ts +14 -0
- package/src/state/blocking-requests.ts +23 -0
- package/src/state/persistence.ts +12 -1
- package/src/state/phase-timing.ts +53 -0
- package/src/state/task-state.ts +2 -0
- package/src/state/timing-merge.ts +25 -0
- package/src/webui/api/index.ts +17 -1
- package/src/webui/api/status.ts +4 -2
- package/src/webui/api/tasks.ts +52 -0
- package/src/webui/protocol.ts +8 -0
- package/src/workflow/approvals.ts +3 -0
- package/src/workflow/workflow.ts +14 -33
- package/webui/dist/assets/index-9daVA-Sp.js +99 -0
- package/webui/dist/assets/index-DKVApIgy.css +2 -0
- package/webui/dist/build.json +142 -94
- package/webui/dist/index.html +4 -4
- package/webui/dist/manifest.webmanifest +2 -2
- package/webui/dist/offline.html +12 -9
- package/src/execution/fallback.ts +0 -75
- package/src/pi/master-fallback.ts +0 -61
- package/webui/dist/assets/index-CHLNxlA1.js +0 -98
- package/webui/dist/assets/index-m9XcQvH8.css +0 -2
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Questionnaire and web tools are included; don't also install extensions register
|
|
|
20
20
|
2. Answer the oracle's questions and approve its proposal, or use Plan to agree on a task first.
|
|
21
21
|
3. Follow the agents in Lobby; configure models, effort and time limits in Settings.
|
|
22
22
|
|
|
23
|
-
The
|
|
23
|
+
The project switcher (top left, `P`) switches among already-running, authorized project lobbies on one localhost origin.
|
|
24
24
|
Upgrading? Reattach older temporary files; previews and attachments are now isolated per project.
|
|
25
25
|
|
|
26
26
|
Small self-contained changes can route to Quick fix. `--task` forces a task;
|
|
@@ -52,6 +52,8 @@ Browser navigation is ignored while typing, composing with an IME or using an ov
|
|
|
52
52
|
| `j` / `k` | Next / previous selectable list row |
|
|
53
53
|
| `g` then a letter | Jump to a named tab (mapping below) |
|
|
54
54
|
| `/` | Focus the current tab's search, when available |
|
|
55
|
+
| `p` | Open the project switcher |
|
|
56
|
+
| `d` | Switch between light and dark |
|
|
55
57
|
| `?`, `Alt+H` | Shortcut help |
|
|
56
58
|
| `Esc` | Close the top overlay or cancel a pending navigation prefix |
|
|
57
59
|
| `Alt+S` | Settings |
|
|
@@ -59,6 +61,22 @@ Browser navigation is ignored while typing, composing with an IME or using an ov
|
|
|
59
61
|
| `Alt+A` / `Alt+T` | Fold / expand Activity / Thinking |
|
|
60
62
|
| `Ctrl+S` | Save the plan on Plan |
|
|
61
63
|
|
|
64
|
+
Every action button prints its key (`Archive E`, `Delete Del`) and the key works while the list or
|
|
65
|
+
the detail has focus (press `Esc` first to leave the message box). Keys that cannot be undone ask first;
|
|
66
|
+
in that question `Y` confirms and `Esc` keeps.
|
|
67
|
+
|
|
68
|
+
| Page | Keys |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| Tasks | `e` archive, `r` restore, `a` auto mode, `Delete` delete |
|
|
71
|
+
| Saved plan | `s` start here, `n` new session, `Delete` discard |
|
|
72
|
+
| Plan | `a` answer questions, `r` retry, `Ctrl+S` save, `n` new plan |
|
|
73
|
+
| Quick fix | `r` run anyway, `t` make it a task, `c` cancel |
|
|
74
|
+
| Git | `r` refresh, `v` review, `q` Jev's read, `x` stop the review |
|
|
75
|
+
| Issues | `r` refresh |
|
|
76
|
+
| Knowledge | `e` edit, `a` add, `c` comment, `f` edit the file, `Delete` delete |
|
|
77
|
+
| Excalidraw | `r` reveal, `c` copy, `o` open, `w` let agents draw, `t` check, `Delete` remove |
|
|
78
|
+
| Sessions | `m` move here, `s` stop, `b` back to the Lobby |
|
|
79
|
+
|
|
62
80
|
`g` mapping: `l` Lobby, `p` Plan, `t` Tasks, `i` Issues, `u` Git,
|
|
63
81
|
`s` Sessions, `n` Knowledge, `q` Quick fix, `m` Metrics, `e` Settings, `x` Excalidraw.
|
|
64
82
|
Configured Alt/Ctrl actions are rebindable through `lobby.keys`; defaults live in
|
package/package.json
CHANGED
package/src/ask/relay.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { previewRoot } from "../state/previews.ts";
|
|
10
10
|
import { isAbsolute, join, resolve } from "node:path";
|
|
11
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { MAX_OPTIONS, MAX_QUESTIONS, type AskQuestion, type AskResult, type Asker } from "./types.ts";
|
|
12
|
+
import { validHtmlPreview, MAX_OPTIONS, MAX_QUESTIONS, type AskQuestion, type AskResult, type Asker } from "./types.ts";
|
|
13
13
|
|
|
14
14
|
/** Env flag the master sets for a subagent that may ask the user. */
|
|
15
15
|
export const ASK_ENV = "BOT_LOBBY_ASK";
|
|
@@ -51,6 +51,7 @@ const text = (value: unknown): value is string => typeof value === "string";
|
|
|
51
51
|
function asQuestion(value: unknown): AskQuestion | undefined {
|
|
52
52
|
const raw = value as Partial<AskQuestion> | null;
|
|
53
53
|
if (!raw || !text(raw.question) || !text(raw.header) || !Array.isArray(raw.options)) return undefined;
|
|
54
|
+
if (raw.options.some((option) => option?.htmlPreview !== undefined && !validHtmlPreview(option.htmlPreview))) return undefined;
|
|
54
55
|
const options = raw.options
|
|
55
56
|
.filter((option) => option && text(option.label))
|
|
56
57
|
.slice(0, MAX_OPTIONS)
|
|
@@ -59,6 +60,7 @@ function asQuestion(value: unknown): AskQuestion | undefined {
|
|
|
59
60
|
...(text(option.description) ? { description: option.description } : {}),
|
|
60
61
|
...(text(option.preview) ? { preview: option.preview } : {}),
|
|
61
62
|
...(text(option.image) ? { image: option.image } : {}),
|
|
63
|
+
...(validHtmlPreview(option.htmlPreview) ? { htmlPreview: option.htmlPreview } : {}),
|
|
62
64
|
}));
|
|
63
65
|
if (options.length === 0) return undefined;
|
|
64
66
|
return { question: raw.question, header: raw.header, options, ...(raw.multiSelect === true ? { multiSelect: true } : {}) };
|
package/src/ask/tool.ts
CHANGED
|
@@ -12,11 +12,19 @@ import { Type } from "typebox";
|
|
|
12
12
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
13
13
|
import { askUser } from "./web.ts";
|
|
14
14
|
import { relayAsker, relayEnabled } from "./relay.ts";
|
|
15
|
-
import { ASK_TOOL, isImagePath, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult, type Asker } from "./types.ts";
|
|
15
|
+
import { ASK_TOOL, isImagePath, validHtmlPreview, MAX_PREVIEW_HTML, MAX_PREVIEW_CSS, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult, type Asker } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
import { detectProjectRoot } from "../state/project.ts";
|
|
18
|
+
import { ownedTask } from "../state/persistence.ts";
|
|
19
|
+
import { withBlockingRequest } from "../state/blocking-requests.ts";
|
|
16
20
|
|
|
17
21
|
export { ASK_TOOL };
|
|
18
22
|
|
|
19
23
|
const OptionSchema = Type.Object({
|
|
24
|
+
htmlPreview: Type.Optional(Type.Object({
|
|
25
|
+
html: Type.String({ maxLength: MAX_PREVIEW_HTML }),
|
|
26
|
+
css: Type.Optional(Type.String({ maxLength: MAX_PREVIEW_CSS })),
|
|
27
|
+
}, { additionalProperties: false })),
|
|
20
28
|
label: Type.String({ maxLength: MAX_LABEL, description: `The option as the user sees and picks it: 1-5 words, at most ${MAX_LABEL} characters.` }),
|
|
21
29
|
description: Type.Optional(Type.String({ description: "What choosing it means: its trade-offs or consequences. Markdown." })),
|
|
22
30
|
preview: Type.Optional(Type.String({ description: "Markdown shown beside the options while this one is focused: a mockup, a code snippet, a diagram, a config. Only when seeing it helps the user compare." })),
|
|
@@ -58,6 +66,7 @@ export function invalidQuestions(questions: readonly AskQuestion[]): string | un
|
|
|
58
66
|
if (RESERVED.has(label)) return `${at}: "${option.label}" is kept for the user's own answer; leave it out`;
|
|
59
67
|
if (labels.has(label)) return `${at} has two options labelled "${option.label}"`;
|
|
60
68
|
if (option.image?.trim() && !isImagePath(option.image)) return `${at}: the image for "${option.label}" must be a .png, .jpg, .gif or .webp file`;
|
|
69
|
+
if (option.htmlPreview !== undefined && !validHtmlPreview(option.htmlPreview)) return `${at}: invalid static HTML/CSS preview`;
|
|
61
70
|
labels.add(label);
|
|
62
71
|
}
|
|
63
72
|
}
|
|
@@ -96,7 +105,7 @@ export function answerSummary(questions: readonly AskQuestion[], result: AskResu
|
|
|
96
105
|
* Register `ask_user_question` in this pi session; in a subagent only when
|
|
97
106
|
* the master relays its questions. `ask` is swappable for tests.
|
|
98
107
|
*/
|
|
99
|
-
export function registerAskTool(pi: ExtensionAPI, ask?: Asker): void {
|
|
108
|
+
export function registerAskTool(pi: ExtensionAPI, ask?: Asker, configDir = ".pi"): void {
|
|
100
109
|
const subagent = isSubagentProcess();
|
|
101
110
|
if (subagent && !relayEnabled()) return;
|
|
102
111
|
const asker = ask ?? (subagent ? relayAsker : askUser);
|
|
@@ -113,7 +122,13 @@ export function registerAskTool(pi: ExtensionAPI, ask?: Asker): void {
|
|
|
113
122
|
const questions = (params as { questions: AskQuestion[] }).questions;
|
|
114
123
|
const invalid = invalidQuestions(questions);
|
|
115
124
|
if (invalid) throw new Error(`${invalid}.`);
|
|
116
|
-
|
|
125
|
+
// A context without a cwd or session cannot be attributed to a task, so the ask runs untracked.
|
|
126
|
+
const root = ctx.cwd ? detectProjectRoot(ctx.cwd, configDir) : undefined;
|
|
127
|
+
const sessionId = ctx.sessionManager?.getSessionId();
|
|
128
|
+
const task = subagent || !root || !sessionId ? undefined : ownedTask(root, configDir, sessionId);
|
|
129
|
+
const tracked = task && root && sessionId ? { root, configDir, taskId: task.id, sessionId } : undefined;
|
|
130
|
+
const run = () => asker(questions, ctx, signal, "oracle");
|
|
131
|
+
const result = tracked ? await withBlockingRequest(tracked, run) : await run();
|
|
117
132
|
return { content: [{ type: "text", text: answerSummary(questions, result) }], details: result };
|
|
118
133
|
},
|
|
119
134
|
renderCall(args, theme) {
|
package/src/ask/types.ts
CHANGED
|
@@ -16,6 +16,8 @@ export const MIN_OPTIONS = 2;
|
|
|
16
16
|
export const MAX_OPTIONS = 4;
|
|
17
17
|
export const MAX_HEADER = 16;
|
|
18
18
|
export const MAX_LABEL = 60;
|
|
19
|
+
export const MAX_PREVIEW_HTML = 100_000;
|
|
20
|
+
export const MAX_PREVIEW_CSS = 50_000;
|
|
19
21
|
|
|
20
22
|
/** The row under every question's options where the user writes their own answer. */
|
|
21
23
|
export const OWN_ANSWER = "Type something.";
|
|
@@ -25,6 +27,8 @@ export const RESERVED = new Set(["other", OWN_ANSWER.toLowerCase(), "next"]);
|
|
|
25
27
|
|
|
26
28
|
export interface AskOption {
|
|
27
29
|
label: string;
|
|
30
|
+
/** Explicit static HTML/CSS; never inferred from Markdown. */
|
|
31
|
+
htmlPreview?: { html: string; css?: string };
|
|
28
32
|
description?: string;
|
|
29
33
|
/** Markdown shown beside the options while this one is focused. */
|
|
30
34
|
preview?: string;
|
|
@@ -32,6 +36,14 @@ export interface AskOption {
|
|
|
32
36
|
image?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
export function validHtmlPreview(value: unknown): value is NonNullable<AskOption["htmlPreview"]> {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
41
|
+
const preview = value as Record<string, unknown>;
|
|
42
|
+
return Object.keys(preview).every((key) => key === "html" || key === "css")
|
|
43
|
+
&& typeof preview.html === "string" && preview.html.length <= MAX_PREVIEW_HTML
|
|
44
|
+
&& (preview.css === undefined || (typeof preview.css === "string" && preview.css.length <= MAX_PREVIEW_CSS));
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
export interface AskQuestion {
|
|
36
48
|
question: string;
|
|
37
49
|
/** A short chip naming the question (a topic, or who asks it). */
|
package/src/ask/web.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { isAbsolute, resolve } from "node:path";
|
|
9
9
|
import { currentWebServer } from "../webui/server.ts";
|
|
10
10
|
import { promptHub } from "../lobby/prompt-hub.ts";
|
|
11
|
-
import type
|
|
11
|
+
import { validHtmlPreview, type AskAnswer, type AskQuestion, type AskResult, type Asker } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
let availability: (() => boolean) | undefined;
|
|
14
14
|
|
|
@@ -43,6 +43,7 @@ function readResult(value: unknown): AskResult | undefined {
|
|
|
43
43
|
|
|
44
44
|
/** The questionnaire in the page. */
|
|
45
45
|
export const askUser: Asker = async (questions, ctx, signal, from) => {
|
|
46
|
+
if (questions.some((question) => question.options.some((option) => option.htmlPreview !== undefined && !validHtmlPreview(option.htmlPreview)))) throw new Error("Invalid static HTML/CSS preview");
|
|
46
47
|
if (questions.length === 0 || !canAsk()) return { answers: [], cancelled: true };
|
|
47
48
|
const outcome = await promptHub.ask("questionnaire", from ?? "oracle", { questions: withAbsoluteImages(questions, ctx.cwd) }, signal ? { signal } : undefined);
|
|
48
49
|
return (outcome.how === "answered" ? readResult(outcome.value) : undefined) ?? { answers: [], cancelled: true };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DeliveryChecksState } from "./types.ts";
|
|
2
|
+
import { api, object, pages, type DeliveryContext } from "./transport.ts";
|
|
3
|
+
|
|
4
|
+
export interface Check { name: string; state: "passing" | "failing" | "pending" }
|
|
5
|
+
export interface Checks { state: DeliveryChecksState; entries: Check[]; reason?: string }
|
|
6
|
+
function checkRun(raw: unknown, sha: string): Check {
|
|
7
|
+
const row = object(raw);
|
|
8
|
+
if (typeof row.name !== "string" || !row.name || row.head_sha !== sha) throw new Error("Stale or malformed check run; refresh review.");
|
|
9
|
+
if (!["queued", "in_progress", "completed", "waiting", "requested", "pending"].includes(String(row.status))) throw new Error("Unknown check status.");
|
|
10
|
+
if (row.status !== "completed") return { name: row.name, state: "pending" };
|
|
11
|
+
if (!["success", "neutral", "skipped", "failure", "cancelled", "timed_out", "action_required", "stale", "startup_failure"].includes(String(row.conclusion))) throw new Error("Unknown check conclusion.");
|
|
12
|
+
return { name: row.name, state: ["success", "neutral", "skipped"].includes(String(row.conclusion)) ? "passing" : "failing" };
|
|
13
|
+
}
|
|
14
|
+
function status(raw: unknown): Check {
|
|
15
|
+
const row = object(raw);
|
|
16
|
+
if (typeof row.context !== "string" || !row.context || !["success", "pending", "failure", "error"].includes(String(row.state))) throw new Error("Malformed commit status.");
|
|
17
|
+
return { name: row.context, state: row.state === "success" ? "passing" : row.state === "pending" ? "pending" : "failing" };
|
|
18
|
+
}
|
|
19
|
+
async function statuses(ctx: DeliveryContext, repo: string, sha: string): Promise<Check[]> {
|
|
20
|
+
const combined = object(await api(ctx, `repos/${repo}/commits/${sha}/status`));
|
|
21
|
+
if (combined.sha !== sha || !Number.isInteger(combined.total_count) || !Array.isArray(combined.statuses) || !["success", "pending", "failure"].includes(String(combined.state))) throw new Error("Malformed combined statuses.");
|
|
22
|
+
const rows = await pages(ctx, `repos/${repo}/commits/${sha}/statuses`);
|
|
23
|
+
// GitHub lists newest first; older results for the same context are superseded.
|
|
24
|
+
const latest = new Map<string, Check>();
|
|
25
|
+
for (const raw of rows) { const entry = status(raw); if (!latest.has(entry.name)) latest.set(entry.name, entry); }
|
|
26
|
+
if (latest.size !== combined.total_count) throw new Error("Statuses changed during lookup; refresh review.");
|
|
27
|
+
return [...latest.values()];
|
|
28
|
+
}
|
|
29
|
+
export async function readChecks(ctx: DeliveryContext, repo: string, sha: string): Promise<Checks> {
|
|
30
|
+
try {
|
|
31
|
+
const runs = await pages(ctx, `repos/${repo}/commits/${sha}/check-runs`, "check_runs");
|
|
32
|
+
const entries = [...runs.map((raw) => checkRun(raw, sha)), ...await statuses(ctx, repo, sha)];
|
|
33
|
+
entries.sort((a, b) => a.name.localeCompare(b.name) || a.state.localeCompare(b.state));
|
|
34
|
+
const state = entries.some((v) => v.state === "failing") ? "failing" : entries.some((v) => v.state === "pending") ? "pending" : entries.length ? "passing" : "absent";
|
|
35
|
+
return { state, entries };
|
|
36
|
+
} catch (error) { return { state: /[Ss]tale/.test((error as Error).message) ? "stale" : "unavailable", entries: [], reason: (error as Error).message }; }
|
|
37
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { constants, closeSync, fsyncSync, ftruncateSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export interface LockIdentity { taskId: string; projectRoot: string; configDir: string; operationId: string }
|
|
6
|
+
export interface LockOwner { token: string; pid: number; identity?: LockIdentity }
|
|
7
|
+
function alive(pid: number): boolean {
|
|
8
|
+
try { process.kill(pid, 0); return true; }
|
|
9
|
+
catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
|
|
10
|
+
}
|
|
11
|
+
function owner(path: string): LockOwner | undefined {
|
|
12
|
+
let fd: number;
|
|
13
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
14
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
15
|
+
try {
|
|
16
|
+
const value = JSON.parse(readFileSync(fd, "utf8")) as LockOwner;
|
|
17
|
+
if (!Number.isInteger(value.pid) || value.pid <= 0 || typeof value.token !== "string" || !value.token) throw new Error("Invalid delivery lock owner.");
|
|
18
|
+
return value;
|
|
19
|
+
} finally { closeSync(fd); }
|
|
20
|
+
}
|
|
21
|
+
function removeOwned(path: string, token: string): void {
|
|
22
|
+
if (owner(path)?.token === token) unlinkSync(path);
|
|
23
|
+
}
|
|
24
|
+
export type DeliveryLock = (() => void) & { setOperation(id: string): void };
|
|
25
|
+
function update(path: string, token: string, operationId: string): void {
|
|
26
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(readFileSync(fd, "utf8")) as LockOwner;
|
|
29
|
+
if (value.token !== token) throw new Error("Delivery lock ownership changed.");
|
|
30
|
+
if (!value.identity) return;
|
|
31
|
+
value.identity.operationId = operationId;
|
|
32
|
+
const text = JSON.stringify(value);
|
|
33
|
+
writeSync(fd, text, 0, "utf8"); ftruncateSync(fd, Buffer.byteLength(text)); fsyncSync(fd);
|
|
34
|
+
} finally { closeSync(fd); }
|
|
35
|
+
}
|
|
36
|
+
function create(path: string, identity?: LockIdentity): DeliveryLock {
|
|
37
|
+
const token = randomUUID();
|
|
38
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
39
|
+
try { writeFileSync(fd, JSON.stringify({ token, pid: process.pid, identity })); fsyncSync(fd); }
|
|
40
|
+
finally { closeSync(fd); }
|
|
41
|
+
return Object.assign(() => removeOwned(path, token), { setOperation: (id: string) => update(path, token, id) });
|
|
42
|
+
}
|
|
43
|
+
function marker(path: string): void {
|
|
44
|
+
const previous = owner(path);
|
|
45
|
+
if (!previous) return;
|
|
46
|
+
if (alive(previous.pid)) throw new Error("Delivery recovery is busy; wait and retry.");
|
|
47
|
+
// A dead marker performed no publishing. Its token must still match before removal.
|
|
48
|
+
removeOwned(path, previous.token);
|
|
49
|
+
}
|
|
50
|
+
/** Synchronous normal claims remain useful for callers that do not authorize recovery. */
|
|
51
|
+
export function acquireDeliveryLock(repository: string, identity?: LockIdentity): DeliveryLock {
|
|
52
|
+
const path = join(realpathSync(repository), "dev-house-delivery-main.lock");
|
|
53
|
+
if (owner(`${path}.recovery`)) throw new Error("Delivery recovery is busy; wait and retry.");
|
|
54
|
+
try { const release = create(path, identity); if (owner(`${path}.recovery`)) { release(); throw new Error("Delivery recovery is busy."); } return release; }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
57
|
+
const previous = owner(path);
|
|
58
|
+
throw new Error(previous && alive(previous.pid) ? "Delivery is busy for this repository/main; wait and refresh." : "Interrupted delivery lock requires read-only recovery.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function reclaim(path: string, previous: LockOwner, recover: (owner: LockOwner) => Promise<void>): Promise<void> {
|
|
62
|
+
const release = create(`${path}.recovery`, previous.identity);
|
|
63
|
+
try {
|
|
64
|
+
const current = owner(path);
|
|
65
|
+
if (current?.token !== previous.token || alive(current.pid)) throw new Error("Delivery owner changed; retry without recovery.");
|
|
66
|
+
await recover(current);
|
|
67
|
+
if (owner(path)?.token !== previous.token) throw new Error("Delivery owner changed during recovery.");
|
|
68
|
+
removeOwned(path, previous.token);
|
|
69
|
+
} finally { release(); }
|
|
70
|
+
}
|
|
71
|
+
/** Reconciliation is read-only; no request can bypass the exclusive recovery marker. */
|
|
72
|
+
export async function recoverDeliveryLock(repository: string, identity: LockIdentity | undefined, recover: (owner: LockOwner) => Promise<void>): Promise<DeliveryLock> {
|
|
73
|
+
const path = join(realpathSync(repository), "dev-house-delivery-main.lock");
|
|
74
|
+
marker(`${path}.recovery`);
|
|
75
|
+
const previous = owner(path);
|
|
76
|
+
if (previous && !alive(previous.pid)) await reclaim(path, previous, recover);
|
|
77
|
+
return acquireDeliveryLock(repository, identity);
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdtempSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { DeliveryOperation, DeliveryResult } from "./types.ts";
|
|
4
|
+
import { command, type DeliveryContext } from "./transport.ts";
|
|
5
|
+
import { refreshMain, sha } from "./source.ts";
|
|
6
|
+
import type { SaveStage } from "./pr.ts";
|
|
7
|
+
|
|
8
|
+
async function ancestor(ctx: DeliveryContext, source: string, target: string): Promise<boolean> {
|
|
9
|
+
const result = await ctx.exec("git", ["merge-base", "--is-ancestor", source, target], { cwd: ctx.cwd, timeout: 20_000 });
|
|
10
|
+
if (result.code !== 0 && result.code !== 1) throw new Error("Remote ancestry cannot be verified; restore repository access before retrying.");
|
|
11
|
+
return result.code === 0;
|
|
12
|
+
}
|
|
13
|
+
export async function reconcileMerge(ctx: DeliveryContext, op: DeliveryOperation): Promise<DeliveryResult | undefined> {
|
|
14
|
+
const remote = await refreshMain(ctx);
|
|
15
|
+
if (await ancestor(ctx, op.mergeCommit ?? op.sourceCommit, remote)) return { action: "merge_main", commit: op.mergeCommit ?? op.sourceCommit };
|
|
16
|
+
}
|
|
17
|
+
async function integrate(ctx: DeliveryContext, op: DeliveryOperation, save: SaveStage): Promise<void> {
|
|
18
|
+
const directory = mkdtempSync(join(realpathSync(op.repository!), "dev-house-integration-"));
|
|
19
|
+
const path = join(directory, "worktree");
|
|
20
|
+
op.integrationPath = path; op.stage = "integrating"; save();
|
|
21
|
+
await command(ctx, "git", ["worktree", "add", "--detach", path, op.targetCommit!]);
|
|
22
|
+
const isolated = { ...ctx, cwd: path };
|
|
23
|
+
try { await mergeCommit(isolated, op, save); }
|
|
24
|
+
finally { await command(ctx, "git", ["worktree", "remove", "--force", path]); }
|
|
25
|
+
}
|
|
26
|
+
async function mergeCommit(ctx: DeliveryContext, op: DeliveryOperation, save: SaveStage): Promise<void> {
|
|
27
|
+
try { await command(ctx, "git", ["merge", "--ff", "--no-edit", op.sourceCommit]); }
|
|
28
|
+
catch { await command(ctx, "git", ["merge", "--abort"]); throw new Error("Isolated merge failed (conflict, hooks or identity). Resolve the task branch and refresh review; main was not pushed."); }
|
|
29
|
+
op.mergeCommit = sha(await command(ctx, "git", ["rev-parse", "HEAD^{commit}"]));
|
|
30
|
+
op.stage = "merged"; save();
|
|
31
|
+
}
|
|
32
|
+
export async function publishMerge(ctx: DeliveryContext, op: DeliveryOperation, save: SaveStage, verify: () => Promise<void>): Promise<DeliveryResult> {
|
|
33
|
+
const existing = await reconcileMerge(ctx, op);
|
|
34
|
+
if (existing) return existing;
|
|
35
|
+
if (!op.mergeCommit) await integrate(ctx, op, save);
|
|
36
|
+
await verify();
|
|
37
|
+
if (await refreshMain(ctx) !== op.targetCommit) throw new Error("Remote main changed. Preserve the recorded merge and refresh review for a fresh explicit approval; no push was attempted.");
|
|
38
|
+
op.stage = "pushing_main"; save();
|
|
39
|
+
try { await command(ctx, "git", ["push", "origin", `${op.mergeCommit}:refs/heads/main`]); }
|
|
40
|
+
catch (error) { const result = await reconcileMerge(ctx, op); if (result) return result; throw error; }
|
|
41
|
+
const result = await reconcileMerge(ctx, op);
|
|
42
|
+
if (!result) throw new Error("Main push cannot be verified; retry reconciliation after remote access recovers.");
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { Task } from "../schemas/task.ts";
|
|
5
|
+
import type { Delivery, DeliveryAction, DeliveryOperation, DeliveryResult } from "./types.ts";
|
|
6
|
+
import { refreshReview, validateReview } from "./review.ts";
|
|
7
|
+
import { command, type DeliveryContext } from "./transport.ts";
|
|
8
|
+
import { readSource } from "./source.ts";
|
|
9
|
+
import { recoverDeliveryLock, type LockIdentity, type LockOwner } from "./lock.ts";
|
|
10
|
+
import { matchingPull, publishPull } from "./pr.ts";
|
|
11
|
+
import { publishMerge, reconcileMerge } from "./merge.ts";
|
|
12
|
+
|
|
13
|
+
export interface DeliveryRequest { reviewId: string; action: DeliveryAction; confirmMain?: boolean }
|
|
14
|
+
export interface DeliveryStore {
|
|
15
|
+
load(): Task; save(task: Task): void;
|
|
16
|
+
identity?: { projectRoot: string; configDir: string };
|
|
17
|
+
recoveryStore?(identity: LockIdentity): DeliveryStore;
|
|
18
|
+
}
|
|
19
|
+
function approved(task: Task, request: DeliveryRequest): Delivery {
|
|
20
|
+
if (task.state !== "completed") throw new Error("Only completed work can be delivered.");
|
|
21
|
+
if (request.action === "merge_main" && request.confirmMain !== true) throw new Error("Explicit confirmation is required: direct merge updates and pushes main.");
|
|
22
|
+
return validateReview(task, request.reviewId);
|
|
23
|
+
}
|
|
24
|
+
function intent(delivery: Delivery, action: DeliveryAction): DeliveryOperation {
|
|
25
|
+
if (!delivery.sourceCommit || !delivery.targetCommit || !delivery.repository || !delivery.remote || !delivery.fingerprint) throw new Error("Review lacks a complete source/main identity. Refresh before delivery.");
|
|
26
|
+
return { id: randomUUID(), action, repository: delivery.repository, remote: delivery.remote, sourceBranch: delivery.sourceBranch,
|
|
27
|
+
sourceCommit: delivery.sourceCommit, targetCommit: delivery.targetCommit, target: "main", fingerprint: delivery.fingerprint,
|
|
28
|
+
reviewId: delivery.reviewId, stage: "intent", startedAt: new Date().toISOString() };
|
|
29
|
+
}
|
|
30
|
+
async function refreshed(task: Task, ctx: DeliveryContext): Promise<Delivery> {
|
|
31
|
+
const copy = structuredClone(task);
|
|
32
|
+
delete copy.delivery!.operation; delete copy.delivery!.result;
|
|
33
|
+
copy.delivery!.status = "pending_approval";
|
|
34
|
+
return refreshReview(copy, ctx);
|
|
35
|
+
}
|
|
36
|
+
async function verify(task: Task, op: DeliveryOperation, ctx: DeliveryContext): Promise<void> {
|
|
37
|
+
const source = await readSource(task, ctx);
|
|
38
|
+
if (source.commit !== op.sourceCommit || source.branch !== op.sourceBranch || source.remote !== op.remote || source.repository !== op.repository) throw new Error("Reviewed source or remote changed. Restore it or refresh review; no further publishing was attempted.");
|
|
39
|
+
}
|
|
40
|
+
async function verifySnapshot(task: Task, op: DeliveryOperation, ctx: DeliveryContext): Promise<void> {
|
|
41
|
+
await verify(task, op, ctx);
|
|
42
|
+
const next = await refreshed(task, ctx);
|
|
43
|
+
if (next.fingerprint !== op.fingerprint || next.blocked[op.action]) throw new Error("Source, main, checks or repository rules changed during delivery. Refresh review and explicitly approve again.");
|
|
44
|
+
}
|
|
45
|
+
function success(task: Task, result: DeliveryResult, store: DeliveryStore): Delivery {
|
|
46
|
+
const delivery = task.delivery!;
|
|
47
|
+
delivery.status = "successful"; delivery.result = result; delete delivery.error;
|
|
48
|
+
delivery.operation!.stage = "verified";
|
|
49
|
+
delivery.operation!.pullNumber = result.pullNumber; delivery.operation!.pullUrl = result.pullUrl;
|
|
50
|
+
store.save(task); return delivery;
|
|
51
|
+
}
|
|
52
|
+
async function reconcile(task: Task, ctx: DeliveryContext): Promise<DeliveryResult | undefined> {
|
|
53
|
+
const op = task.delivery!.operation!;
|
|
54
|
+
const common = realpathSync(resolve(ctx.cwd, await command(ctx, "git", ["rev-parse", "--git-common-dir"])));
|
|
55
|
+
const remote = await command(ctx, "git", ["remote", "get-url", "origin"]);
|
|
56
|
+
if (common !== op.repository || remote !== op.remote) throw new Error("Approved repository or remote changed; restore it before reconciliation.");
|
|
57
|
+
return op.action === "create_pr" ? matchingPull(ctx, op) : reconcileMerge(ctx, op);
|
|
58
|
+
}
|
|
59
|
+
async function prepare(task: Task, request: DeliveryRequest, ctx: DeliveryContext, store: DeliveryStore, operationId: string): Promise<boolean> {
|
|
60
|
+
const old = task.delivery!;
|
|
61
|
+
const next = await refreshed(task, ctx);
|
|
62
|
+
if (next.reviewId !== request.reviewId || next.fingerprint !== old.fingerprint) {
|
|
63
|
+
next.operation = old.operation; next.error = "Review changed. Inspect refreshed source, main and verification and choose an action again. Recorded operations require reconciliation before replacement.";
|
|
64
|
+
task.delivery = next; store.save(task); return false;
|
|
65
|
+
}
|
|
66
|
+
if (next.blocked[request.action]) throw new Error(next.blocked[request.action]);
|
|
67
|
+
task.delivery = next; next.previousOperations = old.previousOperations;
|
|
68
|
+
next.operation = reapprove(old, next, request.action, operationId);
|
|
69
|
+
task.delivery.status = "in_progress"; delete task.delivery.error;
|
|
70
|
+
store.save(task); return true;
|
|
71
|
+
}
|
|
72
|
+
function reapprove(old: Delivery, next: Delivery, action: DeliveryAction, operationId: string): DeliveryOperation {
|
|
73
|
+
const op = old.operation;
|
|
74
|
+
if (op?.action === action && op.fingerprint === next.fingerprint) return { ...op, reviewId: next.reviewId };
|
|
75
|
+
if (op) next.previousOperations = [...(old.previousOperations ?? []), { ...op, stage: "superseded_not_delivered" }].slice(-10);
|
|
76
|
+
return { ...intent(next, action), id: operationId === op?.id ? randomUUID() : operationId };
|
|
77
|
+
}
|
|
78
|
+
async function trustedRecovery(owner: LockOwner, ctx: DeliveryContext, store: DeliveryStore): Promise<{ task: Task; recovery: DeliveryStore }> {
|
|
79
|
+
if (!owner.identity || !store.recoveryStore) throw new Error("Interrupted owner lacks a trusted recovery identity; recovery is blocked.");
|
|
80
|
+
const recovery = store.recoveryStore(owner.identity);
|
|
81
|
+
const root = realpathSync(owner.identity.projectRoot);
|
|
82
|
+
const recordedCommon = realpathSync(resolve(root, await command({ ...ctx, cwd: root }, "git", ["rev-parse", "--git-common-dir"])));
|
|
83
|
+
const common = realpathSync(resolve(ctx.cwd, await command(ctx, "git", ["rev-parse", "--git-common-dir"])));
|
|
84
|
+
if (recordedCommon !== common) throw new Error("Interrupted owner's canonical repository does not match this target.");
|
|
85
|
+
const task = recovery.load();
|
|
86
|
+
if (task.id !== owner.identity.taskId) throw new Error("Interrupted task identity mismatch.");
|
|
87
|
+
const op = task.delivery?.operation;
|
|
88
|
+
if (op && op.id !== owner.identity.operationId && !task.delivery!.previousOperations?.some((previous) => previous.id === owner.identity!.operationId && previous.stage === "superseded_not_delivered")) throw new Error("Interrupted operation identity changed; recovery is blocked.");
|
|
89
|
+
return { task, recovery };
|
|
90
|
+
}
|
|
91
|
+
async function recoverOwner(owner: LockOwner, ctx: DeliveryContext, store: DeliveryStore): Promise<void> {
|
|
92
|
+
const { task, recovery } = await trustedRecovery(owner, ctx, store);
|
|
93
|
+
if (!task.delivery?.operation) return;
|
|
94
|
+
try {
|
|
95
|
+
const result = await reconcile(task, ctx);
|
|
96
|
+
if (result) { success(task, result, recovery); return; }
|
|
97
|
+
task.delivery!.status = "recoverable_failure";
|
|
98
|
+
task.delivery!.error = "Interrupted operation reconciled without delivery; explicitly approve the current review to retry.";
|
|
99
|
+
recovery.save(task);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
task.delivery!.status = "recoverable_failure"; task.delivery!.error = (error as Error).message;
|
|
102
|
+
recovery.save(task); throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function execute(task: Task, request: DeliveryRequest, ctx: DeliveryContext, store: DeliveryStore, operationId: string): Promise<Delivery> {
|
|
106
|
+
if (task.delivery?.status === "successful") return task.delivery;
|
|
107
|
+
const delivery = approved(task, request);
|
|
108
|
+
try {
|
|
109
|
+
if (delivery.operation) { const result = await reconcile(task, ctx); if (result) return success(task, result, store); }
|
|
110
|
+
if (!await prepare(task, request, ctx, store, operationId)) return task.delivery!;
|
|
111
|
+
const op = task.delivery!.operation!;
|
|
112
|
+
const save = () => store.save(task);
|
|
113
|
+
const check = () => verifySnapshot(task, op, ctx);
|
|
114
|
+
const result = op.action === "create_pr" ? await publishPull(ctx, op, save, check) : await publishMerge(ctx, op, save, check);
|
|
115
|
+
return success(task, result, store);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
task.delivery!.status = task.delivery!.operation ? "recoverable_failure" : "pending_approval";
|
|
118
|
+
task.delivery!.error = (error as Error).message; store.save(task); return task.delivery!;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Explicit API only. Lock covers refresh, approval, durable intent, reconciliation and side effects. */
|
|
122
|
+
export async function deliver(request: DeliveryRequest, ctx: DeliveryContext, store: DeliveryStore): Promise<Delivery> {
|
|
123
|
+
const common = realpathSync(resolve(ctx.cwd, await command(ctx, "git", ["rev-parse", "--git-common-dir"])));
|
|
124
|
+
const task = store.load();
|
|
125
|
+
const operationId = task.delivery?.operation?.id ?? randomUUID();
|
|
126
|
+
const identity = store.identity && { ...store.identity, taskId: task.id, operationId };
|
|
127
|
+
const release = await recoverDeliveryLock(common, identity, (owner) => recoverOwner(owner, ctx, store));
|
|
128
|
+
const lockedStore = { ...store, save: (value: Task) => {
|
|
129
|
+
store.save(value);
|
|
130
|
+
if (value.delivery?.operation) release.setOperation(value.delivery.operation.id);
|
|
131
|
+
} };
|
|
132
|
+
try { return await execute(store.load(), request, ctx, lockedStore, operationId); }
|
|
133
|
+
finally { release(); }
|
|
134
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DeliveryOperation, DeliveryResult } from "./types.ts";
|
|
2
|
+
import { command, object, type DeliveryContext } from "./transport.ts";
|
|
3
|
+
import { sha } from "./source.ts";
|
|
4
|
+
|
|
5
|
+
export type SaveStage = () => void;
|
|
6
|
+
export async function matchingPull(ctx: DeliveryContext, op: DeliveryOperation): Promise<DeliveryResult | undefined> {
|
|
7
|
+
const text = await command(ctx, "gh", ["pr", "list", "--state", "all", "--head", op.sourceBranch!, "--base", "main", "--limit", "1000", "--json", "number,url,state,headRefOid,headRefName,baseRefName"]);
|
|
8
|
+
const rows: unknown = JSON.parse(text);
|
|
9
|
+
if (!Array.isArray(rows) || rows.length >= 1000) throw new Error("PR reconciliation is incomplete; inspect repository PRs before retrying.");
|
|
10
|
+
const matches = rows.map(object).filter((row) => row.headRefOid === op.sourceCommit && row.headRefName === op.sourceBranch && row.baseRefName === "main");
|
|
11
|
+
if (matches.length > 1) throw new Error("Multiple matching PRs; reconcile them manually before retrying.");
|
|
12
|
+
const row = matches[0];
|
|
13
|
+
if (!row) return undefined;
|
|
14
|
+
if (row.state === "CLOSED") throw new Error("Matching PR was closed without merging; reopen it or explicitly review a new source commit.");
|
|
15
|
+
if (!["OPEN", "MERGED"].includes(String(row.state)) || !Number.isInteger(row.number) || typeof row.url !== "string" || !/^https:\/\/github\.com\//.test(row.url)) throw new Error("Malformed matching PR; verify its state manually.");
|
|
16
|
+
return { action: "create_pr", pullNumber: Number(row.number), pullUrl: row.url };
|
|
17
|
+
}
|
|
18
|
+
async function remoteBranch(ctx: DeliveryContext, branch: string): Promise<string | undefined> {
|
|
19
|
+
const output = await command(ctx, "git", ["ls-remote", "--heads", "origin", `refs/heads/${branch}`]);
|
|
20
|
+
if (!output) return undefined;
|
|
21
|
+
const [commit, ref] = output.split(/\s+/);
|
|
22
|
+
if (ref !== `refs/heads/${branch}`) throw new Error("Unexpected remote branch identity.");
|
|
23
|
+
return sha(commit!);
|
|
24
|
+
}
|
|
25
|
+
export async function publishPull(ctx: DeliveryContext, op: DeliveryOperation, save: SaveStage, verify: () => Promise<void>): Promise<DeliveryResult> {
|
|
26
|
+
const existing = await matchingPull(ctx, op);
|
|
27
|
+
if (existing) return existing;
|
|
28
|
+
if (await remoteBranch(ctx, op.sourceBranch!) !== op.sourceCommit) {
|
|
29
|
+
try { await command(ctx, "git", ["push", "origin", `${op.sourceCommit}:refs/heads/${op.sourceBranch}`]); }
|
|
30
|
+
catch (error) { if (await remoteBranch(ctx, op.sourceBranch!) !== op.sourceCommit) throw error; }
|
|
31
|
+
}
|
|
32
|
+
if (await remoteBranch(ctx, op.sourceBranch!) !== op.sourceCommit) throw new Error("Task branch push could not be verified; preserve work and retry after remote access recovers.");
|
|
33
|
+
op.stage = "branch_pushed"; save();
|
|
34
|
+
await verify();
|
|
35
|
+
try { await command(ctx, "gh", ["pr", "create", "--head", op.sourceBranch!, "--base", "main", "--title", `Completed work: ${op.sourceBranch}`, "--body", `Explicit delivery of reviewed commit ${op.sourceCommit}.`]); }
|
|
36
|
+
catch (error) { const found = await matchingPull(ctx, op); if (found) return found; throw error; }
|
|
37
|
+
const result = await matchingPull(ctx, op);
|
|
38
|
+
if (!result) throw new Error("PR creation response was not verified; refresh/retry to reconcile before creating another PR.");
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Task } from "../schemas/task.ts";
|
|
3
|
+
import { completionBlockers } from "../master/decisions.ts";
|
|
4
|
+
import { pendingApprovals } from "../workflow/approvals.ts";
|
|
5
|
+
import { qaRequired } from "../workflow/track.ts";
|
|
6
|
+
import type { Delivery } from "./types.ts";
|
|
7
|
+
import { readSource, refreshMain, type Source } from "./source.ts";
|
|
8
|
+
import { readChecks, type Checks } from "./checks.ts";
|
|
9
|
+
import { readRules, type Rules } from "./rules.ts";
|
|
10
|
+
import type { DeliveryContext } from "./transport.ts";
|
|
11
|
+
|
|
12
|
+
export function localVerified(task: Task): boolean {
|
|
13
|
+
return task.state === "completed" && !task.qaWaiver && completionBlockers(task, pendingApprovals(task).length).length === 0
|
|
14
|
+
&& (!qaRequired(task) || task.qaVerdict === "pass");
|
|
15
|
+
}
|
|
16
|
+
export function newDelivery(task: Task, project: string): Delivery {
|
|
17
|
+
return { status: "pending_approval", reviewId: randomUUID(), project, sourceBranch: task.git?.branch ?? "unavailable",
|
|
18
|
+
target: "main", reviewedAt: new Date().toISOString(), blocked: { create_pr: "Refresh review to verify the task source.", merge_main: "Refresh review to verify source, main, checks and repository rules." } };
|
|
19
|
+
}
|
|
20
|
+
export async function completionReview(task: Task, project: string, ctx: DeliveryContext): Promise<Delivery> {
|
|
21
|
+
const delivery = newDelivery(task, project);
|
|
22
|
+
try { const source = await readSource(task, ctx); applySource(delivery, source); delete delivery.blocked.create_pr; }
|
|
23
|
+
catch (error) { delivery.error = (error as Error).message; delivery.blocked = { create_pr: delivery.error, merge_main: delivery.error }; }
|
|
24
|
+
return delivery;
|
|
25
|
+
}
|
|
26
|
+
function applySource(delivery: Delivery, source: Source): void {
|
|
27
|
+
delivery.repository = source.repository;
|
|
28
|
+
delivery.remote = source.remote;
|
|
29
|
+
delivery.sourceBranch = source.branch;
|
|
30
|
+
delivery.sourceCommit = source.commit;
|
|
31
|
+
}
|
|
32
|
+
function mergeReason(checks: Checks, rules: Rules, verified: boolean): string | undefined {
|
|
33
|
+
if (!rules.known || rules.blocked) return rules.blocked;
|
|
34
|
+
if (["unavailable", "stale", "pending", "failing"].includes(checks.state)) return checks.reason ?? `Checks are ${checks.state}. Resolve them and refresh review, then choose Merge again.`;
|
|
35
|
+
const missing = rules.required.filter((name) => !checks.entries.some((entry) => entry.name === name && entry.state === "passing"));
|
|
36
|
+
if (missing.length) return `Required checks missing or not passing: ${missing.join(", ")}. Run them and refresh review.`;
|
|
37
|
+
if (!verified) return "Local completion/QA verification is not passing. Obtain a QA pass; a waiver is not delivery verification.";
|
|
38
|
+
if (checks.state === "absent" && rules.required.length) return "Required repository checks are absent. Run them before merging.";
|
|
39
|
+
}
|
|
40
|
+
function semantic(value: unknown): unknown {
|
|
41
|
+
if (Array.isArray(value)) return value.map(semantic).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
42
|
+
if (!value || typeof value !== "object") return value;
|
|
43
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !/(?:_at|url|^id$|node_id)$/.test(key))
|
|
44
|
+
.sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, semantic(item)]));
|
|
45
|
+
}
|
|
46
|
+
function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(semantic(value))).digest("hex"); }
|
|
47
|
+
async function preflight(task: Task, delivery: Delivery, ctx: DeliveryContext): Promise<unknown> {
|
|
48
|
+
const source = await readSource(task, ctx);
|
|
49
|
+
applySource(delivery, source);
|
|
50
|
+
delete delivery.blocked.create_pr;
|
|
51
|
+
const sourceCtx = { ...ctx, cwd: source.cwd };
|
|
52
|
+
try { delivery.targetCommit = await refreshMain(sourceCtx); }
|
|
53
|
+
catch (error) { delivery.blocked.create_pr = "Target main is unavailable. Restore remote access and main, then refresh review."; throw error; }
|
|
54
|
+
const checks = await readChecks(sourceCtx, source.github, source.commit);
|
|
55
|
+
const rules = await readRules(sourceCtx, source.github);
|
|
56
|
+
const verified = localVerified(task);
|
|
57
|
+
delivery.verification = { checks: checks.state, summary: checks.reason ?? `Remote checks: ${checks.state}. Local completion/QA: ${verified ? "passing" : "not verified"}.`, localVerified: verified, requiredChecks: rules.required, rulesKnown: rules.known };
|
|
58
|
+
const reason = mergeReason(checks, rules, verified);
|
|
59
|
+
delivery.blocked.merge_main = reason;
|
|
60
|
+
if (!reason) delete delivery.blocked.merge_main;
|
|
61
|
+
return { source, main: delivery.targetCommit, checks, rules, verified };
|
|
62
|
+
}
|
|
63
|
+
/** Read-only preflight apart from fetching the remote tracking ref; never publishes. */
|
|
64
|
+
export async function refreshReview(task: Task, ctx: DeliveryContext): Promise<Delivery> {
|
|
65
|
+
if (!task.delivery) throw new Error("This historical task has no delivery review; no retroactive approval was created.");
|
|
66
|
+
if (["successful", "in_progress"].includes(task.delivery.status)) return task.delivery;
|
|
67
|
+
const delivery = structuredClone(task.delivery);
|
|
68
|
+
delete delivery.error;
|
|
69
|
+
delivery.blocked.create_pr = "Task source could not be verified. Restore its clean branch and refresh review.";
|
|
70
|
+
delete delivery.verification;
|
|
71
|
+
delete delivery.targetCommit;
|
|
72
|
+
let snapshot: unknown;
|
|
73
|
+
try { snapshot = await preflight(task, delivery, ctx); }
|
|
74
|
+
catch (error) { delivery.error = (error as Error).message; delivery.blocked.merge_main = delivery.error; snapshot = { source: delivery.sourceCommit, main: delivery.targetCommit, error: delivery.error }; }
|
|
75
|
+
const next = fingerprint(snapshot);
|
|
76
|
+
if (delivery.fingerprint !== next) { delivery.reviewId = randomUUID(); delivery.status = "pending_approval"; delivery.reviewedAt = new Date().toISOString(); }
|
|
77
|
+
delivery.fingerprint = next;
|
|
78
|
+
task.delivery = delivery;
|
|
79
|
+
return delivery;
|
|
80
|
+
}
|
|
81
|
+
export function deferReview(task: Task, reviewId: string): Delivery {
|
|
82
|
+
const delivery = validateReview(task, reviewId);
|
|
83
|
+
if (delivery.operation || ["successful", "in_progress"].includes(delivery.status)) throw new Error("Delivery already started or succeeded; reconcile it before deferring.");
|
|
84
|
+
delivery.status = "deferred";
|
|
85
|
+
return delivery;
|
|
86
|
+
}
|
|
87
|
+
export function validateReview(task: Task, reviewId: string): Delivery {
|
|
88
|
+
if (!task.delivery || task.delivery.reviewId !== reviewId) throw new Error("Review changed. Refresh and review the new source, main and verification before choosing an action.");
|
|
89
|
+
return task.delivery;
|
|
90
|
+
}
|