copilot-operator 0.1.18 → 0.1.19

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 (46) hide show
  1. package/dist/src/api/operator.service.js +10 -0
  2. package/dist/src/cli.js +11 -29
  3. package/dist/src/config/version.js +30 -0
  4. package/dist/src/orchestrator/taskRunner.js +56 -9
  5. package/dist/src/plan/brief.js +16 -8
  6. package/dist/src/plan/importPlan.js +14 -0
  7. package/dist/src/plan/schema.js +15 -0
  8. package/dist/src/plan/systemGuide.js +10 -2
  9. package/dist/src/session/exports.js +9 -0
  10. package/dist/src/session/store.js +2 -0
  11. package/dist/src/vcs/artifacts.js +56 -9
  12. package/dist/src/vcs/inputs.js +145 -28
  13. package/dist/src/vcs/snapshot.js +117 -6
  14. package/dist/src/vcs/taskVcs.js +138 -14
  15. package/dist/web/404.html +2 -2
  16. package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
  17. package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
  18. package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
  19. package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
  20. package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
  21. package/dist/web/appearance.html +2 -2
  22. package/dist/web/appearance.txt +25 -26
  23. package/dist/web/defaults.html +2 -2
  24. package/dist/web/defaults.txt +25 -26
  25. package/dist/web/history.html +2 -2
  26. package/dist/web/history.txt +25 -26
  27. package/dist/web/import.html +2 -2
  28. package/dist/web/import.txt +25 -26
  29. package/dist/web/index.html +2 -2
  30. package/dist/web/index.txt +25 -26
  31. package/dist/web/level1.html +2 -2
  32. package/dist/web/level1.txt +25 -26
  33. package/dist/web/presets.html +2 -2
  34. package/dist/web/presets.txt +25 -26
  35. package/dist/web/sessions/view.html +2 -2
  36. package/dist/web/sessions/view.txt +25 -26
  37. package/dist/web/system.html +2 -2
  38. package/dist/web/system.txt +25 -26
  39. package/package.json +4 -3
  40. package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +0 -1
  41. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
  42. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
  43. package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +0 -1
  44. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
  45. /package/dist/web/_next/static/{JPmlfAEvbNkbghoyvY9uC → ofnRLEgWx9th5w_qtuFnB}/_buildManifest.js +0 -0
  46. /package/dist/web/_next/static/{JPmlfAEvbNkbghoyvY9uC → ofnRLEgWx9th5w_qtuFnB}/_ssgManifest.js +0 -0
@@ -170,9 +170,11 @@ async function commitOnto(top, parent, files, message, branch) {
170
170
  const commit = await git(top, ['-c', 'user.name=copilot-operator', '-c', `user.email=${RUNNER_EMAIL}`, 'commit-tree', tree.stdout, '-p', parent, '-m', message]);
171
171
  if (!commit.ok || !commit.stdout)
172
172
  return { problem: commit.stderr || 'git commit-tree failed' };
173
- const made = await git(top, ['branch', branch, commit.stdout]);
174
- if (!made.ok)
175
- return { problem: made.stderr || `the branch ${branch} could not be made` };
173
+ if (branch) {
174
+ const made = await git(top, ['branch', branch, commit.stdout]);
175
+ if (!made.ok)
176
+ return { problem: made.stderr || `the branch ${branch} could not be made` };
177
+ }
176
178
  return { commit: commit.stdout };
177
179
  }
178
180
  finally {
@@ -180,41 +182,27 @@ async function commitOnto(top, parent, files, message, branch) {
180
182
  }
181
183
  }
182
184
  /**
183
- * Makes sure the commit a session starts from has the operator's input files, and records their
184
- * sums on the start. Returns the start to use — the same one, or one whose commit has the inputs
185
- * committed on top — or why the session cannot start.
186
- *
187
- * Called once the start is known and the working tree holds nothing uncommitted of the inputs (new
188
- * or changed ones went into the snapshot before this). Where the inputs are taken from: the
189
- * repository's HEAD for every pattern it has files for, else the latest session in this same
190
- * repository whose start recorded files for that pattern.
185
+ * Where each input pattern's files are committed: the repository's HEAD first, then `start` (a
186
+ * snapshot approved on the page has them, wherever HEAD has moved since), then the latest earlier
187
+ * session in this repository that recorded them. A pattern found nowhere is `missing`.
191
188
  */
192
- export async function settleInputs(session, dir, start, allSessions) {
193
- const settings = inputSettings(session.vcs);
194
- if (!settings)
195
- return { start };
196
- const topR = await git(dir, ['rev-parse', '--show-toplevel']);
197
- if (!topR.ok || !topR.stdout)
198
- return { problem: `${dir} is not a git repository.` };
199
- const top = resolve(topR.stdout);
189
+ async function inputSources(session, dir, top, patterns, start, allSessions) {
200
190
  const head = (await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'])).stdout;
201
- // Where each pattern's files are: HEAD first, then the latest earlier capture in this repository.
202
191
  const wanted = [];
203
192
  const sources = new Set();
204
193
  const missing = [];
205
- const atHead = head ? await inputsAt(top, head, settings.patterns) : [];
194
+ const atHead = head ? await inputsAt(top, head, patterns) : [];
206
195
  const earlier = (await allSessions())
207
196
  .filter((s) => s.id !== session.id && s.vcsStart?.inputs && norm(s.vcs?.repoDir || s.projectDir || '') === norm(dir))
208
197
  // The newest first: a later capture is the operator's later word on the inputs.
209
198
  .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
210
199
  .map((s) => s.vcsStart);
211
- for (const pattern of settings.patterns) {
200
+ for (const pattern of patterns) {
212
201
  let found = atHead.filter((f) => inScope(f.path, [pattern]));
213
202
  let from = head;
214
- // The start itself next: a snapshot approved on the page has them, wherever HEAD has moved since.
215
- if (found.length === 0) {
216
- found = (await inputsAt(top, start.commit, [pattern])).filter((f) => inScope(f.path, [pattern]));
217
- from = start.commit;
203
+ if (found.length === 0 && start) {
204
+ found = (await inputsAt(top, start, [pattern])).filter((f) => inScope(f.path, [pattern]));
205
+ from = start;
218
206
  }
219
207
  for (const s of earlier) {
220
208
  if (found.length > 0)
@@ -230,6 +218,42 @@ export async function settleInputs(session, dir, start, allSessions) {
230
218
  if (!wanted.some((w) => w.path === f.path))
231
219
  wanted.push(f);
232
220
  }
221
+ return { wanted, sources, missing };
222
+ }
223
+ /**
224
+ * The input patterns that match no file anywhere — not in the working tree, not committed, not
225
+ * captured by an earlier session. Asked before a snapshot is approved: found only afterwards, the
226
+ * snapshot was already taken when the session was refused for it.
227
+ */
228
+ export async function missingInputPatterns(session, dir, top, allSessions) {
229
+ const settings = inputSettings(session.vcs);
230
+ if (!settings)
231
+ return [];
232
+ const inTree = await inputFilesInTree(top, settings.patterns);
233
+ const notInTree = settings.patterns.filter((p) => !inTree.some((f) => inScope(f.path, [p])));
234
+ if (notInTree.length === 0)
235
+ return [];
236
+ return (await inputSources(session, dir, top, notInTree, session.vcsStart?.commit, allSessions)).missing;
237
+ }
238
+ /**
239
+ * Makes sure the commit a session starts from has the operator's input files, and records their
240
+ * sums on the start. Returns the start to use — the same one, or one whose commit has the inputs
241
+ * committed on top — or why the session cannot start.
242
+ *
243
+ * Called once the start is known and the working tree holds nothing uncommitted of the inputs (new
244
+ * or changed ones went into the snapshot before this). Where the inputs are taken from: the
245
+ * repository's HEAD for every pattern it has files for, else the latest session in this same
246
+ * repository whose start recorded files for that pattern.
247
+ */
248
+ export async function settleInputs(session, dir, start, allSessions) {
249
+ const settings = inputSettings(session.vcs);
250
+ if (!settings)
251
+ return { start };
252
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
253
+ if (!topR.ok || !topR.stdout)
254
+ return { problem: `${dir} is not a git repository.` };
255
+ const top = resolve(topR.stdout);
256
+ const { wanted, sources, missing } = await inputSources(session, dir, top, settings.patterns, start.commit, allSessions);
233
257
  if (missing.length > 0) {
234
258
  return {
235
259
  problem: `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file: not in the working tree, not in the repository's HEAD, ` +
@@ -266,12 +290,105 @@ export async function settleInputs(session, dir, start, allSessions) {
266
290
  const files = await inputSumsAt(top, commit, settings.patterns);
267
291
  return { start: { ...start, commit, inputs: { patterns: settings.patterns, files, readOnly: settings.readOnly, ...(carried ? { carried } : {}) } } };
268
292
  }
293
+ /**
294
+ * The input files in the working tree that the session's record does not have as they are: new
295
+ * since it was taken, changed since, or never recorded and not committed. Committed and unchanged
296
+ * ones are not changes.
297
+ */
298
+ export async function inputChanges(top, patterns, recorded) {
299
+ const known = new Map((recorded ?? []).map((f) => [f.path, f.blob]));
300
+ const out = [];
301
+ for (const f of await inputFilesInTree(top, patterns)) {
302
+ const now = await blobOfFile(top, f.path);
303
+ if (!now || known.get(f.path) === now)
304
+ continue;
305
+ if (!known.has(f.path) && f.kind === 'tracked' && (await git(top, ['rev-parse', '--verify', '--quiet', `HEAD:${f.path}`])).stdout === now)
306
+ continue;
307
+ out.push(f);
308
+ }
309
+ return out;
310
+ }
311
+ /**
312
+ * Commits input files that changed after the session started, on its line of work, and moves the
313
+ * working tree onto that commit without losing a byte: per session, on top of the session's branch;
314
+ * per task, on top of the commit every task branch is cut from, on a branch of its own that becomes
315
+ * that commit. The files' bytes go in as git would store them (`hash-object -w --path`), the commit
316
+ * is made with a temporary index, and the files are then staged as they are, so the switch onto the
317
+ * commit keeps them. Asked for by the operator's approval, or without it when `requireApproval` is
318
+ * false. Found in a run on 0.1.18: inputs edited or added once the session had started were "a dirty
319
+ * tree to commit or stash", version control went off, and the task ran without it.
320
+ */
321
+ export async function recaptureInputs(session, dir, paths, target) {
322
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
323
+ if (!topR.ok || !topR.stdout)
324
+ return { problem: `${dir} is not a git repository.` };
325
+ const top = resolve(topR.stdout);
326
+ const files = [];
327
+ for (const path of paths) {
328
+ const blob = await git(top, ['hash-object', '-w', `--path=${path}`, '--', join(top, path)]);
329
+ if (!blob.ok || !blob.stdout)
330
+ return { problem: `${path} could not be read into git: ${blob.stderr}` };
331
+ const mode = (await git(top, ['ls-tree', target.parent, '--', path])).stdout.split(' ')[0] || '100644';
332
+ files.push({ path, mode, blob: blob.stdout });
333
+ }
334
+ const message = [
335
+ 'Capture user-provided inputs',
336
+ '',
337
+ `The operator's input files for session "${session.name}" as they are now, changed or added after the session started.`,
338
+ 'They are inputs: the work reads them and does not change them.',
339
+ '',
340
+ ...paths.slice(0, 200).map((p) => `- ${p}`),
341
+ '',
342
+ 'Committed by copilot-operator. Not pushed.',
343
+ '',
344
+ ].join('\n');
345
+ const made = await commitOnto(top, target.parent, files, message, target.newBranch);
346
+ if ('problem' in made)
347
+ return { problem: `the input files could not be committed: ${made.problem}` };
348
+ const branch = target.ffBranch ?? target.newBranch;
349
+ if (target.ffBranch) {
350
+ // Forward only, from the tip it was read at: nothing on the branch is lost or rewritten.
351
+ const moved = await git(top, ['update-ref', `refs/heads/${target.ffBranch}`, made.commit, target.parent]);
352
+ if (!moved.ok)
353
+ return { problem: `${target.ffBranch} could not be moved onto the inputs' commit: ${moved.stderr}` };
354
+ }
355
+ const staged = await git(top, ['--literal-pathspecs', 'add', '-f', '--', ...paths]);
356
+ if (!staged.ok)
357
+ return { problem: `the input files could not be staged: ${staged.stderr}` };
358
+ const on = (await git(top, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout;
359
+ if (on !== branch) {
360
+ const moved = await git(top, ['checkout', '-q', branch]);
361
+ if (!moved.ok)
362
+ return { problem: `the repository could not be put on ${branch}: ${moved.stderr}` };
363
+ }
364
+ return { commit: made.commit, branch };
365
+ }
366
+ /** The input files as one commit has them, for the guard of a task cut from it. */
367
+ export async function inputsAtCommit(dir, commit, inputs) {
368
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
369
+ if (!topR.ok || !topR.stdout)
370
+ return inputs;
371
+ return { ...inputs, files: await inputSumsAt(resolve(topR.stdout), commit, inputs.patterns) };
372
+ }
373
+ /** The files under the input patterns that git does not track, when a task starts: not the task's to remove. */
374
+ export async function untrackedInputs(dir, patterns) {
375
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
376
+ if (!topR.ok || !topR.stdout)
377
+ return new Set();
378
+ return new Set((await inputFilesInTree(resolve(topR.stdout), patterns)).filter((f) => f.kind !== 'tracked').map((f) => f.path));
379
+ }
269
380
  /**
270
381
  * Puts the input files back as the task's starting commit has them: a changed or removed one from
271
382
  * that commit, a new one matching an input pattern removed. Only on the task's own branch, whose
272
383
  * starting commit has the inputs (see `settleInputs`).
273
384
  */
274
- export async function protectInputs(dir, base, inputs) {
385
+ export async function protectInputs(dir, base, inputs,
386
+ /**
387
+ * Untracked files under the patterns that were there when the task started: the operator's, not
388
+ * the task's, and never removed. Found in a run on 0.1.18: an input the operator added, ignored by
389
+ * .gitignore and so in no commit, was "put back" by deleting it.
390
+ */
391
+ keep = new Set()) {
275
392
  const out = { changed: [], restored: [], failed: [] };
276
393
  const topR = await git(dir, ['rev-parse', '--show-toplevel']);
277
394
  if (!topR.ok || !topR.stdout)
@@ -290,7 +407,7 @@ export async function protectInputs(dir, base, inputs) {
290
407
  }
291
408
  // A file the task added under an input pattern: inputs are the operator's, so it goes.
292
409
  for (const { path, kind } of await inputFilesInTree(top, inputs.patterns)) {
293
- if (known.has(path) || kind === 'tracked')
410
+ if (known.has(path) || kind === 'tracked' || keep.has(path))
294
411
  continue;
295
412
  out.changed.push(path);
296
413
  const abs = resolve(join(top, path));
@@ -27,8 +27,8 @@ import { tmpdir } from 'node:os';
27
27
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
28
28
  import { looksGenerated } from './commitHygiene.js';
29
29
  import { branchNameFrom, freeBranchName, git, gitBytes, isValidBranchName, RUNNER_EMAIL } from './git.js';
30
- import { repoDirOf, sessionStart } from './taskVcs.js';
31
- import { inputFilesInTree, inputSettings } from './inputs.js';
30
+ import { repoDirOf, sessionBranchName, sessionStart } from './taskVcs.js';
31
+ import { inputChanges, inputFilesInTree, inputSettings, inputSumsAt, missingInputPatterns, recaptureInputs } from './inputs.js';
32
32
  import { artifactPatterns } from './artifacts.js';
33
33
  import { inScope } from './scope.js';
34
34
  /** The session's dirty-tree policy, absent fields filled in: reject, and asking first. */
@@ -134,11 +134,15 @@ export async function planSnapshot(session, allSessions = async () => []) {
134
134
  const inputs = inputSettings(session.vcs);
135
135
  const repoDir = repoDirOf(session);
136
136
  const plan = { needed: false, ok: false, policy, requireApproval, repoDir, branch: null, head: null, entries: [] };
137
- if (!session.vcs?.enabled || (policy === 'reject' && !inputs) || session.vcsBaseCommit)
137
+ if (!session.vcs?.enabled || (policy === 'reject' && !inputs))
138
+ return plan;
139
+ if (session.vcsBaseCommit && (!inputs || session.vcs.startFrom === 'existing-branch'))
138
140
  return plan;
139
141
  const top = repoDir ? await topOf(repoDir) : null;
140
142
  if (!top)
141
143
  return { ...plan, problem: `${repoDir || 'The repository folder'} is not a git repository.` };
144
+ if (session.vcsBaseCommit)
145
+ return await planRecapture(session, top, plan);
142
146
  const status = await statusEntries(top);
143
147
  if (!status)
144
148
  return { ...plan, problem: 'git status could not be read.' };
@@ -208,7 +212,14 @@ export async function planSnapshot(session, allSessions = async () => []) {
208
212
  }
209
213
  }
210
214
  const blocked = plan.entries.filter((e) => e.allowed.length === 0);
211
- if (!plan.head)
215
+ // A pattern that matches nothing anywhere is said now, before anything is committed for approval.
216
+ const missing = inputs ? await missingInputPatterns(session, repoDir, top, allSessions) : [];
217
+ if (missing.length > 0) {
218
+ plan.problem =
219
+ `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file in the project, and nothing earlier captured them. ` +
220
+ 'Correct "Input files" (a "*" stays inside one folder; "**" crosses folders), or put the files in the project.';
221
+ }
222
+ else if (!plan.head)
212
223
  plan.problem = 'the repository has no commit yet, so there is nothing for a snapshot to sit on. Make the first commit yourself.';
213
224
  else if (blocked.length > 0)
214
225
  plan.problem = `${blocked.length} change(s) can be neither taken nor left out: ${blocked.map((e) => `${e.path} (${e.reason})`).join('; ')}.`;
@@ -224,6 +235,62 @@ export async function planSnapshot(session, allSessions = async () => []) {
224
235
  plan.ok = !plan.problem;
225
236
  return plan;
226
237
  }
238
+ /**
239
+ * After the session's start: the input files changed or added since it was recorded, to be taken
240
+ * onto its line of work (see `recaptureInputs`). Anything else uncommitted is in the way, as it is
241
+ * before a task, and refuses.
242
+ */
243
+ async function planRecapture(session, top, plan) {
244
+ const inputs = inputSettings(session.vcs);
245
+ if (!inputs || !session.vcsBaseCommit)
246
+ return plan;
247
+ const branchR = await git(top, ['rev-parse', '--abbrev-ref', 'HEAD']);
248
+ const headR = await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD']);
249
+ plan.branch = branchR.ok && branchR.stdout !== 'HEAD' ? branchR.stdout : null;
250
+ plan.head = headR.ok && headR.stdout ? headR.stdout : null;
251
+ const changes = await inputChanges(top, inputs.patterns, session.vcsStart?.inputs?.files);
252
+ if (changes.length === 0)
253
+ return plan;
254
+ plan.needed = true;
255
+ plan.requireApproval = inputs.requireApproval;
256
+ const project = session.projectDir?.trim() ? relative(top, resolve(session.projectDir.trim())).replace(/\\/g, '/') : '';
257
+ const inProject = (p) => !project || project.startsWith('..') || isAbsolute(project) || p.toLowerCase() === project.toLowerCase() || p.toLowerCase().startsWith(`${project.toLowerCase()}/`);
258
+ for (const c of changes) {
259
+ const blocked = looksGenerated(c.path)?.reason === 'a secrets file' ? 'a secrets file is never taken as an input' : !inProject(c.path) ? `outside the project folder ${project}` : undefined;
260
+ plan.entries.push({
261
+ path: c.path,
262
+ kind: c.kind,
263
+ input: true,
264
+ size: (await stat(join(top, c.path)).catch(() => null))?.size,
265
+ allowed: blocked ? [] : ['include'],
266
+ choice: blocked ? null : 'include',
267
+ reason: blocked ?? 'an input file changed or added after the session started: committed on its line of work, then read-only',
268
+ });
269
+ }
270
+ const changed = new Set(changes.map((c) => c.path));
271
+ const others = ((await statusEntries(top)) ?? []).filter((s) => !changed.has(s.path)).map((s) => s.path);
272
+ const blocked = plan.entries.filter((e) => e.allowed.length === 0);
273
+ if (others.length > 0) {
274
+ plan.problem = `besides the input files there are other uncommitted changes (${someOf(others)}). Commit or stash them yourself first.`;
275
+ }
276
+ else if (blocked.length > 0) {
277
+ plan.problem = `${blocked.length} file(s) cannot be taken as inputs: ${blocked.map((e) => `${e.path} (${e.reason})`).join('; ')}.`;
278
+ }
279
+ // Where they go: on the session's one branch when it has it, else on the commit tasks are cut from.
280
+ const sessionBranch = sessionBranchName(session);
281
+ const tip = session.vcs?.branchMode === 'per-session' ? (await git(top, ['rev-parse', '--verify', '--quiet', `refs/heads/${sessionBranch}^{commit}`])).stdout : '';
282
+ if (tip) {
283
+ plan.recapture = { parent: tip, ffBranch: sessionBranch };
284
+ plan.baselineBranch = sessionBranch;
285
+ }
286
+ else {
287
+ const newBranch = await freeBranchName(top, `${branchNameFrom(['input'], session.vcs?.branchPrefix || 'cop/')}/${session.id}`);
288
+ plan.recapture = { parent: session.vcsBaseCommit, newBranch };
289
+ plan.baselineBranch = newBranch;
290
+ }
291
+ plan.ok = !plan.problem;
292
+ return plan;
293
+ }
227
294
  /** A path as a pattern for `.git/info/exclude` that matches that one file and nothing else. */
228
295
  function excludePattern(path) {
229
296
  return `/${path.replace(/[\\*?[]/g, (c) => `\\${c}`).replace(/ $/, '\\ ')}`;
@@ -284,8 +351,52 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
284
351
  if (dirtyPolicy(session.vcs).policy === 'reject' && !inputSettings(session.vcs)) {
285
352
  return { ok: false, problem: 'this session does not take uncommitted changes as a snapshot. Choose that under Version control → "Uncommitted changes" first.' };
286
353
  }
287
- if (session.vcsBaseCommit)
288
- return { ok: false, problem: 'this session has already started; a snapshot is taken only before its first task.' };
354
+ if (session.vcsBaseCommit) {
355
+ // Started already: only input files changed or added since can be taken, onto its line of work.
356
+ const plan = await planSnapshot(session, allSessions);
357
+ if (!plan.needed || !plan.recapture) {
358
+ return { ok: false, problem: 'this session has already started, and no input file has changed since: a snapshot is taken only before its first task.' };
359
+ }
360
+ if (!plan.ok)
361
+ return { ok: false, problem: plan.problem ?? 'the input files cannot be taken.' };
362
+ const paths = plan.entries.map((e) => e.path);
363
+ if (opts.approved) {
364
+ const shown = Object.keys(opts.choices ?? {}).sort();
365
+ if (JSON.stringify(shown) !== JSON.stringify([...paths].sort()) || paths.some((p) => opts.choices?.[p] !== 'include')) {
366
+ return { ok: false, problem: 'the input files changed since the list was shown. Look at the list again; nothing was done.' };
367
+ }
368
+ }
369
+ const made = await recaptureInputs(session, plan.repoDir, paths, plan.recapture);
370
+ if ('problem' in made)
371
+ return { ok: false, problem: made.problem };
372
+ const settings = inputSettings(session.vcs);
373
+ const top = (await topOf(plan.repoDir));
374
+ const before = session.vcsStart;
375
+ const start = {
376
+ ...before,
377
+ inputs: {
378
+ patterns: settings?.patterns ?? [],
379
+ files: await inputSumsAt(top, made.commit, settings?.patterns ?? []),
380
+ readOnly: settings?.readOnly ?? true,
381
+ ...(before.inputs?.carried ? { carried: before.inputs.carried } : {}),
382
+ recaptured: [...(before.inputs?.recaptured ?? []), { commit: made.commit, branch: made.branch, paths: paths.slice(0, 200), approved: opts.approved }],
383
+ },
384
+ };
385
+ await saveSession((s) => {
386
+ s.vcsStart = start;
387
+ // A commit tasks are cut from moves to the inputs' commit; a session branch moved forward itself.
388
+ if (plan.recapture?.newBranch)
389
+ s.vcsBaseCommit = made.commit;
390
+ });
391
+ bus.publish({
392
+ sessionId: session.id,
393
+ type: 'vcs-inputs-recaptured',
394
+ level: 'info',
395
+ message: `${paths.length} input file(s) changed or added since the session started are now the commit ${made.commit.slice(0, 8)} on ${made.branch}; the next task starts from it`,
396
+ data: { commit: made.commit, branch: made.branch, paths: paths.slice(0, 50) },
397
+ });
398
+ return { ok: true, start };
399
+ }
289
400
  const plan = await planSnapshot(session, allSessions);
290
401
  if (!plan.needed)
291
402
  return { ok: false, problem: 'there is nothing uncommitted to take.' };
@@ -31,6 +31,23 @@ export function trackedRepoOf(session) {
31
31
  export async function prepareForTask(session, task, bus, saveSession,
32
32
  /** Every session on record, for `startFrom: previous-session` to find the one before this. */
33
33
  allSessions = async () => []) {
34
+ const prepared = await prepare(session, task, bus, saveSession, allSessions);
35
+ /*
36
+ * Version control that is on but cannot do its part refuses the task, whatever the cause — a dirty
37
+ * tree, a starting branch that is not there, a branch git would not make. It used to be "on but
38
+ * inactive": the task ran, nothing was committed, and the task still ended done with its work
39
+ * loose in the tree. A task that cannot be committed is not run.
40
+ */
41
+ // Not when the operator chose no commits (`commitOnFinish` false): earlier tasks' files are then
42
+ // meant to stay in the tree, and refusing would stop every task after the first.
43
+ if (session.vcs?.enabled && session.vcs.commitOnFinish !== false && !prepared.refuse && !prepared.vcs.branch && prepared.vcs.problem) {
44
+ const why = `version control is on and cannot work for this task: ${prepared.vcs.problem.replace(/\.?\s*$/, '.')} Nothing was run.`;
45
+ bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-refused', level: 'error', message: why });
46
+ return { ...prepared, refuse: why };
47
+ }
48
+ return prepared;
49
+ }
50
+ async function prepare(session, task, bus, saveSession, allSessions) {
34
51
  const settings = session.vcs;
35
52
  if (!settings?.enabled)
36
53
  return { vcs: {}, note: '' };
@@ -93,15 +110,40 @@ allSessions = async () => []) {
93
110
  state = await repoState(dir);
94
111
  }
95
112
  }
113
+ /*
114
+ * Started already, and input files changed or were added since: taken onto the session's line of
115
+ * work (see `recaptureInputs`), with the operator's approval unless they said otherwise. They used
116
+ * to be "a dirty tree to commit or stash": version control went off and the task ran without it.
117
+ */
118
+ if (base && inputs) {
119
+ const plan = await planSnapshot(session, allSessions);
120
+ if (plan.needed && plan.recapture) {
121
+ if (!plan.ok)
122
+ return refuse(`the input files changed since the session started cannot be taken: ${plan.problem} Nothing was run.`);
123
+ if (plan.requireApproval) {
124
+ return refuse(`input files changed or were added since the session started (${someOf(plan.entries.map((e) => e.path))}). Approve them on the session's page, ` +
125
+ 'Version control → "Uncommitted changes", and they are committed on its line of work before the next task. Nothing was run.');
126
+ }
127
+ const taken = await takeSnapshot(session, { approved: false }, bus, saveSession, allSessions);
128
+ if (!taken.ok)
129
+ return refuse(`the changed input files were not taken: ${taken.problem} Nothing was run.`);
130
+ start = taken.start;
131
+ if (plan.recapture.newBranch)
132
+ base = taken.start.inputs?.recaptured?.at(-1)?.commit ?? base;
133
+ state = await repoState(dir);
134
+ }
135
+ }
96
136
  // A dirty tree is the operator's own work in progress. Committing it under the bot's name
97
137
  // or moving it to another branch would both be decisions that are not ours to make — unless
98
138
  // the operator made it, with a snapshot policy, above.
99
139
  if (state.dirty) {
100
140
  const problem = `the repository has uncommitted changes (${state.changed.slice(0, 5).join(', ')}` +
101
141
  `${state.changed.length > 5 ? `, and ${state.changed.length - 5} more` : ''}). ` +
102
- 'Commit or stash them, so the task starts from a known state.';
142
+ (base
143
+ ? 'Commit or stash them, so the task starts from a known state.'
144
+ : 'Commit or stash them, or choose "Take them as a starting snapshot" under Version control → "Uncommitted changes" (input files: "Input files").');
103
145
  bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-dirty', level: 'warn',
104
- message: `version control is on but inactive: ${problem}` });
146
+ message: `version control cannot start: ${problem}` });
105
147
  return { vcs: { problem }, note: '' };
106
148
  }
107
149
  if (!base) {
@@ -274,20 +316,63 @@ export async function sessionStart(session, dir, head, allSessions) {
274
316
  };
275
317
  if (how === 'branch')
276
318
  return await fromBranch();
319
+ /*
320
+ * The session before this one: the one that ran last in this repository, however it ended — not the
321
+ * last one that happened to commit, which skipped a predecessor that had failed or committed
322
+ * nothing and continued from older work as if it were the latest. A session set aside does not count.
323
+ */
277
324
  const here = normalise(dir);
278
- let best = null;
325
+ let pred = null;
279
326
  for (const other of await allSessions()) {
280
- if (other.id === session.id || !other.vcs?.enabled || normalise(repoDirOf(other)) !== here)
327
+ if (other.id === session.id || !other.vcs?.enabled || other.active === false || normalise(repoDirOf(other)) !== here)
281
328
  continue;
282
- const last = lastWorkOf(other);
283
- if (last && (!best || last.at > best.at))
284
- best = { session: other, ...last };
329
+ const at = lastActivityOf(other);
330
+ if (at && (!pred || at > pred.at))
331
+ pred = { session: other, at };
332
+ }
333
+ if (!pred)
334
+ return await fromBranch(`no earlier session has run in this repository, so it starts from "${baseBranch}"`);
335
+ const before = pred.session;
336
+ /*
337
+ * Checked before anything is carried on: the previous session finished, and what it produced is
338
+ * there — a final commit on a branch that still exists, or a result that is artifacts only (or no
339
+ * change at all), which ends where it started. Anything else is refused with what is wrong; it used
340
+ * to fall back to the base branch with a note, and the chain went on without the work it needed.
341
+ */
342
+ const unfinished = before.tasks.filter((t) => t.status !== 'done');
343
+ if (unfinished.length > 0) {
344
+ return {
345
+ problem: `this session continues the previous session in this repository, "${before.name}", which has not finished: ` +
346
+ `${unfinished.slice(0, 6).map((t) => `"${t.title}" is ${t.status}`).join(', ')}${unfinished.length > 6 ? `, and ${unfinished.length - 6} more` : ''}. ` +
347
+ 'Finish it first (Continue or Run again), or choose another "Start from".',
348
+ };
349
+ }
350
+ const work = lastWorkOf(before);
351
+ if (!work) {
352
+ const startedAt = before.vcsBaseCommit;
353
+ if (!startedAt || !(await git(dir, ['cat-file', '-e', `${startedAt}^{commit}`])).ok) {
354
+ return { problem: `the previous session "${before.name}" committed nothing, and the commit it started from is not in the repository, so there is nothing to carry on from.` };
355
+ }
356
+ const artifactsOnly = before.tasks.some((t) => (t.artifactsKept?.length ?? 0) > 0);
357
+ return {
358
+ start: {
359
+ kind: 'previous-session',
360
+ commit: startedAt,
361
+ ...(before.vcsStart?.branch ? { branch: before.vcsStart.branch } : {}),
362
+ fromSession: { id: before.id, name: before.name },
363
+ note: artifactsOnly
364
+ ? `"${before.name}" committed nothing — its result is artifacts only — so this session starts where it started (${startedAt.slice(0, 8)})`
365
+ : `"${before.name}" changed nothing, so this session starts where it started (${startedAt.slice(0, 8)})`,
366
+ },
367
+ };
285
368
  }
286
- if (!best)
287
- return await fromBranch(`no earlier session has committed work in this repository, so it starts from "${baseBranch}"`);
369
+ const best = { session: before, ...work };
288
370
  const tip = await branchTip(dir, best.branch);
289
371
  if (!tip) {
290
- return await fromBranch(`the branch of the previous session "${best.session.name}" (${best.branch}) is no longer in the repository, so it starts from "${baseBranch}"`);
372
+ return {
373
+ problem: `the branch of the previous session "${best.session.name}" (${best.branch}) is no longer in the repository, so its final commit cannot be carried on. ` +
374
+ 'Restore the branch, or choose another "Start from".',
375
+ };
291
376
  }
292
377
  /*
293
378
  * The whole of that session's done work, or nothing. In per-task mode every task has a branch of
@@ -333,6 +418,17 @@ function lastWorkOf(session) {
333
418
  }
334
419
  return best;
335
420
  }
421
+ /** When a session last did anything: the latest start or end of any of its tasks' attempts. */
422
+ function lastActivityOf(session) {
423
+ let at = null;
424
+ for (const t of session.tasks) {
425
+ for (const v of [t.finishedAt, t.startedAt, ...(t.attempts ?? []).flatMap((a) => [a.finishedAt, a.startedAt])]) {
426
+ if (v && (!at || v > at))
427
+ at = v;
428
+ }
429
+ }
430
+ return at;
431
+ }
336
432
  async function branchTip(dir, branch) {
337
433
  if (!(await isValidBranchName(dir, branch)))
338
434
  return null;
@@ -617,6 +713,30 @@ export async function commitTaskResult(session, task, outcome, bus) {
617
713
  ...(foreign.length > 0 ? { foreignCommits: foreign } : {}),
618
714
  });
619
715
  }
716
+ /**
717
+ * Why a task's work is not committed although version control is on and commits it, or undefined
718
+ * when it is (or there was nothing to commit). Read after `commitTaskResult`; a task that would end
719
+ * done ends failed with this instead. See `finish` in the runner.
720
+ */
721
+ export async function commitShortfall(session, before, after, error) {
722
+ if (!session.vcs?.enabled || !session.vcs.commitOnFinish)
723
+ return undefined;
724
+ if (error)
725
+ return `version control failed after the task, so its work was not committed: ${error}. The changes are left in the working tree.`;
726
+ if (!after?.branch) {
727
+ // Never on a branch of its own. The start refuses that now; whatever got through, it is said.
728
+ const state = await repoState(repoDirOf(session));
729
+ return state.isRepo && state.dirty
730
+ ? `version control is on but was not active for this task (${after?.problem ?? before?.problem ?? 'no branch was made'}), so its changes were not committed: ${someOf(state.changed)}. They are left in the working tree.`
731
+ : undefined;
732
+ }
733
+ if (after.problem)
734
+ return `the runner could not commit the task's work: ${after.problem}`;
735
+ if (after.afterCommit && !after.afterCommit.clean) {
736
+ return `after the runner's commit the working tree still has uncommitted changes (${someOf(after.afterCommit.changed)}), so not all of the task's work is on ${after.branch}.`;
737
+ }
738
+ return undefined;
739
+ }
620
740
  /**
621
741
  * Commits what a task left behind when the process running it ended before it could.
622
742
  *
@@ -810,7 +930,7 @@ export async function vcsPreflight(session, allSessions = async () => []) {
810
930
  * file. Input files are looked for even in a clean tree — an ignored input does not make it dirty.
811
931
  */
812
932
  const inputs = !!inputSettings(settings);
813
- if ((dirty || inputs) && !session.vcsBaseCommit && (dirtyPolicy(settings).policy !== 'reject' || inputs) && settings.startFrom !== 'existing-branch') {
933
+ if ((dirty || inputs) && (!session.vcsBaseCommit || inputs) && (dirtyPolicy(settings).policy !== 'reject' || inputs) && settings.startFrom !== 'existing-branch') {
814
934
  const snapshot = await planSnapshot(session, allSessions);
815
935
  if (snapshot.needed) {
816
936
  const listed = snapshot.entries.map((e) => e.path);
@@ -818,9 +938,13 @@ export async function vcsPreflight(session, allSessions = async () => []) {
818
938
  ok: false,
819
939
  repoDir,
820
940
  branch: state.branch ?? undefined,
821
- problem: snapshot.ok
822
- ? `There are uncommitted changes (${someOf(listed, 3)}). They become the session's starting snapshot ${snapshot.requireApproval ? 'once you approve the list below' : 'when its first task starts'}.`
823
- : `There are uncommitted changes, and no starting snapshot can be taken: ${snapshot.problem}`,
941
+ problem: snapshot.recapture
942
+ ? snapshot.ok
943
+ ? `Input files changed or were added since the session started (${someOf(listed, 3)}). They are committed on its line of work ${snapshot.requireApproval ? 'once you approve the list below' : 'when its next task starts'}.`
944
+ : `Input files changed since the session started, and they cannot be taken: ${snapshot.problem}`
945
+ : snapshot.ok
946
+ ? `There are uncommitted changes (${someOf(listed, 3)}). They become the session's starting snapshot ${snapshot.requireApproval ? 'once you approve the list below' : 'when its first task starts'}.`
947
+ : `There are uncommitted changes, and no starting snapshot can be taken: ${snapshot.problem}`,
824
948
  snapshot,
825
949
  };
826
950
  }