copilot-operator 0.1.13 → 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 +663 -367
- 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 +476 -163
- package/dist/src/plan/brief.js +37 -44
- package/dist/src/plan/importPlan.js +15 -7
- package/dist/src/plan/schema.js +64 -29
- package/dist/src/plan/script.js +0 -15
- package/dist/src/plan/systemGuide.js +2 -177
- 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 +49 -9
- package/dist/src/session/store.js +215 -80
- package/dist/src/vcs/scope.js +29 -6
- package/dist/src/vcs/taskVcs.js +78 -10
- 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/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 -173
- 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-84c19f047972ffc3.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → 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 { branchExists, changedFilesBetween, fileAt, gitAvailable, localBranches, plannedBranchName, 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,7 +387,7 @@ 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
|
}
|
|
@@ -279,6 +395,12 @@ let OperatorService = class OperatorService {
|
|
|
279
395
|
merged.baseBranch = merged.baseBranch.trim();
|
|
280
396
|
if (merged.existingBranch !== undefined)
|
|
281
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;
|
|
282
404
|
/*
|
|
283
405
|
* Where the session starts is fixed at its first run. A new choice clears that record, so
|
|
284
406
|
* the next task to run is cut from what was chosen now; the tasks that already ran keep
|
|
@@ -611,15 +733,17 @@ let OperatorService = class OperatorService {
|
|
|
611
733
|
.sort((a, b) => (a.runGroup?.id === runId ? (a.runGroup.order ?? 0) : 0) - (b.runGroup?.id === runId ? (b.runGroup.order ?? 0) : 0));
|
|
612
734
|
if (sessions.length === 0)
|
|
613
735
|
throw new Error(`No session took part in run ${runId}.`);
|
|
614
|
-
|
|
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);
|
|
615
744
|
const name = group?.name?.trim() || `run-${(group?.startedAt ?? '').slice(0, 16).replace(/[:T]/g, '-')}`;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
// A task belongs to the run if this attempt ran in it, or if it was queued for it and
|
|
619
|
-
// never reached — that second kind is what the run's own record on the session is for.
|
|
620
|
-
taskFilter: (s, t) => t.runGroup?.id === runId || (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)),
|
|
621
|
-
label: name,
|
|
622
|
-
};
|
|
745
|
+
// Each task as it ended in this run, not as it is now; see `runScope`.
|
|
746
|
+
scope = runScope(sessions, runId, name);
|
|
623
747
|
}
|
|
624
748
|
else if (where.sessionId) {
|
|
625
749
|
const session = await this.store.getSession(where.sessionId);
|
|
@@ -704,34 +828,56 @@ let OperatorService = class OperatorService {
|
|
|
704
828
|
isRunning(sessionId) {
|
|
705
829
|
return this.running.has(sessionId);
|
|
706
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
|
+
}
|
|
707
847
|
/**
|
|
708
848
|
* Starts the session's queued tasks in the background. Returns immediately; progress
|
|
709
|
-
* arrives on the event stream. One run
|
|
849
|
+
* arrives on the event stream. One run at a time, of this session or any other.
|
|
710
850
|
*/
|
|
711
851
|
async start(sessionId, mode = 'confirm', name) {
|
|
712
852
|
// One conversation at a time is not a policy, it is the browser profile: a second run
|
|
713
|
-
// does not get a second browser, it gets an error about a closed one.
|
|
714
|
-
//
|
|
715
|
-
// still be read, rather than three minutes later in a stack trace.
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
+
}
|
|
735
881
|
}
|
|
736
882
|
/**
|
|
737
883
|
* The same thing `start` does, but it also hands back a promise that settles when the run
|
|
@@ -748,12 +894,8 @@ let OperatorService = class OperatorService {
|
|
|
748
894
|
/** The press of a start button this belongs to, stamped onto every task it reaches. */
|
|
749
895
|
runGroup,
|
|
750
896
|
/** Only these queued tasks, when the operator chose some; the rest stay queued. */
|
|
751
|
-
onlyTasks) {
|
|
897
|
+
onlyTasks, how = {}) {
|
|
752
898
|
await this.init();
|
|
753
|
-
// Every project's Desktop copy brought up to date before the chat opens, when the operator
|
|
754
|
-
// asked for that. Before the run rather than before each task: a task's own mirror already
|
|
755
|
-
// refreshes its project, and the other projects do not change while the bot works.
|
|
756
|
-
await this.refreshDesktopMirrors();
|
|
757
899
|
const idle = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
|
|
758
900
|
if (this.running.has(sessionId))
|
|
759
901
|
return { started: false, reason: 'already running', done: Promise.resolve(idle) };
|
|
@@ -786,6 +928,12 @@ let OperatorService = class OperatorService {
|
|
|
786
928
|
const precondition = unattendedPrecondition(policy);
|
|
787
929
|
if (precondition)
|
|
788
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
|
+
}
|
|
789
937
|
const controller = new AbortController();
|
|
790
938
|
/*
|
|
791
939
|
* The same authorizer in both modes. It used to be `unattendedAuthorizer` here for an unattended
|
|
@@ -813,14 +961,13 @@ let OperatorService = class OperatorService {
|
|
|
813
961
|
bus: this.bus,
|
|
814
962
|
authorizer,
|
|
815
963
|
signal: controller.signal,
|
|
816
|
-
// Read between tasks, so the one in flight finishes properly first. The batch owns
|
|
817
|
-
//
|
|
818
|
-
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,
|
|
819
967
|
// The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
|
|
820
968
|
// and the policy.json of a later task must say so.
|
|
821
969
|
currentMode: () => this.running.get(sessionId)?.mode ?? mode,
|
|
822
970
|
// The Desktop copies follow the work while it happens, not only before the run.
|
|
823
|
-
onWorkChanged: () => this.requestDesktopRefresh(),
|
|
824
971
|
// The session decides whether its tasks are one chain or a set of independent checks.
|
|
825
972
|
continueOnFailure: session.onFailure === 'continue',
|
|
826
973
|
transport,
|
|
@@ -935,20 +1082,64 @@ let OperatorService = class OperatorService {
|
|
|
935
1082
|
* tasks nobody chose stay queued exactly as they were. Absent means every queued task.
|
|
936
1083
|
*/
|
|
937
1084
|
taskIds) {
|
|
938
|
-
await this.init();
|
|
939
|
-
const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
|
|
940
|
-
if (this.batch?.running)
|
|
941
|
-
return { started: false, reason: 'a batch is already running' };
|
|
942
1085
|
const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
|
|
943
|
-
|
|
944
|
-
|
|
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) {
|
|
945
1139
|
// Worked out here rather than asked of the operator again: every way into this — the run
|
|
946
1140
|
// panel, continuing after a failure, "Run again from here" — either has a field they may
|
|
947
1141
|
// have left empty or has no field at all.
|
|
948
1142
|
const chosenName = name?.trim() || (await this.suggestRunName(wanted));
|
|
949
|
-
if (wanted.some((id) => this.running.has(id))) {
|
|
950
|
-
return { started: false, reason: 'one of the selected sessions is already running on its own' };
|
|
951
|
-
}
|
|
952
1143
|
const sessions = [];
|
|
953
1144
|
for (const id of wanted) {
|
|
954
1145
|
const s = await this.store.getSession(id);
|
|
@@ -962,7 +1153,7 @@ let OperatorService = class OperatorService {
|
|
|
962
1153
|
: { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
|
|
963
1154
|
}
|
|
964
1155
|
if (!sessions.some((s) => s.state === 'waiting')) {
|
|
965
|
-
return {
|
|
1156
|
+
return { reason: 'none of the selected sessions has a queued task' };
|
|
966
1157
|
}
|
|
967
1158
|
/*
|
|
968
1159
|
* The same entrance rule `start` has, asked here too and for the same reason: before a browser
|
|
@@ -972,17 +1163,9 @@ let OperatorService = class OperatorService {
|
|
|
972
1163
|
* session was quietly skipped with the reason held in a batch state nobody was looking at. Asked
|
|
973
1164
|
* here, the refusal is the answer to the press, shown under the button that caused it.
|
|
974
1165
|
*/
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
mode,
|
|
979
|
-
allowedPrograms: cfg.execution.allowedPrograms,
|
|
980
|
-
isolation: cfg.execution.isolation,
|
|
981
|
-
lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
|
|
982
|
-
});
|
|
983
|
-
if (blocked)
|
|
984
|
-
return { started: false, reason: blocked };
|
|
985
|
-
}
|
|
1166
|
+
const blocked = await this.unattendedRefusal(mode);
|
|
1167
|
+
if (blocked)
|
|
1168
|
+
return { reason: blocked };
|
|
986
1169
|
// One model for the whole run, chosen here rather than opened on every session first. It
|
|
987
1170
|
// is written onto the sessions instead of being held for the run, so what the session says
|
|
988
1171
|
// it will use and what it used are the same thing afterwards — including for anyone who
|
|
@@ -1014,20 +1197,20 @@ let OperatorService = class OperatorService {
|
|
|
1014
1197
|
});
|
|
1015
1198
|
}
|
|
1016
1199
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
+
},
|
|
1028
1213
|
};
|
|
1029
|
-
void this.runBatch();
|
|
1030
|
-
return { started: true, batch: this.batchState() };
|
|
1031
1214
|
}
|
|
1032
1215
|
/** Stops the session that is running now and leaves the rest of the batch unstarted. */
|
|
1033
1216
|
/**
|
|
@@ -1081,6 +1264,24 @@ let OperatorService = class OperatorService {
|
|
|
1081
1264
|
await this.stop(current.sessionId);
|
|
1082
1265
|
return { stopping: true };
|
|
1083
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
|
+
}
|
|
1084
1285
|
/**
|
|
1085
1286
|
* The batch loop. Nothing here runs in parallel, on purpose: see `beginRun`.
|
|
1086
1287
|
*
|
|
@@ -1088,10 +1289,7 @@ let OperatorService = class OperatorService {
|
|
|
1088
1289
|
* failure is `done`; one with a failed task is `failed`; one the operator stopped, or one
|
|
1089
1290
|
* whose chain stopped early with no failure of its own, is `stopped`.
|
|
1090
1291
|
*/
|
|
1091
|
-
async
|
|
1092
|
-
const batch = this.batch;
|
|
1093
|
-
if (!batch)
|
|
1094
|
-
return;
|
|
1292
|
+
async batchLoop(batch) {
|
|
1095
1293
|
/*
|
|
1096
1294
|
* One browser for the whole batch.
|
|
1097
1295
|
*
|
|
@@ -1196,7 +1394,11 @@ let OperatorService = class OperatorService {
|
|
|
1196
1394
|
message: `starting as part of a batch of ${batch.sessions.length} session(s), in ${batch.mode} mode`,
|
|
1197
1395
|
data: { batchId: batch.id },
|
|
1198
1396
|
});
|
|
1199
|
-
|
|
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
|
+
});
|
|
1200
1402
|
if (!begun.started) {
|
|
1201
1403
|
entry.state = 'skipped';
|
|
1202
1404
|
entry.reason = begun.reason;
|
|
@@ -1301,11 +1503,18 @@ let OperatorService = class OperatorService {
|
|
|
1301
1503
|
*/
|
|
1302
1504
|
const cfg = await this.settings.load();
|
|
1303
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
|
+
*/
|
|
1304
1511
|
const unattendedBlocked = !unattendedPrecondition(unattendedRule)
|
|
1305
1512
|
? undefined
|
|
1306
|
-
:
|
|
1307
|
-
? '
|
|
1308
|
-
: '
|
|
1513
|
+
: unattendedRule.lockedToConfirm
|
|
1514
|
+
? 'lock'
|
|
1515
|
+
: unattendedIsolationRefusal('unattended', cfg.execution.isolation)
|
|
1516
|
+
? 'isolation'
|
|
1517
|
+
: 'allowlist';
|
|
1309
1518
|
return {
|
|
1310
1519
|
text: planBrief({
|
|
1311
1520
|
lang,
|
|
@@ -1353,7 +1562,7 @@ let OperatorService = class OperatorService {
|
|
|
1353
1562
|
check.plan.sessions.forEach((session, i) => {
|
|
1354
1563
|
if (session.vcs?.enabled === false)
|
|
1355
1564
|
return;
|
|
1356
|
-
const dir =
|
|
1565
|
+
const dir = plannedRepoDir(session);
|
|
1357
1566
|
const reason = repoUnusableReason(dir);
|
|
1358
1567
|
if (reason)
|
|
1359
1568
|
repoIssues.push({ path: `sessions[${i}].vcs.repoDir`, message: reason });
|
|
@@ -1369,17 +1578,23 @@ let OperatorService = class OperatorService {
|
|
|
1369
1578
|
return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
|
|
1370
1579
|
/*
|
|
1371
1580
|
* A branch to carry on must be there to carry on — unless a session before it in this same plan
|
|
1372
|
-
* is the one that makes it. Refused now, with the branches that are
|
|
1373
|
-
* first run with a task refused.
|
|
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.
|
|
1374
1588
|
*/
|
|
1375
1589
|
for (const [i, session] of check.plan.sessions.entries()) {
|
|
1376
1590
|
const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
|
|
1377
1591
|
if (!wanted)
|
|
1378
1592
|
continue;
|
|
1379
|
-
const dir =
|
|
1593
|
+
const dir = plannedRepoDir(session);
|
|
1594
|
+
const here = normaliseDir(dir);
|
|
1380
1595
|
const madeEarlier = check.plan.sessions
|
|
1381
1596
|
.slice(0, i)
|
|
1382
|
-
.some((e) =>
|
|
1597
|
+
.some((e) => normaliseDir(plannedRepoDir(e)) === here && branchesMadeBy(e).includes(wanted));
|
|
1383
1598
|
if (madeEarlier || (await branchExists(dir, wanted)))
|
|
1384
1599
|
continue;
|
|
1385
1600
|
const have = await localBranches(dir);
|
|
@@ -1436,7 +1651,9 @@ let OperatorService = class OperatorService {
|
|
|
1436
1651
|
*/
|
|
1437
1652
|
const persona = (await this.getContext('persona', 'en')).content;
|
|
1438
1653
|
// No default models passed: a session the plan gives no model follows Settings at run time.
|
|
1439
|
-
|
|
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());
|
|
1440
1657
|
return {
|
|
1441
1658
|
ok: true,
|
|
1442
1659
|
result: { ...result, warnings: [...check.warnings, ...result.warnings] },
|
|
@@ -1507,14 +1724,26 @@ let OperatorService = class OperatorService {
|
|
|
1507
1724
|
* one would read.
|
|
1508
1725
|
*/
|
|
1509
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;
|
|
1510
1739
|
this.bus.publish({
|
|
1511
1740
|
sessionId,
|
|
1512
1741
|
type: 'run-mode-changed',
|
|
1513
1742
|
level: mode === 'unattended' ? 'warn' : 'info',
|
|
1514
1743
|
message: mode === 'unattended'
|
|
1515
|
-
?
|
|
1516
|
-
:
|
|
1517
|
-
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 } : {}) },
|
|
1518
1747
|
});
|
|
1519
1748
|
if (mode === 'unattended') {
|
|
1520
1749
|
for (const [id, w] of this.waiting) {
|
|
@@ -1534,6 +1763,12 @@ let OperatorService = class OperatorService {
|
|
|
1534
1763
|
}
|
|
1535
1764
|
webAuthorizer(policy, signal) {
|
|
1536
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
|
+
}
|
|
1537
1772
|
// The operator may have pressed "run the rest without asking" on an earlier step.
|
|
1538
1773
|
// This is checked per step rather than captured once, which is what makes the switch
|
|
1539
1774
|
// take effect from the very next step instead of the next run. A held step is the
|
|
@@ -1551,17 +1786,13 @@ let OperatorService = class OperatorService {
|
|
|
1551
1786
|
createdAt: new Date().toISOString(),
|
|
1552
1787
|
...(held ? { network: held.network } : {}),
|
|
1553
1788
|
};
|
|
1554
|
-
if (signal.aborted) {
|
|
1555
|
-
resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
1789
|
this.waiting.set(approval.id, { approval, resolve: resolvePromise });
|
|
1559
1790
|
this.bus.publish({ sessionId: approval.sessionId, taskId: approval.taskId, type: 'approval-requested', level: 'warn',
|
|
1560
1791
|
message: held
|
|
1561
1792
|
? `held for the operator — ${held.network}: ${approval.description}`
|
|
1562
1793
|
: `waiting for approval: ${approval.description}`,
|
|
1563
1794
|
data: { ...approval } });
|
|
1564
|
-
}));
|
|
1795
|
+
}), signal);
|
|
1565
1796
|
}
|
|
1566
1797
|
// --- events ---------------------------------------------------------------------------
|
|
1567
1798
|
recentEvents(sessionId) {
|
|
@@ -1601,24 +1832,68 @@ let OperatorService = class OperatorService {
|
|
|
1601
1832
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1602
1833
|
if (!task)
|
|
1603
1834
|
throw new Error('No such task.');
|
|
1604
|
-
|
|
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;
|
|
1605
1840
|
}
|
|
1606
1841
|
async restore(sessionId, taskId) {
|
|
1607
1842
|
await this.init();
|
|
1608
|
-
if (this.running.has(sessionId)) {
|
|
1609
|
-
return { ok: false, problem: 'This session is running. Stop it before moving the repository to another branch.' };
|
|
1610
|
-
}
|
|
1611
|
-
if (this.batch?.running) {
|
|
1612
|
-
return { ok: false, problem: 'A batch is running. Stop it before moving the repository to another branch.' };
|
|
1613
|
-
}
|
|
1614
1843
|
const session = await this.store.getSession(sessionId);
|
|
1615
1844
|
if (!session)
|
|
1616
1845
|
throw new Error('No such session.');
|
|
1617
1846
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1618
1847
|
if (!task)
|
|
1619
1848
|
throw new Error('No such task.');
|
|
1849
|
+
const refused = await this.restoreRefusal(session);
|
|
1850
|
+
if (refused)
|
|
1851
|
+
return { ok: false, problem: refused };
|
|
1620
1852
|
return await restoreToBase(session, task, this.bus);
|
|
1621
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
|
+
}
|
|
1622
1897
|
/**
|
|
1623
1898
|
* Everything one run was asked to do, from a given task onward, in the order it would run.
|
|
1624
1899
|
*
|
|
@@ -1705,37 +1980,27 @@ let OperatorService = class OperatorService {
|
|
|
1705
1980
|
})),
|
|
1706
1981
|
sessions: sessions.map((s) => ({ id: s.id, name: s.name, tasks: tasks.filter((x) => x.session.id === s.id).length })),
|
|
1707
1982
|
restores: [],
|
|
1708
|
-
|
|
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',
|
|
1709
1989
|
onFailure: group?.onFailure ?? session.onFailure ?? 'stop',
|
|
1710
1990
|
};
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
for (const entry of tasks) {
|
|
1720
|
-
const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
|
|
1721
|
-
if (!dir)
|
|
1722
|
-
continue;
|
|
1723
|
-
// A repository whose affected tasks never ran has nothing to go back to: the run never
|
|
1724
|
-
// touched it. Taking "no base commit recorded" as a refusal blocked "run again from
|
|
1725
|
-
// here" for every plan whose later sessions had not started yet — which is the usual
|
|
1726
|
-
// shape of a run that stopped in the middle.
|
|
1727
|
-
if (!entry.task.startedAt)
|
|
1728
|
-
continue;
|
|
1729
|
-
if (!byRepo.has(dir))
|
|
1730
|
-
byRepo.set(dir, entry);
|
|
1731
|
-
}
|
|
1732
|
-
for (const [dir, entry] of byRepo) {
|
|
1733
|
-
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));
|
|
1734
1999
|
plan.restores.push({
|
|
1735
|
-
repoDir: preview.repoDir || dir,
|
|
1736
|
-
ok: preview.ok,
|
|
1737
|
-
problem: preview.problem,
|
|
1738
|
-
forTask:
|
|
2000
|
+
repoDir: preview.repoDir || target.dir,
|
|
2001
|
+
ok: preview.ok && !refused,
|
|
2002
|
+
problem: refused ?? preview.problem,
|
|
2003
|
+
forTask: target.task.title,
|
|
1739
2004
|
baseCommit: preview.baseCommit,
|
|
1740
2005
|
branchName: preview.branchName,
|
|
1741
2006
|
leftBehind: preview.leftBehind,
|
|
@@ -1767,27 +2032,95 @@ let OperatorService = class OperatorService {
|
|
|
1767
2032
|
if (plan.sessions.some((s) => this.running.has(s.id))) {
|
|
1768
2033
|
return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
|
|
1769
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: [] };
|
|
1770
2093
|
const restored = [];
|
|
1771
2094
|
if (opts.restore !== false) {
|
|
1772
2095
|
for (const entry of plan.restores) {
|
|
1773
2096
|
if (!entry.ok)
|
|
1774
2097
|
return { ...idle, reason: `${entry.repoDir}: ${entry.problem ?? 'the restore could not be prepared.'}` };
|
|
1775
2098
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
+
*/
|
|
1778
2110
|
const session = (await this.store.getSession(sessionId));
|
|
1779
2111
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1780
2112
|
const { tasks } = await this.affectedByRestart(session, task);
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1783
|
-
const
|
|
1784
|
-
if (
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
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);
|
|
1791
2124
|
}
|
|
1792
2125
|
}
|
|
1793
2126
|
let requeued = 0;
|
|
@@ -1800,8 +2133,8 @@ let OperatorService = class OperatorService {
|
|
|
1800
2133
|
}
|
|
1801
2134
|
catch (e) {
|
|
1802
2135
|
// One task refusing to be queued again must not leave the rest half-reset with no
|
|
1803
|
-
// explanation, so it stops here and says which one and why.
|
|
1804
|
-
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) };
|
|
1805
2138
|
}
|
|
1806
2139
|
}
|
|
1807
2140
|
this.bus.publish({
|
|
@@ -1815,12 +2148,102 @@ let OperatorService = class OperatorService {
|
|
|
1815
2148
|
});
|
|
1816
2149
|
if (opts.start === false)
|
|
1817
2150
|
return { started: false, reason: 'prepared, not started', requeued, restored };
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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
|
+
}
|
|
1824
2247
|
}
|
|
1825
2248
|
/** Why a folder cannot be used for version control, or null when it can. */
|
|
1826
2249
|
repoProblem(dir) {
|
|
@@ -1838,7 +2261,7 @@ let OperatorService = class OperatorService {
|
|
|
1838
2261
|
await this.init();
|
|
1839
2262
|
const wanted = normaliseDir(dir);
|
|
1840
2263
|
const cfg = await this.settings.load();
|
|
1841
|
-
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 ?? ''])]
|
|
1842
2265
|
.filter((d) => d.trim())
|
|
1843
2266
|
.map(normaliseDir));
|
|
1844
2267
|
if (!wanted || !known.has(wanted)) {
|
|
@@ -1859,7 +2282,7 @@ let OperatorService = class OperatorService {
|
|
|
1859
2282
|
const problem = repoUnusableReason(rootDir);
|
|
1860
2283
|
const others = (cfg.project?.others ?? []).map((o) => {
|
|
1861
2284
|
const p = repoUnusableReason(o.rootDir);
|
|
1862
|
-
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 } : {}) };
|
|
1863
2286
|
});
|
|
1864
2287
|
return {
|
|
1865
2288
|
rootDir,
|
|
@@ -1867,65 +2290,8 @@ let OperatorService = class OperatorService {
|
|
|
1867
2290
|
repoOk: rootDir !== '' && problem === null,
|
|
1868
2291
|
...(problem ? { repoProblem: problem } : {}),
|
|
1869
2292
|
others,
|
|
1870
|
-
mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
|
|
1871
|
-
desktop: cfg.project?.desktop !== false,
|
|
1872
|
-
mirror: cfg.project?.mirror,
|
|
1873
|
-
contextRoot: contextRoot(cfg),
|
|
1874
2293
|
};
|
|
1875
2294
|
}
|
|
1876
|
-
desktopRefresh = null;
|
|
1877
|
-
desktopRefreshAgain = false;
|
|
1878
|
-
/**
|
|
1879
|
-
* Asks for the Desktop copies to be brought up to date, while a run works.
|
|
1880
|
-
*
|
|
1881
|
-
* They used to be refreshed once, before a run, so a task that changed files left the Desktop —
|
|
1882
|
-
* and OneDrive, and the chat that reads from it — on the code as it was before the task. The
|
|
1883
|
-
* runner now calls this after every round of steps and after every task. One refresh runs at a
|
|
1884
|
-
* time: a request that arrives while one is running is remembered and served by one more pass
|
|
1885
|
-
* after it, so a burst of rounds costs two passes, not one per round, and the last pass always
|
|
1886
|
-
* sees the latest files. A failure is reported and never stops the run.
|
|
1887
|
-
*/
|
|
1888
|
-
requestDesktopRefresh() {
|
|
1889
|
-
if (this.desktopRefresh) {
|
|
1890
|
-
this.desktopRefreshAgain = true;
|
|
1891
|
-
return;
|
|
1892
|
-
}
|
|
1893
|
-
this.desktopRefresh = (async () => {
|
|
1894
|
-
do {
|
|
1895
|
-
this.desktopRefreshAgain = false;
|
|
1896
|
-
await this.refreshDesktopMirrors();
|
|
1897
|
-
} while (this.desktopRefreshAgain);
|
|
1898
|
-
})().finally(() => {
|
|
1899
|
-
this.desktopRefresh = null;
|
|
1900
|
-
});
|
|
1901
|
-
}
|
|
1902
|
-
/** Resolves once no Desktop refresh is running or waiting. For the checks, and for a clean stop. */
|
|
1903
|
-
async desktopRefreshSettled() {
|
|
1904
|
-
while (this.desktopRefresh)
|
|
1905
|
-
await this.desktopRefresh;
|
|
1906
|
-
}
|
|
1907
|
-
/**
|
|
1908
|
-
* Refreshes every project's Desktop folder, when the switch is on. Called before a run
|
|
1909
|
-
* starts, so what OneDrive holds when the chat opens is the code as it is now, for every
|
|
1910
|
-
* project on the Settings page and not only the one the session is about.
|
|
1911
|
-
*/
|
|
1912
|
-
async refreshDesktopMirrors() {
|
|
1913
|
-
const cfg = await this.settings.load();
|
|
1914
|
-
if (!cfg.project?.mirrorToDesktop)
|
|
1915
|
-
return;
|
|
1916
|
-
try {
|
|
1917
|
-
const outcomes = await mirrorAllProjects(cfg);
|
|
1918
|
-
this.bus.publish({
|
|
1919
|
-
sessionId: '*',
|
|
1920
|
-
type: 'desktop-mirror',
|
|
1921
|
-
level: outcomes.some((o) => o.problem) ? 'warn' : 'info',
|
|
1922
|
-
message: outcomes.map((o) => `${o.name}: ${o.result ? describeMirror(o.result) : `not refreshed — ${o.problem}`}`).join('; ') || 'no projects to mirror',
|
|
1923
|
-
});
|
|
1924
|
-
}
|
|
1925
|
-
catch (e) {
|
|
1926
|
-
this.bus.publish({ sessionId: '*', type: 'desktop-mirror-failed', level: 'warn', message: e.message });
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
2295
|
/**
|
|
1930
2296
|
* Stores the project folder and the other folders the operator works in. A field left out
|
|
1931
2297
|
* of the patch is kept; an empty `rootDir` clears the default, which is a real choice: it
|
|
@@ -1938,54 +2304,37 @@ let OperatorService = class OperatorService {
|
|
|
1938
2304
|
* — each of those would be a setting that looks filled in and points nowhere.
|
|
1939
2305
|
*/
|
|
1940
2306
|
async setProject(patch) {
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
const desktop = patch.desktop ?? current.desktop ?? true;
|
|
1972
|
-
const { desktop: _old, ...rest } = current;
|
|
1973
|
-
const cfg = await this.settings.save({
|
|
1974
|
-
...raw,
|
|
1975
|
-
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 } };
|
|
1976
2337
|
});
|
|
1977
|
-
/*
|
|
1978
|
-
* The switch acts at once, both ways. On: every project's folder appears on the Desktop
|
|
1979
|
-
* now, not at the next run, because "I turned it on and nothing happened" is a support
|
|
1980
|
-
* question. Off: the folders go, because a copy of a code base left in OneDrive after the
|
|
1981
|
-
* operator said no is the one outcome the switch must not produce. A changed selection
|
|
1982
|
-
* with the switch on is applied the same way.
|
|
1983
|
-
*/
|
|
1984
|
-
const wasOn = current.mirrorToDesktop ?? false;
|
|
1985
|
-
if (mirrorToDesktop)
|
|
1986
|
-
await mirrorAllProjects(cfg);
|
|
1987
|
-
else if (wasOn)
|
|
1988
|
-
await removeProjectMirrors(cfg);
|
|
1989
2338
|
return await this.project();
|
|
1990
2339
|
}
|
|
1991
2340
|
// --- the model catalogue ----------------------------------------------------------------
|
|
@@ -2010,9 +2359,7 @@ let OperatorService = class OperatorService {
|
|
|
2010
2359
|
*/
|
|
2011
2360
|
async setDefaultReviewModel(name) {
|
|
2012
2361
|
await this.init();
|
|
2013
|
-
|
|
2014
|
-
const copilot = { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() };
|
|
2015
|
-
await this.settings.save({ ...raw, copilot });
|
|
2362
|
+
await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() } }));
|
|
2016
2363
|
this.bus.publish({
|
|
2017
2364
|
sessionId: '*',
|
|
2018
2365
|
type: 'default-review-model-changed',
|
|
@@ -2030,9 +2377,7 @@ let OperatorService = class OperatorService {
|
|
|
2030
2377
|
*/
|
|
2031
2378
|
async setDefaultModel(name) {
|
|
2032
2379
|
await this.init();
|
|
2033
|
-
|
|
2034
|
-
const copilot = { ...(raw.copilot ?? {}), defaultModel: name.trim() };
|
|
2035
|
-
await this.settings.save({ ...raw, copilot });
|
|
2380
|
+
await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultModel: name.trim() } }));
|
|
2036
2381
|
this.bus.publish({
|
|
2037
2382
|
sessionId: '*',
|
|
2038
2383
|
type: 'default-model-changed',
|
|
@@ -2053,26 +2398,27 @@ let OperatorService = class OperatorService {
|
|
|
2053
2398
|
* line-up.
|
|
2054
2399
|
*/
|
|
2055
2400
|
async refreshModels() {
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
}
|
|
2060
|
-
if (
|
|
2061
|
-
throw new Error(
|
|
2062
|
-
|
|
2063
|
-
const cfg = await this.settings.load();
|
|
2064
|
-
const transport = createTransport({
|
|
2065
|
-
profileDir: cfg.resolved.profileDir,
|
|
2066
|
-
transportDir: join(cfg.resolved.runsDir, '_models'),
|
|
2067
|
-
chatUrl: cfg.copilot.url,
|
|
2068
|
-
channel: cfg.copilot.channel,
|
|
2069
|
-
headless: cfg.copilot.headless,
|
|
2070
|
-
replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
|
|
2071
|
-
signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2072
|
-
humanWaitMs: cfg.copilot.humanWaitSec * 1000,
|
|
2073
|
-
keepFailurePage: cfg.copilot.keepFailurePage,
|
|
2074
|
-
});
|
|
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;
|
|
2075
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
|
+
});
|
|
2076
2422
|
await transport.open();
|
|
2077
2423
|
await transport.ensureSignedIn();
|
|
2078
2424
|
const { options, current, note } = await transport.listModels();
|
|
@@ -2081,8 +2427,8 @@ let OperatorService = class OperatorService {
|
|
|
2081
2427
|
return catalogue;
|
|
2082
2428
|
}
|
|
2083
2429
|
finally {
|
|
2084
|
-
await transport
|
|
2085
|
-
this.
|
|
2430
|
+
await transport?.close().catch(() => undefined);
|
|
2431
|
+
this.releaseBrowser(claim.holder);
|
|
2086
2432
|
}
|
|
2087
2433
|
}
|
|
2088
2434
|
// --- the registry ---------------------------------------------------------------------
|
|
@@ -2171,41 +2517,10 @@ let OperatorService = class OperatorService {
|
|
|
2171
2517
|
return out;
|
|
2172
2518
|
}
|
|
2173
2519
|
// --- environment ----------------------------------------------------------------------
|
|
2174
|
-
async dirs(root, respectGitignore = true) {
|
|
2175
|
-
return await listSelectableDirs(root, { respectGitignore });
|
|
2176
|
-
}
|
|
2177
2520
|
/** Opens the machine's own folder dialog and reports what was picked. */
|
|
2178
2521
|
async browseFolder(start) {
|
|
2179
2522
|
return await pickFolder(start);
|
|
2180
2523
|
}
|
|
2181
|
-
/**
|
|
2182
|
-
* What the current selection would copy, without writing anything. This is how the two
|
|
2183
|
-
* switches become checkable: turn the env one on and the `.env` files move from the skipped
|
|
2184
|
-
* list into the copied one, whatever `.gitignore` says about them.
|
|
2185
|
-
*/
|
|
2186
|
-
async previewMirror(input) {
|
|
2187
|
-
const conflicts = findSelectionConflicts(input.includeDirs ?? [], input.excludeDirs ?? []);
|
|
2188
|
-
if (conflicts.length > 0)
|
|
2189
|
-
throw new Error(describeConflicts(conflicts));
|
|
2190
|
-
const cfg = await this.settings.load();
|
|
2191
|
-
const { files, skipped } = await collectFiles({
|
|
2192
|
-
rootDir: input.rootDir,
|
|
2193
|
-
includeDirs: input.includeDirs?.length ? input.includeDirs : ['.'],
|
|
2194
|
-
excludeDirs: input.excludeDirs ?? [],
|
|
2195
|
-
targetDir: '',
|
|
2196
|
-
respectGitignore: input.respectGitignore ?? true,
|
|
2197
|
-
includeEnvFiles: input.includeEnvFiles ?? false,
|
|
2198
|
-
ignoreDirs: cfg.projectMirror.ignoreDirs,
|
|
2199
|
-
maxFileBytes: cfg.projectMirror.maxFileBytes,
|
|
2200
|
-
});
|
|
2201
|
-
let totalBytes = 0;
|
|
2202
|
-
for (const rel of files) {
|
|
2203
|
-
const s = await import('node:fs/promises').then((fs) => fs.stat(join(input.rootDir, rel)).catch(() => null));
|
|
2204
|
-
if (s)
|
|
2205
|
-
totalBytes += s.size;
|
|
2206
|
-
}
|
|
2207
|
-
return { files, skipped, envFiles: files.filter(isEnvPath), totalBytes, conflicts };
|
|
2208
|
-
}
|
|
2209
2524
|
async doctor() {
|
|
2210
2525
|
const cfg = await this.settings.load();
|
|
2211
2526
|
const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
|
|
@@ -2236,7 +2551,6 @@ let OperatorService = class OperatorService {
|
|
|
2236
2551
|
*/
|
|
2237
2552
|
isolation: assessIsolation(cfg.execution.isolation, readIsolationSignals()),
|
|
2238
2553
|
folderDialog: process.platform === 'win32',
|
|
2239
|
-
alwaysIgnoredDirs: DEFAULT_IGNORE_DIRS,
|
|
2240
2554
|
};
|
|
2241
2555
|
}
|
|
2242
2556
|
};
|
|
@@ -2259,21 +2573,3 @@ function resolveRunId(task, requested) {
|
|
|
2259
2573
|
const owned = [task.runId, ...(task.attempts ?? []).map((a) => a.runId)].filter(Boolean);
|
|
2260
2574
|
return owned.includes(requested) ? requested : null;
|
|
2261
2575
|
}
|
|
2262
|
-
function isEnvPath(relPath) {
|
|
2263
|
-
const name = relPath.split('/').pop()?.toLowerCase() ?? '';
|
|
2264
|
-
return name === '.env' || name.startsWith('.env.');
|
|
2265
|
-
}
|
|
2266
|
-
/**
|
|
2267
|
-
* Refuses a selection that contradicts itself, at the point it is saved rather than at the
|
|
2268
|
-
* point it runs. A contradiction is cheap to fix while the form is open and expensive to
|
|
2269
|
-
* discover an hour later, when the first task of a run fails on it.
|
|
2270
|
-
*/
|
|
2271
|
-
function assertMirrorIsCoherent(mirror) {
|
|
2272
|
-
const conflicts = findSelectionConflicts(mirror.includeDirs ?? [], mirror.excludeDirs ?? []);
|
|
2273
|
-
if (conflicts.length > 0) {
|
|
2274
|
-
throw new Error(`The include and exclude lists contradict each other. ${describeConflicts(conflicts)}`);
|
|
2275
|
-
}
|
|
2276
|
-
if (mirror.enabled && mirror.rootDir !== undefined && !mirror.rootDir.trim()) {
|
|
2277
|
-
throw new Error('Attaching project files needs a project root.');
|
|
2278
|
-
}
|
|
2279
|
-
}
|