copilot-operator 0.1.12 → 0.1.15
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/README.md +9 -14
- package/dist/src/api/operator.controller.js +31 -42
- package/dist/src/api/operator.service.js +692 -364
- package/dist/src/api/saveToDesktop.js +1 -1
- package/dist/src/api/security.js +40 -10
- package/dist/src/api/server.js +75 -12
- package/dist/src/api/settings.js +156 -15
- package/dist/src/cli.js +108 -97
- package/dist/src/config/handWritten.js +36 -0
- package/dist/src/config/layout.js +13 -4
- package/dist/src/config/lockedPolicy.js +56 -22
- package/dist/src/config/schema.js +13 -84
- package/dist/src/context/desktopDir.js +44 -0
- package/dist/src/exec/authorizer.js +9 -1
- package/dist/src/exec/checks.js +240 -23
- package/dist/src/exec/dangerous.js +29 -5
- package/dist/src/exec/network.js +105 -28
- package/dist/src/exec/redaction.js +52 -9
- package/dist/src/exec/reportFile.js +4 -5
- package/dist/src/exec/runner.js +146 -13
- package/dist/src/exec/shells.js +41 -6
- package/dist/src/exec/workDir.js +4 -4
- package/dist/src/orchestrator/derivedChecks.js +1 -0
- package/dist/src/orchestrator/review.js +25 -3
- package/dist/src/orchestrator/taskRunner.js +482 -150
- package/dist/src/plan/brief.js +56 -44
- package/dist/src/plan/importPlan.js +18 -7
- package/dist/src/plan/schema.js +90 -31
- package/dist/src/plan/script.js +79 -90
- package/dist/src/plan/systemGuide.js +39 -182
- package/dist/src/protocol/parser.js +5 -1
- package/dist/src/session/debugExport.js +1 -1
- package/dist/src/session/exportRecord.js +1 -1
- package/dist/src/session/exports.js +52 -9
- package/dist/src/session/store.js +215 -80
- package/dist/src/vcs/git.js +77 -2
- package/dist/src/vcs/scope.js +29 -6
- package/dist/src/vcs/taskVcs.js +149 -23
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
- package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
- package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
- package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +18 -10
- package/prompts/level1.md +2 -1
- package/prompts/organisation.bg.md +0 -1
- package/prompts/organisation.en.md +0 -1
- package/dist/src/context/contextFiles.js +0 -295
- package/dist/src/context/desktopMirror.js +0 -135
- package/dist/src/context/projectMirror.js +0 -432
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
- package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +0 -1
- /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
|
@@ -17,16 +17,14 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { join, resolve } from 'node:path';
|
|
19
19
|
import { installLayout } from '../config/layout.js';
|
|
20
|
-
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
|
|
20
|
+
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyDefaultProject, applyTaskPatch } from '../session/store.js';
|
|
21
21
|
import { EventBus } from '../session/events.js';
|
|
22
22
|
import { isContinuable, newId } from '../session/model.js';
|
|
23
23
|
import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
|
|
24
24
|
import { buildExport } from '../session/exportRecord.js';
|
|
25
25
|
import { buildDebugExport } from '../session/debugExport.js';
|
|
26
|
-
import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName, exportMachine, taskAtAttempt, withTask, writeAttemptRecord } from '../session/exports.js';
|
|
26
|
+
import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName, exportMachine, runScope, taskAtAttempt, taskInRun, withTask, writeAttemptRecord } from '../session/exports.js';
|
|
27
27
|
import { suggestRunName } from '../session/runName.js';
|
|
28
|
-
import { mirrorAllProjects, removeProjectMirrors, contextRoot } from '../context/desktopMirror.js';
|
|
29
|
-
import { describeMirror } from '../context/projectMirror.js';
|
|
30
28
|
import { buildStory } from '../session/story.js';
|
|
31
29
|
import { checkPlan } from '../plan/schema.js';
|
|
32
30
|
import { planBrief } from '../plan/brief.js';
|
|
@@ -37,10 +35,9 @@ function sameFolder(a, b) {
|
|
|
37
35
|
}
|
|
38
36
|
import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
|
|
39
37
|
import { readInterruption } from '../session/interruption.js';
|
|
40
|
-
import { commitInterrupted, repoDirOf, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
|
|
41
|
-
import { changedFilesBetween, fileAt, gitAvailable, repoUnusableReason } from '../vcs/git.js';
|
|
38
|
+
import { branchAt, commitInterrupted, repoDirOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
|
|
39
|
+
import { branchExists, changedFilesBetween, fileAt, freeBranchName, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
|
|
42
40
|
import { makeAuthorizer } from '../exec/authorizer.js';
|
|
43
|
-
import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
|
|
44
41
|
import { pickFolder } from './folderPicker.js';
|
|
45
42
|
import { findEdgeUsingProfile } from '../transport/profileLock.js';
|
|
46
43
|
import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } from '../exec/isolation.js';
|
|
@@ -49,21 +46,128 @@ import { createTransport } from '../transport/chatTransport.js';
|
|
|
49
46
|
import { composeHandoff } from '../session/handoff.js';
|
|
50
47
|
import { planSync } from '../vcs/syncCommand.js';
|
|
51
48
|
import { computeMetrics } from '../session/metrics.js';
|
|
52
|
-
import { resolveDesktopDir, desktopIsSynced } from '../context/
|
|
49
|
+
import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
|
|
53
50
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
54
|
-
import {
|
|
51
|
+
import { settingsOf } from './settings.js';
|
|
52
|
+
/**
|
|
53
|
+
* Why the browser cannot be had now, said in terms of what was pressed. The sentences a start, a
|
|
54
|
+
* batch and the model picker gave before there was one rule for all three are kept where they still
|
|
55
|
+
* apply, so the page reads as it did.
|
|
56
|
+
*/
|
|
57
|
+
function browserRefusal(held, ask) {
|
|
58
|
+
const modelsWhileRunning = 'A session is running and it is using the browser profile. Stop it first, then read the models.';
|
|
59
|
+
if (held.kind === 'batch') {
|
|
60
|
+
if (ask.kind === 'models')
|
|
61
|
+
return modelsWhileRunning;
|
|
62
|
+
return ask.kind === 'batch' ? 'a batch is already running' : 'a batch of sessions is running';
|
|
63
|
+
}
|
|
64
|
+
if (held.kind === 'models') {
|
|
65
|
+
return ask.kind === 'models'
|
|
66
|
+
? 'The model list is already being read.'
|
|
67
|
+
: 'the model list is being read from the chat, which has the browser; start this once it has finished';
|
|
68
|
+
}
|
|
69
|
+
// A session running on its own.
|
|
70
|
+
if (ask.kind === 'models')
|
|
71
|
+
return modelsWhileRunning;
|
|
72
|
+
if (ask.kind === 'run') {
|
|
73
|
+
return ask.sessionId === held.sessionId
|
|
74
|
+
? 'already running'
|
|
75
|
+
: 'another session is running, and the browser takes one run at a time; start this one once it has finished';
|
|
76
|
+
}
|
|
77
|
+
return ask.sessionIds.includes(held.sessionId)
|
|
78
|
+
? 'one of the selected sessions is already running on its own'
|
|
79
|
+
: 'a session is running on its own, and the browser takes one run at a time; start the batch once it has finished';
|
|
80
|
+
}
|
|
55
81
|
/** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
|
|
56
82
|
function normaliseDir(dir) {
|
|
57
83
|
if (!dir.trim())
|
|
58
84
|
return '';
|
|
59
85
|
return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
60
86
|
}
|
|
87
|
+
/** The repository a planned session will work in, read the way `repoDirOf` reads an imported one. */
|
|
88
|
+
function plannedRepoDir(session) {
|
|
89
|
+
return session.vcs?.repoDir?.trim() || session.projectDir?.trim() || session.mirror?.rootDir?.trim() || '';
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The branches a planned session will make by name when it runs, as `prepareForTask` names them.
|
|
93
|
+
*
|
|
94
|
+
* None with version control off, and none when it carries on an existing branch, which makes
|
|
95
|
+
* nothing. In per-session mode the one `branchName`; in per-task mode the branch each task names,
|
|
96
|
+
* since `branchName` is ignored there. A branch named for the session's id is not known before the
|
|
97
|
+
* import, and is not one a plan can ask to carry on.
|
|
98
|
+
*/
|
|
99
|
+
function branchesMadeBy(session) {
|
|
100
|
+
const vcs = session.vcs;
|
|
101
|
+
if (!vcs || vcs.enabled === false || vcs.startFrom === 'existing-branch' || vcs.existingBranch?.trim())
|
|
102
|
+
return [];
|
|
103
|
+
const prefix = vcs.branchPrefix?.trim() || 'cop/';
|
|
104
|
+
if (vcs.branchMode === 'per-session')
|
|
105
|
+
return vcs.branchName?.trim() ? [plannedBranchName(vcs.branchName, prefix)] : [];
|
|
106
|
+
return session.tasks.flatMap((t) => (t.vcs?.branch?.trim() ? [plannedBranchName(t.vcs.branch, prefix)] : []));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The one restore each repository gets when a run starts again from a task: the rule, in one place,
|
|
110
|
+
* for the preview and for the restore itself, which used to keep a copy each and drifted apart.
|
|
111
|
+
*
|
|
112
|
+
* One per repository, keyed on the folder as compared rather than on the path as written or on the
|
|
113
|
+
* session: three sessions working in one repository must not take it back three times to three
|
|
114
|
+
* different commits. The earliest affected task that ran is the one that decides, because that is
|
|
115
|
+
* the state the whole re-run starts from.
|
|
116
|
+
*
|
|
117
|
+
* A repository whose affected tasks never ran has nothing to go back to: the run never touched it.
|
|
118
|
+
* Taking "no base commit recorded" as a refusal blocked "run again from here" for every plan whose
|
|
119
|
+
* later sessions had not started yet — which is the usual shape of a run that stopped in the middle.
|
|
120
|
+
*
|
|
121
|
+
* A session that carries on an existing branch cannot be taken back: its run checks that branch out
|
|
122
|
+
* again before the first task, so a restore branch would be left the moment it was made, and going
|
|
123
|
+
* back on the branch itself would take a reset, which this tool never does. That holds for every
|
|
124
|
+
* session of the repository the run reached, not only the one that decides where it goes back to:
|
|
125
|
+
* a later one carrying on a branch would run its tasks again on top of what they did the first
|
|
126
|
+
* time, which is not "the code goes back first" either. So the repository's restore carries the
|
|
127
|
+
* problem of the first such session, and the preview and the restart refuse before anything moves.
|
|
128
|
+
*/
|
|
129
|
+
function restoreTargets(tasks) {
|
|
130
|
+
const byRepo = new Map();
|
|
131
|
+
for (const { session, task } of tasks) {
|
|
132
|
+
if (!session.vcs?.enabled || !task.startedAt)
|
|
133
|
+
continue;
|
|
134
|
+
const dir = repoDirOf(session);
|
|
135
|
+
const key = normaliseDir(dir);
|
|
136
|
+
if (!key)
|
|
137
|
+
continue;
|
|
138
|
+
const target = byRepo.get(key) ?? { dir, session, task };
|
|
139
|
+
byRepo.set(key, target);
|
|
140
|
+
const existing = session.vcs.startFrom === 'existing-branch' ? (session.vcs.existingBranch ?? '').trim() : '';
|
|
141
|
+
if (existing && !target.problem) {
|
|
142
|
+
target.problem =
|
|
143
|
+
`"${session.name}" carries on the existing branch ${existing}, and its run checks that branch out again, so the code ` +
|
|
144
|
+
`would not stay back; going back on ${existing} itself would take a reset, which this tool never does. ` +
|
|
145
|
+
`Queue its tasks again with "Run again" on each, which carries on ${existing} as it is, or move that branch yourself first.`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return [...byRepo.values()];
|
|
149
|
+
}
|
|
150
|
+
/** Whether two folders, as `normaliseDir` gives them, are one folder or one is inside the other. */
|
|
151
|
+
function overlaps(a, b) {
|
|
152
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The reason a restart stopped, with what it had already done by then.
|
|
156
|
+
*
|
|
157
|
+
* A repository taken back and tasks queued again stay so when a later step refuses — nothing here
|
|
158
|
+
* undoes them — and the reason is the one thing the page shows. Said only in the result, they were
|
|
159
|
+
* left for the operator to find in git and in the queue.
|
|
160
|
+
*/
|
|
161
|
+
function afterMoves(reason, restored, requeued) {
|
|
162
|
+
const done = [...restored.map((r) => `taken back ${r}`), ...(requeued > 0 ? [`${requeued} task(s) queued again`] : [])];
|
|
163
|
+
return done.length > 0 ? `${reason} Already done, and left as it is: ${done.join('; ')}.` : reason;
|
|
164
|
+
}
|
|
61
165
|
let OperatorService = class OperatorService {
|
|
62
166
|
/** Where this install keeps its prompts and its records. See `config/layout.ts`. */
|
|
63
167
|
layout = installLayout();
|
|
64
168
|
projectRoot = this.layout.projectRoot;
|
|
65
169
|
dataDir = this.layout.dataDir;
|
|
66
|
-
settings =
|
|
170
|
+
settings = settingsOf(this.layout);
|
|
67
171
|
store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
|
|
68
172
|
bus = new EventBus();
|
|
69
173
|
running = new Map();
|
|
@@ -71,8 +175,21 @@ let OperatorService = class OperatorService {
|
|
|
71
175
|
/** The batch in progress, or the last one that finished. At most one ever exists. */
|
|
72
176
|
batch = null;
|
|
73
177
|
ready = null;
|
|
74
|
-
/**
|
|
75
|
-
|
|
178
|
+
/**
|
|
179
|
+
* What has the browser now: one session's run, a batch, or a read of the model list.
|
|
180
|
+
*
|
|
181
|
+
* The Edge profile takes one writer, and every way into a chat window goes through here: a
|
|
182
|
+
* start, a batch, and reading the models. Each used to look only at its own kind — a start
|
|
183
|
+
* refused only while a batch ran, a batch only a second batch — so while one session ran on its
|
|
184
|
+
* own, starting another or a batch of others opened a second window on the same profile, which
|
|
185
|
+
* fails minutes later with a message about a closed browser. The profile lock is no help inside
|
|
186
|
+
* one process: it waves through a lock this process already holds.
|
|
187
|
+
*
|
|
188
|
+
* Taken at the entrance before its first wait, so two presses that arrive together cannot both
|
|
189
|
+
* find it free, and given back when the window it was taken for has closed or the entrance has
|
|
190
|
+
* refused. See `claimBrowser`.
|
|
191
|
+
*/
|
|
192
|
+
browser = null;
|
|
76
193
|
/**
|
|
77
194
|
* One-time startup work, run lazily on the first request that needs the store.
|
|
78
195
|
*
|
|
@@ -207,30 +324,32 @@ let OperatorService = class OperatorService {
|
|
|
207
324
|
const pending = [...this.waiting.values()].filter((w) => w.approval.sessionId === id).map((w) => w.approval);
|
|
208
325
|
return { ...s, running: this.running.has(id), pending, runMode: this.running.get(id)?.mode };
|
|
209
326
|
}
|
|
210
|
-
async createSession(name,
|
|
327
|
+
async createSession(name, folder) {
|
|
211
328
|
await this.init();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
329
|
+
// Read before the session is made: a settings file that cannot be read refuses the request,
|
|
330
|
+
// and a refusal must leave nothing behind, or each retry adds a session with no project.
|
|
331
|
+
const cfg = await this.settings.load();
|
|
332
|
+
const session = await this.store.createSession(name, folder ?? '');
|
|
215
333
|
/*
|
|
216
334
|
* The models are not written onto a new session. A session with no model of its own uses the
|
|
217
335
|
* one chosen in Settings at the time it runs (see `effectiveModels` in taskRunner.ts), so
|
|
218
336
|
* choosing a model there reaches every such session, the ones created before it included.
|
|
219
337
|
* It used to be copied here, which meant a model chosen after an import reached nothing.
|
|
220
338
|
*/
|
|
221
|
-
const cfg = await this.settings.load();
|
|
222
339
|
const projectDir = (cfg.project?.rootDir ?? '').trim();
|
|
223
|
-
if (!projectDir)
|
|
224
|
-
return session;
|
|
225
340
|
return await this.store.updateSession(session.id, (s) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
341
|
+
/*
|
|
342
|
+
* A folder given here is the session's repository too, in the shape a session made on the
|
|
343
|
+
* Project page's folder has: both fields, one folder. Left empty, every reader that follows
|
|
344
|
+
* `repoDirOf` found the folder, but the review read `vcs.repoDir` itself, and was told there
|
|
345
|
+
* was no repository and no changed files for work committed in that folder. The review asks
|
|
346
|
+
* `trackedRepoOf` now, so a session saved with `repoDir` empty is read right as well; this
|
|
347
|
+
* keeps the record saying what the session works in.
|
|
348
|
+
*/
|
|
349
|
+
if (s.projectDir.trim() && s.vcs && !s.vcs.repoDir.trim())
|
|
350
|
+
s.vcs.repoDir = s.projectDir;
|
|
351
|
+
// The same rule an import applies, for a session given no folder; see `applyDefaultProject`.
|
|
352
|
+
applyDefaultProject(s, projectDir);
|
|
234
353
|
});
|
|
235
354
|
}
|
|
236
355
|
async updateSession(id, patch) {
|
|
@@ -259,11 +378,8 @@ let OperatorService = class OperatorService {
|
|
|
259
378
|
const merged = { ...DEFAULT_REVIEW, ...s.review, ...patch.review };
|
|
260
379
|
s.review = { enabled: merged.enabled !== false, model: (merged.model ?? '').trim() };
|
|
261
380
|
}
|
|
262
|
-
if (patch.
|
|
263
|
-
|
|
264
|
-
assertMirrorIsCoherent(merged);
|
|
265
|
-
s.mirror = merged;
|
|
266
|
-
}
|
|
381
|
+
if (patch.projectDir !== undefined)
|
|
382
|
+
s.projectDir = patch.projectDir.trim();
|
|
267
383
|
if (patch.vcs) {
|
|
268
384
|
const merged = { ...DEFAULT_VCS, ...s.vcs, ...patch.vcs };
|
|
269
385
|
// A prefix is what makes the bot's branches recognisable in `git branch`, so an empty
|
|
@@ -271,12 +387,20 @@ let OperatorService = class OperatorService {
|
|
|
271
387
|
merged.branchPrefix = merged.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
|
|
272
388
|
merged.repoDir = merged.repoDir.trim();
|
|
273
389
|
if (merged.enabled) {
|
|
274
|
-
const reason = repoUnusableReason(merged.repoDir || s.
|
|
390
|
+
const reason = repoUnusableReason(merged.repoDir || s.projectDir || '');
|
|
275
391
|
if (reason)
|
|
276
392
|
throw new Error(reason);
|
|
277
393
|
}
|
|
278
394
|
if (merged.baseBranch !== undefined)
|
|
279
395
|
merged.baseBranch = merged.baseBranch.trim();
|
|
396
|
+
if (merged.existingBranch !== undefined)
|
|
397
|
+
merged.existingBranch = merged.existingBranch.trim();
|
|
398
|
+
// Only the program marks a name as one of its own branches, and a name changed is not that one.
|
|
399
|
+
const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
|
|
400
|
+
if (stillOwn)
|
|
401
|
+
merged.branchNameExact = true;
|
|
402
|
+
else
|
|
403
|
+
delete merged.branchNameExact;
|
|
280
404
|
/*
|
|
281
405
|
* Where the session starts is fixed at its first run. A new choice clears that record, so
|
|
282
406
|
* the next task to run is cut from what was chosen now; the tasks that already ran keep
|
|
@@ -284,6 +408,8 @@ let OperatorService = class OperatorService {
|
|
|
284
408
|
*/
|
|
285
409
|
const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
|
|
286
410
|
(merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
|
|
411
|
+
(merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
|
|
412
|
+
(merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
|
|
287
413
|
merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
|
|
288
414
|
if (startChanged) {
|
|
289
415
|
s.vcsBaseCommit = undefined;
|
|
@@ -607,15 +733,17 @@ let OperatorService = class OperatorService {
|
|
|
607
733
|
.sort((a, b) => (a.runGroup?.id === runId ? (a.runGroup.order ?? 0) : 0) - (b.runGroup?.id === runId ? (b.runGroup.order ?? 0) : 0));
|
|
608
734
|
if (sessions.length === 0)
|
|
609
735
|
throw new Error(`No session took part in run ${runId}.`);
|
|
610
|
-
|
|
736
|
+
/*
|
|
737
|
+
* The run's name and start, from a session's own record of the run — or, once a later run has
|
|
738
|
+
* replaced that record, from a task that ran in it, which carries both. Without the second,
|
|
739
|
+
* the earlier run's file was called "run-" with nothing after it, the kind of name nobody
|
|
740
|
+
* finds again.
|
|
741
|
+
*/
|
|
742
|
+
const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId) ??
|
|
743
|
+
sessions.flatMap((s) => s.tasks.map((t) => taskInRun(t, runId)?.runGroup)).find((g) => g?.id === runId);
|
|
611
744
|
const name = group?.name?.trim() || `run-${(group?.startedAt ?? '').slice(0, 16).replace(/[:T]/g, '-')}`;
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// A task belongs to the run if this attempt ran in it, or if it was queued for it and
|
|
615
|
-
// never reached — that second kind is what the run's own record on the session is for.
|
|
616
|
-
taskFilter: (s, t) => t.runGroup?.id === runId || (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)),
|
|
617
|
-
label: name,
|
|
618
|
-
};
|
|
745
|
+
// Each task as it ended in this run, not as it is now; see `runScope`.
|
|
746
|
+
scope = runScope(sessions, runId, name);
|
|
619
747
|
}
|
|
620
748
|
else if (where.sessionId) {
|
|
621
749
|
const session = await this.store.getSession(where.sessionId);
|
|
@@ -700,34 +828,56 @@ let OperatorService = class OperatorService {
|
|
|
700
828
|
isRunning(sessionId) {
|
|
701
829
|
return this.running.has(sessionId);
|
|
702
830
|
}
|
|
831
|
+
/**
|
|
832
|
+
* Takes the browser for `ask`, or says why it cannot have it. Synchronous on purpose (see
|
|
833
|
+
* `browser`). What it hands back is what `releaseBrowser` is given, so a release that comes late
|
|
834
|
+
* cannot free a browser somebody else has taken since.
|
|
835
|
+
*/
|
|
836
|
+
claimBrowser(ask) {
|
|
837
|
+
if (this.browser)
|
|
838
|
+
return { refused: browserRefusal(this.browser, ask) };
|
|
839
|
+
const holder = ask.kind === 'run' ? { kind: 'run', sessionId: ask.sessionId } : { kind: ask.kind };
|
|
840
|
+
this.browser = holder;
|
|
841
|
+
return { holder };
|
|
842
|
+
}
|
|
843
|
+
releaseBrowser(holder) {
|
|
844
|
+
if (this.browser === holder)
|
|
845
|
+
this.browser = null;
|
|
846
|
+
}
|
|
703
847
|
/**
|
|
704
848
|
* Starts the session's queued tasks in the background. Returns immediately; progress
|
|
705
|
-
* arrives on the event stream. One run
|
|
849
|
+
* arrives on the event stream. One run at a time, of this session or any other.
|
|
706
850
|
*/
|
|
707
851
|
async start(sessionId, mode = 'confirm', name) {
|
|
708
852
|
// One conversation at a time is not a policy, it is the browser profile: a second run
|
|
709
|
-
// does not get a second browser, it gets an error about a closed one.
|
|
710
|
-
//
|
|
711
|
-
// still be read, rather than three minutes later in a stack trace.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
853
|
+
// does not get a second browser, it gets an error about a closed one. Whatever has the
|
|
854
|
+
// browser now — a batch, another session, a read of the models — the start is refused where
|
|
855
|
+
// the reason can still be read, rather than three minutes later in a stack trace.
|
|
856
|
+
const claim = this.claimBrowser({ kind: 'run', sessionId });
|
|
857
|
+
if ('refused' in claim)
|
|
858
|
+
return { started: false, reason: claim.refused };
|
|
859
|
+
try {
|
|
860
|
+
// A run nobody named is named after what it is a run of, rather than left to show up in the
|
|
861
|
+
// register as "Unnamed run" beside five others of the same description.
|
|
862
|
+
const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
|
|
863
|
+
const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
|
|
864
|
+
// A run of one is still a run, and it records the same thing a batch does, so that going
|
|
865
|
+
// back and starting again from a task works the same whether one session was started or six.
|
|
866
|
+
// Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
|
|
867
|
+
const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
|
|
868
|
+
if (!begun.started) {
|
|
869
|
+
this.releaseBrowser(claim.holder);
|
|
870
|
+
return { started: false, reason: begun.reason };
|
|
871
|
+
}
|
|
872
|
+
// The window closes before the run's promise settles (see `runSession`), so the browser is
|
|
873
|
+
// free again exactly when it is.
|
|
874
|
+
void begun.done.finally(() => this.releaseBrowser(claim.holder));
|
|
875
|
+
return { started: true };
|
|
876
|
+
}
|
|
877
|
+
catch (e) {
|
|
878
|
+
this.releaseBrowser(claim.holder);
|
|
879
|
+
throw e;
|
|
880
|
+
}
|
|
731
881
|
}
|
|
732
882
|
/**
|
|
733
883
|
* The same thing `start` does, but it also hands back a promise that settles when the run
|
|
@@ -744,12 +894,8 @@ let OperatorService = class OperatorService {
|
|
|
744
894
|
/** The press of a start button this belongs to, stamped onto every task it reaches. */
|
|
745
895
|
runGroup,
|
|
746
896
|
/** Only these queued tasks, when the operator chose some; the rest stay queued. */
|
|
747
|
-
onlyTasks) {
|
|
897
|
+
onlyTasks, how = {}) {
|
|
748
898
|
await this.init();
|
|
749
|
-
// Every project's Desktop copy brought up to date before the chat opens, when the operator
|
|
750
|
-
// asked for that. Before the run rather than before each task: a task's own mirror already
|
|
751
|
-
// refreshes its project, and the other projects do not change while the bot works.
|
|
752
|
-
await this.refreshDesktopMirrors();
|
|
753
899
|
const idle = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
|
|
754
900
|
if (this.running.has(sessionId))
|
|
755
901
|
return { started: false, reason: 'already running', done: Promise.resolve(idle) };
|
|
@@ -782,6 +928,12 @@ let OperatorService = class OperatorService {
|
|
|
782
928
|
const precondition = unattendedPrecondition(policy);
|
|
783
929
|
if (precondition)
|
|
784
930
|
return { started: false, reason: precondition, done: Promise.resolve(idle) };
|
|
931
|
+
if (how.recordOnSession && runGroup) {
|
|
932
|
+
// The tasks this run takes, as worked out above, so the record and the run cannot disagree.
|
|
933
|
+
await this.store.updateSession(sessionId, (s) => {
|
|
934
|
+
s.runGroup = { ...runGroup, order: 0, taskIds: queuedIds, mode, onFailure: s.onFailure === 'continue' ? 'continue' : 'stop' };
|
|
935
|
+
});
|
|
936
|
+
}
|
|
785
937
|
const controller = new AbortController();
|
|
786
938
|
/*
|
|
787
939
|
* The same authorizer in both modes. It used to be `unattendedAuthorizer` here for an unattended
|
|
@@ -809,14 +961,13 @@ let OperatorService = class OperatorService {
|
|
|
809
961
|
bus: this.bus,
|
|
810
962
|
authorizer,
|
|
811
963
|
signal: controller.signal,
|
|
812
|
-
// Read between tasks, so the one in flight finishes properly first. The batch owns
|
|
813
|
-
//
|
|
814
|
-
shouldPause:
|
|
964
|
+
// Read between tasks, so the one in flight finishes properly first. The batch that owns
|
|
965
|
+
// this run answers it, because a pause is about the whole run and not about this session.
|
|
966
|
+
shouldPause: how.shouldPause,
|
|
815
967
|
// The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
|
|
816
968
|
// and the policy.json of a later task must say so.
|
|
817
969
|
currentMode: () => this.running.get(sessionId)?.mode ?? mode,
|
|
818
970
|
// The Desktop copies follow the work while it happens, not only before the run.
|
|
819
|
-
onWorkChanged: () => this.requestDesktopRefresh(),
|
|
820
971
|
// The session decides whether its tasks are one chain or a set of independent checks.
|
|
821
972
|
continueOnFailure: session.onFailure === 'continue',
|
|
822
973
|
transport,
|
|
@@ -931,20 +1082,64 @@ let OperatorService = class OperatorService {
|
|
|
931
1082
|
* tasks nobody chose stay queued exactly as they were. Absent means every queued task.
|
|
932
1083
|
*/
|
|
933
1084
|
taskIds) {
|
|
934
|
-
await this.init();
|
|
935
|
-
const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
|
|
936
|
-
if (this.batch?.running)
|
|
937
|
-
return { started: false, reason: 'a batch is already running' };
|
|
938
1085
|
const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
|
|
939
|
-
|
|
940
|
-
|
|
1086
|
+
// Before anything is waited for (see `browser`): a second batch, a session running on its own
|
|
1087
|
+
// — one of these or any other — and a read of the models all have the browser already.
|
|
1088
|
+
const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
|
|
1089
|
+
if ('refused' in claim)
|
|
1090
|
+
return { started: false, reason: claim.refused };
|
|
1091
|
+
return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
|
|
1095
|
+
* to the batch once it is under way (`runBatch` gives it back), and given back here on every
|
|
1096
|
+
* refusal. "Run again from here" claims it before it moves anything and starts through this, so
|
|
1097
|
+
* one claim covers the restore, the requeue and the run.
|
|
1098
|
+
*/
|
|
1099
|
+
async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
|
|
1100
|
+
const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
|
|
1101
|
+
let handedOn = false;
|
|
1102
|
+
try {
|
|
1103
|
+
await this.init();
|
|
1104
|
+
if (wanted.length === 0)
|
|
1105
|
+
return { started: false, reason: 'no sessions were selected' };
|
|
1106
|
+
const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name);
|
|
1107
|
+
if (!begun.batch)
|
|
1108
|
+
return { started: false, reason: begun.reason };
|
|
1109
|
+
this.batch = begun.batch;
|
|
1110
|
+
handedOn = true;
|
|
1111
|
+
void this.runBatch(begun.batch, holder);
|
|
1112
|
+
return { started: true, batch: this.batchState() };
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
if (!handedOn)
|
|
1116
|
+
this.releaseBrowser(holder);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Why a batch in this mode may not begin on this machine now, or null when it may: the entrance
|
|
1121
|
+
* rule (`unattendedPrecondition`) asked of the settings as they are at this moment. One place, for
|
|
1122
|
+
* the batch and for "Run again from here", which has to ask it before it moves anything.
|
|
1123
|
+
*/
|
|
1124
|
+
async unattendedRefusal(mode) {
|
|
1125
|
+
const cfg = await this.settings.load();
|
|
1126
|
+
return unattendedPrecondition({
|
|
1127
|
+
mode,
|
|
1128
|
+
allowedPrograms: cfg.execution.allowedPrograms,
|
|
1129
|
+
isolation: cfg.execution.isolation,
|
|
1130
|
+
lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Everything `startBatch` decides before the batch exists: which sessions it has work in, whether
|
|
1135
|
+
* an unattended run may begin, and the models the run panel chose. A refusal comes back as the
|
|
1136
|
+
* reason; nothing is started here.
|
|
1137
|
+
*/
|
|
1138
|
+
async prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name) {
|
|
941
1139
|
// Worked out here rather than asked of the operator again: every way into this — the run
|
|
942
1140
|
// panel, continuing after a failure, "Run again from here" — either has a field they may
|
|
943
1141
|
// have left empty or has no field at all.
|
|
944
1142
|
const chosenName = name?.trim() || (await this.suggestRunName(wanted));
|
|
945
|
-
if (wanted.some((id) => this.running.has(id))) {
|
|
946
|
-
return { started: false, reason: 'one of the selected sessions is already running on its own' };
|
|
947
|
-
}
|
|
948
1143
|
const sessions = [];
|
|
949
1144
|
for (const id of wanted) {
|
|
950
1145
|
const s = await this.store.getSession(id);
|
|
@@ -958,7 +1153,7 @@ let OperatorService = class OperatorService {
|
|
|
958
1153
|
: { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
|
|
959
1154
|
}
|
|
960
1155
|
if (!sessions.some((s) => s.state === 'waiting')) {
|
|
961
|
-
return {
|
|
1156
|
+
return { reason: 'none of the selected sessions has a queued task' };
|
|
962
1157
|
}
|
|
963
1158
|
/*
|
|
964
1159
|
* The same entrance rule `start` has, asked here too and for the same reason: before a browser
|
|
@@ -968,17 +1163,9 @@ let OperatorService = class OperatorService {
|
|
|
968
1163
|
* session was quietly skipped with the reason held in a batch state nobody was looking at. Asked
|
|
969
1164
|
* here, the refusal is the answer to the press, shown under the button that caused it.
|
|
970
1165
|
*/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
mode,
|
|
975
|
-
allowedPrograms: cfg.execution.allowedPrograms,
|
|
976
|
-
isolation: cfg.execution.isolation,
|
|
977
|
-
lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
|
|
978
|
-
});
|
|
979
|
-
if (blocked)
|
|
980
|
-
return { started: false, reason: blocked };
|
|
981
|
-
}
|
|
1166
|
+
const blocked = await this.unattendedRefusal(mode);
|
|
1167
|
+
if (blocked)
|
|
1168
|
+
return { reason: blocked };
|
|
982
1169
|
// One model for the whole run, chosen here rather than opened on every session first. It
|
|
983
1170
|
// is written onto the sessions instead of being held for the run, so what the session says
|
|
984
1171
|
// it will use and what it used are the same thing afterwards — including for anyone who
|
|
@@ -1010,20 +1197,20 @@ let OperatorService = class OperatorService {
|
|
|
1010
1197
|
});
|
|
1011
1198
|
}
|
|
1012
1199
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1200
|
+
return {
|
|
1201
|
+
batch: {
|
|
1202
|
+
id: newId('b-'),
|
|
1203
|
+
startedAt: new Date().toISOString(),
|
|
1204
|
+
name: chosenName,
|
|
1205
|
+
...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
|
|
1206
|
+
mode,
|
|
1207
|
+
onFailure,
|
|
1208
|
+
stopping: false,
|
|
1209
|
+
pausing: false,
|
|
1210
|
+
running: true,
|
|
1211
|
+
sessions,
|
|
1212
|
+
},
|
|
1024
1213
|
};
|
|
1025
|
-
void this.runBatch();
|
|
1026
|
-
return { started: true, batch: this.batchState() };
|
|
1027
1214
|
}
|
|
1028
1215
|
/** Stops the session that is running now and leaves the rest of the batch unstarted. */
|
|
1029
1216
|
/**
|
|
@@ -1077,6 +1264,24 @@ let OperatorService = class OperatorService {
|
|
|
1077
1264
|
await this.stop(current.sessionId);
|
|
1078
1265
|
return { stopping: true };
|
|
1079
1266
|
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Runs a batch that has just been started, and ends what only the batch may end: its hold and
|
|
1269
|
+
* its claim on the browser. Both are let go here, once, however the loop came out — finished,
|
|
1270
|
+
* stopped, failed, or never able to open its window.
|
|
1271
|
+
*/
|
|
1272
|
+
async runBatch(batch, holder) {
|
|
1273
|
+
try {
|
|
1274
|
+
await this.batchLoop(batch);
|
|
1275
|
+
}
|
|
1276
|
+
finally {
|
|
1277
|
+
// A pause holds this batch's queue and nothing after it. Left on, it read as "pausing" on a
|
|
1278
|
+
// batch that had ended, and it was what every later run read, so each stopped before its
|
|
1279
|
+
// first task (see `shouldPause` in `beginRun`).
|
|
1280
|
+
batch.pausing = false;
|
|
1281
|
+
// The batch's window is closed by now, in the loop's own ending.
|
|
1282
|
+
this.releaseBrowser(holder);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1080
1285
|
/**
|
|
1081
1286
|
* The batch loop. Nothing here runs in parallel, on purpose: see `beginRun`.
|
|
1082
1287
|
*
|
|
@@ -1084,10 +1289,7 @@ let OperatorService = class OperatorService {
|
|
|
1084
1289
|
* failure is `done`; one with a failed task is `failed`; one the operator stopped, or one
|
|
1085
1290
|
* whose chain stopped early with no failure of its own, is `stopped`.
|
|
1086
1291
|
*/
|
|
1087
|
-
async
|
|
1088
|
-
const batch = this.batch;
|
|
1089
|
-
if (!batch)
|
|
1090
|
-
return;
|
|
1292
|
+
async batchLoop(batch) {
|
|
1091
1293
|
/*
|
|
1092
1294
|
* One browser for the whole batch.
|
|
1093
1295
|
*
|
|
@@ -1192,7 +1394,11 @@ let OperatorService = class OperatorService {
|
|
|
1192
1394
|
message: `starting as part of a batch of ${batch.sessions.length} session(s), in ${batch.mode} mode`,
|
|
1193
1395
|
data: { batchId: batch.id },
|
|
1194
1396
|
});
|
|
1195
|
-
|
|
1397
|
+
// In the batch's mode as it is now: "run the rest without asking" in an earlier session
|
|
1398
|
+
// switched the batch with it (see `setRunMode`). The hold is this batch's own.
|
|
1399
|
+
const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
|
|
1400
|
+
shouldPause: () => batch.pausing,
|
|
1401
|
+
});
|
|
1196
1402
|
if (!begun.started) {
|
|
1197
1403
|
entry.state = 'skipped';
|
|
1198
1404
|
entry.reason = begun.reason;
|
|
@@ -1297,11 +1503,18 @@ let OperatorService = class OperatorService {
|
|
|
1297
1503
|
*/
|
|
1298
1504
|
const cfg = await this.settings.load();
|
|
1299
1505
|
const unattendedRule = { mode: 'unattended', allowedPrograms: cfg.execution.allowedPrograms, isolation: cfg.execution.isolation, lockedToConfirm: cfg.policyLock?.maxMode === 'confirm' };
|
|
1506
|
+
/*
|
|
1507
|
+
* Which of its refusals it is, asked in the order the rule asks them: the lock, then isolation,
|
|
1508
|
+
* then the allowlist. Anything that was not isolation used to be told as an empty allowlist, so
|
|
1509
|
+
* under a policy lock the brief sent the operator to fill in a list that was already full.
|
|
1510
|
+
*/
|
|
1300
1511
|
const unattendedBlocked = !unattendedPrecondition(unattendedRule)
|
|
1301
1512
|
? undefined
|
|
1302
|
-
:
|
|
1303
|
-
? '
|
|
1304
|
-
: '
|
|
1513
|
+
: unattendedRule.lockedToConfirm
|
|
1514
|
+
? 'lock'
|
|
1515
|
+
: unattendedIsolationRefusal('unattended', cfg.execution.isolation)
|
|
1516
|
+
? 'isolation'
|
|
1517
|
+
: 'allowlist';
|
|
1305
1518
|
return {
|
|
1306
1519
|
text: planBrief({
|
|
1307
1520
|
lang,
|
|
@@ -1349,7 +1562,7 @@ let OperatorService = class OperatorService {
|
|
|
1349
1562
|
check.plan.sessions.forEach((session, i) => {
|
|
1350
1563
|
if (session.vcs?.enabled === false)
|
|
1351
1564
|
return;
|
|
1352
|
-
const dir =
|
|
1565
|
+
const dir = plannedRepoDir(session);
|
|
1353
1566
|
const reason = repoUnusableReason(dir);
|
|
1354
1567
|
if (reason)
|
|
1355
1568
|
repoIssues.push({ path: `sessions[${i}].vcs.repoDir`, message: reason });
|
|
@@ -1361,6 +1574,37 @@ let OperatorService = class OperatorService {
|
|
|
1361
1574
|
});
|
|
1362
1575
|
}
|
|
1363
1576
|
});
|
|
1577
|
+
if (repoIssues.length > 0)
|
|
1578
|
+
return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
|
|
1579
|
+
/*
|
|
1580
|
+
* A branch to carry on must be there to carry on — unless a session before it in this same plan
|
|
1581
|
+
* is the one that makes it, in this same repository. Refused now, with the branches that are
|
|
1582
|
+
* there, rather than at the first run with a task refused.
|
|
1583
|
+
*
|
|
1584
|
+
* The repository is part of the question. A branch is a branch of one repository, and the name
|
|
1585
|
+
* alone let a branch an earlier session makes somewhere else count here: the plan was accepted,
|
|
1586
|
+
* and the session failed at its first task with "no such local branch" — the failure this check
|
|
1587
|
+
* exists to bring forward.
|
|
1588
|
+
*/
|
|
1589
|
+
for (const [i, session] of check.plan.sessions.entries()) {
|
|
1590
|
+
const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
|
|
1591
|
+
if (!wanted)
|
|
1592
|
+
continue;
|
|
1593
|
+
const dir = plannedRepoDir(session);
|
|
1594
|
+
const here = normaliseDir(dir);
|
|
1595
|
+
const madeEarlier = check.plan.sessions
|
|
1596
|
+
.slice(0, i)
|
|
1597
|
+
.some((e) => normaliseDir(plannedRepoDir(e)) === here && branchesMadeBy(e).includes(wanted));
|
|
1598
|
+
if (madeEarlier || (await branchExists(dir, wanted)))
|
|
1599
|
+
continue;
|
|
1600
|
+
const have = await localBranches(dir);
|
|
1601
|
+
repoIssues.push({
|
|
1602
|
+
path: `sessions[${i}].vcs.existingBranch`,
|
|
1603
|
+
message: `${dir} has no local branch "${wanted}" to carry on. ` +
|
|
1604
|
+
(have.length > 0 ? `Its branches: ${have.slice(0, 20).join(', ')}.` : 'It has no branches yet.') +
|
|
1605
|
+
' Give the exact name, or use startFrom "branch" to start a new one.',
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1364
1608
|
if (repoIssues.length > 0)
|
|
1365
1609
|
return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
|
|
1366
1610
|
return { ...check, duplicates: await this.findDuplicates(check.plan) };
|
|
@@ -1407,7 +1651,9 @@ let OperatorService = class OperatorService {
|
|
|
1407
1651
|
*/
|
|
1408
1652
|
const persona = (await this.getContext('persona', 'en')).content;
|
|
1409
1653
|
// No default models passed: a session the plan gives no model follows Settings at run time.
|
|
1410
|
-
|
|
1654
|
+
// The default project is passed, so a session the plan gives no folder works where one made
|
|
1655
|
+
// on the Sessions page would, and not in execution.cwd.
|
|
1656
|
+
const result = await importPlan(this.store, check.plan, '', '', persona, (cfg.project?.rootDir ?? '').trim());
|
|
1411
1657
|
return {
|
|
1412
1658
|
ok: true,
|
|
1413
1659
|
result: { ...result, warnings: [...check.warnings, ...result.warnings] },
|
|
@@ -1478,14 +1724,26 @@ let OperatorService = class OperatorService {
|
|
|
1478
1724
|
* one would read.
|
|
1479
1725
|
*/
|
|
1480
1726
|
run.policy.mode = mode;
|
|
1727
|
+
/*
|
|
1728
|
+
* A batch is one run. The dialog behind "run the rest without asking" says "until this run
|
|
1729
|
+
* ends", and the run the operator started is the batch: the switch used to reach only the
|
|
1730
|
+
* session on screen, and the next session started in the batch's old mode and asked again. So
|
|
1731
|
+
* the sessions the batch has not reached yet start in the mode it is switched to — and back,
|
|
1732
|
+
* when the asking is switched on again. Each still meets the unattended precondition as it
|
|
1733
|
+
* starts (see `beginRun`), under the settings of that moment, and the run recorded on it
|
|
1734
|
+
* (`Session.runGroup`) keeps the mode it was started in.
|
|
1735
|
+
*/
|
|
1736
|
+
const batch = this.batch?.running && this.batch.sessions.some((e) => e.sessionId === sessionId && e.state === 'running') ? this.batch : null;
|
|
1737
|
+
if (batch)
|
|
1738
|
+
batch.mode = mode;
|
|
1481
1739
|
this.bus.publish({
|
|
1482
1740
|
sessionId,
|
|
1483
1741
|
type: 'run-mode-changed',
|
|
1484
1742
|
level: mode === 'unattended' ? 'warn' : 'info',
|
|
1485
1743
|
message: mode === 'unattended'
|
|
1486
|
-
?
|
|
1487
|
-
:
|
|
1488
|
-
data: { mode },
|
|
1744
|
+
? `the rest of this run${batch ? ', the sessions of the batch after this one included,' : ''} will execute without asking; denied patterns are still refused`
|
|
1745
|
+
: `every further step${batch ? ', in this session and the ones of the batch after it,' : ''} will be shown for approval again`,
|
|
1746
|
+
data: { mode, ...(batch ? { batchId: batch.id } : {}) },
|
|
1489
1747
|
});
|
|
1490
1748
|
if (mode === 'unattended') {
|
|
1491
1749
|
for (const [id, w] of this.waiting) {
|
|
@@ -1505,6 +1763,12 @@ let OperatorService = class OperatorService {
|
|
|
1505
1763
|
}
|
|
1506
1764
|
webAuthorizer(policy, signal) {
|
|
1507
1765
|
return makeAuthorizer(policy, (step, ctx, held) => new Promise((resolvePromise) => {
|
|
1766
|
+
// A stopped run answers before anything else, the mode included: "run the rest without
|
|
1767
|
+
// asking" is permission to run the steps of a run that is going, not of one that was stopped.
|
|
1768
|
+
if (signal.aborted) {
|
|
1769
|
+
resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1508
1772
|
// The operator may have pressed "run the rest without asking" on an earlier step.
|
|
1509
1773
|
// This is checked per step rather than captured once, which is what makes the switch
|
|
1510
1774
|
// take effect from the very next step instead of the next run. A held step is the
|
|
@@ -1522,17 +1786,13 @@ let OperatorService = class OperatorService {
|
|
|
1522
1786
|
createdAt: new Date().toISOString(),
|
|
1523
1787
|
...(held ? { network: held.network } : {}),
|
|
1524
1788
|
};
|
|
1525
|
-
if (signal.aborted) {
|
|
1526
|
-
resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
|
|
1527
|
-
return;
|
|
1528
|
-
}
|
|
1529
1789
|
this.waiting.set(approval.id, { approval, resolve: resolvePromise });
|
|
1530
1790
|
this.bus.publish({ sessionId: approval.sessionId, taskId: approval.taskId, type: 'approval-requested', level: 'warn',
|
|
1531
1791
|
message: held
|
|
1532
1792
|
? `held for the operator — ${held.network}: ${approval.description}`
|
|
1533
1793
|
: `waiting for approval: ${approval.description}`,
|
|
1534
1794
|
data: { ...approval } });
|
|
1535
|
-
}));
|
|
1795
|
+
}), signal);
|
|
1536
1796
|
}
|
|
1537
1797
|
// --- events ---------------------------------------------------------------------------
|
|
1538
1798
|
recentEvents(sessionId) {
|
|
@@ -1553,7 +1813,10 @@ let OperatorService = class OperatorService {
|
|
|
1553
1813
|
return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
|
|
1554
1814
|
// `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
|
|
1555
1815
|
// those differ, and the second is the one the operator is asking about.
|
|
1556
|
-
|
|
1816
|
+
const preflight = await vcsPreflight(session);
|
|
1817
|
+
// The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
|
|
1818
|
+
const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
|
|
1819
|
+
return { ...preflight, git, work: sessionBranches(session), branches };
|
|
1557
1820
|
}
|
|
1558
1821
|
/**
|
|
1559
1822
|
* What going back to before a task would do, and then doing it.
|
|
@@ -1569,24 +1832,68 @@ let OperatorService = class OperatorService {
|
|
|
1569
1832
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1570
1833
|
if (!task)
|
|
1571
1834
|
throw new Error('No such task.');
|
|
1572
|
-
|
|
1835
|
+
const preview = await restorePreview(session, task);
|
|
1836
|
+
// Said in the preview too, so the dialog refuses before the operator agrees to anything,
|
|
1837
|
+
// rather than showing what would happen and refusing when they say yes.
|
|
1838
|
+
const refused = await this.restoreRefusal(session);
|
|
1839
|
+
return refused ? { ...preview, ok: false, problem: refused } : preview;
|
|
1573
1840
|
}
|
|
1574
1841
|
async restore(sessionId, taskId) {
|
|
1575
1842
|
await this.init();
|
|
1576
|
-
if (this.running.has(sessionId)) {
|
|
1577
|
-
return { ok: false, problem: 'This session is running. Stop it before moving the repository to another branch.' };
|
|
1578
|
-
}
|
|
1579
|
-
if (this.batch?.running) {
|
|
1580
|
-
return { ok: false, problem: 'A batch is running. Stop it before moving the repository to another branch.' };
|
|
1581
|
-
}
|
|
1582
1843
|
const session = await this.store.getSession(sessionId);
|
|
1583
1844
|
if (!session)
|
|
1584
1845
|
throw new Error('No such session.');
|
|
1585
1846
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1586
1847
|
if (!task)
|
|
1587
1848
|
throw new Error('No such task.');
|
|
1849
|
+
const refused = await this.restoreRefusal(session);
|
|
1850
|
+
if (refused)
|
|
1851
|
+
return { ok: false, problem: refused };
|
|
1588
1852
|
return await restoreToBase(session, task, this.bus);
|
|
1589
1853
|
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Why the repository of this session cannot be moved to another branch now, or null when it can.
|
|
1856
|
+
*
|
|
1857
|
+
* Anything that may be working in that folder at this moment: the session itself, a batch (which
|
|
1858
|
+
* may reach a session in it next), and any other session running there on its own. Only the first
|
|
1859
|
+
* two used to be asked, so a restore checked out a branch under another session in the middle of
|
|
1860
|
+
* its task — and that session then found its repository moved, and left its work uncommitted.
|
|
1861
|
+
* Version control being off for the other session does not make it safe: its commands still run
|
|
1862
|
+
* in that folder.
|
|
1863
|
+
*/
|
|
1864
|
+
async restoreRefusal(session) {
|
|
1865
|
+
if (this.running.has(session.id))
|
|
1866
|
+
return 'This session is running. Stop it before moving the repository to another branch.';
|
|
1867
|
+
if (this.batch?.running)
|
|
1868
|
+
return 'A batch is running. Stop it before moving the repository to another branch.';
|
|
1869
|
+
const other = await this.runningIn(repoDirOf(session));
|
|
1870
|
+
return other
|
|
1871
|
+
? `Session "${other.name}" is running in this repository. Stop it before moving the repository to another branch.`
|
|
1872
|
+
: null;
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* A session running right now whose commands run in this folder, inside it, or in a folder around it.
|
|
1876
|
+
*
|
|
1877
|
+
* By where its commands run, the rule `workingDirFor` decides it by: the repository, else the
|
|
1878
|
+
* project folder, else `execution.cwd`. Compared as folders, not as equal paths: an audit with
|
|
1879
|
+
* version control off whose project is the repository's `web` folder works in the tree a checkout
|
|
1880
|
+
* rewrites, and so does a session whose folder holds the repository. Only the same path used to
|
|
1881
|
+
* count, and a restore changed files under such a session in the middle of its task.
|
|
1882
|
+
*/
|
|
1883
|
+
async runningIn(dir) {
|
|
1884
|
+
const here = normaliseDir(dir);
|
|
1885
|
+
if (!here)
|
|
1886
|
+
return undefined;
|
|
1887
|
+
for (const id of this.running.keys()) {
|
|
1888
|
+
const s = await this.store.getSession(id);
|
|
1889
|
+
if (!s)
|
|
1890
|
+
continue;
|
|
1891
|
+
const there = normaliseDir(repoDirOf(s) || (await this.settings.load()).resolved.cwd);
|
|
1892
|
+
if (there && overlaps(here, there))
|
|
1893
|
+
return s;
|
|
1894
|
+
}
|
|
1895
|
+
return undefined;
|
|
1896
|
+
}
|
|
1590
1897
|
/**
|
|
1591
1898
|
* Everything one run was asked to do, from a given task onward, in the order it would run.
|
|
1592
1899
|
*
|
|
@@ -1673,37 +1980,27 @@ let OperatorService = class OperatorService {
|
|
|
1673
1980
|
})),
|
|
1674
1981
|
sessions: sessions.map((s) => ({ id: s.id, name: s.name, tasks: tasks.filter((x) => x.session.id === s.id).length })),
|
|
1675
1982
|
restores: [],
|
|
1676
|
-
|
|
1983
|
+
/*
|
|
1984
|
+
* Nothing on record says a run with no record of its own ever went unattended, and taken for
|
|
1985
|
+
* one, it was refused on every machine that refuses unattended runs, the default among them:
|
|
1986
|
+
* the dialog has no other mode to offer, so its button could never do anything for such a task.
|
|
1987
|
+
*/
|
|
1988
|
+
mode: group?.mode ?? 'confirm',
|
|
1677
1989
|
onFailure: group?.onFailure ?? session.onFailure ?? 'stop',
|
|
1678
1990
|
};
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
for (const entry of tasks) {
|
|
1688
|
-
const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
|
|
1689
|
-
if (!dir)
|
|
1690
|
-
continue;
|
|
1691
|
-
// A repository whose affected tasks never ran has nothing to go back to: the run never
|
|
1692
|
-
// touched it. Taking "no base commit recorded" as a refusal blocked "run again from
|
|
1693
|
-
// here" for every plan whose later sessions had not started yet — which is the usual
|
|
1694
|
-
// shape of a run that stopped in the middle.
|
|
1695
|
-
if (!entry.task.startedAt)
|
|
1696
|
-
continue;
|
|
1697
|
-
if (!byRepo.has(dir))
|
|
1698
|
-
byRepo.set(dir, entry);
|
|
1699
|
-
}
|
|
1700
|
-
for (const [dir, entry] of byRepo) {
|
|
1701
|
-
const preview = await restorePreview(entry.session, entry.task);
|
|
1991
|
+
// Asked of the machine as it is now, as `restartFrom` will ask it; see `unattendedRefused`.
|
|
1992
|
+
if (plan.mode === 'unattended')
|
|
1993
|
+
plan.unattendedRefused = (await this.unattendedRefusal('unattended')) ?? undefined;
|
|
1994
|
+
for (const target of restoreTargets(tasks)) {
|
|
1995
|
+
const preview = await restorePreview(target.session, target.task);
|
|
1996
|
+
// What would stop the restore itself, asked here as well, so the dialog says it before the
|
|
1997
|
+
// operator agrees rather than after.
|
|
1998
|
+
const refused = target.problem ?? (await this.restoreRefusal(target.session));
|
|
1702
1999
|
plan.restores.push({
|
|
1703
|
-
repoDir: preview.repoDir || dir,
|
|
1704
|
-
ok: preview.ok,
|
|
1705
|
-
problem: preview.problem,
|
|
1706
|
-
forTask:
|
|
2000
|
+
repoDir: preview.repoDir || target.dir,
|
|
2001
|
+
ok: preview.ok && !refused,
|
|
2002
|
+
problem: refused ?? preview.problem,
|
|
2003
|
+
forTask: target.task.title,
|
|
1707
2004
|
baseCommit: preview.baseCommit,
|
|
1708
2005
|
branchName: preview.branchName,
|
|
1709
2006
|
leftBehind: preview.leftBehind,
|
|
@@ -1735,27 +2032,95 @@ let OperatorService = class OperatorService {
|
|
|
1735
2032
|
if (plan.sessions.some((s) => this.running.has(s.id))) {
|
|
1736
2033
|
return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
|
|
1737
2034
|
}
|
|
2035
|
+
/*
|
|
2036
|
+
* Claimed before anything is moved, and held until the run it ends in has the browser: that run
|
|
2037
|
+
* needs it, and another session running on its own, or a read of the models, may have it. Found
|
|
2038
|
+
* out only at the start, the repositories were already taken back and the tasks queued, with
|
|
2039
|
+
* nothing to run them; and only looked at here, the browser could still be taken by a start that
|
|
2040
|
+
* came in while the repositories were being taken back, to the same end. Given back on every way
|
|
2041
|
+
* out that does not start the run.
|
|
2042
|
+
*/
|
|
2043
|
+
const ids = plan.sessions.map((s) => s.id);
|
|
2044
|
+
if (opts.start === false)
|
|
2045
|
+
return await this.restartMoves(sessionId, taskId, plan, opts);
|
|
2046
|
+
/*
|
|
2047
|
+
* The run's entrance rule, asked before anything moves, for the same reason as the browser
|
|
2048
|
+
* below. Asked only when the batch began, a machine that no longer lets this run go unattended
|
|
2049
|
+
* (a policy lock added since, isolation no longer accepted) took the repositories back and
|
|
2050
|
+
* queued the tasks, and only then refused the run they were moved for. The plan says so first
|
|
2051
|
+
* (`unattendedRefused`), so the dialog asks for the run step by step instead.
|
|
2052
|
+
*/
|
|
2053
|
+
const blocked = await this.unattendedRefusal(opts.mode ?? plan.mode);
|
|
2054
|
+
if (blocked)
|
|
2055
|
+
return { ...idle, reason: blocked };
|
|
2056
|
+
const claim = this.claimBrowser({ kind: 'batch', sessionIds: ids });
|
|
2057
|
+
if ('refused' in claim)
|
|
2058
|
+
return { ...idle, reason: claim.refused };
|
|
2059
|
+
let handedOn = false;
|
|
2060
|
+
try {
|
|
2061
|
+
const moved = await this.restartMoves(sessionId, taskId, plan, opts);
|
|
2062
|
+
if (moved.reason !== undefined)
|
|
2063
|
+
return moved;
|
|
2064
|
+
// Named after the task it goes back to, which is the one thing this run is about and the
|
|
2065
|
+
// only thing a heading could usefully say. There is no field to type into on a task card,
|
|
2066
|
+
// so leaving it to the operator meant leaving it blank every time.
|
|
2067
|
+
const runName = await this.suggestRunName(ids, plan.from.title);
|
|
2068
|
+
// From here the claim is the batch's to give back, whichever way its start goes.
|
|
2069
|
+
handedOn = true;
|
|
2070
|
+
/*
|
|
2071
|
+
* Exactly the tasks the preview listed, which are the tasks the run was asked to do from this
|
|
2072
|
+
* one on. Started with none named, the batch took every queued task of these sessions, so a
|
|
2073
|
+
* task the run never chose — queued beside it, or added since — was sent to the chat as well.
|
|
2074
|
+
*/
|
|
2075
|
+
const taskIds = plan.tasks.map((x) => x.taskId);
|
|
2076
|
+
const started = await this.startBatchHolding(claim.holder, ids, opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined, runName, taskIds);
|
|
2077
|
+
// A start refused now leaves the moves in place, and says so with its reason.
|
|
2078
|
+
const reason = started.started || started.reason === undefined ? started.reason : afterMoves(started.reason, moved.restored, moved.requeued);
|
|
2079
|
+
return { started: started.started, reason, requeued: moved.requeued, restored: moved.restored, batch: started.batch };
|
|
2080
|
+
}
|
|
2081
|
+
finally {
|
|
2082
|
+
if (!handedOn)
|
|
2083
|
+
this.releaseBrowser(claim.holder);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* What `restartFrom` moves before its run starts: the repositories taken back, then the tasks
|
|
2088
|
+
* queued again. A refusal, and "prepared, not started", come back with their reason; with none,
|
|
2089
|
+
* everything is in place for the run.
|
|
2090
|
+
*/
|
|
2091
|
+
async restartMoves(sessionId, taskId, plan, opts) {
|
|
2092
|
+
const idle = { started: false, requeued: 0, restored: [] };
|
|
1738
2093
|
const restored = [];
|
|
1739
2094
|
if (opts.restore !== false) {
|
|
1740
2095
|
for (const entry of plan.restores) {
|
|
1741
2096
|
if (!entry.ok)
|
|
1742
2097
|
return { ...idle, reason: `${entry.repoDir}: ${entry.problem ?? 'the restore could not be prepared.'}` };
|
|
1743
2098
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
2099
|
+
/*
|
|
2100
|
+
* Re-read each repository's own pivot and do it for real. The preview above is what the
|
|
2101
|
+
* operator agreed to; this is the same choice (`restoreTargets`), one step further. It was
|
|
2102
|
+
* worked out a second time here, by a copy of the rule that had not been given the preview's
|
|
2103
|
+
* "never started" skip, so a run whose later repository was never reached failed on it after
|
|
2104
|
+
* the first repository had already been moved — and queued nothing.
|
|
2105
|
+
*
|
|
2106
|
+
* A refusal part-way still says what was already moved, in the result and in its reason (the
|
|
2107
|
+
* one thing the page shows): a repository taken back is the operator's to know about, whatever
|
|
2108
|
+
* happened after it.
|
|
2109
|
+
*/
|
|
1746
2110
|
const session = (await this.store.getSession(sessionId));
|
|
1747
2111
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1748
2112
|
const { tasks } = await this.affectedByRestart(session, task);
|
|
1749
|
-
const
|
|
1750
|
-
|
|
1751
|
-
const
|
|
1752
|
-
if (
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
restored.push(`${dir} -> ${done.branch
|
|
2113
|
+
for (const target of restoreTargets(tasks)) {
|
|
2114
|
+
// Asked again at the moment of moving: a session may have started in this folder since.
|
|
2115
|
+
const refused = target.problem ?? (await this.restoreRefusal(target.session));
|
|
2116
|
+
if (refused)
|
|
2117
|
+
return { ...idle, restored, reason: afterMoves(`${target.dir}: ${refused}`, restored, 0) };
|
|
2118
|
+
const done = await restoreToBase(target.session, target.task, this.bus);
|
|
2119
|
+
if (!done.ok || !done.branch) {
|
|
2120
|
+
return { ...idle, restored, reason: afterMoves(`${target.dir}: ${done.problem ?? 'the restore failed.'}`, restored, 0) };
|
|
2121
|
+
}
|
|
2122
|
+
restored.push(`${target.dir} -> ${done.branch}`);
|
|
2123
|
+
await this.rerunFromRestore(target, done.branch, tasks);
|
|
1759
2124
|
}
|
|
1760
2125
|
}
|
|
1761
2126
|
let requeued = 0;
|
|
@@ -1768,8 +2133,8 @@ let OperatorService = class OperatorService {
|
|
|
1768
2133
|
}
|
|
1769
2134
|
catch (e) {
|
|
1770
2135
|
// One task refusing to be queued again must not leave the rest half-reset with no
|
|
1771
|
-
// explanation, so it stops here and says which one and why.
|
|
1772
|
-
return { ...idle, restored, reason: `"${t.title}" could not be queued again: ${e.message}
|
|
2136
|
+
// explanation, so it stops here and says which one and why — and what was done before it.
|
|
2137
|
+
return { ...idle, restored, requeued, reason: afterMoves(`"${t.title}" could not be queued again: ${e.message}`, restored, requeued) };
|
|
1773
2138
|
}
|
|
1774
2139
|
}
|
|
1775
2140
|
this.bus.publish({
|
|
@@ -1783,12 +2148,102 @@ let OperatorService = class OperatorService {
|
|
|
1783
2148
|
});
|
|
1784
2149
|
if (opts.start === false)
|
|
1785
2150
|
return { started: false, reason: 'prepared, not started', requeued, restored };
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2151
|
+
return { started: false, requeued, restored };
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Makes a restore hold for the run that follows it.
|
|
2155
|
+
*
|
|
2156
|
+
* The restore puts the repository on a new branch at the pivot task's starting commit, and the
|
|
2157
|
+
* re-run then puts each session on its own branch. In per-task mode that is right: a re-run cuts
|
|
2158
|
+
* a fresh branch from where the task first started, which is the restore commit. In per-session
|
|
2159
|
+
* mode it undid the restore: the session's one branch was checked out again, with the failed
|
|
2160
|
+
* attempt's commit still on it, so the attempt that was to start from the code as it was began on
|
|
2161
|
+
* the work it was meant to replace, and committed it a second time. So the pivot's session now
|
|
2162
|
+
* works on the restore branch — the code as it was before that task, with the tasks before it —
|
|
2163
|
+
* and its old branch keeps the attempt that failed, as the confirmation says.
|
|
2164
|
+
*
|
|
2165
|
+
* A per-session session after it in the same repository that this run reached has the same
|
|
2166
|
+
* problem whatever it started from: its one branch carries what its tasks did in this run, and
|
|
2167
|
+
* checked out again it would start them over on their own first attempt. So it gets a branch of
|
|
2168
|
+
* its own, and the old one keeps what it did. Where that branch starts depends on where the
|
|
2169
|
+
* session began. From a named branch: where its first task in this run began, as a restore of
|
|
2170
|
+
* that task would go back to, and the session's start stays as recorded. From where the
|
|
2171
|
+
* repository was, or from the session before it: that start was work this run is now redoing, so
|
|
2172
|
+
* it is forgotten, to be worked out again when the session is reached (for a `previous-session`
|
|
2173
|
+
* chain, from the work done again), and the branch is cut from that. A session the run never
|
|
2174
|
+
* reached holds nothing of this run and is left alone.
|
|
2175
|
+
*
|
|
2176
|
+
* A later per-task session is left as it is. From a named branch that is right: each re-run is
|
|
2177
|
+
* cut from where its task first started, that branch's commit. Chained on this run's work it is
|
|
2178
|
+
* not, and is not solved here: its tasks first started on the work being redone, so their re-runs
|
|
2179
|
+
* are cut from it again. Telling that apart from an operator's new "Start from", whose re-runs go
|
|
2180
|
+
* back to where each task first started on purpose (see `updateSession`), needs the restart
|
|
2181
|
+
* written on the session, and the session's record has no field for it.
|
|
2182
|
+
*/
|
|
2183
|
+
async rerunFromRestore(target, branch, tasks) {
|
|
2184
|
+
if (target.session.vcs?.branchMode === 'per-session') {
|
|
2185
|
+
const left = sessionBranchName(target.session);
|
|
2186
|
+
// The restore branch exists by this name, so the name is kept as it is (see `branchNameExact`).
|
|
2187
|
+
await this.store.updateSession(target.session.id, (s) => {
|
|
2188
|
+
if (s.vcs) {
|
|
2189
|
+
s.vcs.branchName = branch;
|
|
2190
|
+
s.vcs.branchNameExact = true;
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
this.bus.publish({
|
|
2194
|
+
sessionId: target.session.id,
|
|
2195
|
+
taskId: target.task.id,
|
|
2196
|
+
type: 'vcs-session-branch',
|
|
2197
|
+
level: 'info',
|
|
2198
|
+
message: `the session carries on from ${branch}, the code as it was before "${target.task.title}"; ${left} keeps the work that came after`,
|
|
2199
|
+
data: { branch, left },
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
const here = normaliseDir(target.dir);
|
|
2203
|
+
const inOrder = [...new Map(tasks.map(({ session }) => [session.id, session])).values()];
|
|
2204
|
+
const later = inOrder.slice(inOrder.findIndex((s) => s.id === target.session.id) + 1);
|
|
2205
|
+
for (const s of later) {
|
|
2206
|
+
// One that carries on an existing branch never gets here: `restoreTargets` refuses the restart.
|
|
2207
|
+
if (!s.vcs?.enabled || s.vcs.branchMode !== 'per-session' || s.vcs.startFrom === 'existing-branch')
|
|
2208
|
+
continue;
|
|
2209
|
+
// No base recorded: version control never got as far as making its branch.
|
|
2210
|
+
if (!s.vcsBaseCommit || normaliseDir(repoDirOf(s)) !== here)
|
|
2211
|
+
continue;
|
|
2212
|
+
const first = tasks.find((x) => x.session.id === s.id && x.task.startedAt)?.task;
|
|
2213
|
+
if (!first)
|
|
2214
|
+
continue;
|
|
2215
|
+
const left = sessionBranchName(s);
|
|
2216
|
+
const fresh = await freeBranchName(target.dir, left);
|
|
2217
|
+
// No record of the start is read as chained, the reading that cannot bring undone work back.
|
|
2218
|
+
const chained = s.vcsStart?.kind !== 'branch';
|
|
2219
|
+
const at = chained ? undefined : restorePoint(s, first);
|
|
2220
|
+
if (at) {
|
|
2221
|
+
const made = await branchAt(target.dir, fresh, at);
|
|
2222
|
+
// Not made: left to the run, which cuts it from the session's start when it is reached.
|
|
2223
|
+
if (!made.ok) {
|
|
2224
|
+
this.bus.publish({ sessionId: s.id, type: 'vcs-problem', level: 'warn', message: `${fresh} could not be made at ${at.slice(0, 8)}: ${made.problem}` });
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
await this.store.updateSession(s.id, (x) => {
|
|
2228
|
+
if (chained) {
|
|
2229
|
+
x.vcsBaseCommit = undefined;
|
|
2230
|
+
x.vcsStart = undefined;
|
|
2231
|
+
}
|
|
2232
|
+
if (x.vcs) {
|
|
2233
|
+
x.vcs.branchName = fresh;
|
|
2234
|
+
x.vcs.branchNameExact = true;
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
this.bus.publish({
|
|
2238
|
+
sessionId: s.id,
|
|
2239
|
+
type: 'vcs-session-branch',
|
|
2240
|
+
level: 'info',
|
|
2241
|
+
message: chained
|
|
2242
|
+
? `the session began from work this run is doing again, so it starts again when it is reached, on a new branch ${fresh}; ${left} keeps what it did before`
|
|
2243
|
+
: `the session starts its tasks again on a new branch ${fresh}, from where "${first.title}" began; ${left} keeps what it did before`,
|
|
2244
|
+
data: { branch: fresh, left },
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
1792
2247
|
}
|
|
1793
2248
|
/** Why a folder cannot be used for version control, or null when it can. */
|
|
1794
2249
|
repoProblem(dir) {
|
|
@@ -1806,7 +2261,7 @@ let OperatorService = class OperatorService {
|
|
|
1806
2261
|
await this.init();
|
|
1807
2262
|
const wanted = normaliseDir(dir);
|
|
1808
2263
|
const cfg = await this.settings.load();
|
|
1809
|
-
const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.
|
|
2264
|
+
const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.projectDir ?? ''])]
|
|
1810
2265
|
.filter((d) => d.trim())
|
|
1811
2266
|
.map(normaliseDir));
|
|
1812
2267
|
if (!wanted || !known.has(wanted)) {
|
|
@@ -1827,7 +2282,7 @@ let OperatorService = class OperatorService {
|
|
|
1827
2282
|
const problem = repoUnusableReason(rootDir);
|
|
1828
2283
|
const others = (cfg.project?.others ?? []).map((o) => {
|
|
1829
2284
|
const p = repoUnusableReason(o.rootDir);
|
|
1830
|
-
return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {})
|
|
2285
|
+
return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}) };
|
|
1831
2286
|
});
|
|
1832
2287
|
return {
|
|
1833
2288
|
rootDir,
|
|
@@ -1835,65 +2290,8 @@ let OperatorService = class OperatorService {
|
|
|
1835
2290
|
repoOk: rootDir !== '' && problem === null,
|
|
1836
2291
|
...(problem ? { repoProblem: problem } : {}),
|
|
1837
2292
|
others,
|
|
1838
|
-
mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
|
|
1839
|
-
desktop: cfg.project?.desktop !== false,
|
|
1840
|
-
mirror: cfg.project?.mirror,
|
|
1841
|
-
contextRoot: contextRoot(cfg),
|
|
1842
2293
|
};
|
|
1843
2294
|
}
|
|
1844
|
-
desktopRefresh = null;
|
|
1845
|
-
desktopRefreshAgain = false;
|
|
1846
|
-
/**
|
|
1847
|
-
* Asks for the Desktop copies to be brought up to date, while a run works.
|
|
1848
|
-
*
|
|
1849
|
-
* They used to be refreshed once, before a run, so a task that changed files left the Desktop —
|
|
1850
|
-
* and OneDrive, and the chat that reads from it — on the code as it was before the task. The
|
|
1851
|
-
* runner now calls this after every round of steps and after every task. One refresh runs at a
|
|
1852
|
-
* time: a request that arrives while one is running is remembered and served by one more pass
|
|
1853
|
-
* after it, so a burst of rounds costs two passes, not one per round, and the last pass always
|
|
1854
|
-
* sees the latest files. A failure is reported and never stops the run.
|
|
1855
|
-
*/
|
|
1856
|
-
requestDesktopRefresh() {
|
|
1857
|
-
if (this.desktopRefresh) {
|
|
1858
|
-
this.desktopRefreshAgain = true;
|
|
1859
|
-
return;
|
|
1860
|
-
}
|
|
1861
|
-
this.desktopRefresh = (async () => {
|
|
1862
|
-
do {
|
|
1863
|
-
this.desktopRefreshAgain = false;
|
|
1864
|
-
await this.refreshDesktopMirrors();
|
|
1865
|
-
} while (this.desktopRefreshAgain);
|
|
1866
|
-
})().finally(() => {
|
|
1867
|
-
this.desktopRefresh = null;
|
|
1868
|
-
});
|
|
1869
|
-
}
|
|
1870
|
-
/** Resolves once no Desktop refresh is running or waiting. For the checks, and for a clean stop. */
|
|
1871
|
-
async desktopRefreshSettled() {
|
|
1872
|
-
while (this.desktopRefresh)
|
|
1873
|
-
await this.desktopRefresh;
|
|
1874
|
-
}
|
|
1875
|
-
/**
|
|
1876
|
-
* Refreshes every project's Desktop folder, when the switch is on. Called before a run
|
|
1877
|
-
* starts, so what OneDrive holds when the chat opens is the code as it is now, for every
|
|
1878
|
-
* project on the Settings page and not only the one the session is about.
|
|
1879
|
-
*/
|
|
1880
|
-
async refreshDesktopMirrors() {
|
|
1881
|
-
const cfg = await this.settings.load();
|
|
1882
|
-
if (!cfg.project?.mirrorToDesktop)
|
|
1883
|
-
return;
|
|
1884
|
-
try {
|
|
1885
|
-
const outcomes = await mirrorAllProjects(cfg);
|
|
1886
|
-
this.bus.publish({
|
|
1887
|
-
sessionId: '*',
|
|
1888
|
-
type: 'desktop-mirror',
|
|
1889
|
-
level: outcomes.some((o) => o.problem) ? 'warn' : 'info',
|
|
1890
|
-
message: outcomes.map((o) => `${o.name}: ${o.result ? describeMirror(o.result) : `not refreshed — ${o.problem}`}`).join('; ') || 'no projects to mirror',
|
|
1891
|
-
});
|
|
1892
|
-
}
|
|
1893
|
-
catch (e) {
|
|
1894
|
-
this.bus.publish({ sessionId: '*', type: 'desktop-mirror-failed', level: 'warn', message: e.message });
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
2295
|
/**
|
|
1898
2296
|
* Stores the project folder and the other folders the operator works in. A field left out
|
|
1899
2297
|
* of the patch is kept; an empty `rootDir` clears the default, which is a real choice: it
|
|
@@ -1906,54 +2304,37 @@ let OperatorService = class OperatorService {
|
|
|
1906
2304
|
* — each of those would be a setting that looks filled in and points nowhere.
|
|
1907
2305
|
*/
|
|
1908
2306
|
async setProject(patch) {
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
const desktop = patch.desktop ?? current.desktop ?? true;
|
|
1940
|
-
const { desktop: _old, ...rest } = current;
|
|
1941
|
-
const cfg = await this.settings.save({
|
|
1942
|
-
...raw,
|
|
1943
|
-
project: { ...rest, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}), ...(desktop === false ? { desktop: false } : {}) },
|
|
2307
|
+
// Worked out from the file in its turn (see `Settings.update`), so a model chosen at the same
|
|
2308
|
+
// moment is kept, and a refusal below leaves the file as it was.
|
|
2309
|
+
await this.settings.update((raw) => {
|
|
2310
|
+
const current = (raw.project ?? {});
|
|
2311
|
+
const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
|
|
2312
|
+
const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
|
|
2313
|
+
if (rootDir && !existsSync(rootDir))
|
|
2314
|
+
throw new Error(`The folder ${rootDir} does not exist on this machine.`);
|
|
2315
|
+
const others = (patch.others ?? current.others ?? []).map((o) => ({
|
|
2316
|
+
name: (o.name ?? '').trim(),
|
|
2317
|
+
rootDir: (o.rootDir ?? '').trim(),
|
|
2318
|
+
}));
|
|
2319
|
+
const seen = new Set();
|
|
2320
|
+
for (const o of others) {
|
|
2321
|
+
if (!o.name)
|
|
2322
|
+
throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
|
|
2323
|
+
if (!o.rootDir)
|
|
2324
|
+
throw new Error(`The project "${o.name}" needs a folder.`);
|
|
2325
|
+
if (!existsSync(o.rootDir))
|
|
2326
|
+
throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
|
|
2327
|
+
if (sameFolder(o.rootDir, rootDir))
|
|
2328
|
+
throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
|
|
2329
|
+
const key = o.name.toLowerCase();
|
|
2330
|
+
if (seen.has(key))
|
|
2331
|
+
throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
|
|
2332
|
+
seen.add(key);
|
|
2333
|
+
}
|
|
2334
|
+
// What the removed Desktop copies left in the stored project is dropped with the next save.
|
|
2335
|
+
const { mirror: _m, desktop: _d, mirrorToDesktop: _t, ...rest } = current;
|
|
2336
|
+
return { ...raw, project: { ...rest, rootDir, name, others } };
|
|
1944
2337
|
});
|
|
1945
|
-
/*
|
|
1946
|
-
* The switch acts at once, both ways. On: every project's folder appears on the Desktop
|
|
1947
|
-
* now, not at the next run, because "I turned it on and nothing happened" is a support
|
|
1948
|
-
* question. Off: the folders go, because a copy of a code base left in OneDrive after the
|
|
1949
|
-
* operator said no is the one outcome the switch must not produce. A changed selection
|
|
1950
|
-
* with the switch on is applied the same way.
|
|
1951
|
-
*/
|
|
1952
|
-
const wasOn = current.mirrorToDesktop ?? false;
|
|
1953
|
-
if (mirrorToDesktop)
|
|
1954
|
-
await mirrorAllProjects(cfg);
|
|
1955
|
-
else if (wasOn)
|
|
1956
|
-
await removeProjectMirrors(cfg);
|
|
1957
2338
|
return await this.project();
|
|
1958
2339
|
}
|
|
1959
2340
|
// --- the model catalogue ----------------------------------------------------------------
|
|
@@ -1978,9 +2359,7 @@ let OperatorService = class OperatorService {
|
|
|
1978
2359
|
*/
|
|
1979
2360
|
async setDefaultReviewModel(name) {
|
|
1980
2361
|
await this.init();
|
|
1981
|
-
|
|
1982
|
-
const copilot = { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() };
|
|
1983
|
-
await this.settings.save({ ...raw, copilot });
|
|
2362
|
+
await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() } }));
|
|
1984
2363
|
this.bus.publish({
|
|
1985
2364
|
sessionId: '*',
|
|
1986
2365
|
type: 'default-review-model-changed',
|
|
@@ -1998,9 +2377,7 @@ let OperatorService = class OperatorService {
|
|
|
1998
2377
|
*/
|
|
1999
2378
|
async setDefaultModel(name) {
|
|
2000
2379
|
await this.init();
|
|
2001
|
-
|
|
2002
|
-
const copilot = { ...(raw.copilot ?? {}), defaultModel: name.trim() };
|
|
2003
|
-
await this.settings.save({ ...raw, copilot });
|
|
2380
|
+
await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultModel: name.trim() } }));
|
|
2004
2381
|
this.bus.publish({
|
|
2005
2382
|
sessionId: '*',
|
|
2006
2383
|
type: 'default-model-changed',
|
|
@@ -2021,26 +2398,27 @@ let OperatorService = class OperatorService {
|
|
|
2021
2398
|
* line-up.
|
|
2022
2399
|
*/
|
|
2023
2400
|
async refreshModels() {
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
}
|
|
2028
|
-
if (
|
|
2029
|
-
throw new Error(
|
|
2030
|
-
|
|
2031
|
-
const cfg = await this.settings.load();
|
|
2032
|
-
const transport = createTransport({
|
|
2033
|
-
profileDir: cfg.resolved.profileDir,
|
|
2034
|
-
transportDir: join(cfg.resolved.runsDir, '_models'),
|
|
2035
|
-
chatUrl: cfg.copilot.url,
|
|
2036
|
-
channel: cfg.copilot.channel,
|
|
2037
|
-
headless: cfg.copilot.headless,
|
|
2038
|
-
replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
|
|
2039
|
-
signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2040
|
-
humanWaitMs: cfg.copilot.humanWaitSec * 1000,
|
|
2041
|
-
keepFailurePage: cfg.copilot.keepFailurePage,
|
|
2042
|
-
});
|
|
2401
|
+
// Asked of the one claim every window goes through (see `browser`), before anything is waited
|
|
2402
|
+
// for. It used to count the sessions running, which is none in the moment between two
|
|
2403
|
+
// sessions of a batch, while the batch's window is still open.
|
|
2404
|
+
const claim = this.claimBrowser({ kind: 'models' });
|
|
2405
|
+
if ('refused' in claim)
|
|
2406
|
+
throw new Error(claim.refused);
|
|
2407
|
+
let transport = null;
|
|
2043
2408
|
try {
|
|
2409
|
+
await this.init();
|
|
2410
|
+
const cfg = await this.settings.load();
|
|
2411
|
+
transport = createTransport({
|
|
2412
|
+
profileDir: cfg.resolved.profileDir,
|
|
2413
|
+
transportDir: join(cfg.resolved.runsDir, '_models'),
|
|
2414
|
+
chatUrl: cfg.copilot.url,
|
|
2415
|
+
channel: cfg.copilot.channel,
|
|
2416
|
+
headless: cfg.copilot.headless,
|
|
2417
|
+
replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
|
|
2418
|
+
signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2419
|
+
humanWaitMs: cfg.copilot.humanWaitSec * 1000,
|
|
2420
|
+
keepFailurePage: cfg.copilot.keepFailurePage,
|
|
2421
|
+
});
|
|
2044
2422
|
await transport.open();
|
|
2045
2423
|
await transport.ensureSignedIn();
|
|
2046
2424
|
const { options, current, note } = await transport.listModels();
|
|
@@ -2049,8 +2427,8 @@ let OperatorService = class OperatorService {
|
|
|
2049
2427
|
return catalogue;
|
|
2050
2428
|
}
|
|
2051
2429
|
finally {
|
|
2052
|
-
await transport
|
|
2053
|
-
this.
|
|
2430
|
+
await transport?.close().catch(() => undefined);
|
|
2431
|
+
this.releaseBrowser(claim.holder);
|
|
2054
2432
|
}
|
|
2055
2433
|
}
|
|
2056
2434
|
// --- the registry ---------------------------------------------------------------------
|
|
@@ -2139,41 +2517,10 @@ let OperatorService = class OperatorService {
|
|
|
2139
2517
|
return out;
|
|
2140
2518
|
}
|
|
2141
2519
|
// --- environment ----------------------------------------------------------------------
|
|
2142
|
-
async dirs(root, respectGitignore = true) {
|
|
2143
|
-
return await listSelectableDirs(root, { respectGitignore });
|
|
2144
|
-
}
|
|
2145
2520
|
/** Opens the machine's own folder dialog and reports what was picked. */
|
|
2146
2521
|
async browseFolder(start) {
|
|
2147
2522
|
return await pickFolder(start);
|
|
2148
2523
|
}
|
|
2149
|
-
/**
|
|
2150
|
-
* What the current selection would copy, without writing anything. This is how the two
|
|
2151
|
-
* switches become checkable: turn the env one on and the `.env` files move from the skipped
|
|
2152
|
-
* list into the copied one, whatever `.gitignore` says about them.
|
|
2153
|
-
*/
|
|
2154
|
-
async previewMirror(input) {
|
|
2155
|
-
const conflicts = findSelectionConflicts(input.includeDirs ?? [], input.excludeDirs ?? []);
|
|
2156
|
-
if (conflicts.length > 0)
|
|
2157
|
-
throw new Error(describeConflicts(conflicts));
|
|
2158
|
-
const cfg = await this.settings.load();
|
|
2159
|
-
const { files, skipped } = await collectFiles({
|
|
2160
|
-
rootDir: input.rootDir,
|
|
2161
|
-
includeDirs: input.includeDirs?.length ? input.includeDirs : ['.'],
|
|
2162
|
-
excludeDirs: input.excludeDirs ?? [],
|
|
2163
|
-
targetDir: '',
|
|
2164
|
-
respectGitignore: input.respectGitignore ?? true,
|
|
2165
|
-
includeEnvFiles: input.includeEnvFiles ?? false,
|
|
2166
|
-
ignoreDirs: cfg.projectMirror.ignoreDirs,
|
|
2167
|
-
maxFileBytes: cfg.projectMirror.maxFileBytes,
|
|
2168
|
-
});
|
|
2169
|
-
let totalBytes = 0;
|
|
2170
|
-
for (const rel of files) {
|
|
2171
|
-
const s = await import('node:fs/promises').then((fs) => fs.stat(join(input.rootDir, rel)).catch(() => null));
|
|
2172
|
-
if (s)
|
|
2173
|
-
totalBytes += s.size;
|
|
2174
|
-
}
|
|
2175
|
-
return { files, skipped, envFiles: files.filter(isEnvPath), totalBytes, conflicts };
|
|
2176
|
-
}
|
|
2177
2524
|
async doctor() {
|
|
2178
2525
|
const cfg = await this.settings.load();
|
|
2179
2526
|
const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
|
|
@@ -2204,7 +2551,6 @@ let OperatorService = class OperatorService {
|
|
|
2204
2551
|
*/
|
|
2205
2552
|
isolation: assessIsolation(cfg.execution.isolation, readIsolationSignals()),
|
|
2206
2553
|
folderDialog: process.platform === 'win32',
|
|
2207
|
-
alwaysIgnoredDirs: DEFAULT_IGNORE_DIRS,
|
|
2208
2554
|
};
|
|
2209
2555
|
}
|
|
2210
2556
|
};
|
|
@@ -2227,21 +2573,3 @@ function resolveRunId(task, requested) {
|
|
|
2227
2573
|
const owned = [task.runId, ...(task.attempts ?? []).map((a) => a.runId)].filter(Boolean);
|
|
2228
2574
|
return owned.includes(requested) ? requested : null;
|
|
2229
2575
|
}
|
|
2230
|
-
function isEnvPath(relPath) {
|
|
2231
|
-
const name = relPath.split('/').pop()?.toLowerCase() ?? '';
|
|
2232
|
-
return name === '.env' || name.startsWith('.env.');
|
|
2233
|
-
}
|
|
2234
|
-
/**
|
|
2235
|
-
* Refuses a selection that contradicts itself, at the point it is saved rather than at the
|
|
2236
|
-
* point it runs. A contradiction is cheap to fix while the form is open and expensive to
|
|
2237
|
-
* discover an hour later, when the first task of a run fails on it.
|
|
2238
|
-
*/
|
|
2239
|
-
function assertMirrorIsCoherent(mirror) {
|
|
2240
|
-
const conflicts = findSelectionConflicts(mirror.includeDirs ?? [], mirror.excludeDirs ?? []);
|
|
2241
|
-
if (conflicts.length > 0) {
|
|
2242
|
-
throw new Error(`The include and exclude lists contradict each other. ${describeConflicts(conflicts)}`);
|
|
2243
|
-
}
|
|
2244
|
-
if (mirror.enabled && mirror.rootDir !== undefined && !mirror.rootDir.trim()) {
|
|
2245
|
-
throw new Error('Attaching project files needs a project root.');
|
|
2246
|
-
}
|
|
2247
|
-
}
|