copilot-operator 0.1.13 → 0.1.16
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 +42 -42
- package/dist/src/api/operator.service.js +743 -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 +33 -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/transport/copilotTransport.js +39 -1
- 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-7fd4fe96682299f5.js +1 -0
- package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.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 +19 -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 → GhdY7nVcYi-WV3j7saVPK}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_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,137 @@ 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
|
+
const loginWhileBusy = 'The browser profile is in use right now. Sign in once the run, or the reading of the models, has finished.';
|
|
60
|
+
// The sign-in window holds the profile until the operator has signed in and it closes.
|
|
61
|
+
if (held.kind === 'login') {
|
|
62
|
+
return ask.kind === 'login'
|
|
63
|
+
? 'The sign-in window is already open. Finish signing in there.'
|
|
64
|
+
: 'the sign-in window is open and has the browser profile; finish signing in first';
|
|
65
|
+
}
|
|
66
|
+
if (ask.kind === 'login')
|
|
67
|
+
return loginWhileBusy;
|
|
68
|
+
if (held.kind === 'batch') {
|
|
69
|
+
if (ask.kind === 'models')
|
|
70
|
+
return modelsWhileRunning;
|
|
71
|
+
return ask.kind === 'batch' ? 'a batch is already running' : 'a batch of sessions is running';
|
|
72
|
+
}
|
|
73
|
+
if (held.kind === 'models') {
|
|
74
|
+
return ask.kind === 'models'
|
|
75
|
+
? 'The model list is already being read.'
|
|
76
|
+
: 'the model list is being read from the chat, which has the browser; start this once it has finished';
|
|
77
|
+
}
|
|
78
|
+
// A session running on its own.
|
|
79
|
+
if (ask.kind === 'models')
|
|
80
|
+
return modelsWhileRunning;
|
|
81
|
+
if (ask.kind === 'run') {
|
|
82
|
+
return ask.sessionId === held.sessionId
|
|
83
|
+
? 'already running'
|
|
84
|
+
: 'another session is running, and the browser takes one run at a time; start this one once it has finished';
|
|
85
|
+
}
|
|
86
|
+
return ask.sessionIds.includes(held.sessionId)
|
|
87
|
+
? 'one of the selected sessions is already running on its own'
|
|
88
|
+
: 'a session is running on its own, and the browser takes one run at a time; start the batch once it has finished';
|
|
89
|
+
}
|
|
55
90
|
/** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
|
|
56
91
|
function normaliseDir(dir) {
|
|
57
92
|
if (!dir.trim())
|
|
58
93
|
return '';
|
|
59
94
|
return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
60
95
|
}
|
|
96
|
+
/** The repository a planned session will work in, read the way `repoDirOf` reads an imported one. */
|
|
97
|
+
function plannedRepoDir(session) {
|
|
98
|
+
return session.vcs?.repoDir?.trim() || session.projectDir?.trim() || session.mirror?.rootDir?.trim() || '';
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The branches a planned session will make by name when it runs, as `prepareForTask` names them.
|
|
102
|
+
*
|
|
103
|
+
* None with version control off, and none when it carries on an existing branch, which makes
|
|
104
|
+
* nothing. In per-session mode the one `branchName`; in per-task mode the branch each task names,
|
|
105
|
+
* since `branchName` is ignored there. A branch named for the session's id is not known before the
|
|
106
|
+
* import, and is not one a plan can ask to carry on.
|
|
107
|
+
*/
|
|
108
|
+
function branchesMadeBy(session) {
|
|
109
|
+
const vcs = session.vcs;
|
|
110
|
+
if (!vcs || vcs.enabled === false || vcs.startFrom === 'existing-branch' || vcs.existingBranch?.trim())
|
|
111
|
+
return [];
|
|
112
|
+
const prefix = vcs.branchPrefix?.trim() || 'cop/';
|
|
113
|
+
if (vcs.branchMode === 'per-session')
|
|
114
|
+
return vcs.branchName?.trim() ? [plannedBranchName(vcs.branchName, prefix)] : [];
|
|
115
|
+
return session.tasks.flatMap((t) => (t.vcs?.branch?.trim() ? [plannedBranchName(t.vcs.branch, prefix)] : []));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The one restore each repository gets when a run starts again from a task: the rule, in one place,
|
|
119
|
+
* for the preview and for the restore itself, which used to keep a copy each and drifted apart.
|
|
120
|
+
*
|
|
121
|
+
* One per repository, keyed on the folder as compared rather than on the path as written or on the
|
|
122
|
+
* session: three sessions working in one repository must not take it back three times to three
|
|
123
|
+
* different commits. The earliest affected task that ran is the one that decides, because that is
|
|
124
|
+
* the state the whole re-run starts from.
|
|
125
|
+
*
|
|
126
|
+
* A repository whose affected tasks never ran has nothing to go back to: the run never touched it.
|
|
127
|
+
* Taking "no base commit recorded" as a refusal blocked "run again from here" for every plan whose
|
|
128
|
+
* later sessions had not started yet — which is the usual shape of a run that stopped in the middle.
|
|
129
|
+
*
|
|
130
|
+
* A session that carries on an existing branch cannot be taken back: its run checks that branch out
|
|
131
|
+
* again before the first task, so a restore branch would be left the moment it was made, and going
|
|
132
|
+
* back on the branch itself would take a reset, which this tool never does. That holds for every
|
|
133
|
+
* session of the repository the run reached, not only the one that decides where it goes back to:
|
|
134
|
+
* a later one carrying on a branch would run its tasks again on top of what they did the first
|
|
135
|
+
* time, which is not "the code goes back first" either. So the repository's restore carries the
|
|
136
|
+
* problem of the first such session, and the preview and the restart refuse before anything moves.
|
|
137
|
+
*/
|
|
138
|
+
function restoreTargets(tasks) {
|
|
139
|
+
const byRepo = new Map();
|
|
140
|
+
for (const { session, task } of tasks) {
|
|
141
|
+
if (!session.vcs?.enabled || !task.startedAt)
|
|
142
|
+
continue;
|
|
143
|
+
const dir = repoDirOf(session);
|
|
144
|
+
const key = normaliseDir(dir);
|
|
145
|
+
if (!key)
|
|
146
|
+
continue;
|
|
147
|
+
const target = byRepo.get(key) ?? { dir, session, task };
|
|
148
|
+
byRepo.set(key, target);
|
|
149
|
+
const existing = session.vcs.startFrom === 'existing-branch' ? (session.vcs.existingBranch ?? '').trim() : '';
|
|
150
|
+
if (existing && !target.problem) {
|
|
151
|
+
target.problem =
|
|
152
|
+
`"${session.name}" carries on the existing branch ${existing}, and its run checks that branch out again, so the code ` +
|
|
153
|
+
`would not stay back; going back on ${existing} itself would take a reset, which this tool never does. ` +
|
|
154
|
+
`Queue its tasks again with "Run again" on each, which carries on ${existing} as it is, or move that branch yourself first.`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [...byRepo.values()];
|
|
158
|
+
}
|
|
159
|
+
/** Whether two folders, as `normaliseDir` gives them, are one folder or one is inside the other. */
|
|
160
|
+
function overlaps(a, b) {
|
|
161
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The reason a restart stopped, with what it had already done by then.
|
|
165
|
+
*
|
|
166
|
+
* A repository taken back and tasks queued again stay so when a later step refuses — nothing here
|
|
167
|
+
* undoes them — and the reason is the one thing the page shows. Said only in the result, they were
|
|
168
|
+
* left for the operator to find in git and in the queue.
|
|
169
|
+
*/
|
|
170
|
+
function afterMoves(reason, restored, requeued) {
|
|
171
|
+
const done = [...restored.map((r) => `taken back ${r}`), ...(requeued > 0 ? [`${requeued} task(s) queued again`] : [])];
|
|
172
|
+
return done.length > 0 ? `${reason} Already done, and left as it is: ${done.join('; ')}.` : reason;
|
|
173
|
+
}
|
|
61
174
|
let OperatorService = class OperatorService {
|
|
62
175
|
/** Where this install keeps its prompts and its records. See `config/layout.ts`. */
|
|
63
176
|
layout = installLayout();
|
|
64
177
|
projectRoot = this.layout.projectRoot;
|
|
65
178
|
dataDir = this.layout.dataDir;
|
|
66
|
-
settings =
|
|
179
|
+
settings = settingsOf(this.layout);
|
|
67
180
|
store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
|
|
68
181
|
bus = new EventBus();
|
|
69
182
|
running = new Map();
|
|
@@ -71,8 +184,21 @@ let OperatorService = class OperatorService {
|
|
|
71
184
|
/** The batch in progress, or the last one that finished. At most one ever exists. */
|
|
72
185
|
batch = null;
|
|
73
186
|
ready = null;
|
|
74
|
-
/**
|
|
75
|
-
|
|
187
|
+
/**
|
|
188
|
+
* What has the browser now: one session's run, a batch, or a read of the model list.
|
|
189
|
+
*
|
|
190
|
+
* The Edge profile takes one writer, and every way into a chat window goes through here: a
|
|
191
|
+
* start, a batch, and reading the models. Each used to look only at its own kind — a start
|
|
192
|
+
* refused only while a batch ran, a batch only a second batch — so while one session ran on its
|
|
193
|
+
* own, starting another or a batch of others opened a second window on the same profile, which
|
|
194
|
+
* fails minutes later with a message about a closed browser. The profile lock is no help inside
|
|
195
|
+
* one process: it waves through a lock this process already holds.
|
|
196
|
+
*
|
|
197
|
+
* Taken at the entrance before its first wait, so two presses that arrive together cannot both
|
|
198
|
+
* find it free, and given back when the window it was taken for has closed or the entrance has
|
|
199
|
+
* refused. See `claimBrowser`.
|
|
200
|
+
*/
|
|
201
|
+
browser = null;
|
|
76
202
|
/**
|
|
77
203
|
* One-time startup work, run lazily on the first request that needs the store.
|
|
78
204
|
*
|
|
@@ -207,30 +333,32 @@ let OperatorService = class OperatorService {
|
|
|
207
333
|
const pending = [...this.waiting.values()].filter((w) => w.approval.sessionId === id).map((w) => w.approval);
|
|
208
334
|
return { ...s, running: this.running.has(id), pending, runMode: this.running.get(id)?.mode };
|
|
209
335
|
}
|
|
210
|
-
async createSession(name,
|
|
336
|
+
async createSession(name, folder) {
|
|
211
337
|
await this.init();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
338
|
+
// Read before the session is made: a settings file that cannot be read refuses the request,
|
|
339
|
+
// and a refusal must leave nothing behind, or each retry adds a session with no project.
|
|
340
|
+
const cfg = await this.settings.load();
|
|
341
|
+
const session = await this.store.createSession(name, folder ?? '');
|
|
215
342
|
/*
|
|
216
343
|
* The models are not written onto a new session. A session with no model of its own uses the
|
|
217
344
|
* one chosen in Settings at the time it runs (see `effectiveModels` in taskRunner.ts), so
|
|
218
345
|
* choosing a model there reaches every such session, the ones created before it included.
|
|
219
346
|
* It used to be copied here, which meant a model chosen after an import reached nothing.
|
|
220
347
|
*/
|
|
221
|
-
const cfg = await this.settings.load();
|
|
222
348
|
const projectDir = (cfg.project?.rootDir ?? '').trim();
|
|
223
|
-
if (!projectDir)
|
|
224
|
-
return session;
|
|
225
349
|
return await this.store.updateSession(session.id, (s) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
350
|
+
/*
|
|
351
|
+
* A folder given here is the session's repository too, in the shape a session made on the
|
|
352
|
+
* Project page's folder has: both fields, one folder. Left empty, every reader that follows
|
|
353
|
+
* `repoDirOf` found the folder, but the review read `vcs.repoDir` itself, and was told there
|
|
354
|
+
* was no repository and no changed files for work committed in that folder. The review asks
|
|
355
|
+
* `trackedRepoOf` now, so a session saved with `repoDir` empty is read right as well; this
|
|
356
|
+
* keeps the record saying what the session works in.
|
|
357
|
+
*/
|
|
358
|
+
if (s.projectDir.trim() && s.vcs && !s.vcs.repoDir.trim())
|
|
359
|
+
s.vcs.repoDir = s.projectDir;
|
|
360
|
+
// The same rule an import applies, for a session given no folder; see `applyDefaultProject`.
|
|
361
|
+
applyDefaultProject(s, projectDir);
|
|
234
362
|
});
|
|
235
363
|
}
|
|
236
364
|
async updateSession(id, patch) {
|
|
@@ -259,11 +387,8 @@ let OperatorService = class OperatorService {
|
|
|
259
387
|
const merged = { ...DEFAULT_REVIEW, ...s.review, ...patch.review };
|
|
260
388
|
s.review = { enabled: merged.enabled !== false, model: (merged.model ?? '').trim() };
|
|
261
389
|
}
|
|
262
|
-
if (patch.
|
|
263
|
-
|
|
264
|
-
assertMirrorIsCoherent(merged);
|
|
265
|
-
s.mirror = merged;
|
|
266
|
-
}
|
|
390
|
+
if (patch.projectDir !== undefined)
|
|
391
|
+
s.projectDir = patch.projectDir.trim();
|
|
267
392
|
if (patch.vcs) {
|
|
268
393
|
const merged = { ...DEFAULT_VCS, ...s.vcs, ...patch.vcs };
|
|
269
394
|
// A prefix is what makes the bot's branches recognisable in `git branch`, so an empty
|
|
@@ -271,7 +396,7 @@ let OperatorService = class OperatorService {
|
|
|
271
396
|
merged.branchPrefix = merged.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
|
|
272
397
|
merged.repoDir = merged.repoDir.trim();
|
|
273
398
|
if (merged.enabled) {
|
|
274
|
-
const reason = repoUnusableReason(merged.repoDir || s.
|
|
399
|
+
const reason = repoUnusableReason(merged.repoDir || s.projectDir || '');
|
|
275
400
|
if (reason)
|
|
276
401
|
throw new Error(reason);
|
|
277
402
|
}
|
|
@@ -279,6 +404,12 @@ let OperatorService = class OperatorService {
|
|
|
279
404
|
merged.baseBranch = merged.baseBranch.trim();
|
|
280
405
|
if (merged.existingBranch !== undefined)
|
|
281
406
|
merged.existingBranch = merged.existingBranch.trim();
|
|
407
|
+
// Only the program marks a name as one of its own branches, and a name changed is not that one.
|
|
408
|
+
const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
|
|
409
|
+
if (stillOwn)
|
|
410
|
+
merged.branchNameExact = true;
|
|
411
|
+
else
|
|
412
|
+
delete merged.branchNameExact;
|
|
282
413
|
/*
|
|
283
414
|
* Where the session starts is fixed at its first run. A new choice clears that record, so
|
|
284
415
|
* the next task to run is cut from what was chosen now; the tasks that already ran keep
|
|
@@ -611,15 +742,17 @@ let OperatorService = class OperatorService {
|
|
|
611
742
|
.sort((a, b) => (a.runGroup?.id === runId ? (a.runGroup.order ?? 0) : 0) - (b.runGroup?.id === runId ? (b.runGroup.order ?? 0) : 0));
|
|
612
743
|
if (sessions.length === 0)
|
|
613
744
|
throw new Error(`No session took part in run ${runId}.`);
|
|
614
|
-
|
|
745
|
+
/*
|
|
746
|
+
* The run's name and start, from a session's own record of the run — or, once a later run has
|
|
747
|
+
* replaced that record, from a task that ran in it, which carries both. Without the second,
|
|
748
|
+
* the earlier run's file was called "run-" with nothing after it, the kind of name nobody
|
|
749
|
+
* finds again.
|
|
750
|
+
*/
|
|
751
|
+
const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId) ??
|
|
752
|
+
sessions.flatMap((s) => s.tasks.map((t) => taskInRun(t, runId)?.runGroup)).find((g) => g?.id === runId);
|
|
615
753
|
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
|
-
};
|
|
754
|
+
// Each task as it ended in this run, not as it is now; see `runScope`.
|
|
755
|
+
scope = runScope(sessions, runId, name);
|
|
623
756
|
}
|
|
624
757
|
else if (where.sessionId) {
|
|
625
758
|
const session = await this.store.getSession(where.sessionId);
|
|
@@ -704,34 +837,56 @@ let OperatorService = class OperatorService {
|
|
|
704
837
|
isRunning(sessionId) {
|
|
705
838
|
return this.running.has(sessionId);
|
|
706
839
|
}
|
|
840
|
+
/**
|
|
841
|
+
* Takes the browser for `ask`, or says why it cannot have it. Synchronous on purpose (see
|
|
842
|
+
* `browser`). What it hands back is what `releaseBrowser` is given, so a release that comes late
|
|
843
|
+
* cannot free a browser somebody else has taken since.
|
|
844
|
+
*/
|
|
845
|
+
claimBrowser(ask) {
|
|
846
|
+
if (this.browser)
|
|
847
|
+
return { refused: browserRefusal(this.browser, ask) };
|
|
848
|
+
const holder = ask.kind === 'run' ? { kind: 'run', sessionId: ask.sessionId } : { kind: ask.kind };
|
|
849
|
+
this.browser = holder;
|
|
850
|
+
return { holder };
|
|
851
|
+
}
|
|
852
|
+
releaseBrowser(holder) {
|
|
853
|
+
if (this.browser === holder)
|
|
854
|
+
this.browser = null;
|
|
855
|
+
}
|
|
707
856
|
/**
|
|
708
857
|
* Starts the session's queued tasks in the background. Returns immediately; progress
|
|
709
|
-
* arrives on the event stream. One run
|
|
858
|
+
* arrives on the event stream. One run at a time, of this session or any other.
|
|
710
859
|
*/
|
|
711
860
|
async start(sessionId, mode = 'confirm', name) {
|
|
712
861
|
// 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
|
-
|
|
862
|
+
// does not get a second browser, it gets an error about a closed one. Whatever has the
|
|
863
|
+
// browser now — a batch, another session, a read of the models — the start is refused where
|
|
864
|
+
// the reason can still be read, rather than three minutes later in a stack trace.
|
|
865
|
+
const claim = this.claimBrowser({ kind: 'run', sessionId });
|
|
866
|
+
if ('refused' in claim)
|
|
867
|
+
return { started: false, reason: claim.refused };
|
|
868
|
+
try {
|
|
869
|
+
// A run nobody named is named after what it is a run of, rather than left to show up in the
|
|
870
|
+
// register as "Unnamed run" beside five others of the same description.
|
|
871
|
+
const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
|
|
872
|
+
const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
|
|
873
|
+
// A run of one is still a run, and it records the same thing a batch does, so that going
|
|
874
|
+
// back and starting again from a task works the same whether one session was started or six.
|
|
875
|
+
// Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
|
|
876
|
+
const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
|
|
877
|
+
if (!begun.started) {
|
|
878
|
+
this.releaseBrowser(claim.holder);
|
|
879
|
+
return { started: false, reason: begun.reason };
|
|
880
|
+
}
|
|
881
|
+
// The window closes before the run's promise settles (see `runSession`), so the browser is
|
|
882
|
+
// free again exactly when it is.
|
|
883
|
+
void begun.done.finally(() => this.releaseBrowser(claim.holder));
|
|
884
|
+
return { started: true };
|
|
885
|
+
}
|
|
886
|
+
catch (e) {
|
|
887
|
+
this.releaseBrowser(claim.holder);
|
|
888
|
+
throw e;
|
|
889
|
+
}
|
|
735
890
|
}
|
|
736
891
|
/**
|
|
737
892
|
* The same thing `start` does, but it also hands back a promise that settles when the run
|
|
@@ -748,12 +903,8 @@ let OperatorService = class OperatorService {
|
|
|
748
903
|
/** The press of a start button this belongs to, stamped onto every task it reaches. */
|
|
749
904
|
runGroup,
|
|
750
905
|
/** Only these queued tasks, when the operator chose some; the rest stay queued. */
|
|
751
|
-
onlyTasks) {
|
|
906
|
+
onlyTasks, how = {}) {
|
|
752
907
|
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
908
|
const idle = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
|
|
758
909
|
if (this.running.has(sessionId))
|
|
759
910
|
return { started: false, reason: 'already running', done: Promise.resolve(idle) };
|
|
@@ -786,6 +937,12 @@ let OperatorService = class OperatorService {
|
|
|
786
937
|
const precondition = unattendedPrecondition(policy);
|
|
787
938
|
if (precondition)
|
|
788
939
|
return { started: false, reason: precondition, done: Promise.resolve(idle) };
|
|
940
|
+
if (how.recordOnSession && runGroup) {
|
|
941
|
+
// The tasks this run takes, as worked out above, so the record and the run cannot disagree.
|
|
942
|
+
await this.store.updateSession(sessionId, (s) => {
|
|
943
|
+
s.runGroup = { ...runGroup, order: 0, taskIds: queuedIds, mode, onFailure: s.onFailure === 'continue' ? 'continue' : 'stop' };
|
|
944
|
+
});
|
|
945
|
+
}
|
|
789
946
|
const controller = new AbortController();
|
|
790
947
|
/*
|
|
791
948
|
* The same authorizer in both modes. It used to be `unattendedAuthorizer` here for an unattended
|
|
@@ -813,14 +970,13 @@ let OperatorService = class OperatorService {
|
|
|
813
970
|
bus: this.bus,
|
|
814
971
|
authorizer,
|
|
815
972
|
signal: controller.signal,
|
|
816
|
-
// Read between tasks, so the one in flight finishes properly first. The batch owns
|
|
817
|
-
//
|
|
818
|
-
shouldPause:
|
|
973
|
+
// Read between tasks, so the one in flight finishes properly first. The batch that owns
|
|
974
|
+
// this run answers it, because a pause is about the whole run and not about this session.
|
|
975
|
+
shouldPause: how.shouldPause,
|
|
819
976
|
// The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
|
|
820
977
|
// and the policy.json of a later task must say so.
|
|
821
978
|
currentMode: () => this.running.get(sessionId)?.mode ?? mode,
|
|
822
979
|
// The Desktop copies follow the work while it happens, not only before the run.
|
|
823
|
-
onWorkChanged: () => this.requestDesktopRefresh(),
|
|
824
980
|
// The session decides whether its tasks are one chain or a set of independent checks.
|
|
825
981
|
continueOnFailure: session.onFailure === 'continue',
|
|
826
982
|
transport,
|
|
@@ -935,20 +1091,64 @@ let OperatorService = class OperatorService {
|
|
|
935
1091
|
* tasks nobody chose stay queued exactly as they were. Absent means every queued task.
|
|
936
1092
|
*/
|
|
937
1093
|
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
1094
|
const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
|
|
943
|
-
|
|
944
|
-
|
|
1095
|
+
// Before anything is waited for (see `browser`): a second batch, a session running on its own
|
|
1096
|
+
// — one of these or any other — and a read of the models all have the browser already.
|
|
1097
|
+
const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
|
|
1098
|
+
if ('refused' in claim)
|
|
1099
|
+
return { started: false, reason: claim.refused };
|
|
1100
|
+
return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
|
|
1104
|
+
* to the batch once it is under way (`runBatch` gives it back), and given back here on every
|
|
1105
|
+
* refusal. "Run again from here" claims it before it moves anything and starts through this, so
|
|
1106
|
+
* one claim covers the restore, the requeue and the run.
|
|
1107
|
+
*/
|
|
1108
|
+
async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
|
|
1109
|
+
const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
|
|
1110
|
+
let handedOn = false;
|
|
1111
|
+
try {
|
|
1112
|
+
await this.init();
|
|
1113
|
+
if (wanted.length === 0)
|
|
1114
|
+
return { started: false, reason: 'no sessions were selected' };
|
|
1115
|
+
const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name);
|
|
1116
|
+
if (!begun.batch)
|
|
1117
|
+
return { started: false, reason: begun.reason };
|
|
1118
|
+
this.batch = begun.batch;
|
|
1119
|
+
handedOn = true;
|
|
1120
|
+
void this.runBatch(begun.batch, holder);
|
|
1121
|
+
return { started: true, batch: this.batchState() };
|
|
1122
|
+
}
|
|
1123
|
+
finally {
|
|
1124
|
+
if (!handedOn)
|
|
1125
|
+
this.releaseBrowser(holder);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Why a batch in this mode may not begin on this machine now, or null when it may: the entrance
|
|
1130
|
+
* rule (`unattendedPrecondition`) asked of the settings as they are at this moment. One place, for
|
|
1131
|
+
* the batch and for "Run again from here", which has to ask it before it moves anything.
|
|
1132
|
+
*/
|
|
1133
|
+
async unattendedRefusal(mode) {
|
|
1134
|
+
const cfg = await this.settings.load();
|
|
1135
|
+
return unattendedPrecondition({
|
|
1136
|
+
mode,
|
|
1137
|
+
allowedPrograms: cfg.execution.allowedPrograms,
|
|
1138
|
+
isolation: cfg.execution.isolation,
|
|
1139
|
+
lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Everything `startBatch` decides before the batch exists: which sessions it has work in, whether
|
|
1144
|
+
* an unattended run may begin, and the models the run panel chose. A refusal comes back as the
|
|
1145
|
+
* reason; nothing is started here.
|
|
1146
|
+
*/
|
|
1147
|
+
async prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name) {
|
|
945
1148
|
// Worked out here rather than asked of the operator again: every way into this — the run
|
|
946
1149
|
// panel, continuing after a failure, "Run again from here" — either has a field they may
|
|
947
1150
|
// have left empty or has no field at all.
|
|
948
1151
|
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
1152
|
const sessions = [];
|
|
953
1153
|
for (const id of wanted) {
|
|
954
1154
|
const s = await this.store.getSession(id);
|
|
@@ -962,7 +1162,7 @@ let OperatorService = class OperatorService {
|
|
|
962
1162
|
: { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
|
|
963
1163
|
}
|
|
964
1164
|
if (!sessions.some((s) => s.state === 'waiting')) {
|
|
965
|
-
return {
|
|
1165
|
+
return { reason: 'none of the selected sessions has a queued task' };
|
|
966
1166
|
}
|
|
967
1167
|
/*
|
|
968
1168
|
* The same entrance rule `start` has, asked here too and for the same reason: before a browser
|
|
@@ -972,17 +1172,9 @@ let OperatorService = class OperatorService {
|
|
|
972
1172
|
* session was quietly skipped with the reason held in a batch state nobody was looking at. Asked
|
|
973
1173
|
* here, the refusal is the answer to the press, shown under the button that caused it.
|
|
974
1174
|
*/
|
|
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
|
-
}
|
|
1175
|
+
const blocked = await this.unattendedRefusal(mode);
|
|
1176
|
+
if (blocked)
|
|
1177
|
+
return { reason: blocked };
|
|
986
1178
|
// One model for the whole run, chosen here rather than opened on every session first. It
|
|
987
1179
|
// is written onto the sessions instead of being held for the run, so what the session says
|
|
988
1180
|
// it will use and what it used are the same thing afterwards — including for anyone who
|
|
@@ -1014,20 +1206,20 @@ let OperatorService = class OperatorService {
|
|
|
1014
1206
|
});
|
|
1015
1207
|
}
|
|
1016
1208
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1209
|
+
return {
|
|
1210
|
+
batch: {
|
|
1211
|
+
id: newId('b-'),
|
|
1212
|
+
startedAt: new Date().toISOString(),
|
|
1213
|
+
name: chosenName,
|
|
1214
|
+
...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
|
|
1215
|
+
mode,
|
|
1216
|
+
onFailure,
|
|
1217
|
+
stopping: false,
|
|
1218
|
+
pausing: false,
|
|
1219
|
+
running: true,
|
|
1220
|
+
sessions,
|
|
1221
|
+
},
|
|
1028
1222
|
};
|
|
1029
|
-
void this.runBatch();
|
|
1030
|
-
return { started: true, batch: this.batchState() };
|
|
1031
1223
|
}
|
|
1032
1224
|
/** Stops the session that is running now and leaves the rest of the batch unstarted. */
|
|
1033
1225
|
/**
|
|
@@ -1081,6 +1273,24 @@ let OperatorService = class OperatorService {
|
|
|
1081
1273
|
await this.stop(current.sessionId);
|
|
1082
1274
|
return { stopping: true };
|
|
1083
1275
|
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Runs a batch that has just been started, and ends what only the batch may end: its hold and
|
|
1278
|
+
* its claim on the browser. Both are let go here, once, however the loop came out — finished,
|
|
1279
|
+
* stopped, failed, or never able to open its window.
|
|
1280
|
+
*/
|
|
1281
|
+
async runBatch(batch, holder) {
|
|
1282
|
+
try {
|
|
1283
|
+
await this.batchLoop(batch);
|
|
1284
|
+
}
|
|
1285
|
+
finally {
|
|
1286
|
+
// A pause holds this batch's queue and nothing after it. Left on, it read as "pausing" on a
|
|
1287
|
+
// batch that had ended, and it was what every later run read, so each stopped before its
|
|
1288
|
+
// first task (see `shouldPause` in `beginRun`).
|
|
1289
|
+
batch.pausing = false;
|
|
1290
|
+
// The batch's window is closed by now, in the loop's own ending.
|
|
1291
|
+
this.releaseBrowser(holder);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1084
1294
|
/**
|
|
1085
1295
|
* The batch loop. Nothing here runs in parallel, on purpose: see `beginRun`.
|
|
1086
1296
|
*
|
|
@@ -1088,10 +1298,7 @@ let OperatorService = class OperatorService {
|
|
|
1088
1298
|
* failure is `done`; one with a failed task is `failed`; one the operator stopped, or one
|
|
1089
1299
|
* whose chain stopped early with no failure of its own, is `stopped`.
|
|
1090
1300
|
*/
|
|
1091
|
-
async
|
|
1092
|
-
const batch = this.batch;
|
|
1093
|
-
if (!batch)
|
|
1094
|
-
return;
|
|
1301
|
+
async batchLoop(batch) {
|
|
1095
1302
|
/*
|
|
1096
1303
|
* One browser for the whole batch.
|
|
1097
1304
|
*
|
|
@@ -1196,7 +1403,11 @@ let OperatorService = class OperatorService {
|
|
|
1196
1403
|
message: `starting as part of a batch of ${batch.sessions.length} session(s), in ${batch.mode} mode`,
|
|
1197
1404
|
data: { batchId: batch.id },
|
|
1198
1405
|
});
|
|
1199
|
-
|
|
1406
|
+
// In the batch's mode as it is now: "run the rest without asking" in an earlier session
|
|
1407
|
+
// switched the batch with it (see `setRunMode`). The hold is this batch's own.
|
|
1408
|
+
const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
|
|
1409
|
+
shouldPause: () => batch.pausing,
|
|
1410
|
+
});
|
|
1200
1411
|
if (!begun.started) {
|
|
1201
1412
|
entry.state = 'skipped';
|
|
1202
1413
|
entry.reason = begun.reason;
|
|
@@ -1301,11 +1512,18 @@ let OperatorService = class OperatorService {
|
|
|
1301
1512
|
*/
|
|
1302
1513
|
const cfg = await this.settings.load();
|
|
1303
1514
|
const unattendedRule = { mode: 'unattended', allowedPrograms: cfg.execution.allowedPrograms, isolation: cfg.execution.isolation, lockedToConfirm: cfg.policyLock?.maxMode === 'confirm' };
|
|
1515
|
+
/*
|
|
1516
|
+
* Which of its refusals it is, asked in the order the rule asks them: the lock, then isolation,
|
|
1517
|
+
* then the allowlist. Anything that was not isolation used to be told as an empty allowlist, so
|
|
1518
|
+
* under a policy lock the brief sent the operator to fill in a list that was already full.
|
|
1519
|
+
*/
|
|
1304
1520
|
const unattendedBlocked = !unattendedPrecondition(unattendedRule)
|
|
1305
1521
|
? undefined
|
|
1306
|
-
:
|
|
1307
|
-
? '
|
|
1308
|
-
: '
|
|
1522
|
+
: unattendedRule.lockedToConfirm
|
|
1523
|
+
? 'lock'
|
|
1524
|
+
: unattendedIsolationRefusal('unattended', cfg.execution.isolation)
|
|
1525
|
+
? 'isolation'
|
|
1526
|
+
: 'allowlist';
|
|
1309
1527
|
return {
|
|
1310
1528
|
text: planBrief({
|
|
1311
1529
|
lang,
|
|
@@ -1353,7 +1571,7 @@ let OperatorService = class OperatorService {
|
|
|
1353
1571
|
check.plan.sessions.forEach((session, i) => {
|
|
1354
1572
|
if (session.vcs?.enabled === false)
|
|
1355
1573
|
return;
|
|
1356
|
-
const dir =
|
|
1574
|
+
const dir = plannedRepoDir(session);
|
|
1357
1575
|
const reason = repoUnusableReason(dir);
|
|
1358
1576
|
if (reason)
|
|
1359
1577
|
repoIssues.push({ path: `sessions[${i}].vcs.repoDir`, message: reason });
|
|
@@ -1369,17 +1587,23 @@ let OperatorService = class OperatorService {
|
|
|
1369
1587
|
return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
|
|
1370
1588
|
/*
|
|
1371
1589
|
* 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.
|
|
1590
|
+
* is the one that makes it, in this same repository. Refused now, with the branches that are
|
|
1591
|
+
* there, rather than at the first run with a task refused.
|
|
1592
|
+
*
|
|
1593
|
+
* The repository is part of the question. A branch is a branch of one repository, and the name
|
|
1594
|
+
* alone let a branch an earlier session makes somewhere else count here: the plan was accepted,
|
|
1595
|
+
* and the session failed at its first task with "no such local branch" — the failure this check
|
|
1596
|
+
* exists to bring forward.
|
|
1374
1597
|
*/
|
|
1375
1598
|
for (const [i, session] of check.plan.sessions.entries()) {
|
|
1376
1599
|
const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
|
|
1377
1600
|
if (!wanted)
|
|
1378
1601
|
continue;
|
|
1379
|
-
const dir =
|
|
1602
|
+
const dir = plannedRepoDir(session);
|
|
1603
|
+
const here = normaliseDir(dir);
|
|
1380
1604
|
const madeEarlier = check.plan.sessions
|
|
1381
1605
|
.slice(0, i)
|
|
1382
|
-
.some((e) =>
|
|
1606
|
+
.some((e) => normaliseDir(plannedRepoDir(e)) === here && branchesMadeBy(e).includes(wanted));
|
|
1383
1607
|
if (madeEarlier || (await branchExists(dir, wanted)))
|
|
1384
1608
|
continue;
|
|
1385
1609
|
const have = await localBranches(dir);
|
|
@@ -1436,7 +1660,9 @@ let OperatorService = class OperatorService {
|
|
|
1436
1660
|
*/
|
|
1437
1661
|
const persona = (await this.getContext('persona', 'en')).content;
|
|
1438
1662
|
// No default models passed: a session the plan gives no model follows Settings at run time.
|
|
1439
|
-
|
|
1663
|
+
// The default project is passed, so a session the plan gives no folder works where one made
|
|
1664
|
+
// on the Sessions page would, and not in execution.cwd.
|
|
1665
|
+
const result = await importPlan(this.store, check.plan, '', '', persona, (cfg.project?.rootDir ?? '').trim());
|
|
1440
1666
|
return {
|
|
1441
1667
|
ok: true,
|
|
1442
1668
|
result: { ...result, warnings: [...check.warnings, ...result.warnings] },
|
|
@@ -1507,14 +1733,26 @@ let OperatorService = class OperatorService {
|
|
|
1507
1733
|
* one would read.
|
|
1508
1734
|
*/
|
|
1509
1735
|
run.policy.mode = mode;
|
|
1736
|
+
/*
|
|
1737
|
+
* A batch is one run. The dialog behind "run the rest without asking" says "until this run
|
|
1738
|
+
* ends", and the run the operator started is the batch: the switch used to reach only the
|
|
1739
|
+
* session on screen, and the next session started in the batch's old mode and asked again. So
|
|
1740
|
+
* the sessions the batch has not reached yet start in the mode it is switched to — and back,
|
|
1741
|
+
* when the asking is switched on again. Each still meets the unattended precondition as it
|
|
1742
|
+
* starts (see `beginRun`), under the settings of that moment, and the run recorded on it
|
|
1743
|
+
* (`Session.runGroup`) keeps the mode it was started in.
|
|
1744
|
+
*/
|
|
1745
|
+
const batch = this.batch?.running && this.batch.sessions.some((e) => e.sessionId === sessionId && e.state === 'running') ? this.batch : null;
|
|
1746
|
+
if (batch)
|
|
1747
|
+
batch.mode = mode;
|
|
1510
1748
|
this.bus.publish({
|
|
1511
1749
|
sessionId,
|
|
1512
1750
|
type: 'run-mode-changed',
|
|
1513
1751
|
level: mode === 'unattended' ? 'warn' : 'info',
|
|
1514
1752
|
message: mode === 'unattended'
|
|
1515
|
-
?
|
|
1516
|
-
:
|
|
1517
|
-
data: { mode },
|
|
1753
|
+
? `the rest of this run${batch ? ', the sessions of the batch after this one included,' : ''} will execute without asking; denied patterns are still refused`
|
|
1754
|
+
: `every further step${batch ? ', in this session and the ones of the batch after it,' : ''} will be shown for approval again`,
|
|
1755
|
+
data: { mode, ...(batch ? { batchId: batch.id } : {}) },
|
|
1518
1756
|
});
|
|
1519
1757
|
if (mode === 'unattended') {
|
|
1520
1758
|
for (const [id, w] of this.waiting) {
|
|
@@ -1534,6 +1772,12 @@ let OperatorService = class OperatorService {
|
|
|
1534
1772
|
}
|
|
1535
1773
|
webAuthorizer(policy, signal) {
|
|
1536
1774
|
return makeAuthorizer(policy, (step, ctx, held) => new Promise((resolvePromise) => {
|
|
1775
|
+
// A stopped run answers before anything else, the mode included: "run the rest without
|
|
1776
|
+
// asking" is permission to run the steps of a run that is going, not of one that was stopped.
|
|
1777
|
+
if (signal.aborted) {
|
|
1778
|
+
resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1537
1781
|
// The operator may have pressed "run the rest without asking" on an earlier step.
|
|
1538
1782
|
// This is checked per step rather than captured once, which is what makes the switch
|
|
1539
1783
|
// take effect from the very next step instead of the next run. A held step is the
|
|
@@ -1551,17 +1795,13 @@ let OperatorService = class OperatorService {
|
|
|
1551
1795
|
createdAt: new Date().toISOString(),
|
|
1552
1796
|
...(held ? { network: held.network } : {}),
|
|
1553
1797
|
};
|
|
1554
|
-
if (signal.aborted) {
|
|
1555
|
-
resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
1798
|
this.waiting.set(approval.id, { approval, resolve: resolvePromise });
|
|
1559
1799
|
this.bus.publish({ sessionId: approval.sessionId, taskId: approval.taskId, type: 'approval-requested', level: 'warn',
|
|
1560
1800
|
message: held
|
|
1561
1801
|
? `held for the operator — ${held.network}: ${approval.description}`
|
|
1562
1802
|
: `waiting for approval: ${approval.description}`,
|
|
1563
1803
|
data: { ...approval } });
|
|
1564
|
-
}));
|
|
1804
|
+
}), signal);
|
|
1565
1805
|
}
|
|
1566
1806
|
// --- events ---------------------------------------------------------------------------
|
|
1567
1807
|
recentEvents(sessionId) {
|
|
@@ -1601,24 +1841,68 @@ let OperatorService = class OperatorService {
|
|
|
1601
1841
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1602
1842
|
if (!task)
|
|
1603
1843
|
throw new Error('No such task.');
|
|
1604
|
-
|
|
1844
|
+
const preview = await restorePreview(session, task);
|
|
1845
|
+
// Said in the preview too, so the dialog refuses before the operator agrees to anything,
|
|
1846
|
+
// rather than showing what would happen and refusing when they say yes.
|
|
1847
|
+
const refused = await this.restoreRefusal(session);
|
|
1848
|
+
return refused ? { ...preview, ok: false, problem: refused } : preview;
|
|
1605
1849
|
}
|
|
1606
1850
|
async restore(sessionId, taskId) {
|
|
1607
1851
|
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
1852
|
const session = await this.store.getSession(sessionId);
|
|
1615
1853
|
if (!session)
|
|
1616
1854
|
throw new Error('No such session.');
|
|
1617
1855
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1618
1856
|
if (!task)
|
|
1619
1857
|
throw new Error('No such task.');
|
|
1858
|
+
const refused = await this.restoreRefusal(session);
|
|
1859
|
+
if (refused)
|
|
1860
|
+
return { ok: false, problem: refused };
|
|
1620
1861
|
return await restoreToBase(session, task, this.bus);
|
|
1621
1862
|
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Why the repository of this session cannot be moved to another branch now, or null when it can.
|
|
1865
|
+
*
|
|
1866
|
+
* Anything that may be working in that folder at this moment: the session itself, a batch (which
|
|
1867
|
+
* may reach a session in it next), and any other session running there on its own. Only the first
|
|
1868
|
+
* two used to be asked, so a restore checked out a branch under another session in the middle of
|
|
1869
|
+
* its task — and that session then found its repository moved, and left its work uncommitted.
|
|
1870
|
+
* Version control being off for the other session does not make it safe: its commands still run
|
|
1871
|
+
* in that folder.
|
|
1872
|
+
*/
|
|
1873
|
+
async restoreRefusal(session) {
|
|
1874
|
+
if (this.running.has(session.id))
|
|
1875
|
+
return 'This session is running. Stop it before moving the repository to another branch.';
|
|
1876
|
+
if (this.batch?.running)
|
|
1877
|
+
return 'A batch is running. Stop it before moving the repository to another branch.';
|
|
1878
|
+
const other = await this.runningIn(repoDirOf(session));
|
|
1879
|
+
return other
|
|
1880
|
+
? `Session "${other.name}" is running in this repository. Stop it before moving the repository to another branch.`
|
|
1881
|
+
: null;
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* A session running right now whose commands run in this folder, inside it, or in a folder around it.
|
|
1885
|
+
*
|
|
1886
|
+
* By where its commands run, the rule `workingDirFor` decides it by: the repository, else the
|
|
1887
|
+
* project folder, else `execution.cwd`. Compared as folders, not as equal paths: an audit with
|
|
1888
|
+
* version control off whose project is the repository's `web` folder works in the tree a checkout
|
|
1889
|
+
* rewrites, and so does a session whose folder holds the repository. Only the same path used to
|
|
1890
|
+
* count, and a restore changed files under such a session in the middle of its task.
|
|
1891
|
+
*/
|
|
1892
|
+
async runningIn(dir) {
|
|
1893
|
+
const here = normaliseDir(dir);
|
|
1894
|
+
if (!here)
|
|
1895
|
+
return undefined;
|
|
1896
|
+
for (const id of this.running.keys()) {
|
|
1897
|
+
const s = await this.store.getSession(id);
|
|
1898
|
+
if (!s)
|
|
1899
|
+
continue;
|
|
1900
|
+
const there = normaliseDir(repoDirOf(s) || (await this.settings.load()).resolved.cwd);
|
|
1901
|
+
if (there && overlaps(here, there))
|
|
1902
|
+
return s;
|
|
1903
|
+
}
|
|
1904
|
+
return undefined;
|
|
1905
|
+
}
|
|
1622
1906
|
/**
|
|
1623
1907
|
* Everything one run was asked to do, from a given task onward, in the order it would run.
|
|
1624
1908
|
*
|
|
@@ -1705,37 +1989,27 @@ let OperatorService = class OperatorService {
|
|
|
1705
1989
|
})),
|
|
1706
1990
|
sessions: sessions.map((s) => ({ id: s.id, name: s.name, tasks: tasks.filter((x) => x.session.id === s.id).length })),
|
|
1707
1991
|
restores: [],
|
|
1708
|
-
|
|
1992
|
+
/*
|
|
1993
|
+
* Nothing on record says a run with no record of its own ever went unattended, and taken for
|
|
1994
|
+
* one, it was refused on every machine that refuses unattended runs, the default among them:
|
|
1995
|
+
* the dialog has no other mode to offer, so its button could never do anything for such a task.
|
|
1996
|
+
*/
|
|
1997
|
+
mode: group?.mode ?? 'confirm',
|
|
1709
1998
|
onFailure: group?.onFailure ?? session.onFailure ?? 'stop',
|
|
1710
1999
|
};
|
|
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);
|
|
2000
|
+
// Asked of the machine as it is now, as `restartFrom` will ask it; see `unattendedRefused`.
|
|
2001
|
+
if (plan.mode === 'unattended')
|
|
2002
|
+
plan.unattendedRefused = (await this.unattendedRefusal('unattended')) ?? undefined;
|
|
2003
|
+
for (const target of restoreTargets(tasks)) {
|
|
2004
|
+
const preview = await restorePreview(target.session, target.task);
|
|
2005
|
+
// What would stop the restore itself, asked here as well, so the dialog says it before the
|
|
2006
|
+
// operator agrees rather than after.
|
|
2007
|
+
const refused = target.problem ?? (await this.restoreRefusal(target.session));
|
|
1734
2008
|
plan.restores.push({
|
|
1735
|
-
repoDir: preview.repoDir || dir,
|
|
1736
|
-
ok: preview.ok,
|
|
1737
|
-
problem: preview.problem,
|
|
1738
|
-
forTask:
|
|
2009
|
+
repoDir: preview.repoDir || target.dir,
|
|
2010
|
+
ok: preview.ok && !refused,
|
|
2011
|
+
problem: refused ?? preview.problem,
|
|
2012
|
+
forTask: target.task.title,
|
|
1739
2013
|
baseCommit: preview.baseCommit,
|
|
1740
2014
|
branchName: preview.branchName,
|
|
1741
2015
|
leftBehind: preview.leftBehind,
|
|
@@ -1767,27 +2041,95 @@ let OperatorService = class OperatorService {
|
|
|
1767
2041
|
if (plan.sessions.some((s) => this.running.has(s.id))) {
|
|
1768
2042
|
return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
|
|
1769
2043
|
}
|
|
2044
|
+
/*
|
|
2045
|
+
* Claimed before anything is moved, and held until the run it ends in has the browser: that run
|
|
2046
|
+
* needs it, and another session running on its own, or a read of the models, may have it. Found
|
|
2047
|
+
* out only at the start, the repositories were already taken back and the tasks queued, with
|
|
2048
|
+
* nothing to run them; and only looked at here, the browser could still be taken by a start that
|
|
2049
|
+
* came in while the repositories were being taken back, to the same end. Given back on every way
|
|
2050
|
+
* out that does not start the run.
|
|
2051
|
+
*/
|
|
2052
|
+
const ids = plan.sessions.map((s) => s.id);
|
|
2053
|
+
if (opts.start === false)
|
|
2054
|
+
return await this.restartMoves(sessionId, taskId, plan, opts);
|
|
2055
|
+
/*
|
|
2056
|
+
* The run's entrance rule, asked before anything moves, for the same reason as the browser
|
|
2057
|
+
* below. Asked only when the batch began, a machine that no longer lets this run go unattended
|
|
2058
|
+
* (a policy lock added since, isolation no longer accepted) took the repositories back and
|
|
2059
|
+
* queued the tasks, and only then refused the run they were moved for. The plan says so first
|
|
2060
|
+
* (`unattendedRefused`), so the dialog asks for the run step by step instead.
|
|
2061
|
+
*/
|
|
2062
|
+
const blocked = await this.unattendedRefusal(opts.mode ?? plan.mode);
|
|
2063
|
+
if (blocked)
|
|
2064
|
+
return { ...idle, reason: blocked };
|
|
2065
|
+
const claim = this.claimBrowser({ kind: 'batch', sessionIds: ids });
|
|
2066
|
+
if ('refused' in claim)
|
|
2067
|
+
return { ...idle, reason: claim.refused };
|
|
2068
|
+
let handedOn = false;
|
|
2069
|
+
try {
|
|
2070
|
+
const moved = await this.restartMoves(sessionId, taskId, plan, opts);
|
|
2071
|
+
if (moved.reason !== undefined)
|
|
2072
|
+
return moved;
|
|
2073
|
+
// Named after the task it goes back to, which is the one thing this run is about and the
|
|
2074
|
+
// only thing a heading could usefully say. There is no field to type into on a task card,
|
|
2075
|
+
// so leaving it to the operator meant leaving it blank every time.
|
|
2076
|
+
const runName = await this.suggestRunName(ids, plan.from.title);
|
|
2077
|
+
// From here the claim is the batch's to give back, whichever way its start goes.
|
|
2078
|
+
handedOn = true;
|
|
2079
|
+
/*
|
|
2080
|
+
* Exactly the tasks the preview listed, which are the tasks the run was asked to do from this
|
|
2081
|
+
* one on. Started with none named, the batch took every queued task of these sessions, so a
|
|
2082
|
+
* task the run never chose — queued beside it, or added since — was sent to the chat as well.
|
|
2083
|
+
*/
|
|
2084
|
+
const taskIds = plan.tasks.map((x) => x.taskId);
|
|
2085
|
+
const started = await this.startBatchHolding(claim.holder, ids, opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined, runName, taskIds);
|
|
2086
|
+
// A start refused now leaves the moves in place, and says so with its reason.
|
|
2087
|
+
const reason = started.started || started.reason === undefined ? started.reason : afterMoves(started.reason, moved.restored, moved.requeued);
|
|
2088
|
+
return { started: started.started, reason, requeued: moved.requeued, restored: moved.restored, batch: started.batch };
|
|
2089
|
+
}
|
|
2090
|
+
finally {
|
|
2091
|
+
if (!handedOn)
|
|
2092
|
+
this.releaseBrowser(claim.holder);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* What `restartFrom` moves before its run starts: the repositories taken back, then the tasks
|
|
2097
|
+
* queued again. A refusal, and "prepared, not started", come back with their reason; with none,
|
|
2098
|
+
* everything is in place for the run.
|
|
2099
|
+
*/
|
|
2100
|
+
async restartMoves(sessionId, taskId, plan, opts) {
|
|
2101
|
+
const idle = { started: false, requeued: 0, restored: [] };
|
|
1770
2102
|
const restored = [];
|
|
1771
2103
|
if (opts.restore !== false) {
|
|
1772
2104
|
for (const entry of plan.restores) {
|
|
1773
2105
|
if (!entry.ok)
|
|
1774
2106
|
return { ...idle, reason: `${entry.repoDir}: ${entry.problem ?? 'the restore could not be prepared.'}` };
|
|
1775
2107
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
2108
|
+
/*
|
|
2109
|
+
* Re-read each repository's own pivot and do it for real. The preview above is what the
|
|
2110
|
+
* operator agreed to; this is the same choice (`restoreTargets`), one step further. It was
|
|
2111
|
+
* worked out a second time here, by a copy of the rule that had not been given the preview's
|
|
2112
|
+
* "never started" skip, so a run whose later repository was never reached failed on it after
|
|
2113
|
+
* the first repository had already been moved — and queued nothing.
|
|
2114
|
+
*
|
|
2115
|
+
* A refusal part-way still says what was already moved, in the result and in its reason (the
|
|
2116
|
+
* one thing the page shows): a repository taken back is the operator's to know about, whatever
|
|
2117
|
+
* happened after it.
|
|
2118
|
+
*/
|
|
1778
2119
|
const session = (await this.store.getSession(sessionId));
|
|
1779
2120
|
const task = session.tasks.find((t) => t.id === taskId);
|
|
1780
2121
|
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
|
|
2122
|
+
for (const target of restoreTargets(tasks)) {
|
|
2123
|
+
// Asked again at the moment of moving: a session may have started in this folder since.
|
|
2124
|
+
const refused = target.problem ?? (await this.restoreRefusal(target.session));
|
|
2125
|
+
if (refused)
|
|
2126
|
+
return { ...idle, restored, reason: afterMoves(`${target.dir}: ${refused}`, restored, 0) };
|
|
2127
|
+
const done = await restoreToBase(target.session, target.task, this.bus);
|
|
2128
|
+
if (!done.ok || !done.branch) {
|
|
2129
|
+
return { ...idle, restored, reason: afterMoves(`${target.dir}: ${done.problem ?? 'the restore failed.'}`, restored, 0) };
|
|
2130
|
+
}
|
|
2131
|
+
restored.push(`${target.dir} -> ${done.branch}`);
|
|
2132
|
+
await this.rerunFromRestore(target, done.branch, tasks);
|
|
1791
2133
|
}
|
|
1792
2134
|
}
|
|
1793
2135
|
let requeued = 0;
|
|
@@ -1800,8 +2142,8 @@ let OperatorService = class OperatorService {
|
|
|
1800
2142
|
}
|
|
1801
2143
|
catch (e) {
|
|
1802
2144
|
// 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}
|
|
2145
|
+
// explanation, so it stops here and says which one and why — and what was done before it.
|
|
2146
|
+
return { ...idle, restored, requeued, reason: afterMoves(`"${t.title}" could not be queued again: ${e.message}`, restored, requeued) };
|
|
1805
2147
|
}
|
|
1806
2148
|
}
|
|
1807
2149
|
this.bus.publish({
|
|
@@ -1815,12 +2157,102 @@ let OperatorService = class OperatorService {
|
|
|
1815
2157
|
});
|
|
1816
2158
|
if (opts.start === false)
|
|
1817
2159
|
return { started: false, reason: 'prepared, not started', requeued, restored };
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
2160
|
+
return { started: false, requeued, restored };
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Makes a restore hold for the run that follows it.
|
|
2164
|
+
*
|
|
2165
|
+
* The restore puts the repository on a new branch at the pivot task's starting commit, and the
|
|
2166
|
+
* re-run then puts each session on its own branch. In per-task mode that is right: a re-run cuts
|
|
2167
|
+
* a fresh branch from where the task first started, which is the restore commit. In per-session
|
|
2168
|
+
* mode it undid the restore: the session's one branch was checked out again, with the failed
|
|
2169
|
+
* attempt's commit still on it, so the attempt that was to start from the code as it was began on
|
|
2170
|
+
* the work it was meant to replace, and committed it a second time. So the pivot's session now
|
|
2171
|
+
* works on the restore branch — the code as it was before that task, with the tasks before it —
|
|
2172
|
+
* and its old branch keeps the attempt that failed, as the confirmation says.
|
|
2173
|
+
*
|
|
2174
|
+
* A per-session session after it in the same repository that this run reached has the same
|
|
2175
|
+
* problem whatever it started from: its one branch carries what its tasks did in this run, and
|
|
2176
|
+
* checked out again it would start them over on their own first attempt. So it gets a branch of
|
|
2177
|
+
* its own, and the old one keeps what it did. Where that branch starts depends on where the
|
|
2178
|
+
* session began. From a named branch: where its first task in this run began, as a restore of
|
|
2179
|
+
* that task would go back to, and the session's start stays as recorded. From where the
|
|
2180
|
+
* repository was, or from the session before it: that start was work this run is now redoing, so
|
|
2181
|
+
* it is forgotten, to be worked out again when the session is reached (for a `previous-session`
|
|
2182
|
+
* chain, from the work done again), and the branch is cut from that. A session the run never
|
|
2183
|
+
* reached holds nothing of this run and is left alone.
|
|
2184
|
+
*
|
|
2185
|
+
* A later per-task session is left as it is. From a named branch that is right: each re-run is
|
|
2186
|
+
* cut from where its task first started, that branch's commit. Chained on this run's work it is
|
|
2187
|
+
* not, and is not solved here: its tasks first started on the work being redone, so their re-runs
|
|
2188
|
+
* are cut from it again. Telling that apart from an operator's new "Start from", whose re-runs go
|
|
2189
|
+
* back to where each task first started on purpose (see `updateSession`), needs the restart
|
|
2190
|
+
* written on the session, and the session's record has no field for it.
|
|
2191
|
+
*/
|
|
2192
|
+
async rerunFromRestore(target, branch, tasks) {
|
|
2193
|
+
if (target.session.vcs?.branchMode === 'per-session') {
|
|
2194
|
+
const left = sessionBranchName(target.session);
|
|
2195
|
+
// The restore branch exists by this name, so the name is kept as it is (see `branchNameExact`).
|
|
2196
|
+
await this.store.updateSession(target.session.id, (s) => {
|
|
2197
|
+
if (s.vcs) {
|
|
2198
|
+
s.vcs.branchName = branch;
|
|
2199
|
+
s.vcs.branchNameExact = true;
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
this.bus.publish({
|
|
2203
|
+
sessionId: target.session.id,
|
|
2204
|
+
taskId: target.task.id,
|
|
2205
|
+
type: 'vcs-session-branch',
|
|
2206
|
+
level: 'info',
|
|
2207
|
+
message: `the session carries on from ${branch}, the code as it was before "${target.task.title}"; ${left} keeps the work that came after`,
|
|
2208
|
+
data: { branch, left },
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
const here = normaliseDir(target.dir);
|
|
2212
|
+
const inOrder = [...new Map(tasks.map(({ session }) => [session.id, session])).values()];
|
|
2213
|
+
const later = inOrder.slice(inOrder.findIndex((s) => s.id === target.session.id) + 1);
|
|
2214
|
+
for (const s of later) {
|
|
2215
|
+
// One that carries on an existing branch never gets here: `restoreTargets` refuses the restart.
|
|
2216
|
+
if (!s.vcs?.enabled || s.vcs.branchMode !== 'per-session' || s.vcs.startFrom === 'existing-branch')
|
|
2217
|
+
continue;
|
|
2218
|
+
// No base recorded: version control never got as far as making its branch.
|
|
2219
|
+
if (!s.vcsBaseCommit || normaliseDir(repoDirOf(s)) !== here)
|
|
2220
|
+
continue;
|
|
2221
|
+
const first = tasks.find((x) => x.session.id === s.id && x.task.startedAt)?.task;
|
|
2222
|
+
if (!first)
|
|
2223
|
+
continue;
|
|
2224
|
+
const left = sessionBranchName(s);
|
|
2225
|
+
const fresh = await freeBranchName(target.dir, left);
|
|
2226
|
+
// No record of the start is read as chained, the reading that cannot bring undone work back.
|
|
2227
|
+
const chained = s.vcsStart?.kind !== 'branch';
|
|
2228
|
+
const at = chained ? undefined : restorePoint(s, first);
|
|
2229
|
+
if (at) {
|
|
2230
|
+
const made = await branchAt(target.dir, fresh, at);
|
|
2231
|
+
// Not made: left to the run, which cuts it from the session's start when it is reached.
|
|
2232
|
+
if (!made.ok) {
|
|
2233
|
+
this.bus.publish({ sessionId: s.id, type: 'vcs-problem', level: 'warn', message: `${fresh} could not be made at ${at.slice(0, 8)}: ${made.problem}` });
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
await this.store.updateSession(s.id, (x) => {
|
|
2237
|
+
if (chained) {
|
|
2238
|
+
x.vcsBaseCommit = undefined;
|
|
2239
|
+
x.vcsStart = undefined;
|
|
2240
|
+
}
|
|
2241
|
+
if (x.vcs) {
|
|
2242
|
+
x.vcs.branchName = fresh;
|
|
2243
|
+
x.vcs.branchNameExact = true;
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
this.bus.publish({
|
|
2247
|
+
sessionId: s.id,
|
|
2248
|
+
type: 'vcs-session-branch',
|
|
2249
|
+
level: 'info',
|
|
2250
|
+
message: chained
|
|
2251
|
+
? `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`
|
|
2252
|
+
: `the session starts its tasks again on a new branch ${fresh}, from where "${first.title}" began; ${left} keeps what it did before`,
|
|
2253
|
+
data: { branch: fresh, left },
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
1824
2256
|
}
|
|
1825
2257
|
/** Why a folder cannot be used for version control, or null when it can. */
|
|
1826
2258
|
repoProblem(dir) {
|
|
@@ -1838,7 +2270,7 @@ let OperatorService = class OperatorService {
|
|
|
1838
2270
|
await this.init();
|
|
1839
2271
|
const wanted = normaliseDir(dir);
|
|
1840
2272
|
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.
|
|
2273
|
+
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
2274
|
.filter((d) => d.trim())
|
|
1843
2275
|
.map(normaliseDir));
|
|
1844
2276
|
if (!wanted || !known.has(wanted)) {
|
|
@@ -1859,7 +2291,7 @@ let OperatorService = class OperatorService {
|
|
|
1859
2291
|
const problem = repoUnusableReason(rootDir);
|
|
1860
2292
|
const others = (cfg.project?.others ?? []).map((o) => {
|
|
1861
2293
|
const p = repoUnusableReason(o.rootDir);
|
|
1862
|
-
return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {})
|
|
2294
|
+
return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}) };
|
|
1863
2295
|
});
|
|
1864
2296
|
return {
|
|
1865
2297
|
rootDir,
|
|
@@ -1867,65 +2299,8 @@ let OperatorService = class OperatorService {
|
|
|
1867
2299
|
repoOk: rootDir !== '' && problem === null,
|
|
1868
2300
|
...(problem ? { repoProblem: problem } : {}),
|
|
1869
2301
|
others,
|
|
1870
|
-
mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
|
|
1871
|
-
desktop: cfg.project?.desktop !== false,
|
|
1872
|
-
mirror: cfg.project?.mirror,
|
|
1873
|
-
contextRoot: contextRoot(cfg),
|
|
1874
2302
|
};
|
|
1875
2303
|
}
|
|
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
2304
|
/**
|
|
1930
2305
|
* Stores the project folder and the other folders the operator works in. A field left out
|
|
1931
2306
|
* of the patch is kept; an empty `rootDir` clears the default, which is a real choice: it
|
|
@@ -1938,54 +2313,37 @@ let OperatorService = class OperatorService {
|
|
|
1938
2313
|
* — each of those would be a setting that looks filled in and points nowhere.
|
|
1939
2314
|
*/
|
|
1940
2315
|
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 } : {}) },
|
|
2316
|
+
// Worked out from the file in its turn (see `Settings.update`), so a model chosen at the same
|
|
2317
|
+
// moment is kept, and a refusal below leaves the file as it was.
|
|
2318
|
+
await this.settings.update((raw) => {
|
|
2319
|
+
const current = (raw.project ?? {});
|
|
2320
|
+
const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
|
|
2321
|
+
const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
|
|
2322
|
+
if (rootDir && !existsSync(rootDir))
|
|
2323
|
+
throw new Error(`The folder ${rootDir} does not exist on this machine.`);
|
|
2324
|
+
const others = (patch.others ?? current.others ?? []).map((o) => ({
|
|
2325
|
+
name: (o.name ?? '').trim(),
|
|
2326
|
+
rootDir: (o.rootDir ?? '').trim(),
|
|
2327
|
+
}));
|
|
2328
|
+
const seen = new Set();
|
|
2329
|
+
for (const o of others) {
|
|
2330
|
+
if (!o.name)
|
|
2331
|
+
throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
|
|
2332
|
+
if (!o.rootDir)
|
|
2333
|
+
throw new Error(`The project "${o.name}" needs a folder.`);
|
|
2334
|
+
if (!existsSync(o.rootDir))
|
|
2335
|
+
throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
|
|
2336
|
+
if (sameFolder(o.rootDir, rootDir))
|
|
2337
|
+
throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
|
|
2338
|
+
const key = o.name.toLowerCase();
|
|
2339
|
+
if (seen.has(key))
|
|
2340
|
+
throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
|
|
2341
|
+
seen.add(key);
|
|
2342
|
+
}
|
|
2343
|
+
// What the removed Desktop copies left in the stored project is dropped with the next save.
|
|
2344
|
+
const { mirror: _m, desktop: _d, mirrorToDesktop: _t, ...rest } = current;
|
|
2345
|
+
return { ...raw, project: { ...rest, rootDir, name, others } };
|
|
1976
2346
|
});
|
|
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
2347
|
return await this.project();
|
|
1990
2348
|
}
|
|
1991
2349
|
// --- the model catalogue ----------------------------------------------------------------
|
|
@@ -2010,9 +2368,7 @@ let OperatorService = class OperatorService {
|
|
|
2010
2368
|
*/
|
|
2011
2369
|
async setDefaultReviewModel(name) {
|
|
2012
2370
|
await this.init();
|
|
2013
|
-
|
|
2014
|
-
const copilot = { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() };
|
|
2015
|
-
await this.settings.save({ ...raw, copilot });
|
|
2371
|
+
await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() } }));
|
|
2016
2372
|
this.bus.publish({
|
|
2017
2373
|
sessionId: '*',
|
|
2018
2374
|
type: 'default-review-model-changed',
|
|
@@ -2030,9 +2386,7 @@ let OperatorService = class OperatorService {
|
|
|
2030
2386
|
*/
|
|
2031
2387
|
async setDefaultModel(name) {
|
|
2032
2388
|
await this.init();
|
|
2033
|
-
|
|
2034
|
-
const copilot = { ...(raw.copilot ?? {}), defaultModel: name.trim() };
|
|
2035
|
-
await this.settings.save({ ...raw, copilot });
|
|
2389
|
+
await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultModel: name.trim() } }));
|
|
2036
2390
|
this.bus.publish({
|
|
2037
2391
|
sessionId: '*',
|
|
2038
2392
|
type: 'default-model-changed',
|
|
@@ -2052,27 +2406,99 @@ let OperatorService = class OperatorService {
|
|
|
2052
2406
|
* gets to choose from, which is the only way this keeps working when Microsoft changes the
|
|
2053
2407
|
* line-up.
|
|
2054
2408
|
*/
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2409
|
+
/**
|
|
2410
|
+
* "Sign in" from Settings: what `cop login` does, from the page.
|
|
2411
|
+
*
|
|
2412
|
+
* Opens Edge with the bot's own profile — the one in Settings, which is the one every run uses —
|
|
2413
|
+
* and waits for the operator to sign in to Microsoft 365 Copilot. With an account, the profile is
|
|
2414
|
+
* signed out first and the chat asked for that account, because Edge on a work machine otherwise
|
|
2415
|
+
* signs the profile in with whatever account Windows knows. The bot never types a credential:
|
|
2416
|
+
* the person signs in, completes any verification, and the window closes once the chat is there.
|
|
2417
|
+
*
|
|
2418
|
+
* The request stays open until then (up to `copilot.signInTimeoutSec`). It takes the browser like
|
|
2419
|
+
* a run does, so it is refused while a run or a reading of the models has it, and they are while
|
|
2420
|
+
* it is open.
|
|
2421
|
+
*/
|
|
2422
|
+
async login(account) {
|
|
2423
|
+
const upn = account?.trim() || undefined;
|
|
2424
|
+
if (upn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(upn)) {
|
|
2425
|
+
throw new Error(`"${upn}" is not an account address. Give the work or school address, for example name@company.com, or leave it empty.`);
|
|
2059
2426
|
}
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
-
});
|
|
2427
|
+
const claim = this.claimBrowser({ kind: 'login' });
|
|
2428
|
+
if ('refused' in claim)
|
|
2429
|
+
throw new Error(claim.refused);
|
|
2430
|
+
let transport = null;
|
|
2075
2431
|
try {
|
|
2432
|
+
await this.init();
|
|
2433
|
+
const cfg = await this.settings.load();
|
|
2434
|
+
transport = createTransport({
|
|
2435
|
+
profileDir: cfg.resolved.profileDir,
|
|
2436
|
+
transportDir: join(cfg.resolved.runsDir, '_login'),
|
|
2437
|
+
chatUrl: cfg.copilot.url,
|
|
2438
|
+
channel: cfg.copilot.channel,
|
|
2439
|
+
// Always on screen, whatever Settings say: a person has to see the window to sign in.
|
|
2440
|
+
headless: false,
|
|
2441
|
+
replyTimeoutMs: 60_000,
|
|
2442
|
+
signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2443
|
+
humanWaitMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2444
|
+
keepFailurePage: cfg.copilot.keepFailurePage,
|
|
2445
|
+
});
|
|
2446
|
+
await transport.open();
|
|
2447
|
+
if (upn) {
|
|
2448
|
+
await transport.signOut();
|
|
2449
|
+
await transport.gotoChatAs(upn, cfg.copilot.url);
|
|
2450
|
+
}
|
|
2451
|
+
await transport.ensureSignedIn(upn ? undefined : cfg.copilot.url);
|
|
2452
|
+
const accounts = await transport.findAccountsInPage();
|
|
2453
|
+
if (!upn) {
|
|
2454
|
+
return {
|
|
2455
|
+
ok: true,
|
|
2456
|
+
accounts,
|
|
2457
|
+
message: accounts.length > 0 ? `Signed in. The page shows: ${accounts.join(', ')}.` : 'Signed in.',
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
const matched = accounts.some((a) => a.toLowerCase() === upn.toLowerCase());
|
|
2461
|
+
if (matched)
|
|
2462
|
+
return { ok: true, accounts, account: upn, matched, message: `Signed in as ${upn}.` };
|
|
2463
|
+
if (accounts.length > 0) {
|
|
2464
|
+
return {
|
|
2465
|
+
ok: false,
|
|
2466
|
+
accounts,
|
|
2467
|
+
account: upn,
|
|
2468
|
+
matched,
|
|
2469
|
+
message: `Signed in, but not as ${upn}: the page shows ${accounts.join(', ')}. Sign in again and choose "Use another account".`,
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
return { ok: true, accounts, account: upn, message: `Signed in. The account could not be read from the page, so ${upn} is not confirmed.` };
|
|
2473
|
+
}
|
|
2474
|
+
finally {
|
|
2475
|
+
// Always closed: a window left open keeps the profile locked, and the next run would fail on it.
|
|
2476
|
+
await transport?.close().catch(() => undefined);
|
|
2477
|
+
this.releaseBrowser(claim.holder);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
async refreshModels() {
|
|
2481
|
+
// Asked of the one claim every window goes through (see `browser`), before anything is waited
|
|
2482
|
+
// for. It used to count the sessions running, which is none in the moment between two
|
|
2483
|
+
// sessions of a batch, while the batch's window is still open.
|
|
2484
|
+
const claim = this.claimBrowser({ kind: 'models' });
|
|
2485
|
+
if ('refused' in claim)
|
|
2486
|
+
throw new Error(claim.refused);
|
|
2487
|
+
let transport = null;
|
|
2488
|
+
try {
|
|
2489
|
+
await this.init();
|
|
2490
|
+
const cfg = await this.settings.load();
|
|
2491
|
+
transport = createTransport({
|
|
2492
|
+
profileDir: cfg.resolved.profileDir,
|
|
2493
|
+
transportDir: join(cfg.resolved.runsDir, '_models'),
|
|
2494
|
+
chatUrl: cfg.copilot.url,
|
|
2495
|
+
channel: cfg.copilot.channel,
|
|
2496
|
+
headless: cfg.copilot.headless,
|
|
2497
|
+
replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
|
|
2498
|
+
signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2499
|
+
humanWaitMs: cfg.copilot.humanWaitSec * 1000,
|
|
2500
|
+
keepFailurePage: cfg.copilot.keepFailurePage,
|
|
2501
|
+
});
|
|
2076
2502
|
await transport.open();
|
|
2077
2503
|
await transport.ensureSignedIn();
|
|
2078
2504
|
const { options, current, note } = await transport.listModels();
|
|
@@ -2081,8 +2507,8 @@ let OperatorService = class OperatorService {
|
|
|
2081
2507
|
return catalogue;
|
|
2082
2508
|
}
|
|
2083
2509
|
finally {
|
|
2084
|
-
await transport
|
|
2085
|
-
this.
|
|
2510
|
+
await transport?.close().catch(() => undefined);
|
|
2511
|
+
this.releaseBrowser(claim.holder);
|
|
2086
2512
|
}
|
|
2087
2513
|
}
|
|
2088
2514
|
// --- the registry ---------------------------------------------------------------------
|
|
@@ -2171,41 +2597,10 @@ let OperatorService = class OperatorService {
|
|
|
2171
2597
|
return out;
|
|
2172
2598
|
}
|
|
2173
2599
|
// --- environment ----------------------------------------------------------------------
|
|
2174
|
-
async dirs(root, respectGitignore = true) {
|
|
2175
|
-
return await listSelectableDirs(root, { respectGitignore });
|
|
2176
|
-
}
|
|
2177
2600
|
/** Opens the machine's own folder dialog and reports what was picked. */
|
|
2178
2601
|
async browseFolder(start) {
|
|
2179
2602
|
return await pickFolder(start);
|
|
2180
2603
|
}
|
|
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
2604
|
async doctor() {
|
|
2210
2605
|
const cfg = await this.settings.load();
|
|
2211
2606
|
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 +2631,6 @@ let OperatorService = class OperatorService {
|
|
|
2236
2631
|
*/
|
|
2237
2632
|
isolation: assessIsolation(cfg.execution.isolation, readIsolationSignals()),
|
|
2238
2633
|
folderDialog: process.platform === 'win32',
|
|
2239
|
-
alwaysIgnoredDirs: DEFAULT_IGNORE_DIRS,
|
|
2240
2634
|
};
|
|
2241
2635
|
}
|
|
2242
2636
|
};
|
|
@@ -2259,21 +2653,3 @@ function resolveRunId(task, requested) {
|
|
|
2259
2653
|
const owned = [task.runId, ...(task.attempts ?? []).map((a) => a.runId)].filter(Boolean);
|
|
2260
2654
|
return owned.includes(requested) ? requested : null;
|
|
2261
2655
|
}
|
|
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
|
-
}
|