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.
- package/dist/src/api/operator.service.js +10 -0
- package/dist/src/cli.js +11 -29
- package/dist/src/config/version.js +30 -0
- package/dist/src/orchestrator/taskRunner.js +56 -9
- package/dist/src/plan/brief.js +16 -8
- package/dist/src/plan/importPlan.js +14 -0
- package/dist/src/plan/schema.js +15 -0
- package/dist/src/plan/systemGuide.js +10 -2
- package/dist/src/session/exports.js +9 -0
- package/dist/src/session/store.js +2 -0
- package/dist/src/vcs/artifacts.js +56 -9
- package/dist/src/vcs/inputs.js +145 -28
- package/dist/src/vcs/snapshot.js +117 -6
- package/dist/src/vcs/taskVcs.js +138 -14
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +25 -26
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +25 -26
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +25 -26
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +25 -26
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +25 -26
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +25 -26
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +25 -26
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +25 -26
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +25 -26
- package/package.json +4 -3
- package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +0 -1
- package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +0 -1
- package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
- /package/dist/web/_next/static/{JPmlfAEvbNkbghoyvY9uC → ofnRLEgWx9th5w_qtuFnB}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{JPmlfAEvbNkbghoyvY9uC → ofnRLEgWx9th5w_qtuFnB}/_ssgManifest.js +0 -0
package/dist/src/vcs/inputs.js
CHANGED
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
200
|
+
for (const pattern of patterns) {
|
|
212
201
|
let found = atHead.filter((f) => inScope(f.path, [pattern]));
|
|
213
202
|
let from = head;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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));
|
package/dist/src/vcs/snapshot.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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.' };
|
package/dist/src/vcs/taskVcs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
283
|
-
if (
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
822
|
-
?
|
|
823
|
-
|
|
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
|
}
|