copilot-operator 0.1.38 → 0.1.40
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 +128 -2
- package/dist/src/api/operator.service.js +473 -39
- package/dist/src/api/server.js +7 -0
- package/dist/src/log/terminal.js +145 -0
- package/dist/src/orchestrator/contract.js +77 -6
- package/dist/src/plan/brief.js +1375 -1369
- package/dist/src/plan/schema.js +5 -0
- package/dist/src/plan/systemGuide.js +218 -20
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/startFailure.js +91 -0
- package/dist/src/vcs/prepareFromRemote.js +7 -20
- package/dist/src/vcs/remoteAuth.js +26 -1
- package/dist/src/vcs/runPreflight.js +31 -14
- package/dist/src/vcs/snapshot.js +3 -1
- package/dist/src/vcs/startChoice.js +295 -0
- package/dist/src/vcs/taskVcs.js +61 -5
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
- package/dist/web/_next/static/chunks/{549-7e35e4fdefadb05d.js → 549-f295c8429e83b25b.js} +1 -1
- package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
- package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
- package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
- package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
- package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +28 -26
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +28 -26
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +28 -26
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +28 -26
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +28 -26
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +28 -26
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +28 -26
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +28 -26
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +28 -26
- package/package.json +158 -156
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +0 -1
- package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-ce99494b1997ee25.js +0 -1
- package/dist/web/_next/static/css/c37978b44194c37b.css +0 -1
- /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_ssgManifest.js +0 -0
|
@@ -27,7 +27,7 @@ import { join, resolve } from 'node:path';
|
|
|
27
27
|
import { branchNameFrom, describeUpdate, freeBranchName, git, updateFromRemote } from './git.js';
|
|
28
28
|
import { commitOnto, dirtyBesidesInputs, inputSettings, inputSumsAt, missingInputPatterns } from './inputs.js';
|
|
29
29
|
import { approvalMismatch, dirtyPolicy, takeSnapshot } from './snapshot.js';
|
|
30
|
-
import { repoDirOf, sessionBranchName, vcsPreflight } from './taskVcs.js';
|
|
30
|
+
import { baseRefOf, repoDirOf, sessionBranchName, vcsPreflight } from './taskVcs.js';
|
|
31
31
|
import { artifactPatterns } from './artifacts.js';
|
|
32
32
|
import { inScope } from './scope.js';
|
|
33
33
|
import { findSuspicious } from './commitHygiene.js';
|
|
@@ -58,8 +58,15 @@ async function groupFor(list, allSessions) {
|
|
|
58
58
|
const branch = branchR.ok && branchR.stdout !== 'HEAD' ? branchR.stdout : null;
|
|
59
59
|
const head = headR.ok && headR.stdout ? headR.stdout : null;
|
|
60
60
|
const startFrom = first.vcs?.startFrom ?? 'head';
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/*
|
|
62
|
+
* The branch the sessions start from, by its name on screen (`origin/x` for a remote branch chosen in
|
|
63
|
+
* the start window) and the ref git reads for it: a remote-tracking branch is never the local one of
|
|
64
|
+
* the same name, and is not brought up to anything here — the start window fetched it.
|
|
65
|
+
*/
|
|
66
|
+
const base = startFrom === 'branch' || startFrom === 'previous-session' ? baseRefOf(first.vcs) : undefined;
|
|
67
|
+
const baseBranch = base?.name;
|
|
68
|
+
const baseHead = base ? (await git(repoDir, ['rev-parse', '--verify', '--quiet', `${base.ref}^{commit}`])).stdout || null : undefined;
|
|
69
|
+
const updates = first.vcs?.updateFromRemote !== false && !base?.remote;
|
|
63
70
|
const settings = inputSettings(first.vcs);
|
|
64
71
|
const workName = first.vcs?.startFrom === 'existing-branch'
|
|
65
72
|
? first.vcs.existingBranch?.trim()
|
|
@@ -69,7 +76,7 @@ async function groupFor(list, allSessions) {
|
|
|
69
76
|
const workBranch = workName ? { name: workName, head: (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${workName}^{commit}`])).stdout || null } : undefined;
|
|
70
77
|
const sessions = list.map((s, i) => {
|
|
71
78
|
const sf = s.vcs?.startFrom ?? 'head';
|
|
72
|
-
const role = i === 0 ? 'first' : sf === 'previous-session' ? 'inherits' : sf === 'branch' && (s.vcs
|
|
79
|
+
const role = i === 0 ? 'first' : sf === 'previous-session' ? 'inherits' : sf === 'branch' && baseRefOf(s.vcs).name === baseBranch && !s.vcsBaseCommit ? 'shares' : 'own';
|
|
73
80
|
// Started means a task of it ran; a start recorded by the panel (a snapshot) is not that (live run 2026-10-03).
|
|
74
81
|
const started = s.tasks.some((t) => !!t.startedAt || (t.attempts ?? []).some((a) => !!a.startedAt));
|
|
75
82
|
return { id: s.id, name: s.name, startFrom: sf, started, hasStart: !!s.vcsBaseCommit, role };
|
|
@@ -111,16 +118,21 @@ async function groupFor(list, allSessions) {
|
|
|
111
118
|
* The branches the sessions name, before the browser opens rather than at their first task: a
|
|
112
119
|
* branch to carry on that is not there, a base branch that is not there.
|
|
113
120
|
*/
|
|
121
|
+
const otherThanStart = (!!problem && !pre.startProblem) || blocking;
|
|
122
|
+
let branchMissing = false;
|
|
114
123
|
for (const s of list) {
|
|
115
124
|
if (s.vcsBaseCommit)
|
|
116
125
|
continue;
|
|
117
|
-
const named = s.vcs?.startFrom === 'existing-branch' ? s.vcs.existingBranch?.trim() : s.vcs?.startFrom === 'branch' ? s.vcs
|
|
118
|
-
if (!named)
|
|
126
|
+
const named = s.vcs?.startFrom === 'existing-branch' ? { ref: `refs/heads/${s.vcs.existingBranch?.trim() ?? ''}`, name: s.vcs.existingBranch?.trim() ?? '', remote: undefined } : s.vcs?.startFrom === 'branch' ? baseRefOf(s.vcs) : null;
|
|
127
|
+
if (!named?.name)
|
|
119
128
|
continue;
|
|
120
|
-
const there = (await git(repoDir, ['rev-parse', '--verify', '--quiet',
|
|
129
|
+
const there = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `${named.ref}^{commit}`])).stdout;
|
|
121
130
|
if (!there) {
|
|
122
131
|
blocking = true;
|
|
123
|
-
|
|
132
|
+
branchMissing = true;
|
|
133
|
+
problem =
|
|
134
|
+
`${problem ? `${problem} ` : ''}"${s.name}" is set to ${s.vcs?.startFrom === 'existing-branch' ? 'carry on' : 'start from'} the ${named.remote ? 'remote' : 'local'} branch "${named.name}", and there is no such branch` +
|
|
135
|
+
(named.remote ? ' here: start the run again and choose it in the start window, which fetches it.' : ': create it, or choose another branch when you start the run.');
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
138
|
const noCommits = first.vcs?.commitOnFinish === false;
|
|
@@ -136,7 +148,7 @@ async function groupFor(list, allSessions) {
|
|
|
136
148
|
}
|
|
137
149
|
if (baseBranch && !first.vcsBaseCommit && inputs.length > 0) {
|
|
138
150
|
const why = !baseHead
|
|
139
|
-
? `there is no local branch "${baseBranch}"`
|
|
151
|
+
? `there is no ${base?.remote ? 'fetched copy of the remote' : 'local'} branch "${baseBranch}"`
|
|
140
152
|
: unrelated.length > 0
|
|
141
153
|
? `besides the input files there are other uncommitted changes (${unrelated.slice(0, 5).map((u) => u.path).join(', ')}${unrelated.length > 5 ? '…' : ''}): commit or stash them first${branch !== baseBranch ? ', or use the current branch instead' : ', or take them as a starting snapshot'}`
|
|
142
154
|
: blockedInputs.length > 0
|
|
@@ -151,11 +163,11 @@ async function groupFor(list, allSessions) {
|
|
|
151
163
|
recommended: !why,
|
|
152
164
|
...(why ? { why } : {}),
|
|
153
165
|
// The update from the remote said first: it moves the base branch, and the checkout with it when that is the branch checked out (live run 2026-10-04).
|
|
154
|
-
result: (
|
|
166
|
+
result: (updates
|
|
155
167
|
? `First brings "${baseBranch}" up to its remote, fast-forward only${branch === baseBranch ? ', which moves your checkout with it' : ''}; then commits`
|
|
156
168
|
: 'Commits') +
|
|
157
|
-
` the ${inputs.length} input file(s) on top of "${baseBranch}" (${short(baseHead)}${
|
|
158
|
-
`without switching branches: your checkout${branch ? ` of "${branch}"` : ''} and the files stay as they are${
|
|
169
|
+
` the ${inputs.length} input file(s) on top of "${baseBranch}" (${short(baseHead)}${updates ? ' now, or where the update takes it' : ''}) as "Capture user-provided inputs", on the new branch ${baselineName}, ` +
|
|
170
|
+
`without switching branches: your checkout${branch ? ` of "${branch}"` : ''} and the files stay as they are${updates && branch === baseBranch ? ', apart from that update' : ''}. ` +
|
|
159
171
|
`${names(sharing)} ${verb(sharing, 'starts', 'start')} from it${inheriting.length > 0 ? `; ${names(inheriting)} ${verb(inheriting, 'carries', 'carry')} it on through the chain` : ''}.`,
|
|
160
172
|
});
|
|
161
173
|
}
|
|
@@ -218,6 +230,7 @@ async function groupFor(list, allSessions) {
|
|
|
218
230
|
head,
|
|
219
231
|
...(baseBranch ? { baseBranch, baseHead } : {}),
|
|
220
232
|
...(workBranch && workBranch.name !== branch ? { workBranch } : {}),
|
|
233
|
+
...(!ready && !otherThanStart && (branchMissing || pre.startProblem) ? { startChoiceCanFix: true } : {}),
|
|
221
234
|
...(settings && inputs.length === 0 && !first.vcsBaseCommit && !blocking
|
|
222
235
|
? { carry: `the input files (${settings.patterns.join(', ')}) are not in the working tree: when the first task starts they are committed on top of its start as ${prefix}input/${first.id}, from the latest earlier capture of them` }
|
|
223
236
|
: {}),
|
|
@@ -257,6 +270,8 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
|
|
|
257
270
|
if (x.vcs) {
|
|
258
271
|
x.vcs.startFrom = 'branch';
|
|
259
272
|
x.vcs.baseBranch = group.branch ?? undefined;
|
|
273
|
+
// The branch the repository is on is a local one.
|
|
274
|
+
delete x.vcs.baseRemote;
|
|
260
275
|
}
|
|
261
276
|
x.vcsBaseCommit = undefined;
|
|
262
277
|
x.vcsStart = undefined;
|
|
@@ -289,8 +304,10 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
|
|
|
289
304
|
* branch does: the snapshot is the commit every session here starts from, and it skipped the update
|
|
290
305
|
* without a word, so a run cut its work from a main the team had already moved past (live run 2026-10-03).
|
|
291
306
|
*/
|
|
292
|
-
|
|
293
|
-
const
|
|
307
|
+
// Not a remote branch chosen in the start window: that was fetched there, and has no local branch to move.
|
|
308
|
+
const baseRef = baseRefOf(first.vcs);
|
|
309
|
+
const update = first.vcs?.updateFromRemote !== false && !baseRef.remote ? await updateFromRemote(top, group.baseBranch) : undefined;
|
|
310
|
+
const baseHead = (await git(top, ['rev-parse', '--verify', '--quiet', `${baseRef.ref}^{commit}`])).stdout || group.baseHead;
|
|
294
311
|
const files = [];
|
|
295
312
|
for (const path of paths) {
|
|
296
313
|
const blob = await git(top, ['hash-object', '-w', `--path=${path}`, '--', join(top, path)]);
|
package/dist/src/vcs/snapshot.js
CHANGED
|
@@ -119,7 +119,9 @@ async function startProblem(session, dir, branch, head, allSessions) {
|
|
|
119
119
|
const commit = resolved.start?.commit;
|
|
120
120
|
if (!commit || commit === head)
|
|
121
121
|
return undefined;
|
|
122
|
-
const wanted = resolved.start?.kind === 'previous-session'
|
|
122
|
+
const wanted = resolved.start?.kind === 'previous-session'
|
|
123
|
+
? `the end of "${resolved.start.fromSession?.name ?? ''}"'s branch ${resolved.start.branch}`
|
|
124
|
+
: `the ${resolved.start?.remote ? 'remote' : 'local'} branch ${resolved.start?.branch ?? ''}`;
|
|
123
125
|
return (`the changes are on ${where}, and this session is set to start from ${wanted} (${commit.slice(0, 8)}). ` +
|
|
124
126
|
'A snapshot is taken where the changes are, so the session would not start where it was told to. ' +
|
|
125
127
|
`Put the repository on ${resolved.start?.branch ?? 'that branch'} first, or choose "From wherever the repository is (as until now)".`);
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { git, isAncestor, isValidBranchName, lastFetchedAt } from './git.js';
|
|
2
|
+
import { fetchAsOperator, fetchCommandFor } from './remoteAuth.js';
|
|
3
|
+
import { describeStart, remoteTip, repoDirOf } from './taskVcs.js';
|
|
4
|
+
const lines = (s) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
5
|
+
const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
|
|
6
|
+
/** A task of the session has run: a start recorded since then is the one its work stands on. */
|
|
7
|
+
export function startIsFixed(session) {
|
|
8
|
+
const ran = session.tasks.some((t) => !!t.startedAt || (t.attempts ?? []).some((a) => !!a.startedAt));
|
|
9
|
+
return !!session.vcsBaseCommit && ran;
|
|
10
|
+
}
|
|
11
|
+
/** The branches of the repository at `dir`, read without touching the network. */
|
|
12
|
+
export async function repoBranches(dir) {
|
|
13
|
+
const empty = (problem) => ({ repoDir: dir, problem, current: null, head: null, local: [], remotes: [] });
|
|
14
|
+
const inside = await git(dir, ['rev-parse', '--is-inside-work-tree']);
|
|
15
|
+
if (!inside.ok || inside.stdout !== 'true')
|
|
16
|
+
return empty(`${dir} is not a git repository.`);
|
|
17
|
+
const current = await git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
18
|
+
const head = await git(dir, ['rev-parse', '--verify', '--quiet', 'HEAD']);
|
|
19
|
+
const refs = await git(dir, ['for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes']);
|
|
20
|
+
const remoteNames = lines((await git(dir, ['remote'])).stdout);
|
|
21
|
+
const local = [];
|
|
22
|
+
const byRemote = new Map(remoteNames.map((r) => [r, []]));
|
|
23
|
+
for (const line of refs.ok ? lines(refs.stdout) : []) {
|
|
24
|
+
const [commit, ref] = [line.slice(0, line.indexOf(' ')), line.slice(line.indexOf(' ') + 1)];
|
|
25
|
+
if (ref.startsWith('refs/heads/'))
|
|
26
|
+
local.push({ name: ref.slice('refs/heads/'.length), commit });
|
|
27
|
+
else if (ref.startsWith('refs/remotes/')) {
|
|
28
|
+
const rest = ref.slice('refs/remotes/'.length);
|
|
29
|
+
const remote = remoteNames.find((r) => rest.startsWith(`${r}/`));
|
|
30
|
+
const name = remote ? rest.slice(remote.length + 1) : '';
|
|
31
|
+
// `origin/HEAD` points at a branch; it is not one.
|
|
32
|
+
if (!remote || !name || name === 'HEAD')
|
|
33
|
+
continue;
|
|
34
|
+
byRemote.get(remote)?.push({ name, commit });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const gitDir = (await git(dir, ['rev-parse', '--absolute-git-dir'])).stdout;
|
|
38
|
+
const fetched = gitDir ? lastFetchedAt(gitDir) : undefined;
|
|
39
|
+
return {
|
|
40
|
+
repoDir: dir,
|
|
41
|
+
current: current.ok && current.stdout ? current.stdout : null,
|
|
42
|
+
head: head.ok && head.stdout ? head.stdout : null,
|
|
43
|
+
local,
|
|
44
|
+
remotes: [...byRemote.entries()].map(([name, branches]) => ({ name, branches })),
|
|
45
|
+
...(fetched ? { lastFetched: fetched } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The remote a branch is most likely wanted from: `origin`, else the first one. */
|
|
49
|
+
export function defaultRemote(repo) {
|
|
50
|
+
return repo.remotes.find((r) => r.name === 'origin')?.name ?? repo.remotes[0]?.name;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* What the start window offers first for a session: its own setting, as one of the choices. A local
|
|
54
|
+
* branch that is not here is offered from the remote when the remote has it, else the branch the
|
|
55
|
+
* repository is on — a start from wherever it happens to be (`head`) is offered as that branch, which
|
|
56
|
+
* is what it would have been.
|
|
57
|
+
*/
|
|
58
|
+
export function defaultChoice(session, repo) {
|
|
59
|
+
const vcs = session.vcs;
|
|
60
|
+
const how = vcs?.startFrom ?? 'head';
|
|
61
|
+
if (how === 'previous-session')
|
|
62
|
+
return { from: 'previous-session' };
|
|
63
|
+
const onBranch = how === 'existing-branch';
|
|
64
|
+
// A branch the session names, as against one assumed (`main`, or wherever the repository is).
|
|
65
|
+
const named = (how === 'head' ? '' : (onBranch ? vcs?.existingBranch : vcs?.baseBranch)?.trim()) ?? '';
|
|
66
|
+
const wanted = named || (how === 'head' ? repo.current ?? '' : 'main');
|
|
67
|
+
const remote = vcs?.baseRemote?.trim();
|
|
68
|
+
const hasRemote = (r, b) => !!repo.remotes.find((x) => x.name === r)?.branches.some((x) => x.name === b);
|
|
69
|
+
if (remote && hasRemote(remote, wanted))
|
|
70
|
+
return { from: 'remote', remote, branch: wanted, ...(onBranch ? { onBranch } : {}) };
|
|
71
|
+
if (wanted && repo.local.some((b) => b.name === wanted))
|
|
72
|
+
return { from: 'local', branch: wanted, ...(onBranch ? { onBranch } : {}) };
|
|
73
|
+
const other = remote && repo.remotes.some((x) => x.name === remote) ? remote : defaultRemote(repo);
|
|
74
|
+
if (other && wanted && hasRemote(other, wanted))
|
|
75
|
+
return { from: 'remote', remote: other, branch: wanted, ...(onBranch ? { onBranch } : {}) };
|
|
76
|
+
/*
|
|
77
|
+
* Named, and neither here nor fetched yet — a branch a colleague pushed after the last fetch: offered from
|
|
78
|
+
* the remote all the same, which the window fetches by name. Offered as the branch the repository is on,
|
|
79
|
+
* it was started from that instead with one press, and the session's own branch was written over.
|
|
80
|
+
*/
|
|
81
|
+
if (named && other)
|
|
82
|
+
return { from: 'remote', remote: other, branch: named, ...(onBranch ? { onBranch } : {}) };
|
|
83
|
+
return { from: 'local', branch: repo.current ?? repo.local[0]?.name ?? wanted };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The sessions of a run as the start window shows them, in run order, with the branches of each
|
|
87
|
+
* repository they work in.
|
|
88
|
+
*/
|
|
89
|
+
export async function startOptions(sessions) {
|
|
90
|
+
const repos = new Map();
|
|
91
|
+
const out = [];
|
|
92
|
+
const seenRepos = new Set();
|
|
93
|
+
for (const s of sessions) {
|
|
94
|
+
const dir = s.vcs?.enabled ? repoDirOf(s) : '';
|
|
95
|
+
if (!dir) {
|
|
96
|
+
out.push({ id: s.id, name: s.name, vcs: false, previousOffered: false });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = norm(dir);
|
|
100
|
+
if (!repos.has(key))
|
|
101
|
+
repos.set(key, await repoBranches(dir));
|
|
102
|
+
const repo = repos.get(key);
|
|
103
|
+
const earlierHere = seenRepos.has(key);
|
|
104
|
+
seenRepos.add(key);
|
|
105
|
+
const entry = {
|
|
106
|
+
id: s.id,
|
|
107
|
+
name: s.name,
|
|
108
|
+
vcs: true,
|
|
109
|
+
repoDir: repo.repoDir,
|
|
110
|
+
previousOffered: s.vcs?.startFrom === 'previous-session' || earlierHere,
|
|
111
|
+
};
|
|
112
|
+
if (startIsFixed(s) && s.vcsStart)
|
|
113
|
+
entry.fixed = describeStart(s.vcsStart);
|
|
114
|
+
else if (startIsFixed(s))
|
|
115
|
+
entry.fixed = `it started from ${s.vcsBaseCommit?.slice(0, 8)}`;
|
|
116
|
+
else {
|
|
117
|
+
if (s.vcsStart)
|
|
118
|
+
entry.prepared = describeStart(s.vcsStart);
|
|
119
|
+
if (!repo.problem)
|
|
120
|
+
entry.choice = defaultChoice(s, repo);
|
|
121
|
+
}
|
|
122
|
+
out.push(entry);
|
|
123
|
+
}
|
|
124
|
+
return { repos: [...repos.values()], sessions: out };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Checks a choice against the repository and works out what it sets. Nothing is written to the session
|
|
128
|
+
* here; for `remote` with `onBranch` the local branch is made, or moved forward, since that is the
|
|
129
|
+
* choice — and only then, after everything else about it has been checked, and never with `dry`, which
|
|
130
|
+
* only says whether it could be done (every session of a run is checked so before any branch moves).
|
|
131
|
+
*/
|
|
132
|
+
export async function applyStartChoice(session, choice, opts = {}) {
|
|
133
|
+
// Nothing to choose without version control, or once the start is fixed: see `startIsFixed`.
|
|
134
|
+
if (!session.vcs?.enabled || startIsFixed(session))
|
|
135
|
+
return { ok: true };
|
|
136
|
+
const dir = repoDirOf(session);
|
|
137
|
+
const before = pointOf(session.vcs);
|
|
138
|
+
if (choice.from === 'previous-session') {
|
|
139
|
+
const vcs = { startFrom: 'previous-session' };
|
|
140
|
+
return { ok: true, applied: { vcs, clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit, said: `"${session.name}" carries on the session before it in this repository` } };
|
|
141
|
+
}
|
|
142
|
+
const name = choice.branch.trim();
|
|
143
|
+
if (!name || !(await isValidBranchName(dir, name)))
|
|
144
|
+
return { ok: false, problem: `"${choice.branch}" is not a branch name git accepts.` };
|
|
145
|
+
if (choice.from === 'local') {
|
|
146
|
+
const tip = (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}^{commit}`])).stdout;
|
|
147
|
+
if (!tip)
|
|
148
|
+
return { ok: false, problem: `${dir} has no local branch "${name}". Choose one it has, or the remote branch of that name.` };
|
|
149
|
+
const vcs = choice.onBranch
|
|
150
|
+
? { startFrom: 'existing-branch', existingBranch: name, baseRemote: undefined, updateFromRemote: false }
|
|
151
|
+
: { startFrom: 'branch', baseBranch: name, baseRemote: undefined, existingBranch: undefined, updateFromRemote: false };
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
applied: {
|
|
155
|
+
vcs,
|
|
156
|
+
clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit,
|
|
157
|
+
said: choice.onBranch
|
|
158
|
+
? `"${session.name}" works on the local branch ${name} itself (${tip.slice(0, 8)}), as it is here`
|
|
159
|
+
: `"${session.name}" starts from the local branch ${name} (${tip.slice(0, 8)}), as it is here`,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const remote = choice.remote.trim();
|
|
164
|
+
const remotes = lines((await git(dir, ['remote'])).stdout);
|
|
165
|
+
if (!remotes.includes(remote))
|
|
166
|
+
return { ok: false, problem: `${dir} has no remote called "${remote}"${remotes.length > 0 ? ` (it has: ${remotes.join(', ')})` : ''}.` };
|
|
167
|
+
const theirs = await remoteTip(dir, remote, name);
|
|
168
|
+
if (!theirs)
|
|
169
|
+
return { ok: false, problem: `${remote}/${name} is not known here: it was not fetched. Start again, and it is fetched first.` };
|
|
170
|
+
if (!choice.onBranch) {
|
|
171
|
+
const vcs = { startFrom: 'branch', baseBranch: name, baseRemote: remote, existingBranch: undefined, updateFromRemote: false };
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
applied: {
|
|
175
|
+
vcs,
|
|
176
|
+
clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit,
|
|
177
|
+
said: `"${session.name}" starts from the remote branch ${remote}/${name} (${theirs.slice(0, 8)}), as fetched now; the local branch ${name} is not touched`,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Work on the branch itself: the local branch made from the remote one, or brought up to it.
|
|
182
|
+
const ours = (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}^{commit}`])).stdout;
|
|
183
|
+
let note;
|
|
184
|
+
if (!ours) {
|
|
185
|
+
// By its full name: `origin/x` alone is looked up among tags and local branches first.
|
|
186
|
+
const made = opts.dry ? { ok: true, stdout: '', stderr: '' } : await git(dir, ['branch', '--track', name, `refs/remotes/${remote}/${name}`]);
|
|
187
|
+
if (!made.ok)
|
|
188
|
+
return { ok: false, problem: `the local branch ${name} could not be made from ${remote}/${name}: ${(made.stderr || made.stdout).split(/\r?\n/)[0]}` };
|
|
189
|
+
note = `made the local branch ${name} from ${remote}/${name} (${theirs.slice(0, 8)})`;
|
|
190
|
+
}
|
|
191
|
+
else if (ours === theirs) {
|
|
192
|
+
note = `the local branch ${name} is already where ${remote}/${name} is (${theirs.slice(0, 8)})`;
|
|
193
|
+
}
|
|
194
|
+
else if (await isAncestor(dir, ours, theirs)) {
|
|
195
|
+
const current = (await git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout;
|
|
196
|
+
if (current === name) {
|
|
197
|
+
const dirty = (await git(dir, ['status', '--porcelain', '--untracked-files=no'])).stdout.trim();
|
|
198
|
+
if (dirty)
|
|
199
|
+
return { ok: false, problem: `${name} is checked out with uncommitted changes, so it cannot be brought up to ${remote}/${name}. Commit or stash them first; nothing was changed.` };
|
|
200
|
+
}
|
|
201
|
+
const moved = opts.dry
|
|
202
|
+
? { ok: true, stdout: '', stderr: '' }
|
|
203
|
+
: current === name
|
|
204
|
+
? await git(dir, ['merge', '--ff-only', '--quiet', theirs])
|
|
205
|
+
: await git(dir, ['update-ref', '-m', `copilot-operator: brought up to ${remote}/${name} in the start window`, `refs/heads/${name}`, theirs, ours]);
|
|
206
|
+
if (!moved.ok)
|
|
207
|
+
return { ok: false, problem: `${name} could not be brought up to ${remote}/${name}: ${(moved.stderr || moved.stdout).split(/\r?\n/)[0]}. Nothing else was changed.` };
|
|
208
|
+
note = `brought the local branch ${name} up to ${remote}/${name}: ${ours.slice(0, 8)} → ${theirs.slice(0, 8)}, fast-forward`;
|
|
209
|
+
}
|
|
210
|
+
else if (await isAncestor(dir, theirs, ours)) {
|
|
211
|
+
note = `the local branch ${name} has commits ${remote}/${name} does not; it was left as it is (${ours.slice(0, 8)}) and the work goes on top of them`;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
problem: `the local branch ${name} (${ours.slice(0, 8)}) and ${remote}/${name} (${theirs.slice(0, 8)}) have each gone their own way, so working on it would mean choosing one of them. ` +
|
|
217
|
+
`Nothing was changed. Start from ${remote}/${name} on a new branch instead, or bring the two together yourself first.`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const vcs = { startFrom: 'existing-branch', existingBranch: name, baseRemote: remote, updateFromRemote: false };
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
applied: {
|
|
224
|
+
vcs,
|
|
225
|
+
clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit,
|
|
226
|
+
note,
|
|
227
|
+
said: `"${session.name}" works on the branch ${name} itself, from ${remote}/${name} as fetched now`,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Where a session's settings say it starts: the fields that decide the commit, nothing else. */
|
|
232
|
+
function pointOf(vcs) {
|
|
233
|
+
return JSON.stringify([vcs?.startFrom ?? 'head', vcs?.baseBranch?.trim() || 'main', vcs?.baseRemote?.trim() ?? '', vcs?.existingBranch?.trim() ?? '']);
|
|
234
|
+
}
|
|
235
|
+
function changed(before, after) {
|
|
236
|
+
return before !== pointOf(after);
|
|
237
|
+
}
|
|
238
|
+
/** The fields of an `AppliedChoice` written onto a session's version control: `undefined` removes one. */
|
|
239
|
+
export function withChoice(vcs, patch) {
|
|
240
|
+
const out = { ...vcs };
|
|
241
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
242
|
+
if (v === undefined)
|
|
243
|
+
delete out[k];
|
|
244
|
+
else
|
|
245
|
+
out[k] = v;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The refspecs that fetch exactly these branches of `remote` into their remote-tracking branches, or
|
|
251
|
+
* why one of the names cannot be fetched.
|
|
252
|
+
*/
|
|
253
|
+
export async function refspecsFor(dir, remote, branches) {
|
|
254
|
+
const refspecs = [];
|
|
255
|
+
for (const b of [...new Set(branches.map((x) => x.trim()).filter(Boolean))]) {
|
|
256
|
+
if (!(await isValidBranchName(dir, b)))
|
|
257
|
+
return { problem: `"${b}" is not a branch name git accepts.` };
|
|
258
|
+
refspecs.push(`+refs/heads/${b}:refs/remotes/${remote}/${b}`);
|
|
259
|
+
}
|
|
260
|
+
return { refspecs };
|
|
261
|
+
}
|
|
262
|
+
/** The branch names a failed fetch said the remote does not have. */
|
|
263
|
+
export function missingOnRemote(detail) {
|
|
264
|
+
return [...detail.matchAll(/couldn't find remote ref (?:refs\/heads\/)?(\S+)/gi)].map((m) => m[1].replace(/[.,;:]+$/, ''));
|
|
265
|
+
}
|
|
266
|
+
/** Where git keeps the repository's records, for `lastFetchedAt`. */
|
|
267
|
+
export async function gitDirOf(dir) {
|
|
268
|
+
const r = await git(dir, ['rev-parse', '--absolute-git-dir']);
|
|
269
|
+
return r.ok && r.stdout ? r.stdout : null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Fetches `branches` of `remote` — or the whole remote, pruned, when none are named — for the start
|
|
273
|
+
* window, where the operator is looking: through Git's password window when there is one (`askpass`).
|
|
274
|
+
* Only remote-tracking branches move; no local branch, no checkout.
|
|
275
|
+
*/
|
|
276
|
+
export async function fetchForStart(dir, remote, branches, askpass) {
|
|
277
|
+
const remotes = lines((await git(dir, ['remote'])).stdout);
|
|
278
|
+
if (!remotes.includes(remote))
|
|
279
|
+
return { ok: false, problem: `${dir} has no remote called "${remote}"${remotes.length > 0 ? ` (it has: ${remotes.join(', ')})` : ''}.`, missing: [] };
|
|
280
|
+
const specs = branches && branches.length > 0 ? await refspecsFor(dir, remote, branches) : { refspecs: [] };
|
|
281
|
+
if ('problem' in specs)
|
|
282
|
+
return { ok: false, problem: specs.problem, missing: [] };
|
|
283
|
+
const gitDir = await gitDirOf(dir);
|
|
284
|
+
// Read before fetching: a fetch that fails empties FETCH_HEAD, and with it the time of the last one that worked.
|
|
285
|
+
const lastBefore = gitDir ? lastFetchedAt(gitDir) : undefined;
|
|
286
|
+
const fetched = await fetchAsOperator(dir, remote, { askpass, refspecs: specs.refspecs });
|
|
287
|
+
const missing = fetched.ok ? [] : missingOnRemote(fetched.detail ?? '');
|
|
288
|
+
return {
|
|
289
|
+
ok: fetched.ok,
|
|
290
|
+
fetched,
|
|
291
|
+
missing,
|
|
292
|
+
...(fetched.ok ? {} : { fetchCommand: fetchCommandFor(dir, remote), ...(lastBefore ? { lastFetched: lastBefore } : {}) }),
|
|
293
|
+
repo: await repoBranches(dir),
|
|
294
|
+
};
|
|
295
|
+
}
|
package/dist/src/vcs/taskVcs.js
CHANGED
|
@@ -375,9 +375,25 @@ export async function sessionStart(session, dir, head, allSessions,
|
|
|
375
375
|
opts = {}) {
|
|
376
376
|
const how = session.vcs?.startFrom ?? 'head';
|
|
377
377
|
const baseBranch = session.vcs?.baseBranch?.trim() || 'main';
|
|
378
|
+
const baseRemote = session.vcs?.baseRemote?.trim() || '';
|
|
378
379
|
if (how === 'head')
|
|
379
380
|
return head ? { start: { kind: 'head', commit: head } } : {};
|
|
380
381
|
const fromBranch = async (note) => {
|
|
382
|
+
/*
|
|
383
|
+
* A remote branch chosen in the start window: the remote-tracking branch as that window fetched it,
|
|
384
|
+
* with the operator there to type a key's passphrase. Not fetched again here — a run's fetch may not
|
|
385
|
+
* ask anybody anything — and the local branch of that name is neither read nor moved.
|
|
386
|
+
*/
|
|
387
|
+
if (baseRemote) {
|
|
388
|
+
const tip = await remoteTip(dir, baseRemote, baseBranch);
|
|
389
|
+
if (!tip) {
|
|
390
|
+
return {
|
|
391
|
+
problem: `this session is set to start from the remote branch "${baseRemote}/${baseBranch}", and ${dir} has no copy of it. ` +
|
|
392
|
+
'Start the run again and choose it in the start window, which fetches it.',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { start: { kind: 'branch', commit: tip, branch: `${baseRemote}/${baseBranch}`, remote: baseRemote, ...(note ? { note } : {}) } };
|
|
396
|
+
}
|
|
381
397
|
// The code as it is on the server, not as this checkout last pulled it: see `updateFromRemote`.
|
|
382
398
|
const update = !opts.dry && session.vcs?.updateFromRemote !== false && (await branchTip(dir, baseBranch)) ? await updateFromRemote(dir, baseBranch) : undefined;
|
|
383
399
|
const tip = await branchTip(dir, baseBranch);
|
|
@@ -520,6 +536,22 @@ async function branchTip(dir, branch) {
|
|
|
520
536
|
const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
|
|
521
537
|
return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
|
|
522
538
|
}
|
|
539
|
+
/** Where `<remote>/<branch>` was when it was last fetched here, or null when it never was. */
|
|
540
|
+
export async function remoteTip(dir, remote, branch) {
|
|
541
|
+
if (!/^[\w.-]+$/.test(remote) || !(await isValidBranchName(dir, branch)))
|
|
542
|
+
return null;
|
|
543
|
+
const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}^{commit}`]);
|
|
544
|
+
return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The ref a session's `startFrom: branch` reads, and the name it is shown by: `refs/remotes/origin/x`
|
|
548
|
+
* as `origin/x` for a remote branch chosen in the start window, else `refs/heads/<baseBranch>`.
|
|
549
|
+
*/
|
|
550
|
+
export function baseRefOf(vcs) {
|
|
551
|
+
const branch = vcs?.baseBranch?.trim() || 'main';
|
|
552
|
+
const remote = vcs?.baseRemote?.trim();
|
|
553
|
+
return remote ? { ref: `refs/remotes/${remote}/${branch}`, name: `${remote}/${branch}`, remote, branch } : { ref: `refs/heads/${branch}`, name: branch, branch };
|
|
554
|
+
}
|
|
523
555
|
function normalise(dir) {
|
|
524
556
|
return dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
|
|
525
557
|
}
|
|
@@ -535,7 +567,9 @@ export function describeStart(start) {
|
|
|
535
567
|
: start.kind === 'existing-branch'
|
|
536
568
|
? `this session carries on the existing branch ${start.branch}, from its tip ${at}`
|
|
537
569
|
: start.kind === 'branch'
|
|
538
|
-
?
|
|
570
|
+
? start.remote
|
|
571
|
+
? `this session starts from the remote branch ${start.branch} (${at}), as fetched when the run was started`
|
|
572
|
+
: `this session starts from the local branch ${start.branch} (${at})`
|
|
539
573
|
: `this session starts from where the repository was (${at})`;
|
|
540
574
|
const updated = start.update ? ` (${describeUpdate(start.update)})` : '';
|
|
541
575
|
const plus = carried ? `, plus the operator's input files carried from ${carried.from} as ${start.commit.slice(0, 8)} on ${carried.branch}` : '';
|
|
@@ -552,11 +586,11 @@ export async function whereLeft(session) {
|
|
|
552
586
|
const dir = repoDirOf(session);
|
|
553
587
|
if (!session.vcs?.enabled || files.length === 0 || !dir)
|
|
554
588
|
return null;
|
|
555
|
-
const base = session.vcs
|
|
589
|
+
const { ref, name: base } = baseRefOf(session.vcs);
|
|
556
590
|
const state = await repoState(dir).catch(() => null);
|
|
557
591
|
if (!state?.isRepo || !state.branch || state.branch === base)
|
|
558
592
|
return null;
|
|
559
|
-
const onBase = await git(dir, ['cat-file', '-e',
|
|
593
|
+
const onBase = await git(dir, ['cat-file', '-e', `${ref}:${files[0].path}`]);
|
|
560
594
|
if (onBase.ok)
|
|
561
595
|
return null;
|
|
562
596
|
const paths = files.map((f) => f.path);
|
|
@@ -598,6 +632,27 @@ export function sessionBranches(session) {
|
|
|
598
632
|
function firstAttemptBase(task) {
|
|
599
633
|
return earliestBase(task);
|
|
600
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* The branch a per-task session's task will be put on when its next attempt starts, worked out before the
|
|
637
|
+
* run by the rules `prepareForTask` applies at the start: a continuation, or a new prompt on finished work,
|
|
638
|
+
* goes on the previous attempt's branch; an attempt after one that committed nothing goes on that empty
|
|
639
|
+
* branch again; otherwise the planned or derived name, "-aN" from the second attempt on. A task that has
|
|
640
|
+
* run is judged as the attempt it would be queued as, by a plain re-run (no continuation). The one thing
|
|
641
|
+
* not asked here is whether that empty branch still sits where it was cut, which only the start can see.
|
|
642
|
+
*/
|
|
643
|
+
export function nextAttemptBranch(session, task) {
|
|
644
|
+
const prefix = session.vcs?.branchPrefix || 'cop/';
|
|
645
|
+
const ran = task.status !== 'queued';
|
|
646
|
+
const attempt = (task.attempt ?? 1) + (ran ? 1 : 0);
|
|
647
|
+
const prev = ran ? { vcs: task.vcs } : task.attempts?.at(-1);
|
|
648
|
+
const carried = !ran && (task.continuing || task.buildsOn) ? task.attempts?.at(-1)?.vcs?.branch : undefined;
|
|
649
|
+
if (carried)
|
|
650
|
+
return carried;
|
|
651
|
+
if (prev?.vcs?.branch && prev.vcs.baseCommit && !prev.vcs.commit && prev.vcs.baseCommit === firstAttemptBase(task))
|
|
652
|
+
return prev.vcs.branch;
|
|
653
|
+
const planned = task.vcsPlan?.branch?.trim();
|
|
654
|
+
return planned ? plannedBranchName(planned, prefix, attempt) : branchNameFrom([session.name, task.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
|
|
655
|
+
}
|
|
601
656
|
async function switchTo(session, task, dir, wantedName, from, bus,
|
|
602
657
|
/** `mustExist`: carry on this branch or nothing; never create it. */
|
|
603
658
|
opts) {
|
|
@@ -718,7 +773,7 @@ export function noteFor(repoDir, vcs, ctx = { mode: 'per-task', earlier: [] }) {
|
|
|
718
773
|
: ctx.start?.kind === 'existing-branch'
|
|
719
774
|
? `This session carries on the existing branch \`${ctx.start.branch}\`: the work already on it is in your working tree. Build on it; do not redo it.`
|
|
720
775
|
: ctx.start?.kind === 'branch'
|
|
721
|
-
? `This session started from the local branch \`${ctx.start.branch}
|
|
776
|
+
? `This session started from the ${ctx.start.remote ? 'remote branch' : 'local branch'} \`${ctx.start.branch}\`${ctx.start.remote ? ' (the code on the server, fetched when the run was started)' : ''}, not from any earlier session's work${ctx.start.note ? ` (${ctx.start.note})` : ''}.`
|
|
722
777
|
: '';
|
|
723
778
|
return [
|
|
724
779
|
'## Version control',
|
|
@@ -1143,8 +1198,9 @@ export async function vcsPreflight(session, allSessions = async () => []) {
|
|
|
1143
1198
|
*/
|
|
1144
1199
|
if (!session.vcsBaseCommit && settings.startFrom === 'previous-session') {
|
|
1145
1200
|
const planned = await sessionStart(session, repoDir, state.head, allSessions, { dry: true });
|
|
1201
|
+
// About where it starts only: another choice in the start window gets past it (see `RunVcsGroup.startChoiceCanFix`).
|
|
1146
1202
|
if ('problem' in planned)
|
|
1147
|
-
return { ok: false, repoDir, branch: state.branch ?? undefined, problem: planned.problem };
|
|
1203
|
+
return { ok: false, repoDir, branch: state.branch ?? undefined, problem: planned.problem, startProblem: true };
|
|
1148
1204
|
}
|
|
1149
1205
|
return { ok: true, repoDir, branch: state.branch ?? undefined };
|
|
1150
1206
|
}
|