copilot-operator 0.1.16 → 0.1.18
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/dist/src/api/operator.controller.js +23 -0
- package/dist/src/api/operator.service.js +55 -3
- package/dist/src/orchestrator/derivedChecks.js +19 -1
- package/dist/src/orchestrator/review.js +2 -2
- package/dist/src/orchestrator/taskRunner.js +81 -8
- package/dist/src/plan/brief.js +60 -0
- package/dist/src/plan/importPlan.js +35 -1
- package/dist/src/plan/schema.js +62 -0
- package/dist/src/plan/systemGuide.js +72 -0
- package/dist/src/protocol/replySchema.js +1 -1
- package/dist/src/protocol/reviewSchema.js +10 -5
- package/dist/src/session/exports.js +46 -13
- package/dist/src/session/model.js +20 -0
- package/dist/src/session/store.js +146 -6
- package/dist/src/vcs/artifacts.js +138 -0
- package/dist/src/vcs/inputs.js +332 -0
- package/dist/src/vcs/snapshot.js +395 -0
- package/dist/src/vcs/taskVcs.js +183 -27
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
- package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
- package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +1 -0
- package/dist/web/_next/static/css/{e2a2c9eb3c226313.css → c37978b44194c37b.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +9 -3
- package/prompts/level1.md +547 -546
- package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +0 -1
- package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +0 -1
- /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → JPmlfAEvbNkbghoyvY9uC}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → JPmlfAEvbNkbghoyvY9uC}/_ssgManifest.js +0 -0
|
@@ -827,6 +827,22 @@ export const SYSTEM_GUIDE = [
|
|
|
827
827
|
doesEn: 'Deletes every session ticked in the first column after one question, also during a run; never the one running. Run folders and chats survive.',
|
|
828
828
|
doesBg: 'Изтрива всички отметнати в първата колона сесии след един въпрос, и по време на пускане; никога работещата. Папките и разговорите остават.',
|
|
829
829
|
},
|
|
830
|
+
{
|
|
831
|
+
key: 'home.moveUp',
|
|
832
|
+
kind: 'button',
|
|
833
|
+
en: 'Move {name} up in the list',
|
|
834
|
+
bg: 'Премести {name} нагоре в списъка',
|
|
835
|
+
doesEn: 'The ↑ arrow in the Order column: one place up. ↓ is one place down. Dragging a row onto another moves it there. An imported plan comes in on top in its own order.',
|
|
836
|
+
doesBg: 'Стрелката ↑ в колона „Ред“: едно място нагоре. ↓ е едно надолу. Плъзгане на ред върху друг го мести там. Импортиран план идва най-отгоре в своя ред.',
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
key: 'home.moveDown',
|
|
840
|
+
kind: 'button',
|
|
841
|
+
en: 'Move {name} down in the list',
|
|
842
|
+
bg: 'Премести {name} надолу в списъка',
|
|
843
|
+
doesEn: 'The ↓ arrow in the Order column: one place down. A run of several takes the ticked sessions top to bottom.',
|
|
844
|
+
doesBg: 'Стрелката ↓ в колона „Ред“: едно място надолу. Пускане на няколко взима отметнатите сесии отгоре надолу.',
|
|
845
|
+
},
|
|
830
846
|
{
|
|
831
847
|
key: 'home.delete',
|
|
832
848
|
kind: 'button',
|
|
@@ -1192,6 +1208,62 @@ export const SYSTEM_GUIDE = [
|
|
|
1192
1208
|
doesEn: 'One local commit per task whatever the outcome; unticked leaves the changes loose in the working tree.',
|
|
1193
1209
|
doesBg: 'По един локален комит на задача, независимо от изхода; без отметка промените остават свободни в работното дърво.',
|
|
1194
1210
|
},
|
|
1211
|
+
{
|
|
1212
|
+
key: 'vcs.dirty',
|
|
1213
|
+
kind: 'note',
|
|
1214
|
+
en: 'Uncommitted changes',
|
|
1215
|
+
bg: 'Некомитнати промени',
|
|
1216
|
+
doesEn: 'What the first task does when the repository has uncommitted changes: keep version control out (as until now), or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
|
|
1217
|
+
doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: без контрол на версиите (както досега) или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
key: 'vcs.snapshotTake',
|
|
1221
|
+
kind: 'button',
|
|
1222
|
+
en: 'Take the starting snapshot',
|
|
1223
|
+
bg: 'Направи началната снимка',
|
|
1224
|
+
doesEn: 'Under the list of uncommitted files, each set to "Include in the starting snapshot" or "Leave out of the run": commits the included ones on the snapshot branch; left-out files go to the repository\'s .git/info/exclude. Until it is pressed a run of the session is refused and nothing changes.',
|
|
1225
|
+
doesBg: 'Под списъка с некомитнати файлове, всеки с „Включи в началната снимка“ или „Остави извън изпълнението“: комитва включените на клона на снимката; оставените извън отиват в .git/info/exclude на хранилището. Докато не е натиснат, пускане на сесията се отказва и нищо не се променя.',
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
key: 'vcs.inputs',
|
|
1229
|
+
kind: 'note',
|
|
1230
|
+
en: 'Input files',
|
|
1231
|
+
bg: 'Входни файлове',
|
|
1232
|
+
doesEn: 'The heading over the operator\'s input files and whether they are read-only.',
|
|
1233
|
+
doesBg: 'Заглавието над входните файлове на оператора и дали са само за четене.',
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
key: 'vcs.inputsField',
|
|
1237
|
+
kind: 'field',
|
|
1238
|
+
en: 'Your input files, one pattern per line (repository-relative)',
|
|
1239
|
+
bg: 'Твоите входни файлове, по един шаблон на ред (спрямо хранилището)',
|
|
1240
|
+
doesEn: 'Files the operator provides — schemas, test data. They go into the commit every session of the run starts from (new or ignored ones in the starting snapshot, listed for approval); a later session whose start lacks them has them committed on top of it; their SHA-256 sums are recorded. In a plan: "userInputs": { "paths": [...] }.',
|
|
1241
|
+
doesBg: 'Файлове, които операторът дава — схеми, тестови данни. Влизат в комита, от който тръгва всяка сесия на пускането (новите или ignored — в началната снимка, със списък за одобрение); следваща сесия, чието начало ги няма, ги получава комитнати върху него; SHA-256 сумите им се записват. В план: "userInputs": { "paths": [...] }.',
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
key: 'vcs.inputsReadOnly',
|
|
1245
|
+
kind: 'checkbox',
|
|
1246
|
+
en: 'Read-only while the tasks run',
|
|
1247
|
+
bg: 'Само за четене, докато задачите работят',
|
|
1248
|
+
doesEn: 'On by default. A changed, deleted or added input file is put back after every round and the chat told; one that cannot be put back fails the task.',
|
|
1249
|
+
doesBg: 'Включено по подразбиране. Променен, изтрит или добавен входен файл се връща след всеки рунд и чатът научава; такъв, който не може да се върне, проваля задачата.',
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
key: 'vcs.artifacts',
|
|
1253
|
+
kind: 'note',
|
|
1254
|
+
en: 'Artifacts',
|
|
1255
|
+
bg: 'Artifacts',
|
|
1256
|
+
doesEn: 'The heading over the evidence files the work writes.',
|
|
1257
|
+
doesBg: 'Заглавието над файловете с доказателства, които работата пише.',
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
key: 'vcs.artifactsField',
|
|
1261
|
+
kind: 'field',
|
|
1262
|
+
en: 'Evidence the work writes, one pattern per line (repository-relative)',
|
|
1263
|
+
bg: 'Доказателства, които работата пише, по един шаблон на ред (спрямо хранилището)',
|
|
1264
|
+
doesEn: 'Reports, ZIP archives, test results: never committed (kept out through .git/info/exclude, .gitignore untouched) and copied into each attempt\'s record with their sums. In a plan: "artifacts": { "paths": [...] }.',
|
|
1265
|
+
doesBg: 'Отчети, ZIP архиви, резултати от тестове: никога не се комитват (стоят извън git чрез .git/info/exclude, .gitignore не се пипа) и се копират към записа на всеки опит със сумите си. В план: "artifacts": { "paths": [...] }.',
|
|
1266
|
+
},
|
|
1195
1267
|
{
|
|
1196
1268
|
key: 'vcs.ready',
|
|
1197
1269
|
kind: 'note',
|
|
@@ -83,7 +83,7 @@ export const DeviationSchema = z.object({
|
|
|
83
83
|
* claim to test, and to the record. The finding is named by the id the runner gave it.
|
|
84
84
|
*/
|
|
85
85
|
export const DisputeSchema = z.object({
|
|
86
|
-
/** The finding's id as it was given: `r1f2
|
|
86
|
+
/** The finding's id as it was given: `r1f2`, or `a2r1f2` on a later attempt. */
|
|
87
87
|
finding: z.string().trim().min(1),
|
|
88
88
|
/** Why it is wrong. */
|
|
89
89
|
why: z.string().trim().min(1),
|
|
@@ -166,13 +166,18 @@ export function isGrounded(basis, sources) {
|
|
|
166
166
|
return sources.some((s) => groundingKey(s).includes(key));
|
|
167
167
|
}
|
|
168
168
|
/**
|
|
169
|
-
* The name a finding is referred to by, once the runner has it: round and position
|
|
169
|
+
* The name a finding is referred to by, once the runner has it: round and position, and from the
|
|
170
|
+
* task's second attempt on the attempt as well — `r1f2`, then `a2r1f2`.
|
|
170
171
|
*
|
|
171
|
-
* Given by the runner rather than the reviewer so that it is unique
|
|
172
|
-
*
|
|
172
|
+
* Given by the runner rather than the reviewer so that it is unique and cannot be forgotten. It is
|
|
173
|
+
* what the implementer names in `disputed`, what a check given with the finding is named after, and
|
|
174
|
+
* what the record shows. Unique across the task's attempts, not only its rounds, because those checks
|
|
175
|
+
* are kept for every later attempt (`Task.reviewChecks`) and the rounds count from 1 on each: with
|
|
176
|
+
* round and position alone, attempt 2's first finding was `r1f1` beside attempt 1's kept `r1f1`, and
|
|
177
|
+
* a dispute of the one suspended the check of the other. The first attempt keeps the short form.
|
|
173
178
|
*/
|
|
174
|
-
export function findingId(round, index) {
|
|
175
|
-
return `r${round}f${index + 1}`;
|
|
179
|
+
export function findingId(round, index, attempt = 1) {
|
|
180
|
+
return `${attempt > 1 ? `a${attempt}` : ''}r${round}f${index + 1}`;
|
|
176
181
|
}
|
|
177
182
|
/** True when nothing the reviewer found is something the implementer could fix. */
|
|
178
183
|
export function allAboutTheTask(findings) {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { existsSync } from 'node:fs';
|
|
22
22
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
|
+
import { TASK_FIELD_ON_RERUN } from './store.js';
|
|
24
25
|
import { withoutPersona } from '../plan/importPlan.js';
|
|
25
26
|
const EXPECTED_HEADER = '### Expected result';
|
|
26
27
|
const GOAL_HEADER = '## Goal of this session';
|
|
@@ -147,6 +148,10 @@ export function buildPlanExport(scope) {
|
|
|
147
148
|
...(s.vcs?.existingBranch ? { existingBranch: s.vcs.existingBranch } : {}),
|
|
148
149
|
// Only when switched off: absent is on.
|
|
149
150
|
...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
|
|
151
|
+
// Only when chosen: absent is reject, as it always was.
|
|
152
|
+
...(s.vcs?.dirtyWorktree && s.vcs.dirtyWorktree.policy !== 'reject' ? { dirtyWorktree: { ...s.vcs.dirtyWorktree } } : {}),
|
|
153
|
+
...(s.vcs?.userInputs?.paths.length ? { userInputs: { ...s.vcs.userInputs } } : {}),
|
|
154
|
+
...(s.vcs?.artifacts?.paths.length ? { artifacts: { ...s.vcs.artifacts } } : {}),
|
|
150
155
|
},
|
|
151
156
|
review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
|
|
152
157
|
...(s.projectDir ? { projectDir: s.projectDir } : {}),
|
|
@@ -229,27 +234,32 @@ export function taskAtAttempt(task, attempt) {
|
|
|
229
234
|
const a = task.attempts?.[attempt - 1];
|
|
230
235
|
if (!a)
|
|
231
236
|
return null;
|
|
237
|
+
/*
|
|
238
|
+
* By the same table `rerunTask` follows: what was archived is read from the attempt, what was
|
|
239
|
+
* cleared is absent. Field by field, by hand, an earlier attempt read back with the latest one's
|
|
240
|
+
* handoff, counts, limit and fresh-chat mark.
|
|
241
|
+
*/
|
|
242
|
+
const asItWas = {};
|
|
243
|
+
for (const [key, what] of Object.entries(TASK_FIELD_ON_RERUN)) {
|
|
244
|
+
if (what === 'archived')
|
|
245
|
+
asItWas[key] = a[key];
|
|
246
|
+
if (what === 'cleared')
|
|
247
|
+
asItWas[key] = undefined;
|
|
248
|
+
}
|
|
232
249
|
return {
|
|
233
250
|
...task,
|
|
251
|
+
...asItWas,
|
|
234
252
|
attempt,
|
|
235
253
|
status: a.status,
|
|
236
|
-
runId: a.runId,
|
|
237
|
-
runGroup: a.runGroup,
|
|
238
|
-
startedAt: a.startedAt,
|
|
239
|
-
finishedAt: a.finishedAt,
|
|
240
254
|
iterations: a.iterations,
|
|
241
|
-
|
|
242
|
-
reason: a.reason,
|
|
243
|
-
deviations: a.deviations,
|
|
244
|
-
disputes: a.disputes,
|
|
255
|
+
// The task's own text and definition as they were when that attempt ran, where it was kept.
|
|
245
256
|
title: a.title,
|
|
246
257
|
prompt: a.prompt,
|
|
247
258
|
level2: a.level2,
|
|
248
259
|
checks: a.checks ?? task.checks,
|
|
249
260
|
vcsPlan: a.vcsPlan ?? task.vcsPlan,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
vcs: a.vcs,
|
|
261
|
+
scope: a.scope ?? task.scope,
|
|
262
|
+
readOnly: a.readOnly ?? task.readOnly,
|
|
253
263
|
attempts: task.attempts?.slice(0, attempt - 1),
|
|
254
264
|
continuing: undefined,
|
|
255
265
|
buildsOn: undefined,
|
|
@@ -434,7 +444,25 @@ async function domainTask(session, task, runsDir) {
|
|
|
434
444
|
const started = task.startedAt ? Date.parse(task.startedAt) : NaN;
|
|
435
445
|
const finished = task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
|
436
446
|
const out = {
|
|
437
|
-
|
|
447
|
+
/*
|
|
448
|
+
* Where the session started — the baseline commit, a snapshot of the operator's changes, the
|
|
449
|
+
* input files with their SHA-256 sums — so a reader can check the work against its inputs.
|
|
450
|
+
*/
|
|
451
|
+
session: {
|
|
452
|
+
id: session.id,
|
|
453
|
+
name: session.name,
|
|
454
|
+
...(session.vcsStart
|
|
455
|
+
? {
|
|
456
|
+
start: {
|
|
457
|
+
kind: session.vcsStart.kind,
|
|
458
|
+
commit: session.vcsStart.commit,
|
|
459
|
+
branch: session.vcsStart.branch,
|
|
460
|
+
...(session.vcsStart.snapshot ? { snapshot: session.vcsStart.snapshot } : {}),
|
|
461
|
+
...(session.vcsStart.inputs ? { inputs: session.vcsStart.inputs } : {}),
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
: {}),
|
|
465
|
+
},
|
|
438
466
|
task: {
|
|
439
467
|
id: task.id,
|
|
440
468
|
title: task.title,
|
|
@@ -442,6 +470,8 @@ async function domainTask(session, task, runsDir) {
|
|
|
442
470
|
readOnly: task.readOnly ?? false,
|
|
443
471
|
scope: task.scope ?? [],
|
|
444
472
|
scopeReverted: task.scopeReverted ?? [],
|
|
473
|
+
inputsRestored: task.inputsRestored ?? [],
|
|
474
|
+
artifactsKept: task.artifactsKept ?? [],
|
|
445
475
|
handoff: task.handoff ?? null,
|
|
446
476
|
stopCode: task.stopCode ?? null,
|
|
447
477
|
prompt: p.prompt,
|
|
@@ -524,7 +554,7 @@ async function domainTask(session, task, runsDir) {
|
|
|
524
554
|
whatTheChatTried: aRounds,
|
|
525
555
|
whatWasActuallyDone: { repository: a.vcs, deviations: a.deviations, disputes: a.disputes },
|
|
526
556
|
whyItFailed: aFailed
|
|
527
|
-
? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1) }
|
|
557
|
+
? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1), leftovers: a.leftovers }
|
|
528
558
|
: undefined,
|
|
529
559
|
});
|
|
530
560
|
}
|
|
@@ -605,6 +635,9 @@ async function botTask(session, task, runsDir) {
|
|
|
605
635
|
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 })),
|
|
606
636
|
problems: aEvents.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed),
|
|
607
637
|
events: aEvents.map(trimmed),
|
|
638
|
+
// That attempt's own: what it left running and the machine it ran on.
|
|
639
|
+
processes: { leftovers: a.leftovers },
|
|
640
|
+
environment: a.environment,
|
|
608
641
|
};
|
|
609
642
|
})),
|
|
610
643
|
};
|
|
@@ -21,6 +21,26 @@ export function tidyVcsPlan(plan) {
|
|
|
21
21
|
export function isContinuable(t) {
|
|
22
22
|
return t.status === 'limit-reached' || t.status === 'aborted' || (!!t.limit && (t.status === 'failed' || t.status === 'blocked'));
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* How many times the runner ran the task again in a fresh conversation, on its own, in the run that
|
|
26
|
+
* produced its latest attempt (`limits.retryBlockedInFreshChat`): the attempts at the end of its
|
|
27
|
+
* history that it marked `freshRetry`, counted back to the one the operator started. A row that says
|
|
28
|
+
* "retried in a fresh chat, then done" means the chat was the cause; "still blocked after 2" means
|
|
29
|
+
* it was not.
|
|
30
|
+
*
|
|
31
|
+
* Derived, never stored. It was a counter on the task that the runner added to and nothing reset,
|
|
32
|
+
* so every Run again or Continue of a task that blocked added the run's retries to the last run's:
|
|
33
|
+
* the register said 2, then 4, then 6, while each run had retried twice.
|
|
34
|
+
*/
|
|
35
|
+
export function freshRetriesOfLatestRun(task) {
|
|
36
|
+
if (!task.freshRetry)
|
|
37
|
+
return 0;
|
|
38
|
+
let n = 1;
|
|
39
|
+
const before = task.attempts ?? [];
|
|
40
|
+
for (let i = before.length - 1; i >= 0 && before[i]?.freshRetry; i -= 1)
|
|
41
|
+
n += 1;
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
24
44
|
export function newId(prefix = '') {
|
|
25
45
|
const now = new Date();
|
|
26
46
|
const p = (n) => String(n).padStart(2, '0');
|
|
@@ -29,6 +29,87 @@ const ACTIVE_STATUSES = ['running', 'waiting-approval'];
|
|
|
29
29
|
* feature works at all would mean most runs go unreviewed.
|
|
30
30
|
*/
|
|
31
31
|
export const DEFAULT_REVIEW = { enabled: true, model: '' };
|
|
32
|
+
/**
|
|
33
|
+
* Where a session sits in the list: its position, or, for one saved before the list could be
|
|
34
|
+
* arranged, minus the time it was created — which keeps those newest first, as they always were,
|
|
35
|
+
* and above any arranged session only if they are newer than the list's top was.
|
|
36
|
+
*/
|
|
37
|
+
export function sessionListKey(s) {
|
|
38
|
+
if (typeof s.position === 'number' && Number.isFinite(s.position))
|
|
39
|
+
return s.position;
|
|
40
|
+
const t = Date.parse(s.createdAt);
|
|
41
|
+
return Number.isNaN(t) ? 0 : -t;
|
|
42
|
+
}
|
|
43
|
+
/** The sessions list's order: by position, then newest first, then by id so it never wobbles. */
|
|
44
|
+
export function bySessionList(a, b) {
|
|
45
|
+
const byKey = sessionListKey(a) - sessionListKey(b);
|
|
46
|
+
if (byKey !== 0)
|
|
47
|
+
return byKey;
|
|
48
|
+
const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
|
49
|
+
if (byTime !== 0 && !Number.isNaN(byTime))
|
|
50
|
+
return byTime;
|
|
51
|
+
return b.id.localeCompare(a.id);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What `rerunTask` does with every field of a task when a new attempt begins — Run again, Continue,
|
|
55
|
+
* a new prompt, the runner's own fresh-chat retry.
|
|
56
|
+
*
|
|
57
|
+
* archived belongs to the attempt that ended: copied onto its `TaskAttempt`, cleared on the task
|
|
58
|
+
* cleared belongs to that attempt and is not kept on the record: cleared
|
|
59
|
+
* reset given the new attempt's starting value (status queued, iterations 0, attempt + 1, …)
|
|
60
|
+
* kept the task's own, across attempts: its definition, or something every attempt inherits
|
|
61
|
+
*
|
|
62
|
+
* A record over every key of `Task`, so a field added to the task without a decision here does not
|
|
63
|
+
* compile. Fields that were left out of `rerunTask` by default were counted on by the next attempt:
|
|
64
|
+
* the fresh-chat retry count said 2, then 4, then 6 while each run had retried twice, and what an
|
|
65
|
+
* attempt left running was listed under every attempt after it. `npm run check:attempts` holds
|
|
66
|
+
* `rerunTask` to this table.
|
|
67
|
+
*/
|
|
68
|
+
export const TASK_FIELD_ON_RERUN = {
|
|
69
|
+
id: 'kept',
|
|
70
|
+
title: 'kept',
|
|
71
|
+
level2: 'kept',
|
|
72
|
+
prompt: 'kept',
|
|
73
|
+
createdAt: 'kept',
|
|
74
|
+
checks: 'kept',
|
|
75
|
+
vcsPlan: 'kept',
|
|
76
|
+
reviewEnabled: 'kept',
|
|
77
|
+
readOnly: 'kept',
|
|
78
|
+
scope: 'kept',
|
|
79
|
+
// What one review noticed is arithmetic for every attempt after it.
|
|
80
|
+
reviewChecks: 'kept',
|
|
81
|
+
attempts: 'kept',
|
|
82
|
+
status: 'reset',
|
|
83
|
+
iterations: 'reset',
|
|
84
|
+
attempt: 'reset',
|
|
85
|
+
continuing: 'reset',
|
|
86
|
+
buildsOn: 'reset',
|
|
87
|
+
runId: 'archived',
|
|
88
|
+
runGroup: 'archived',
|
|
89
|
+
startedAt: 'archived',
|
|
90
|
+
finishedAt: 'archived',
|
|
91
|
+
summary: 'archived',
|
|
92
|
+
reason: 'archived',
|
|
93
|
+
deviations: 'archived',
|
|
94
|
+
disputes: 'archived',
|
|
95
|
+
checkResults: 'archived',
|
|
96
|
+
review: 'archived',
|
|
97
|
+
stats: 'archived',
|
|
98
|
+
freshRetry: 'archived',
|
|
99
|
+
stopCode: 'archived',
|
|
100
|
+
limit: 'archived',
|
|
101
|
+
scopeReverted: 'archived',
|
|
102
|
+
inputsRestored: 'archived',
|
|
103
|
+
artifactsKept: 'archived',
|
|
104
|
+
vcs: 'archived',
|
|
105
|
+
leftovers: 'archived',
|
|
106
|
+
environment: 'archived',
|
|
107
|
+
handoff: 'archived',
|
|
108
|
+
interruption: 'cleared',
|
|
109
|
+
finalReply: 'cleared',
|
|
110
|
+
firstMessage: 'cleared',
|
|
111
|
+
logFile: 'cleared',
|
|
112
|
+
};
|
|
32
113
|
export const DEFAULT_VCS = {
|
|
33
114
|
enabled: true,
|
|
34
115
|
repoDir: '',
|
|
@@ -254,12 +335,50 @@ export class SessionStore {
|
|
|
254
335
|
if (s)
|
|
255
336
|
out.push(s);
|
|
256
337
|
}
|
|
257
|
-
return out.sort(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
338
|
+
return out.sort(bySessionList);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Puts these sessions at the top of the list, in this order.
|
|
342
|
+
*
|
|
343
|
+
* An import calls it with the plan's sessions, so they appear as the plan wrote them. Before
|
|
344
|
+
* this, the list was newest first, and a plan whose sessions were created one after another
|
|
345
|
+
* read upside down.
|
|
346
|
+
*/
|
|
347
|
+
async placeOnTop(ids) {
|
|
348
|
+
const wanted = new Set(ids);
|
|
349
|
+
const others = (await this.listSessions()).filter((s) => !wanted.has(s.id));
|
|
350
|
+
const top = others.length > 0 ? Math.min(...others.map(sessionListKey)) : 0;
|
|
351
|
+
for (const [i, id] of ids.entries()) {
|
|
352
|
+
await this.updateSession(id, (s) => {
|
|
353
|
+
s.position = top - ids.length + i;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The operator's own order: every session, top to bottom.
|
|
359
|
+
*
|
|
360
|
+
* The whole list is given, not one move, so two pages moving rows at once cannot leave positions
|
|
361
|
+
* that contradict each other: the last order sent is the order there is. A list that names a
|
|
362
|
+
* session that does not exist, or leaves one out, is refused rather than guessed at.
|
|
363
|
+
*/
|
|
364
|
+
async reorderSessions(ids) {
|
|
365
|
+
const all = await this.listSessions();
|
|
366
|
+
const known = new Set(all.map((s) => s.id));
|
|
367
|
+
const given = new Set(ids);
|
|
368
|
+
if (given.size !== ids.length)
|
|
369
|
+
throw new Error('The order names a session twice.');
|
|
370
|
+
const unknown = ids.filter((id) => !known.has(id));
|
|
371
|
+
if (unknown.length > 0)
|
|
372
|
+
throw new Error(`The order names a session that does not exist: ${unknown.join(', ')}.`);
|
|
373
|
+
const missing = all.filter((s) => !given.has(s.id)).map((s) => s.name);
|
|
374
|
+
if (missing.length > 0)
|
|
375
|
+
throw new Error(`The order leaves out ${missing.length} session(s): ${missing.slice(0, 5).join(', ')}. Reload the list and try again.`);
|
|
376
|
+
for (const [i, id] of ids.entries()) {
|
|
377
|
+
await this.updateSession(id, (s) => {
|
|
378
|
+
s.position = i;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return await this.listSessions();
|
|
263
382
|
}
|
|
264
383
|
/**
|
|
265
384
|
* The session of this id, or null when there is none.
|
|
@@ -282,10 +401,13 @@ export class SessionStore {
|
|
|
282
401
|
return await this.readSession(path);
|
|
283
402
|
}
|
|
284
403
|
async createSession(name, projectDir = '') {
|
|
404
|
+
// A new session goes on top of the list, where it is looked for.
|
|
405
|
+
const existing = await this.listSessions();
|
|
285
406
|
const session = {
|
|
286
407
|
id: newId(),
|
|
287
408
|
name: name.trim() || 'untitled',
|
|
288
409
|
createdAt: new Date().toISOString(),
|
|
410
|
+
position: existing.length > 0 ? Math.min(...existing.map(sessionListKey)) - 1 : 0,
|
|
289
411
|
status: 'idle',
|
|
290
412
|
contractSent: false,
|
|
291
413
|
// A queue is a chain until the operator says otherwise, which is how it has always
|
|
@@ -414,6 +536,9 @@ export class SessionStore {
|
|
|
414
536
|
...(current.interruption ? { interruption: current.interruption } : {}),
|
|
415
537
|
});
|
|
416
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* What a new attempt does with each field of a task — see `TASK_FIELD_ON_RERUN`.
|
|
541
|
+
*/
|
|
417
542
|
async rerunTask(sessionId, taskId, patch = {},
|
|
418
543
|
/** Present when the new attempt carries on from the last one (see `continueTask`). */
|
|
419
544
|
continuing,
|
|
@@ -433,6 +558,8 @@ export class SessionStore {
|
|
|
433
558
|
{
|
|
434
559
|
runId: t.runId,
|
|
435
560
|
runGroup: t.runGroup,
|
|
561
|
+
leftovers: t.leftovers,
|
|
562
|
+
environment: t.environment,
|
|
436
563
|
status: t.status,
|
|
437
564
|
startedAt: t.startedAt,
|
|
438
565
|
finishedAt: t.finishedAt,
|
|
@@ -456,7 +583,11 @@ export class SessionStore {
|
|
|
456
583
|
stopCode: t.stopCode,
|
|
457
584
|
limit: t.limit,
|
|
458
585
|
scope: t.scope,
|
|
586
|
+
readOnly: t.readOnly,
|
|
459
587
|
scopeReverted: t.scopeReverted,
|
|
588
|
+
handoff: t.handoff,
|
|
589
|
+
inputsRestored: t.inputsRestored,
|
|
590
|
+
artifactsKept: t.artifactsKept,
|
|
460
591
|
vcs: t.vcs,
|
|
461
592
|
},
|
|
462
593
|
];
|
|
@@ -491,9 +622,15 @@ export class SessionStore {
|
|
|
491
622
|
t.stats = undefined;
|
|
492
623
|
t.handoff = undefined;
|
|
493
624
|
t.scopeReverted = undefined;
|
|
625
|
+
t.inputsRestored = undefined;
|
|
626
|
+
t.artifactsKept = undefined;
|
|
494
627
|
t.freshRetry = undefined;
|
|
495
628
|
t.stopCode = undefined;
|
|
496
629
|
t.limit = undefined;
|
|
630
|
+
// What this attempt left running and the machine it ran on are this attempt's, as the rest:
|
|
631
|
+
// left in place, the next attempt's list was added to this one's and read as its own.
|
|
632
|
+
t.leftovers = undefined;
|
|
633
|
+
t.environment = undefined;
|
|
497
634
|
// The branch of the finished attempt stays in the repository and stays on the record
|
|
498
635
|
// above; the next attempt gets its own, cut from the same commit this one started at.
|
|
499
636
|
t.vcs = undefined;
|
|
@@ -556,6 +693,9 @@ export class SessionStore {
|
|
|
556
693
|
// Sessions written before the queue could be told what to do on a failure behave the
|
|
557
694
|
// way they always did: the chain stops.
|
|
558
695
|
s.onFailure ??= 'stop';
|
|
696
|
+
// A counter that added up across runs, written before it was derived (`freshRetriesOfLatestRun`).
|
|
697
|
+
for (const t of s.tasks ?? [])
|
|
698
|
+
delete t.autoRetries;
|
|
559
699
|
return s;
|
|
560
700
|
}
|
|
561
701
|
catch {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence the work produces — reports, ZIP archives, test results — kept with the run, not in git.
|
|
3
|
+
*
|
|
4
|
+
* Found in a real run: evidence a task wrote was either committed with the work or, to stop that,
|
|
5
|
+
* `.gitignore` had to be changed for it, and a later session that needed an earlier one's discovery
|
|
6
|
+
* report had nothing reliable to read. `vcs.artifacts.paths` names those files, and:
|
|
7
|
+
*
|
|
8
|
+
* - they are never committed: the patterns go into the repository's own `.git/info/exclude`
|
|
9
|
+
* (local, never committed, `.gitignore` untouched) when the session's first task starts, so the
|
|
10
|
+
* runner's commit and a task's scope both leave them alone. A file git already tracks is not
|
|
11
|
+
* affected by an exclude, and is said so;
|
|
12
|
+
* - when each task ends, whatever its outcome, the files are copied into the attempt's record,
|
|
13
|
+
* `artifacts/project/<path>`, with their sizes and SHA-256 sums on the task — so the evidence of
|
|
14
|
+
* each attempt is kept even after a later one overwrites it in the project.
|
|
15
|
+
*
|
|
16
|
+
* Works with version control off too: then nothing is excluded, only kept.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
21
|
+
import { looksGenerated } from './commitHygiene.js';
|
|
22
|
+
import { git } from './git.js';
|
|
23
|
+
import { normalisePatterns } from './inputs.js';
|
|
24
|
+
import { inScope } from './scope.js';
|
|
25
|
+
/** The session's artifact patterns, without ones that name the whole project. */
|
|
26
|
+
export function artifactPatterns(vcs) {
|
|
27
|
+
return normalisePatterns(vcs?.artifacts?.paths ?? []);
|
|
28
|
+
}
|
|
29
|
+
/** A pattern for `.git/info/exclude`: anchored at the repository's top, as a scope pattern is. */
|
|
30
|
+
function excludeLine(pattern) {
|
|
31
|
+
return `/${pattern.replace(/^\/+/, '')}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Writes the session's artifact patterns into `.git/info/exclude`, once each. Returns the files
|
|
35
|
+
* matching them that git already tracks, which an exclude does not stop from being committed.
|
|
36
|
+
*/
|
|
37
|
+
export async function excludeArtifacts(dir, session) {
|
|
38
|
+
const patterns = artifactPatterns(session.vcs);
|
|
39
|
+
if (patterns.length === 0)
|
|
40
|
+
return { tracked: [] };
|
|
41
|
+
const where = await git(dir, ['rev-parse', '--git-path', 'info/exclude']);
|
|
42
|
+
if (!where.ok || !where.stdout)
|
|
43
|
+
return { problem: "the repository's exclude file could not be found", tracked: [] };
|
|
44
|
+
const top = (await git(dir, ['rev-parse', '--show-toplevel'])).stdout || dir;
|
|
45
|
+
const file = resolve(dir, where.stdout);
|
|
46
|
+
const before = await readFile(file, 'utf8').catch(() => '');
|
|
47
|
+
const have = new Set(before.split(/\r?\n/).map((l) => l.trim()));
|
|
48
|
+
const add = patterns.map(excludeLine).filter((l) => !have.has(l));
|
|
49
|
+
if (add.length > 0) {
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(dirname(file), { recursive: true });
|
|
52
|
+
const block = [`# copilot-operator: artifacts of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}) — kept with the run, never committed`, ...add, ''].join('\n');
|
|
53
|
+
await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return { problem: `the exclude file could not be written: ${e.message}`, tracked: [] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const tracked = (await git(resolve(top), ['ls-files'])).stdout.split('\n').filter((p) => p && inScope(p, patterns));
|
|
60
|
+
return { tracked };
|
|
61
|
+
}
|
|
62
|
+
const LIMITS = { files: 1000, fileBytes: 100 * 1024 * 1024, totalBytes: 500 * 1024 * 1024 };
|
|
63
|
+
/** Files under `root` matching the patterns, found on disk: git's view does not matter for evidence. */
|
|
64
|
+
async function matching(root, patterns) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const starts = new Set(patterns.map((p) => {
|
|
67
|
+
const fixed = [];
|
|
68
|
+
for (const part of p.replace(/\/+$/, '').split('/')) {
|
|
69
|
+
if (/[*?[]/.test(part))
|
|
70
|
+
break;
|
|
71
|
+
fixed.push(part);
|
|
72
|
+
}
|
|
73
|
+
return fixed.join('/');
|
|
74
|
+
}));
|
|
75
|
+
const walk = async (rel) => {
|
|
76
|
+
if (out.length > LIMITS.files * 2)
|
|
77
|
+
return;
|
|
78
|
+
const abs = join(root, rel);
|
|
79
|
+
const info = await stat(abs).catch(() => null);
|
|
80
|
+
if (!info)
|
|
81
|
+
return;
|
|
82
|
+
if (info.isFile()) {
|
|
83
|
+
if (inScope(rel, patterns))
|
|
84
|
+
out.push(rel);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!info.isDirectory())
|
|
88
|
+
return;
|
|
89
|
+
const name = rel.split('/').pop() ?? '';
|
|
90
|
+
if (name === '.git' || name === 'node_modules' || name === '.copilot-operator')
|
|
91
|
+
return;
|
|
92
|
+
for (const child of await readdir(abs).catch(() => []))
|
|
93
|
+
await walk(rel ? `${rel}/${child}` : child);
|
|
94
|
+
};
|
|
95
|
+
for (const start of starts)
|
|
96
|
+
await walk(start);
|
|
97
|
+
return [...new Set(out)].sort();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Copies the artifact files into the attempt's record, `<dest>/<path>`. Secrets are never copied;
|
|
101
|
+
* past the limits the rest is skipped and said.
|
|
102
|
+
*/
|
|
103
|
+
export async function keepArtifacts(root, patterns, dest) {
|
|
104
|
+
const kept = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
if (patterns.length === 0 || !root)
|
|
107
|
+
return { kept, skipped };
|
|
108
|
+
let total = 0;
|
|
109
|
+
const base = resolve(root);
|
|
110
|
+
for (const rel of await matching(root, patterns)) {
|
|
111
|
+
const from = resolve(join(root, rel));
|
|
112
|
+
if (!from.startsWith(base + sep))
|
|
113
|
+
continue;
|
|
114
|
+
if (looksGenerated(rel)?.reason === 'a secrets file') {
|
|
115
|
+
skipped.push(`${rel} (a secrets file)`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const info = await stat(from).catch(() => null);
|
|
119
|
+
if (!info)
|
|
120
|
+
continue;
|
|
121
|
+
if (kept.length >= LIMITS.files || info.size > LIMITS.fileBytes || total + info.size > LIMITS.totalBytes) {
|
|
122
|
+
skipped.push(`${rel} (past the limit of ${LIMITS.files} files, ${LIMITS.fileBytes / 1048576} MB a file, ${LIMITS.totalBytes / 1048576} MB in all)`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const to = join(dest, rel);
|
|
126
|
+
try {
|
|
127
|
+
await mkdir(dirname(to), { recursive: true });
|
|
128
|
+
await copyFile(from, to);
|
|
129
|
+
const bytes = await readFile(to);
|
|
130
|
+
kept.push({ path: rel, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
131
|
+
total += info.size;
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
skipped.push(`${rel} (${e.message})`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { kept, skipped };
|
|
138
|
+
}
|