@kylecheng3146/agent-ops 0.6.0 → 0.7.1
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 +60 -1
- package/dist/packages/cli/src/args.js +19 -2
- package/dist/packages/cli/src/bin.js +14 -4
- package/dist/packages/cli/src/cli.js +20 -2
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +19 -2
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- package/dist/packages/cli/src/commands/review.js +10 -4
- package/dist/packages/cli/src/commands/run.js +6 -2
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/context.js +7 -5
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +23 -2
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +261 -0
- package/dist/packages/cli/src/parallel-deps.js +4 -2
- package/dist/packages/cli/src/run-deps.js +9 -2
- package/dist/packages/cli/src/run-entry.js +9 -0
- package/dist/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +5 -0
- package/dist/runtime/src/fs/transaction.js +2 -1
- package/dist/runtime/src/hooks/codex-loop.js +4 -0
- package/dist/runtime/src/hooks/completion-gate.js +38 -3
- package/dist/runtime/src/install/harness.js +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +268 -0
- package/dist/runtime/src/office/page.js +887 -0
- package/dist/runtime/src/office/repos.js +43 -0
- package/dist/runtime/src/office/scene.js +181 -0
- package/dist/runtime/src/office/server.js +143 -0
- package/dist/runtime/src/office/sessions.js +130 -0
- package/dist/runtime/src/office/snapshot.js +117 -0
- package/dist/runtime/src/parallel/finish.js +3 -0
- package/dist/runtime/src/parallel/guard.js +7 -1
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/dist/runtime/src/run/phase.js +37 -0
- package/dist/runtime/src/run/service.js +8 -0
- package/dist/runtime/src/schema/validate.js +13 -2
- package/dist/runtime/src/security/permissions.js +6 -0
- package/dist/runtime/src/verify/source-fingerprint.js +4 -0
- package/docs/en/spec/harness-adapters.md +122 -1
- package/docs/zh-TW/spec/harness-adapters.md +100 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +10 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
3
|
+
const MAX_REPOS = 64;
|
|
4
|
+
const MAX_ROOT = 4_096;
|
|
5
|
+
export const officeReposPath = (home) => join(home.dir, "repos.json");
|
|
6
|
+
function parse(value) {
|
|
7
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
8
|
+
return null;
|
|
9
|
+
const item = value;
|
|
10
|
+
const root = (text) => typeof text === "string" && text.length > 0 && text.length <= MAX_ROOT && !/[\0\r\n]/u.test(text);
|
|
11
|
+
if (!root(item.mainRoot) || !root(item.commonDir) || typeof item.lastSeenAt !== "string" || !Number.isFinite(Date.parse(item.lastSeenAt)))
|
|
12
|
+
return null;
|
|
13
|
+
return { mainRoot: item.mainRoot, commonDir: item.commonDir, lastSeenAt: item.lastSeenAt };
|
|
14
|
+
}
|
|
15
|
+
export async function readOfficeRepos(home) {
|
|
16
|
+
const source = await readPrivateFile(officeReposPath(home), home.anchor).catch(() => null);
|
|
17
|
+
if (source === null)
|
|
18
|
+
return [];
|
|
19
|
+
try {
|
|
20
|
+
const repos = JSON.parse(source).repos;
|
|
21
|
+
return Array.isArray(repos) ? repos.slice(0, MAX_REPOS).map(parse).filter((repo) => repo !== null) : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function update(home, change) {
|
|
28
|
+
const path = officeReposPath(home);
|
|
29
|
+
await withPrivateFileLock(path, home.anchor, async () => {
|
|
30
|
+
const repos = change(await readOfficeRepos(home)).slice(-MAX_REPOS);
|
|
31
|
+
await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, repos }) + "\n", home.anchor);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Adds or refreshes one repository; the newest stays last. */
|
|
35
|
+
export async function registerOfficeRepo(home, repo, now = Date.now()) {
|
|
36
|
+
await update(home, repos => [...repos.filter(item => item.commonDir !== repo.commonDir),
|
|
37
|
+
{ mainRoot: repo.mainRoot, commonDir: repo.commonDir, lastSeenAt: new Date(now).toISOString() }]);
|
|
38
|
+
}
|
|
39
|
+
export async function forgetOfficeRepos(home, commonDirs) {
|
|
40
|
+
if (commonDirs.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
await update(home, repos => repos.filter(item => !commonDirs.includes(item.commonDir)));
|
|
43
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure snapshot -> scene mapping in tile units. Self-contained on purpose: the
|
|
3
|
+
* page embeds this exact function with Function.prototype.toString, so it may
|
|
4
|
+
* not reference imports or module-level values.
|
|
5
|
+
*/
|
|
6
|
+
export function sceneModel(snapshot) {
|
|
7
|
+
const ROOM_COLS = 72, ROOM_ROWS = 40, OVERVIEW_COLS = 80, OVERVIEW_ROWS = 38;
|
|
8
|
+
const PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
|
|
9
|
+
const labels = {
|
|
10
|
+
planning: "Planning", implementing: "Implementing", verifying: "Verifying", reviewing: "Reviewing", integrating: "Integrating", unknown: "Unassigned"
|
|
11
|
+
};
|
|
12
|
+
const areas = {
|
|
13
|
+
planning: { x: 3, y: 8, width: 17, height: 12, prop: "whiteboard" },
|
|
14
|
+
implementing: { x: 3, y: 23, width: 29, height: 11, prop: "desk" },
|
|
15
|
+
verifying: { x: 49, y: 8, width: 20, height: 12, prop: "bench" },
|
|
16
|
+
reviewing: { x: 38, y: 23, width: 30, height: 11, prop: "table" },
|
|
17
|
+
integrating: { x: 22, y: 34, width: 25, height: 5, prop: "door" }
|
|
18
|
+
};
|
|
19
|
+
const position = (phase, index, count = 1) => {
|
|
20
|
+
const area = areas[phase] ?? areas.implementing;
|
|
21
|
+
// Keep every actor inside the phase area, even when a run has many
|
|
22
|
+
// workers in one phase. Fractional spacing compresses a crowd without
|
|
23
|
+
// dropping actors or sending them through the room walls.
|
|
24
|
+
const inset = 2, columns = Math.max(1, Math.min(count, Math.floor((area.width - inset * 2) / 3)));
|
|
25
|
+
const rows = Math.max(1, Math.ceil(count / columns));
|
|
26
|
+
const xSpan = Math.max(0, area.width - inset * 2), ySpan = Math.max(0, area.height - inset * 2);
|
|
27
|
+
const column = index % columns, row = Math.floor(index / columns);
|
|
28
|
+
return { x: area.x + inset + (columns === 1 ? xSpan * .35 : column * xSpan / (columns - 1)),
|
|
29
|
+
y: area.y + inset + (rows === 1 ? ySpan * .55 : row * ySpan / (rows - 1)) };
|
|
30
|
+
};
|
|
31
|
+
const progressOf = (value) => {
|
|
32
|
+
if (value === null || typeof value !== "object")
|
|
33
|
+
return null;
|
|
34
|
+
const p = value;
|
|
35
|
+
return { passed: typeof p.passed === "number" ? p.passed : 0, total: typeof p.total === "number" ? p.total : 0,
|
|
36
|
+
verify: p.verify === "PASS" || p.verify === "FAIL" ? p.verify : null, review: p.review === "PASS" || p.review === "FAIL" ? p.review : null };
|
|
37
|
+
};
|
|
38
|
+
const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 1) + "…" : value;
|
|
39
|
+
const optional = (value) => value !== null && typeof value === "object" ? value : {};
|
|
40
|
+
const boardFor = (progress, status, taskId, pending) => ({
|
|
41
|
+
passed: progress?.passed ?? 0, total: progress?.total ?? 0,
|
|
42
|
+
verify: progress?.verify ?? "pending", review: progress?.review ?? "pending", pending, status, taskId
|
|
43
|
+
});
|
|
44
|
+
const floors = [];
|
|
45
|
+
const dialogue = [];
|
|
46
|
+
// Two repositories may hold rooms with the same name; the repo keeps their keys apart.
|
|
47
|
+
const repoOf = (value) => {
|
|
48
|
+
const repo = optional(value).repo;
|
|
49
|
+
return typeof repo === "string" && repo.length > 0 ? repo : null;
|
|
50
|
+
};
|
|
51
|
+
const scoped = (repo, key) => repo === null ? key : "repo:" + repo + "|" + key;
|
|
52
|
+
const titled = (repo, title) => short(repo === null ? title : repo + " · " + title, 34);
|
|
53
|
+
const makeAreas = () => PHASES.map(phase => {
|
|
54
|
+
const area = areas[phase];
|
|
55
|
+
return { phase, x: area.x, y: area.y, width: area.width, height: area.height, prop: area.prop, label: labels[phase] };
|
|
56
|
+
});
|
|
57
|
+
const makeProps = (key) => [
|
|
58
|
+
{ kind: "window", x: 56, y: 1, key: key + ":window" },
|
|
59
|
+
{ kind: "whiteboard", x: 3, y: 10, key: key + ":planning", phase: "planning" },
|
|
60
|
+
{ kind: "desk", x: 5, y: 25, key: key + ":implementing", phase: "implementing" },
|
|
61
|
+
{ kind: "bench", x: 49, y: 10, key: key + ":verifying", phase: "verifying" },
|
|
62
|
+
{ kind: "table", x: 40, y: 25, key: key + ":reviewing", phase: "reviewing" },
|
|
63
|
+
{ kind: "desk", x: 24, y: 35, key: key + ":integrating", phase: "integrating" },
|
|
64
|
+
{ kind: "chair", x: 13, y: 30, key: key + ":chair" },
|
|
65
|
+
{ kind: "shelf", x: 2, y: 29, key: key + ":shelf" },
|
|
66
|
+
{ kind: "shelf", x: 34, y: 20, key: key + ":shelf-middle" },
|
|
67
|
+
{ kind: "plant", x: 2, y: 6, key: key + ":plant-left" },
|
|
68
|
+
{ kind: "plant", x: 22, y: 16, key: key + ":plant-middle" },
|
|
69
|
+
{ kind: "plant", x: 65, y: 6, key: key + ":plant-right" },
|
|
70
|
+
{ kind: "plant", x: 57, y: 34, key: key + ":plant-bottom" },
|
|
71
|
+
{ kind: "door", x: 65, y: 33, key: key + ":door" }
|
|
72
|
+
];
|
|
73
|
+
const makeRun = (run) => {
|
|
74
|
+
const extra = optional(run);
|
|
75
|
+
const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
|
|
76
|
+
const questions = run.questions;
|
|
77
|
+
const repo = repoOf(run);
|
|
78
|
+
const key = scoped(repo, run.runId);
|
|
79
|
+
const props = makeProps(key);
|
|
80
|
+
const actors = [];
|
|
81
|
+
const papers = { [key + ":implementing"]: Math.min(8, run.agents.reduce((sum, agent) => sum + (agent.diff?.files ?? 0), 0)) };
|
|
82
|
+
let passed = 0, total = 0, verify = null, review = null;
|
|
83
|
+
run.agents.forEach((agent, index) => {
|
|
84
|
+
const phase = agent.phase;
|
|
85
|
+
const p = progressOf(agent.progress);
|
|
86
|
+
const phaseIndex = actors.filter(actor => actor.phase === phase).length;
|
|
87
|
+
const phaseCount = run.agents.filter(candidate => candidate.phase === phase).length;
|
|
88
|
+
const point = position(phase, phaseIndex, phaseCount);
|
|
89
|
+
if ((phase === "implementing" || phase === "unknown") && phaseIndex > 0) {
|
|
90
|
+
const deskKey = key + ":desk:" + agent.id;
|
|
91
|
+
props.push({ kind: "desk", x: point.x - 2, y: point.y - 5, key: deskKey, phase: "implementing", label: short(agent.id) });
|
|
92
|
+
papers[deskKey] = Math.min(8, agent.diff?.files ?? 0);
|
|
93
|
+
}
|
|
94
|
+
passed += p?.passed ?? 0;
|
|
95
|
+
total += p?.total ?? 0;
|
|
96
|
+
if (p?.verify === "FAIL")
|
|
97
|
+
verify = "FAIL";
|
|
98
|
+
else if (p?.verify === "PASS" && verify === null)
|
|
99
|
+
verify = "PASS";
|
|
100
|
+
if (p?.review === "FAIL")
|
|
101
|
+
review = "FAIL";
|
|
102
|
+
else if (p?.review === "PASS" && review === null)
|
|
103
|
+
review = "PASS";
|
|
104
|
+
actors.push({ key: key + ":" + agent.id, id: agent.id, kind: agent.role, x: point.x, y: point.y, alert: agent.role === "coordinator" && questions.length > 0,
|
|
105
|
+
label: short(agent.id), status: agent.status, phase, taskId: agent.taskId || "unassigned", progress: p,
|
|
106
|
+
narration: agent.narration, questionCount: agent.role === "coordinator" ? questions.length : 0, host: "unknown" });
|
|
107
|
+
dialogue.push(`${run.title}: ${agent.id} ${agent.narration}.`);
|
|
108
|
+
});
|
|
109
|
+
run.reviewers.forEach((reviewer, index) => {
|
|
110
|
+
const reviewerIndex = actors.filter(actor => actor.phase === "reviewing").length;
|
|
111
|
+
const reviewerCount = run.agents.filter(agent => agent.phase === "reviewing").length + run.reviewers.length;
|
|
112
|
+
const point = position("reviewing", reviewerIndex, reviewerCount);
|
|
113
|
+
actors.push({ key: key + ":review:" + reviewer.slot, id: "reviewer " + reviewer.slot, kind: "reviewer", x: point.x, y: point.y, alert: false,
|
|
114
|
+
label: "reviewer " + reviewer.slot, status: "reviewing", phase: "reviewing", taskId: reviewer.taskId ?? "unassigned",
|
|
115
|
+
progress: null, narration: "reviewing", questionCount: 0, host: "unknown" });
|
|
116
|
+
});
|
|
117
|
+
const rootProgress = { passed, total, verify, review };
|
|
118
|
+
const taskId = run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
|
|
119
|
+
if (questions.length > 0)
|
|
120
|
+
dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
|
|
121
|
+
return { key, sourceIndex: null, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions,
|
|
122
|
+
board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
|
|
123
|
+
clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
|
|
124
|
+
completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
125
|
+
};
|
|
126
|
+
const makeDesk = (desk, index) => {
|
|
127
|
+
const extra = optional(desk);
|
|
128
|
+
const phase = extra.phase === "planning" || extra.phase === "implementing" || extra.phase === "verifying" || extra.phase === "reviewing" || extra.phase === "integrating" ? extra.phase : "unknown";
|
|
129
|
+
const status = typeof extra.status === "string" ? extra.status : desk.narration;
|
|
130
|
+
const taskId = typeof extra.taskId === "string" && extra.taskId.length > 0 ? extra.taskId : "unassigned";
|
|
131
|
+
const p = progressOf(extra.progress);
|
|
132
|
+
const questionsValue = Array.isArray(extra.questions) ? extra.questions : [];
|
|
133
|
+
const questions = questionsValue.map(value => {
|
|
134
|
+
const item = optional(value);
|
|
135
|
+
return { questionId: typeof item.questionId === "string" ? item.questionId : "unknown", prompt: typeof item.prompt === "string" ? item.prompt : "" };
|
|
136
|
+
});
|
|
137
|
+
const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
|
|
138
|
+
const repo = repoOf(desk);
|
|
139
|
+
const key = scoped(repo, "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":"));
|
|
140
|
+
const props = makeProps(key);
|
|
141
|
+
const point = position(phase, 0, 1);
|
|
142
|
+
const host = typeof extra.host === "string" ? extra.host : "unknown";
|
|
143
|
+
const actor = { key: key + ":actor", id: desk.name, kind: "worker", x: point.x, y: point.y, alert: questions.length > 0, label: short(desk.name), status,
|
|
144
|
+
phase, taskId, progress: p, narration: desk.narration, questionCount: questions.length, host };
|
|
145
|
+
if (questions.length > 0)
|
|
146
|
+
dialogue.push(`${desk.name}: waiting for your answer.`);
|
|
147
|
+
dialogue.push(`${desk.name}: ${desk.narration}.`);
|
|
148
|
+
return { key, sourceIndex: index, kind: "desk", title: titled(repo, desk.name), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
|
|
149
|
+
board: boardFor(p, status, taskId, questions.length), questions, papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
|
|
150
|
+
clock: 1, phase, status, completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
151
|
+
};
|
|
152
|
+
snapshot.runs.forEach(run => floors.push(makeRun(run)));
|
|
153
|
+
snapshot.lobby.forEach((desk, index) => floors.push(makeDesk(desk, index)));
|
|
154
|
+
snapshot.reviews.forEach((review, index) => {
|
|
155
|
+
const repo = repoOf(review);
|
|
156
|
+
const key = scoped(repo, "review:" + review.slot + ":" + index);
|
|
157
|
+
const props = makeProps(key);
|
|
158
|
+
const point = position("reviewing", 0);
|
|
159
|
+
const actor = { key: key + ":actor", id: "reviewer " + review.slot, kind: "reviewer", x: point.x, y: point.y, alert: false, label: "reviewer " + review.slot,
|
|
160
|
+
status: "reviewing", phase: "reviewing", taskId: review.taskId ?? "unassigned", progress: null,
|
|
161
|
+
narration: "reviewing", questionCount: 0, host: "unknown" };
|
|
162
|
+
dialogue.push(`Review slot ${review.slot}: ${review.taskId ?? "unassigned"}.`);
|
|
163
|
+
floors.push({ key, sourceIndex: index, kind: "review", title: titled(repo, "Review slot " + review.slot), top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
|
|
164
|
+
board: boardFor(null, "reviewing", review.taskId ?? "unassigned", 0), papers: {}, books: { lit: 0, total: 0 }, clock: 1,
|
|
165
|
+
phase: "reviewing", status: "reviewing", completedAt: null, repo, overview: { x: 0, y: 0, width: 0, height: 0 } });
|
|
166
|
+
});
|
|
167
|
+
// Rooms of one repository sit together; the sort is stable inside each.
|
|
168
|
+
floors.sort((a, b) => (a.repo ?? "").localeCompare(b.repo ?? ""));
|
|
169
|
+
if (dialogue.length === 0)
|
|
170
|
+
dialogue.push("The office is quiet. No agent is at work.");
|
|
171
|
+
const columns = floors.length === 0 ? 1 : Math.min(floors.length, Math.max(1, Math.ceil(Math.sqrt(floors.length * OVERVIEW_COLS / OVERVIEW_ROWS))));
|
|
172
|
+
const rows = Math.max(1, Math.ceil(floors.length / columns));
|
|
173
|
+
const gap = Math.max(0, Math.min(1, (OVERVIEW_COLS - columns) / (columns + 1), (OVERVIEW_ROWS - rows) / (rows + 1)));
|
|
174
|
+
const cardWidth = Math.max(0.25, (OVERVIEW_COLS - gap * (columns + 1)) / columns);
|
|
175
|
+
const cardHeight = Math.max(0.25, (OVERVIEW_ROWS - gap * (rows + 1)) / rows);
|
|
176
|
+
floors.forEach((floor, index) => {
|
|
177
|
+
const x = gap + (index % columns) * (cardWidth + gap), y = gap + Math.floor(index / columns) * (cardHeight + gap);
|
|
178
|
+
floor.overview = { x, y, width: cardWidth, height: cardHeight };
|
|
179
|
+
});
|
|
180
|
+
return { cols: OVERVIEW_COLS, rows: OVERVIEW_ROWS, roomCols: ROOM_COLS, roomRows: ROOM_ROWS, floors, dialogue };
|
|
181
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { localStatePaths, readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
import { officePage } from "./page.js";
|
|
8
|
+
export const OFFICE_IDLE_MS = 10 * 60 * 1000;
|
|
9
|
+
const ACTIVE = new Set(["active", "awaiting-input", "stopping"]);
|
|
10
|
+
/** The user's one Office, beside the other per-user agent-ops state. */
|
|
11
|
+
export const userOfficeHome = (homeDirectory) => ({ anchor: homeDirectory, dir: join(localStatePaths(homeDirectory).root, "office") });
|
|
12
|
+
/** Where 0.7 kept one Office per repository. */
|
|
13
|
+
export const legacyOfficeHome = (commonDir) => ({ anchor: commonDir, dir: join(commonDir, "agent-ops") });
|
|
14
|
+
export const officeRecordPath = (home) => join(home.dir, "office.json");
|
|
15
|
+
const officeStartLockPath = (home) => join(home.dir, "office-start.lock");
|
|
16
|
+
export const officeUrl = (record) => `http://127.0.0.1:${record.port}/?token=${record.token}`;
|
|
17
|
+
function same(a, b) {
|
|
18
|
+
const left = Buffer.from(a), right = Buffer.from(b);
|
|
19
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
20
|
+
}
|
|
21
|
+
function send(res, status, type, body, headers = {}) {
|
|
22
|
+
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
|
23
|
+
"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", ...headers });
|
|
24
|
+
res.end(body);
|
|
25
|
+
}
|
|
26
|
+
/** Loopback-only, token-gated, GET-only view of the building. */
|
|
27
|
+
export function createOfficeServer(options) {
|
|
28
|
+
const token = options.token ?? randomBytes(24).toString("base64url");
|
|
29
|
+
const now = options.now ?? Date.now;
|
|
30
|
+
const idleMs = options.idleMs ?? OFFICE_IDLE_MS;
|
|
31
|
+
let lastActive = now();
|
|
32
|
+
let port = 0;
|
|
33
|
+
let idle = false;
|
|
34
|
+
const handle = async (req, res) => {
|
|
35
|
+
// DNS-rebinding guard: a foreign name resolving to loopback still carries its own Host.
|
|
36
|
+
if (req.headers.host !== `127.0.0.1:${port}`)
|
|
37
|
+
return send(res, 421, "text/plain", "Misdirected request\n");
|
|
38
|
+
if (req.method !== "GET")
|
|
39
|
+
return send(res, 405, "text/plain", "Method not allowed\n", { Allow: "GET" });
|
|
40
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
41
|
+
if (!same(url.searchParams.get("token") ?? "", token))
|
|
42
|
+
return send(res, 403, "text/plain", "Forbidden\n");
|
|
43
|
+
if (url.pathname === "/") {
|
|
44
|
+
const nonce = randomBytes(16).toString("base64");
|
|
45
|
+
return send(res, 200, "text/html; charset=utf-8", officePage(nonce), { "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'` });
|
|
46
|
+
}
|
|
47
|
+
if (url.pathname === "/snapshot.json")
|
|
48
|
+
return send(res, 200, "application/json", JSON.stringify(await options.snapshot()));
|
|
49
|
+
return send(res, 404, "text/plain", "Not found\n");
|
|
50
|
+
};
|
|
51
|
+
const server = createServer((req, res) => {
|
|
52
|
+
handle(req, res).catch(() => { if (!res.headersSent)
|
|
53
|
+
send(res, 500, "text/plain", "Snapshot unavailable\n");
|
|
54
|
+
else
|
|
55
|
+
res.end(); });
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
server, token,
|
|
59
|
+
listen: async () => await new Promise((resolve, reject) => {
|
|
60
|
+
server.once("error", reject);
|
|
61
|
+
server.listen(0, "127.0.0.1", () => {
|
|
62
|
+
const address = server.address();
|
|
63
|
+
port = typeof address === "object" && address !== null ? address.port : 0;
|
|
64
|
+
resolve(port);
|
|
65
|
+
});
|
|
66
|
+
}),
|
|
67
|
+
tick: async () => {
|
|
68
|
+
const snapshot = await options.snapshot().catch(() => null);
|
|
69
|
+
const sessionIsPresent = snapshot?.lobby.some(desk => (desk.sessionActive ?? desk.status === "active") && (desk.completedAt === undefined || desk.completedAt === null)) ?? false;
|
|
70
|
+
if (snapshot === null || snapshot.runs.some(run => ACTIVE.has(run.status)) || sessionIsPresent)
|
|
71
|
+
lastActive = now();
|
|
72
|
+
if (!idle && now() - lastActive >= idleMs) {
|
|
73
|
+
idle = true;
|
|
74
|
+
options.onIdle();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
close: async () => await new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); })
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function alive(pid) {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return error.code === "EPERM";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** A recorded server counts only if its process lives and it answers with its own token. */
|
|
90
|
+
export async function readLiveOffice(home) {
|
|
91
|
+
const source = await readPrivateFile(officeRecordPath(home), home.anchor).catch(() => null);
|
|
92
|
+
if (source === null)
|
|
93
|
+
return null;
|
|
94
|
+
try {
|
|
95
|
+
const record = JSON.parse(source);
|
|
96
|
+
if (!Number.isSafeInteger(record.pid) || !Number.isSafeInteger(record.port) || typeof record.token !== "string" || !alive(record.pid))
|
|
97
|
+
return null;
|
|
98
|
+
const response = await fetch(`http://127.0.0.1:${record.port}/snapshot.json?token=${encodeURIComponent(record.token)}`, { signal: AbortSignal.timeout(2000) });
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
return response.status === 200 ? record : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Starts serving unless another live server already owns this home.
|
|
108
|
+
* Returns null when this process should exit because one is already running.
|
|
109
|
+
*/
|
|
110
|
+
export async function claimOffice(home, office, pid = process.pid) {
|
|
111
|
+
return await withPrivateFileLock(officeRecordPath(home), home.anchor, async () => {
|
|
112
|
+
if (await readLiveOffice(home) !== null)
|
|
113
|
+
return null;
|
|
114
|
+
const record = { pid, port: await office.listen(), token: office.token, startedAt: new Date().toISOString() };
|
|
115
|
+
await writePrivateFile(officeRecordPath(home), JSON.stringify(record) + "\n", home.anchor);
|
|
116
|
+
return record;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Reuse the live server, or start one and wait for it to record itself. */
|
|
120
|
+
export async function ensureOffice(home, start, options = {}) {
|
|
121
|
+
return await withPrivateFileLock(officeStartLockPath(home), home.anchor, async () => {
|
|
122
|
+
const live = await readLiveOffice(home);
|
|
123
|
+
if (live !== null)
|
|
124
|
+
return officeUrl(live);
|
|
125
|
+
await start();
|
|
126
|
+
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, options.pollMs ?? 200));
|
|
129
|
+
const started = await readLiveOffice(home);
|
|
130
|
+
if (started !== null)
|
|
131
|
+
return officeUrl(started);
|
|
132
|
+
}
|
|
133
|
+
throw new AgentOpsError("OFFICE_START_FAILED", "The office server did not record itself in time.");
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** Drop the record on exit, unless another server has replaced it. */
|
|
137
|
+
export async function releaseOffice(home, record) {
|
|
138
|
+
await withPrivateFileLock(officeRecordPath(home), home.anchor, async () => {
|
|
139
|
+
const source = await readPrivateFile(officeRecordPath(home), home.anchor).catch(() => null);
|
|
140
|
+
if (source !== null && JSON.parse(source).token === record.token)
|
|
141
|
+
await rm(officeRecordPath(home), { force: true });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
3
|
+
export const OFFICE_SESSION_STALE_MS = 30 * 60 * 1000;
|
|
4
|
+
export const OFFICE_SESSION_RETENTION_MS = 2 * 60 * 60 * 1000;
|
|
5
|
+
const OFFICE_SESSION_SCHEMA_VERSION = 1;
|
|
6
|
+
const MAX_SESSIONS = 512;
|
|
7
|
+
const MAX_TEXT = 256;
|
|
8
|
+
const MAX_ROOT = 4_096;
|
|
9
|
+
export const officeSessionsPath = (commonDir) => join(commonDir, "agent-ops", "office-sessions.json");
|
|
10
|
+
function bounded(value, max) {
|
|
11
|
+
return typeof value === "string" && value.length > 0 && value.length <= max &&
|
|
12
|
+
!/[\0\r\n]/u.test(value);
|
|
13
|
+
}
|
|
14
|
+
function optional(value, max = MAX_TEXT) {
|
|
15
|
+
return value === undefined ? undefined : bounded(value, max) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
function timestamp(value) {
|
|
18
|
+
return bounded(value, 64) && Number.isFinite(Date.parse(value));
|
|
19
|
+
}
|
|
20
|
+
function parseRecord(value) {
|
|
21
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
22
|
+
return null;
|
|
23
|
+
const item = value;
|
|
24
|
+
if (item.schemaVersion !== OFFICE_SESSION_SCHEMA_VERSION ||
|
|
25
|
+
!bounded(item.sessionId, MAX_TEXT) || !bounded(item.harness, MAX_TEXT) ||
|
|
26
|
+
!bounded(item.root, MAX_ROOT) || !timestamp(item.firstSeenAt) ||
|
|
27
|
+
!timestamp(item.lastSeenAt) || !["active", "idle"].includes(String(item.status))) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (item.completedAt !== undefined && item.completedAt !== null && !timestamp(item.completedAt))
|
|
31
|
+
return null;
|
|
32
|
+
const record = {
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
sessionId: item.sessionId,
|
|
35
|
+
harness: item.harness,
|
|
36
|
+
root: item.root,
|
|
37
|
+
firstSeenAt: item.firstSeenAt,
|
|
38
|
+
lastSeenAt: item.lastSeenAt,
|
|
39
|
+
status: item.status,
|
|
40
|
+
...(optional(item.agentId) === undefined ? {} : { agentId: optional(item.agentId) }),
|
|
41
|
+
...(optional(item.runId) === undefined ? {} : { runId: optional(item.runId) }),
|
|
42
|
+
...(optional(item.workerId) === undefined ? {} : { workerId: optional(item.workerId) }),
|
|
43
|
+
...(optional(item.taskId) === undefined ? {} : { taskId: optional(item.taskId) }),
|
|
44
|
+
...(optional(item.phase) === undefined ? {} : { phase: optional(item.phase) }),
|
|
45
|
+
...(optional(item.host) === undefined ? {} : { host: optional(item.host) }),
|
|
46
|
+
...(item.completedAt === undefined ? {} : { completedAt: item.completedAt })
|
|
47
|
+
};
|
|
48
|
+
return record;
|
|
49
|
+
}
|
|
50
|
+
async function readRecords(commonDir) {
|
|
51
|
+
const source = await readPrivateFile(officeSessionsPath(commonDir), commonDir).catch(() => null);
|
|
52
|
+
if (source === null)
|
|
53
|
+
return [];
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(source);
|
|
56
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
57
|
+
return [];
|
|
58
|
+
const sessions = value.sessions;
|
|
59
|
+
if (!Array.isArray(sessions))
|
|
60
|
+
return [];
|
|
61
|
+
return sessions.slice(0, MAX_SESSIONS).map(parseRecord).filter((record) => record !== null);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function retained(record, now) {
|
|
68
|
+
const lastSeen = Date.parse(record.lastSeenAt);
|
|
69
|
+
if (!Number.isFinite(lastSeen))
|
|
70
|
+
return false;
|
|
71
|
+
if (record.status === "active") {
|
|
72
|
+
// ponytail: one TTL handles crashed hook clients; a persistent host heartbeat can replace it if needed.
|
|
73
|
+
return now - lastSeen <= OFFICE_SESSION_STALE_MS;
|
|
74
|
+
}
|
|
75
|
+
const completed = record.completedAt === null || record.completedAt === undefined
|
|
76
|
+
? lastSeen
|
|
77
|
+
: Date.parse(record.completedAt);
|
|
78
|
+
return Number.isFinite(completed) && now - completed < OFFICE_SESSION_RETENTION_MS;
|
|
79
|
+
}
|
|
80
|
+
function prune(records, now) {
|
|
81
|
+
return records.filter(record => retained(record, now)).slice(-MAX_SESSIONS);
|
|
82
|
+
}
|
|
83
|
+
export async function readOfficeSessions(commonDir, now = Date.now()) {
|
|
84
|
+
return prune(await readRecords(commonDir), now);
|
|
85
|
+
}
|
|
86
|
+
export async function recordOfficeSession(options) {
|
|
87
|
+
if (!bounded(options.sessionId, MAX_TEXT) || !bounded(options.harness, MAX_TEXT) ||
|
|
88
|
+
!bounded(options.projectRoot, MAX_ROOT) || !bounded(options.commonDir, MAX_ROOT))
|
|
89
|
+
return;
|
|
90
|
+
const now = options.now ?? Date.now();
|
|
91
|
+
const at = new Date(now).toISOString();
|
|
92
|
+
const path = officeSessionsPath(options.commonDir);
|
|
93
|
+
await withPrivateFileLock(path, options.commonDir, async () => {
|
|
94
|
+
const records = prune(await readRecords(options.commonDir), now);
|
|
95
|
+
const previous = records.find(record => record.sessionId === options.sessionId);
|
|
96
|
+
const next = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
sessionId: options.sessionId,
|
|
99
|
+
harness: options.harness,
|
|
100
|
+
root: options.projectRoot,
|
|
101
|
+
firstSeenAt: previous?.firstSeenAt ?? at,
|
|
102
|
+
lastSeenAt: at,
|
|
103
|
+
status: options.event === "stop" ? "idle" : "active",
|
|
104
|
+
...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
|
|
105
|
+
...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
|
|
106
|
+
...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
|
|
107
|
+
...(options.taskId ?? previous?.taskId ? { taskId: options.taskId ?? previous?.taskId } : {}),
|
|
108
|
+
...(options.phase ?? previous?.phase ? { phase: options.phase ?? previous?.phase } : {}),
|
|
109
|
+
...(options.host ?? previous?.host ? { host: options.host ?? previous?.host } : {}),
|
|
110
|
+
// A finished session keeps its closed room through the tool calls that
|
|
111
|
+
// report the result; only a new start (or resume) reopens it.
|
|
112
|
+
...(options.event !== "start" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
|
|
113
|
+
};
|
|
114
|
+
const without = records.filter(record => record.sessionId !== options.sessionId);
|
|
115
|
+
await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Closes a session's room once `worktree finish` merged its work. Unknown sessions stay unknown. */
|
|
119
|
+
export async function markOfficeSessionCompleted(commonDir, sessionId, now = Date.now()) {
|
|
120
|
+
const path = officeSessionsPath(commonDir);
|
|
121
|
+
await withPrivateFileLock(path, commonDir, async () => {
|
|
122
|
+
const records = prune(await readRecords(commonDir), now);
|
|
123
|
+
if (!records.some(record => record.sessionId === sessionId))
|
|
124
|
+
return;
|
|
125
|
+
const at = new Date(now).toISOString();
|
|
126
|
+
const next = records.map(record => record.sessionId === sessionId
|
|
127
|
+
? { ...record, status: "idle", phase: "integrating", lastSeenAt: at, completedAt: at } : record);
|
|
128
|
+
await writePrivateFile(path, JSON.stringify({ schemaVersion: OFFICE_SESSION_SCHEMA_VERSION, sessions: next }) + "\n", commonDir);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { activeWallTimeMs } from "../run/scheduler.js";
|
|
2
|
+
const COMPLETED_RETENTION_MS = 2 * 60 * 60 * 1000;
|
|
3
|
+
const LIVE = new Set(["assigned", "starting", "running", "idle", "handing-off", "fenced", "delivered", "blocked"]);
|
|
4
|
+
const PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
|
|
5
|
+
function phaseOf(value) {
|
|
6
|
+
return value !== undefined && PHASES.has(value) ? value : "unknown";
|
|
7
|
+
}
|
|
8
|
+
/** Decision 8: narration comes from changed paths only. */
|
|
9
|
+
export function narrate(diff) {
|
|
10
|
+
const path = diff?.recent ?? diff?.paths[0] ?? null;
|
|
11
|
+
if (path === null)
|
|
12
|
+
return null;
|
|
13
|
+
if (path.startsWith("docs/"))
|
|
14
|
+
return "writing docs";
|
|
15
|
+
if (path.startsWith("tests/"))
|
|
16
|
+
return "writing tests";
|
|
17
|
+
return "editing " + path;
|
|
18
|
+
}
|
|
19
|
+
function quote(text) {
|
|
20
|
+
return "'" + text.replaceAll("'", "'\\''") + "'";
|
|
21
|
+
}
|
|
22
|
+
/** Pure aggregation of runs, worktrees and review slots into one building. */
|
|
23
|
+
export function buildOfficeSnapshot(input) {
|
|
24
|
+
const byPath = new Map(input.worktrees.map(w => [w.path, w]));
|
|
25
|
+
const claimed = new Set();
|
|
26
|
+
const runs = input.runs.filter(run => !["complete", "failed"].includes(run.status) || input.now - Date.parse(run.updatedAt) < COMPLETED_RETENTION_MS)
|
|
27
|
+
.map((run) => {
|
|
28
|
+
const roots = new Set(run.workers.map(w => w.worktree).filter((p) => p !== null));
|
|
29
|
+
const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
|
|
30
|
+
reviewers.forEach(r => claimed.add(r));
|
|
31
|
+
const usedMs = activeWallTimeMs(run.budget.activeIntervals, input.now);
|
|
32
|
+
const agents = run.workers.filter(w => w.workerId === run.coordinatorId || LIVE.has(w.status)).map((w) => {
|
|
33
|
+
const tree = w.worktree === null ? undefined : byPath.get(w.worktree);
|
|
34
|
+
const phase = w.phase ?? "unknown";
|
|
35
|
+
return { id: w.workerId, role: w.workerId === run.coordinatorId ? "coordinator" : "worker", status: w.status, phase,
|
|
36
|
+
taskId: w.taskId, progress: run.tasks.find(t => t.taskId === w.taskId)?.progress ?? null,
|
|
37
|
+
worktree: tree?.name ?? null, diff: tree?.diff ?? null,
|
|
38
|
+
narration: narrate(tree?.diff ?? null) ?? (phase === "unknown" ? w.status : phase) };
|
|
39
|
+
});
|
|
40
|
+
const questions = run.questions.filter(q => q.answeredAt === null).map(q => ({ questionId: q.questionId, prompt: q.prompt }));
|
|
41
|
+
return { runId: run.runId, title: run.rootTaskId ?? run.runId,
|
|
42
|
+
status: run.status, phase: run.phase ?? "unknown",
|
|
43
|
+
budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
|
|
44
|
+
questions, agents, reviewers,
|
|
45
|
+
...(run.status === "complete" || run.status === "failed" ? { completedAt: run.updatedAt } : {}),
|
|
46
|
+
commands: [`agent-ops run status ${run.runId}`, `agent-ops run logs ${run.runId}`,
|
|
47
|
+
...questions.map(q => `agent-ops run respond ${run.runId} --question-id ${quote(q.questionId)} --answer "..."`),
|
|
48
|
+
`agent-ops run resume ${run.runId}`, `agent-ops run stop ${run.runId}`] };
|
|
49
|
+
});
|
|
50
|
+
const sessions = input.sessions ?? [];
|
|
51
|
+
const bySession = new Map(sessions.map(session => [session.sessionId, session]));
|
|
52
|
+
const runIds = new Set(input.runs.map(run => run.runId));
|
|
53
|
+
const runSessionIds = new Set(input.worktrees.filter(worktree => worktree.runId !== undefined).map(worktree => worktree.sessionId));
|
|
54
|
+
input.runs.flatMap(run => run.workers).forEach(worker => {
|
|
55
|
+
runSessionIds.add(worker.ownerSessionId);
|
|
56
|
+
if (worker.nativeSessionId !== null)
|
|
57
|
+
runSessionIds.add(worker.nativeSessionId);
|
|
58
|
+
});
|
|
59
|
+
const lobby = [];
|
|
60
|
+
const represented = new Set();
|
|
61
|
+
for (const worktree of input.worktrees) {
|
|
62
|
+
if (worktree.runId !== undefined)
|
|
63
|
+
continue;
|
|
64
|
+
const session = bySession.get(worktree.sessionId);
|
|
65
|
+
// An active task in a live worktree is new work, whatever closed the session before.
|
|
66
|
+
const completedAt = worktree.completedAt ?? (worktree.status === "active" ? null : session?.completedAt ?? null);
|
|
67
|
+
if (completedAt !== null && input.now - Date.parse(completedAt) >= COMPLETED_RETENTION_MS)
|
|
68
|
+
continue;
|
|
69
|
+
lobby.push({
|
|
70
|
+
name: worktree.name,
|
|
71
|
+
branch: worktree.branch,
|
|
72
|
+
sessionId: worktree.sessionId,
|
|
73
|
+
sessionActive: session?.status === "active" && completedAt === null,
|
|
74
|
+
diff: worktree.diff,
|
|
75
|
+
narration: narrate(worktree.diff) ?? worktree.phase ?? session?.phase ?? worktree.status ?? session?.status ?? "idle",
|
|
76
|
+
commands: ["agent-ops worktree list", `agent-ops worktree finish ${quote(worktree.name)}`],
|
|
77
|
+
...(worktree.phase === undefined && session?.phase === undefined ? {} : { phase: phaseOf(worktree.phase ?? session?.phase) }),
|
|
78
|
+
...(worktree.status === undefined && session?.taskStatus === undefined && session?.status === undefined ? {} : { status: worktree.status ?? session?.taskStatus ?? session?.status }),
|
|
79
|
+
...(worktree.taskId === undefined && session?.taskId === undefined ? {} : { taskId: worktree.taskId ?? session?.taskId ?? null }),
|
|
80
|
+
...(worktree.progress === undefined && session?.progress === undefined ? {} : { progress: worktree.progress ?? session?.progress ?? null }),
|
|
81
|
+
...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt }),
|
|
82
|
+
...(worktree.host === undefined && session?.host === undefined ? {} : { host: worktree.host ?? session?.host }),
|
|
83
|
+
});
|
|
84
|
+
represented.add(worktree.sessionId);
|
|
85
|
+
}
|
|
86
|
+
for (const session of sessions) {
|
|
87
|
+
if (represented.has(session.sessionId) || runSessionIds.has(session.sessionId) || (session.runId !== undefined && runIds.has(session.runId)))
|
|
88
|
+
continue;
|
|
89
|
+
represented.add(session.sessionId);
|
|
90
|
+
const completedAt = session.completedAt ?? null;
|
|
91
|
+
lobby.push({
|
|
92
|
+
name: session.sessionId,
|
|
93
|
+
branch: "(no worktree)",
|
|
94
|
+
sessionId: session.sessionId,
|
|
95
|
+
sessionActive: session.status === "active" && completedAt === null,
|
|
96
|
+
diff: { files: 0, insertions: 0, deletions: 0, paths: [], recent: null },
|
|
97
|
+
narration: session.phase === undefined ? session.taskStatus ?? session.status ?? "idle" : phaseOf(session.phase),
|
|
98
|
+
commands: [`agent-ops task status --session ${quote(session.sessionId)}`],
|
|
99
|
+
...(session.phase === undefined ? {} : { phase: phaseOf(session.phase) }),
|
|
100
|
+
status: session.taskStatus ?? session.status,
|
|
101
|
+
...(session.taskId === undefined ? {} : { taskId: session.taskId }),
|
|
102
|
+
...(session.progress === undefined ? {} : { progress: session.progress }),
|
|
103
|
+
...(completedAt === null ? {} : { completedAt }),
|
|
104
|
+
...(session.host === undefined ? {} : { host: session.host })
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: input.reviews.filter(r => !claimed.has(r)) };
|
|
108
|
+
}
|
|
109
|
+
/** One building from several repositories' snapshots; every room names its repository. */
|
|
110
|
+
export function mergeOfficeSnapshots(parts, now) {
|
|
111
|
+
return {
|
|
112
|
+
generatedAt: new Date(now).toISOString(),
|
|
113
|
+
runs: parts.flatMap(({ repo, snapshot }) => snapshot.runs.map(run => ({ ...run, repo }))),
|
|
114
|
+
lobby: parts.flatMap(({ repo, snapshot }) => snapshot.lobby.map(desk => ({ ...desk, repo }))),
|
|
115
|
+
reviews: parts.flatMap(({ repo, snapshot }) => snapshot.reviews.map(review => ({ ...review, repo })))
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -11,6 +11,7 @@ import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunInteg
|
|
|
11
11
|
import { canonicalJson } from "../config/hash.js";
|
|
12
12
|
import { listWorktrees } from "./manage.js";
|
|
13
13
|
import { withPrivateFileLock } from "../security/permissions.js";
|
|
14
|
+
import { markOfficeSessionCompleted } from "../office/sessions.js";
|
|
14
15
|
import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
15
16
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
16
17
|
const LOCK_POLL_MS = 2_000;
|
|
@@ -488,6 +489,8 @@ export async function finishWorktree(deps, options) {
|
|
|
488
489
|
}
|
|
489
490
|
});
|
|
490
491
|
}
|
|
492
|
+
// Office is display-only: its room closing never fails or partially fails a finish.
|
|
493
|
+
await markOfficeSessionCompleted(commonDir, record.sessionId).catch(() => undefined);
|
|
491
494
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
492
495
|
if (options.finalProof !== undefined) {
|
|
493
496
|
for (const child of sessionChildren) {
|