copilot-operator 0.1.0

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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Three JSON views of the same work, for handing to whoever — or whatever — has to reason
3
+ * about it next. Each answers one question and nothing else, because a file that answers all
4
+ * three is the debug export next door, and the person debugging one task does not want to
5
+ * scroll past the machine's PATH to find the prompt.
6
+ *
7
+ * plan What was asked. The sessions and tasks in the plan format, as they are *now*: a
8
+ * plan written by a chat model and then edited in the UI comes back out with the
9
+ * edits, and a queue built by hand comes out as a plan that never existed as a file.
10
+ * It re-imports.
11
+ * domain What happened to the work. The task as it was given, what the chat tried, what
12
+ * was actually done to the repository, and why it did not end done — in the words
13
+ * of the task, not of the runner.
14
+ * bot What the runner did. The environment, every transcript event, every step with its
15
+ * exit code and duration, the transport's retries, what was reaped, what the review
16
+ * machinery went through. Nothing about the domain that is not needed to read it.
17
+ *
18
+ * All three exist for one task and for one run, because those are the two sizes a question
19
+ * comes in: "why did this one fail" and "what did this whole run do".
20
+ */
21
+ import { readFile } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ import { withoutPersona } from '../plan/importPlan.js';
24
+ const EXPECTED_HEADER = '### Expected result';
25
+ const GOAL_HEADER = '## Goal of this session';
26
+ const MAX_STRING_CHARS = 2000;
27
+ function clip(text, max = MAX_STRING_CHARS) {
28
+ return text.length > max ? `${text.slice(0, max)}… (${text.length - max} more characters)` : text;
29
+ }
30
+ function tasksOf(scope) {
31
+ const out = [];
32
+ for (const session of scope.sessions) {
33
+ for (const task of session.tasks) {
34
+ if (!scope.taskFilter || scope.taskFilter(session, task))
35
+ out.push({ session, task });
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ function stamp() {
41
+ return new Date().toISOString().slice(0, 16).replace(/[:T]/g, '').replace(/-/g, '');
42
+ }
43
+ function fileLabel(label) {
44
+ return label.replace(/[^\p{L}\p{N}._ -]/gu, '').trim().replace(/\s+/g, '-').slice(0, 60) || 'export';
45
+ }
46
+ export function exportFileName(kind, label) {
47
+ return `copilot-operator-${kind}-${fileLabel(label)}-${stamp()}.json`;
48
+ }
49
+ // ------------------------------------------------------------------------------------------
50
+ // plan: what was asked, in the format that goes back in
51
+ // ------------------------------------------------------------------------------------------
52
+ /** The prompt as stored is the plan's prompt with `expected` appended under a heading. */
53
+ function splitPrompt(prompt) {
54
+ const i = prompt.indexOf(`\n\n${EXPECTED_HEADER}\n\n`);
55
+ if (i < 0)
56
+ return { prompt, expected: '' };
57
+ return { prompt: prompt.slice(0, i).trim(), expected: prompt.slice(i + EXPECTED_HEADER.length + 4).trim() };
58
+ }
59
+ /** The session's level 2 is the plan's level 2 with the goal on top under a heading. */
60
+ function splitLevel2(level2) {
61
+ const text = level2.trim();
62
+ if (!text.startsWith(GOAL_HEADER))
63
+ return { goal: '', level2: text };
64
+ const body = text.slice(GOAL_HEADER.length).trim();
65
+ const gap = body.indexOf('\n\n');
66
+ if (gap < 0)
67
+ return { goal: body, level2: '' };
68
+ return { goal: body.slice(0, gap).trim(), level2: body.slice(gap).trim() };
69
+ }
70
+ /** The level 2 most of a session's tasks carry, which is the one the plan gave the session. */
71
+ /**
72
+ * A task's level 2 as the plan wrote it: the operator's persona taken off the front.
73
+ *
74
+ * The import puts the persona that was in force on every task. The plan export is for importing
75
+ * again, where the persona in the field *then* is the one that should apply — so it is lifted out
76
+ * here, exactly, by its closing line. Left in, it would sit in front of the session's goal, the goal
77
+ * would no longer be found at the head of the text, and a second import would add a second persona
78
+ * on top of the first. The record exports keep it: they describe what ran, and the approach is part
79
+ * of what ran.
80
+ */
81
+ function planLevel2(t) {
82
+ return withoutPersona(t.level2).trim();
83
+ }
84
+ function commonLevel2(tasks) {
85
+ const counts = new Map();
86
+ for (const t of tasks)
87
+ counts.set(planLevel2(t), (counts.get(planLevel2(t)) ?? 0) + 1);
88
+ let best = '';
89
+ let n = 0;
90
+ for (const [text, count] of counts) {
91
+ if (count > n) {
92
+ best = text;
93
+ n = count;
94
+ }
95
+ }
96
+ return best;
97
+ }
98
+ function planCheck(c) {
99
+ const out = { name: c.name, expect: c.expect };
100
+ if (c.run)
101
+ out.run = c.run;
102
+ if (c.shell)
103
+ out.shell = c.shell;
104
+ if (c.cwd)
105
+ out.cwd = c.cwd;
106
+ if (c.file)
107
+ out.file = c.file;
108
+ if (c.value !== undefined && c.value !== '')
109
+ out.value = c.value;
110
+ return out;
111
+ }
112
+ export function buildPlanExport(scope) {
113
+ const sessions = scope.sessions.filter((s) => s.tasks.some((t) => !scope.taskFilter || scope.taskFilter(s, t)));
114
+ const groups = new Set(sessions.map((s) => (s.conversationGroup ?? '').trim()));
115
+ const shared = sessions.length > 1 && groups.size === 1 && !groups.has('');
116
+ const runFailure = sessions.find((s) => s.runGroup?.onFailure)?.runGroup?.onFailure ?? 'stop';
117
+ return {
118
+ version: 1,
119
+ plan: scope.label,
120
+ notes: `Exported from copilot-operator on ${new Date().toISOString()}: the sessions and tasks as they are now, ` +
121
+ 'including anything edited in the UI after the original plan (if there was one) was imported. ' +
122
+ 'Task outcomes are not part of this file; it is the plan, and it can be imported again.',
123
+ onFailure: runFailure,
124
+ conversation: shared ? 'shared' : 'per-session',
125
+ sessions: sessions.map((s) => {
126
+ const tasks = s.tasks.filter((t) => !scope.taskFilter || scope.taskFilter(s, t));
127
+ const level2 = commonLevel2(tasks);
128
+ const split = splitLevel2(level2);
129
+ const session = {
130
+ name: s.name,
131
+ goal: split.goal,
132
+ model: s.model ?? '',
133
+ onFailure: s.onFailure ?? 'stop',
134
+ level2: split.level2,
135
+ conversationGroup: shared ? '' : (s.conversationGroup ?? ''),
136
+ vcs: {
137
+ enabled: s.vcs?.enabled ?? false,
138
+ repoDir: s.vcs?.repoDir ?? '',
139
+ branchMode: s.vcs?.branchMode ?? 'per-task',
140
+ commitOnFinish: s.vcs?.commitOnFinish ?? true,
141
+ branchPrefix: s.vcs?.branchPrefix ?? 'cop/',
142
+ branchName: s.vcs?.branchName ?? '',
143
+ },
144
+ review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
145
+ mirror: {
146
+ enabled: s.mirror.enabled,
147
+ rootDir: s.mirror.rootDir,
148
+ includeDirs: s.mirror.includeDirs,
149
+ excludeDirs: s.mirror.excludeDirs,
150
+ respectGitignore: s.mirror.respectGitignore ?? true,
151
+ includeEnvFiles: s.mirror.includeEnvFiles ?? false,
152
+ },
153
+ tasks: tasks.map((t) => {
154
+ const p = splitPrompt(t.prompt);
155
+ const task = { title: t.title, prompt: p.prompt, expected: p.expected };
156
+ if (planLevel2(t) !== level2)
157
+ task.level2 = planLevel2(t);
158
+ if (t.vcsPlan?.branch || t.vcsPlan?.commitMessage) {
159
+ task.vcs = { ...(t.vcsPlan.branch ? { branch: t.vcsPlan.branch } : {}), ...(t.vcsPlan.commitMessage ? { commitMessage: t.vcsPlan.commitMessage } : {}) };
160
+ }
161
+ if (t.checks && t.checks.length > 0)
162
+ task.checks = t.checks.map(planCheck);
163
+ if (t.reviewEnabled === false)
164
+ task.review = false;
165
+ if (t.readOnly)
166
+ task.readOnly = true;
167
+ return task;
168
+ }),
169
+ };
170
+ return session;
171
+ }),
172
+ };
173
+ }
174
+ async function readEvents(runsDir, runId) {
175
+ if (!runId)
176
+ return [];
177
+ const raw = await readFile(join(runsDir, runId, 'transcript.jsonl'), 'utf8').catch(() => null);
178
+ if (raw === null)
179
+ return [];
180
+ const out = [];
181
+ for (const line of raw.split('\n')) {
182
+ if (!line.trim())
183
+ continue;
184
+ try {
185
+ out.push(JSON.parse(line));
186
+ }
187
+ catch {
188
+ // A half-written last line when the process died is not a reason to lose the rest.
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+ /** Every string in an event cut to a readable length; the run folder keeps the full text. */
194
+ function trimmed(event) {
195
+ const out = {};
196
+ for (const [k, v] of Object.entries(event)) {
197
+ out[k] = typeof v === 'string' ? clip(v, 600) : v;
198
+ }
199
+ return out;
200
+ }
201
+ /** Steps joined from proposed/started/finished/skipped, in the order they were proposed. */
202
+ function stepsOf(events) {
203
+ const byKey = new Map();
204
+ let iteration = 0;
205
+ for (const e of events) {
206
+ if (e.type === 'reply-parsed')
207
+ iteration = Number(e.iteration ?? iteration + 1);
208
+ const id = typeof e.id === 'number' ? e.id : undefined;
209
+ if (id === undefined)
210
+ continue;
211
+ const key = `${iteration}:${id}`;
212
+ if (e.type === 'step-proposed') {
213
+ byKey.set(key, { id, iteration, description: typeof e.description === 'string' ? clip(e.description, 600) : undefined });
214
+ }
215
+ else if (e.type === 'step-finished') {
216
+ const s = byKey.get(key) ?? { id, iteration };
217
+ byKey.set(key, { ...s, outcome: String(e.outcome ?? ''), exitCode: typeof e.exitCode === 'number' ? e.exitCode : undefined, durationMs: typeof e.durationMs === 'number' ? e.durationMs : undefined });
218
+ }
219
+ else if (e.type === 'step-skipped' || e.type === 'step-repeated' || e.type === 'step-damaged') {
220
+ const s = byKey.get(key) ?? { id, iteration };
221
+ byKey.set(key, { ...s, refused: String(e.reason ?? e.message ?? e.type) });
222
+ }
223
+ }
224
+ return [...byKey.values()];
225
+ }
226
+ // ------------------------------------------------------------------------------------------
227
+ // domain: what happened to the work
228
+ // ------------------------------------------------------------------------------------------
229
+ /** What a closing `blocked` reply said it tried and needed, read back out of the reply. */
230
+ function blockedDetail(finalReply) {
231
+ if (!finalReply)
232
+ return undefined;
233
+ const m = finalReply.match(/```json\s*([\s\S]*?)```/) ?? [null, finalReply];
234
+ try {
235
+ const j = JSON.parse((m[1] ?? '').trim());
236
+ const tried = Array.isArray(j.tried) ? j.tried.map(String) : undefined;
237
+ const needed = typeof j.needed === 'string' ? j.needed : undefined;
238
+ return tried || needed ? { tried, needed } : undefined;
239
+ }
240
+ catch {
241
+ return undefined;
242
+ }
243
+ }
244
+ async function domainTask(session, task, runsDir) {
245
+ const events = await readEvents(runsDir, task.runId);
246
+ const steps = stepsOf(events);
247
+ const rounds = events
248
+ .filter((e) => e.type === 'reply-parsed')
249
+ .map((e) => ({
250
+ iteration: e.iteration,
251
+ status: e.status,
252
+ notes: typeof e.notes === 'string' ? clip(e.notes, 1000) : undefined,
253
+ steps: steps.filter((s) => s.iteration === e.iteration).map((s) => ({ id: s.id, ran: s.description, outcome: s.refused ? `refused: ${s.refused}` : s.outcome, exitCode: s.exitCode })),
254
+ }));
255
+ const p = splitPrompt(task.prompt);
256
+ const failed = task.status !== 'done' && task.status !== 'queued' && task.status !== 'running' && task.status !== 'waiting-approval';
257
+ const started = task.startedAt ? Date.parse(task.startedAt) : NaN;
258
+ const finished = task.finishedAt ? Date.parse(task.finishedAt) : NaN;
259
+ const out = {
260
+ session: { id: session.id, name: session.name },
261
+ task: {
262
+ id: task.id,
263
+ title: task.title,
264
+ attempt: task.attempt ?? 1,
265
+ readOnly: task.readOnly ?? false,
266
+ prompt: p.prompt,
267
+ expected: p.expected,
268
+ level2: task.level2,
269
+ checks: (task.checks ?? []).map(planCheck),
270
+ },
271
+ outcome: {
272
+ status: task.status,
273
+ reason: task.reason,
274
+ summary: task.summary,
275
+ startedAt: task.startedAt,
276
+ finishedAt: task.finishedAt,
277
+ durationMs: Number.isFinite(started) && Number.isFinite(finished) ? finished - started : undefined,
278
+ iterations: task.iterations,
279
+ checks: task.checkResults,
280
+ },
281
+ whatTheChatTried: rounds,
282
+ whatWasActuallyDone: {
283
+ repository: task.vcs,
284
+ deviations: task.deviations,
285
+ disputes: task.disputes,
286
+ filesAttachedFirst: undefined,
287
+ },
288
+ review: task.review
289
+ ? {
290
+ verdict: task.review.verdict,
291
+ rounds: task.review.rounds,
292
+ stepsRun: task.review.stepsRun,
293
+ model: task.review.model,
294
+ summary: task.review.summary,
295
+ findings: task.review.findings,
296
+ problem: task.review.problem,
297
+ }
298
+ : undefined,
299
+ };
300
+ if (failed) {
301
+ out.whyItFailed = {
302
+ status: task.status,
303
+ reason: task.reason,
304
+ failingChecks: (task.checkResults ?? []).filter((c) => !c.passed),
305
+ blocked: blockedDetail(task.finalReply),
306
+ reviewFindings: task.review?.verdict === 'fail' ? task.review.findings : undefined,
307
+ leftovers: task.leftovers,
308
+ lastRound: rounds.at(-1),
309
+ };
310
+ }
311
+ /*
312
+ * The attempts before this one, each with its own transcript.
313
+ *
314
+ * After "fix the prompt and queue it again" the task on the record is queued and the failure
315
+ * that prompted the fix lives only here — so an export taken then, which is exactly when
316
+ * somebody wants to hand the failure to a model, has to carry what the earlier attempt tried
317
+ * and why it ended, not just that it did.
318
+ */
319
+ if (task.attempts && task.attempts.length > 0) {
320
+ const earlier = [];
321
+ for (const [i, a] of task.attempts.entries()) {
322
+ const aEvents = await readEvents(runsDir, a.runId);
323
+ const aSteps = stepsOf(aEvents);
324
+ const aRounds = aEvents
325
+ .filter((e) => e.type === 'reply-parsed')
326
+ .map((e) => ({
327
+ iteration: e.iteration,
328
+ status: e.status,
329
+ notes: typeof e.notes === 'string' ? clip(e.notes, 1000) : undefined,
330
+ steps: aSteps.filter((s) => s.iteration === e.iteration).map((s) => ({ id: s.id, ran: s.description, outcome: s.refused ? `refused: ${s.refused}` : s.outcome, exitCode: s.exitCode })),
331
+ }));
332
+ const aFailed = a.status !== 'done';
333
+ earlier.push({
334
+ attempt: i + 1,
335
+ runId: a.runId,
336
+ status: a.status,
337
+ reason: a.reason,
338
+ summary: a.summary,
339
+ iterations: a.iterations,
340
+ startedAt: a.startedAt,
341
+ finishedAt: a.finishedAt,
342
+ prompt: a.prompt !== task.prompt ? splitPrompt(a.prompt).prompt : undefined,
343
+ whatTheChatTried: aRounds,
344
+ whatWasActuallyDone: { repository: a.vcs, deviations: a.deviations, disputes: a.disputes },
345
+ whyItFailed: aFailed
346
+ ? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1) }
347
+ : undefined,
348
+ });
349
+ }
350
+ out.earlierAttempts = earlier;
351
+ }
352
+ return out;
353
+ }
354
+ export async function buildDomainExport(scope, runsDir) {
355
+ const pairs = tasksOf(scope);
356
+ const tasks = [];
357
+ for (const { session, task } of pairs)
358
+ tasks.push(await domainTask(session, task, runsDir));
359
+ const counts = { tasks: pairs.length, done: 0, failed: 0, open: 0 };
360
+ for (const { task } of pairs) {
361
+ if (task.status === 'done')
362
+ counts.done += 1;
363
+ else if (task.status === 'queued' || task.status === 'running' || task.status === 'waiting-approval')
364
+ counts.open += 1;
365
+ else
366
+ counts.failed += 1;
367
+ }
368
+ return {
369
+ exportedAt: new Date().toISOString(),
370
+ about: `copilot-operator, the work: ${scope.label}. What each task asked, what the chat tried, what was done to the repository, and why a task did not end done. Nothing about the runner itself; that is the bot export.`,
371
+ counts,
372
+ tasks,
373
+ };
374
+ }
375
+ // ------------------------------------------------------------------------------------------
376
+ // bot: what the runner did
377
+ // ------------------------------------------------------------------------------------------
378
+ const TRANSPORT_TYPES = new Set(['message-sent', 'format-error', 'report-written', 'report-redacted', 'report-send-failed', 'attachment-missing', 'chat-registered', 'download']);
379
+ async function botTask(session, task, runsDir) {
380
+ const events = await readEvents(runsDir, task.runId);
381
+ const steps = stepsOf(events);
382
+ const byType = new Map();
383
+ for (const e of events)
384
+ byType.set(String(e.type), (byType.get(String(e.type)) ?? 0) + 1);
385
+ const problems = events.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed);
386
+ const reviewEvents = events.filter((e) => String(e.type).startsWith('review-') || String(e.type).startsWith('finding-'));
387
+ return {
388
+ session: { id: session.id, name: session.name, model: session.model, modelInUse: session.modelInUse, reviewModel: session.review?.model },
389
+ task: { id: task.id, title: task.title, attempt: task.attempt ?? 1, status: task.status, runId: task.runId, runFolder: task.runId ? join(runsDir, task.runId) : undefined, logFile: task.logFile },
390
+ timing: { startedAt: task.startedAt, finishedAt: task.finishedAt, iterations: task.iterations },
391
+ environment: task.environment,
392
+ transport: {
393
+ counts: Object.fromEntries([...byType.entries()].filter(([t]) => TRANSPORT_TYPES.has(t))),
394
+ events: events.filter((e) => TRANSPORT_TYPES.has(String(e.type))).map(trimmed),
395
+ },
396
+ steps: steps.map((s) => ({ iteration: s.iteration, id: s.id, command: s.description, outcome: s.outcome, exitCode: s.exitCode, durationMs: s.durationMs, refused: s.refused })),
397
+ checks: {
398
+ results: task.checkResults,
399
+ rounds: byType.get('checks-started') ?? 0,
400
+ fromReviews: task.reviewChecks,
401
+ },
402
+ processes: { leftovers: task.leftovers, reaped: events.filter((e) => e.type === 'processes-reaped').map(trimmed), notOurs: events.filter((e) => e.type === 'processes-not-ours').map(trimmed) },
403
+ mirror: events.filter((e) => String(e.type).startsWith('mirror')).map(trimmed),
404
+ review: {
405
+ verdict: task.review?.verdict,
406
+ rounds: task.review?.rounds,
407
+ stepsRun: task.review?.stepsRun,
408
+ model: task.review?.model,
409
+ problem: task.review?.problem,
410
+ formatErrors: byType.get('review-format-error') ?? 0,
411
+ events: reviewEvents.map(trimmed),
412
+ },
413
+ problems,
414
+ eventCounts: Object.fromEntries(byType.entries()),
415
+ events: events.map(trimmed),
416
+ // Earlier attempts with their own run folders: the same technical record, per attempt,
417
+ // because a task queued again after a failure has no transcript of its own yet.
418
+ earlierAttempts: await Promise.all((task.attempts ?? []).map(async (a, i) => {
419
+ const aEvents = await readEvents(runsDir, a.runId);
420
+ return {
421
+ attempt: i + 1,
422
+ runId: a.runId,
423
+ runFolder: a.runId ? join(runsDir, a.runId) : undefined,
424
+ status: a.status,
425
+ steps: stepsOf(aEvents).map((s) => ({ iteration: s.iteration, id: s.id, command: s.description, outcome: s.outcome, exitCode: s.exitCode, durationMs: s.durationMs, refused: s.refused })),
426
+ problems: aEvents.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed),
427
+ events: aEvents.map(trimmed),
428
+ };
429
+ })),
430
+ };
431
+ }
432
+ export async function buildBotExport(scope, runsDir, machine) {
433
+ const pairs = tasksOf(scope);
434
+ const tasks = [];
435
+ for (const { session, task } of pairs)
436
+ tasks.push(await botTask(session, task, runsDir));
437
+ return {
438
+ exportedAt: new Date().toISOString(),
439
+ about: `copilot-operator, the runner: ${scope.label}. The environment, every transcript event, every step with its exit code, the transport's retries, what was reaped, what the review machinery did. Read the domain export for what the task was about.`,
440
+ machine,
441
+ tasks,
442
+ };
443
+ }
444
+ // ------------------------------------------------------------------------------------------
445
+ // bundle: all three views of the same chosen tasks, in one file
446
+ // ------------------------------------------------------------------------------------------
447
+ /**
448
+ * The three exports of one chosen set of tasks, in a single document.
449
+ *
450
+ * The three are downloaded separately because they answer separate questions, and that is right
451
+ * when the question is known. It is wrong for the thing the operator actually does most: hand a
452
+ * failure to a chat model and ask what happened. That needs all three — the plan says what was
453
+ * asked, the work says what the chat did with it, the runner says what the machine did — and
454
+ * until now it meant three downloads, three attachments, and remembering which was which.
455
+ *
456
+ * **The three stay whole rather than being interleaved per task.** Merging them into one array
457
+ * of tasks, each carrying its plan, its work and its runner, reads better and is the wrong
458
+ * trade: the plan export's whole point is that it goes back in through the import page, and a
459
+ * plan folded inside a fourth format is a plan nothing can read. So this is a wrapper, each
460
+ * part exactly the document it would have been on its own, and anything that could read one of
461
+ * them still can.
462
+ *
463
+ * `work` and `runner` are what the interface calls them. Inside the code they are `domain` and
464
+ * `bot`, which is a naming the operator has never been shown and should not meet here.
465
+ */
466
+ export async function buildBundleExport(scope, runsDir, machine) {
467
+ const pairs = tasksOf(scope);
468
+ return {
469
+ exportedAt: new Date().toISOString(),
470
+ about: `copilot-operator, all three views of ${pairs.length} chosen task(s): ${scope.label}. ` +
471
+ '`plan` is what was asked, in the format that imports again; `work` is what happened to it; ' +
472
+ '`runner` is what the machine did. Read `work` first, then `runner` when the machine is the suspect.',
473
+ chosen: pairs.map(({ session, task }) => ({
474
+ session: session.name,
475
+ sessionId: session.id,
476
+ task: task.title,
477
+ taskId: task.id,
478
+ status: task.status,
479
+ })),
480
+ plan: buildPlanExport(scope),
481
+ work: await buildDomainExport(scope, runsDir),
482
+ runner: await buildBotExport(scope, runsDir, machine),
483
+ };
484
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A plan with its blanks removed, or nothing at all when both are blank.
3
+ *
4
+ * One function rather than three, because a task made in the form, a task made by an import
5
+ * and a task edited afterwards all have to end up in the same shape on disk. An empty string
6
+ * is how the form says "I do not want a name here", and it must leave no trace behind.
7
+ */
8
+ export function tidyVcsPlan(plan) {
9
+ const branch = plan?.branch?.trim();
10
+ const commitMessage = plan?.commitMessage?.trim();
11
+ if (!branch && !commitMessage)
12
+ return undefined;
13
+ return { ...(branch ? { branch } : {}), ...(commitMessage ? { commitMessage } : {}) };
14
+ }
15
+ export function newId(prefix = '') {
16
+ const now = new Date();
17
+ const p = (n) => String(n).padStart(2, '0');
18
+ const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}` +
19
+ `-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
20
+ const rnd = Math.random().toString(36).slice(2, 6);
21
+ return `${prefix}${stamp}-${rnd}`;
22
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How long a session's run folder is kept.
3
+ *
4
+ * A run folder holds every step's raw output, the reports that were uploaded, the reviewer's
5
+ * transcripts and, on a failure, a screenshot of the signed-in page. Nothing used to remove any of
6
+ * it, which for an operator's own machine is a choice and for a company is a gap: a retention
7
+ * rule says how long such records may exist, and a tool that keeps them forever cannot follow it.
8
+ *
9
+ * `runsRetentionDays` in the config is the rule; 0, the default, keeps everything, because nothing
10
+ * here should delete an operator's records without their say. When set, the API removes at start
11
+ * every session folder whose last change is older than that. Folders whose name begins with `_`
12
+ * are the bot's own working folders (`_browser`, `_models`) and are left alone.
13
+ */
14
+ import { readdir, rm, stat } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ /** Removes the session run folders older than `keepDays`; returns the names removed. */
17
+ export async function pruneRuns(runsDir, keepDays, now = Date.now()) {
18
+ if (!Number.isFinite(keepDays) || keepDays <= 0)
19
+ return [];
20
+ const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
21
+ const removed = [];
22
+ const entries = await readdir(runsDir, { withFileTypes: true }).catch(() => []);
23
+ for (const entry of entries) {
24
+ if (!entry.isDirectory() || entry.name.startsWith('_'))
25
+ continue;
26
+ const path = join(runsDir, entry.name);
27
+ const s = await stat(path).catch(() => null);
28
+ if (!s || s.mtimeMs >= cutoff)
29
+ continue;
30
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
31
+ removed.push(entry.name);
32
+ }
33
+ return removed;
34
+ }
@@ -0,0 +1,36 @@
1
+ /** A trailing ` #12` taken off, so the counter never stacks up into `name #2 #3`. */
2
+ function stripNumber(name) {
3
+ return name.replace(/\s*#\d+$/, '').trim();
4
+ }
5
+ export function suggestRunName(all, sessionIds, about) {
6
+ const wanted = new Set(sessionIds);
7
+ /** Every name any run has ever carried, wherever it was written down. */
8
+ const used = new Set();
9
+ /** The names these particular sessions last ran under, newest first. */
10
+ const theirs = [];
11
+ for (const session of all) {
12
+ const groups = [session.runGroup, ...session.tasks.flatMap((t) => [t.runGroup, ...(t.attempts ?? []).map((a) => a.runGroup)])];
13
+ for (const group of groups) {
14
+ if (!group?.name)
15
+ continue;
16
+ used.add(group.name);
17
+ if (wanted.has(session.id))
18
+ theirs.push({ name: group.name, at: group.startedAt });
19
+ }
20
+ }
21
+ theirs.sort((a, b) => b.at.localeCompare(a.at));
22
+ const chosen = all.filter((s) => wanted.has(s.id));
23
+ const base = stripNumber(theirs[0]?.name ?? '') ||
24
+ (chosen.length === 1 ? chosen[0].name.trim() : '') ||
25
+ (about ?? '').trim() ||
26
+ // Nothing to go on: say what it is rather than leave it blank again.
27
+ (chosen.length > 0 ? `${chosen.length} sessions` : 'run');
28
+ if (!used.has(base))
29
+ return base;
30
+ for (let n = 2; n < 1000; n += 1) {
31
+ const candidate = `${base} #${n}`;
32
+ if (!used.has(candidate))
33
+ return candidate;
34
+ }
35
+ return base;
36
+ }