copilot-operator 0.1.39 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/operator.controller.js +101 -2
- package/dist/src/api/operator.service.js +292 -25
- package/dist/src/api/server.js +7 -0
- package/dist/src/log/terminal.js +145 -0
- package/dist/src/plan/brief.js +1375 -1369
- package/dist/src/plan/schema.js +5 -0
- package/dist/src/plan/systemGuide.js +158 -16
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/startFailure.js +91 -0
- package/dist/src/transport/copilotTransport.js +28 -10
- package/dist/src/transport/locators.js +7 -1
- package/dist/src/vcs/prepareFromRemote.js +7 -20
- package/dist/src/vcs/remoteAuth.js +26 -1
- package/dist/src/vcs/runPreflight.js +31 -14
- package/dist/src/vcs/snapshot.js +3 -1
- package/dist/src/vcs/startChoice.js +295 -0
- package/dist/src/vcs/taskVcs.js +40 -5
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
- package/dist/web/_next/static/chunks/549-f295c8429e83b25b.js +1 -0
- package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
- package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
- package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
- package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +27 -25
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +27 -25
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +27 -25
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +27 -25
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +27 -25
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +27 -25
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +27 -25
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +27 -25
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +27 -25
- package/package.json +158 -157
- package/dist/web/_next/static/chunks/549-bfde82e4a70ed1a0.js +0 -1
- package/dist/web/_next/static/chunks/584-eba7ffe26cf1be83.js +0 -1
- package/dist/web/_next/static/chunks/867-7fccf68b0840c0de.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-d3461384566aa332.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-945be57e397ae162.js +0 -1
- /package/dist/web/_next/static/{KYi1jz7Awse_66Fg8c7Ur → -htN5GTSrd3W3f5j_QC2y}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{KYi1jz7Awse_66Fg8c7Ur → -htN5GTSrd3W3f5j_QC2y}/_ssgManifest.js +0 -0
|
@@ -55,6 +55,29 @@ function asContextKind(kind) {
|
|
|
55
55
|
}
|
|
56
56
|
return kind;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The start window's choices as the service takes them: one per session id, each of a known shape, and
|
|
60
|
+
* nothing else. A choice of any other shape is dropped, which leaves that session on its settings.
|
|
61
|
+
*/
|
|
62
|
+
function startsFrom(raw) {
|
|
63
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
64
|
+
return undefined;
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [id, c] of Object.entries(raw)) {
|
|
67
|
+
if (!c || typeof c !== 'object')
|
|
68
|
+
continue;
|
|
69
|
+
const x = c;
|
|
70
|
+
const branch = typeof x.branch === 'string' ? x.branch : '';
|
|
71
|
+
const onBranch = x.onBranch === true ? { onBranch: true } : {};
|
|
72
|
+
if (x.from === 'previous-session')
|
|
73
|
+
out[id] = { from: 'previous-session' };
|
|
74
|
+
else if (x.from === 'local' && branch)
|
|
75
|
+
out[id] = { from: 'local', branch, ...onBranch };
|
|
76
|
+
else if (x.from === 'remote' && branch && typeof x.remote === 'string' && x.remote)
|
|
77
|
+
out[id] = { from: 'remote', remote: x.remote, branch, ...onBranch };
|
|
78
|
+
}
|
|
79
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
80
|
+
}
|
|
58
81
|
function contentDisposition(fileName) {
|
|
59
82
|
const ascii = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
60
83
|
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
|
@@ -349,7 +372,47 @@ let OperatorController = class OperatorController {
|
|
|
349
372
|
}
|
|
350
373
|
// --- run control ------------------------------------------------------------------------
|
|
351
374
|
start(id, body) {
|
|
352
|
-
return this.ops.start(id, body?.mode === 'unattended' ? 'unattended' : 'confirm', body?.name);
|
|
375
|
+
return this.ops.start(id, body?.mode === 'unattended' ? 'unattended' : 'confirm', body?.name, startsFrom(body?.starts));
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The start window's question: where each of these sessions starts, and the branches of their
|
|
379
|
+
* repositories as they are here (nothing fetched). See `vcs/startChoice.ts`.
|
|
380
|
+
*/
|
|
381
|
+
startOptions(sessions) {
|
|
382
|
+
return this.ops.startChoices((sessions ?? '').split(',').map((x) => x.trim()).filter(Boolean)).catch(fail);
|
|
383
|
+
}
|
|
384
|
+
/** Fetches branches of a remote for the start window — through Git's password window when it asks. */
|
|
385
|
+
startFetch(body) {
|
|
386
|
+
const branches = Array.isArray(body?.branches) ? body.branches.filter((b) => typeof b === 'string') : null;
|
|
387
|
+
return this.ops.startFetch(body?.dir ?? '', body?.remote ?? '', branches).catch(fail);
|
|
388
|
+
}
|
|
389
|
+
/** Starts that did not get as far as a first task, newest first; `unanswered=1` only those no press was answered with. */
|
|
390
|
+
startFailures(unanswered) {
|
|
391
|
+
return this.ops.recentStartFailures(unanswered === '1' || unanswered === 'true');
|
|
392
|
+
}
|
|
393
|
+
/** One of them as a text file: the error, where it stopped, the run's record, the events and the console. */
|
|
394
|
+
async startFailureReport(id, res) {
|
|
395
|
+
try {
|
|
396
|
+
const { fileName, content } = await this.ops.startFailureReport(id);
|
|
397
|
+
res.type('text/plain; charset=utf-8');
|
|
398
|
+
res.setHeader('content-disposition', contentDisposition(fileName));
|
|
399
|
+
res.send(content);
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
failText(res, e);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** What the bot's console showed, the latest lines, as a text file. See `log/terminal.ts`. */
|
|
406
|
+
async terminal(res) {
|
|
407
|
+
try {
|
|
408
|
+
const { fileName, content } = await this.ops.terminalReport();
|
|
409
|
+
res.type('text/plain; charset=utf-8');
|
|
410
|
+
res.setHeader('content-disposition', contentDisposition(fileName));
|
|
411
|
+
res.send(content);
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
failText(res, e);
|
|
415
|
+
}
|
|
353
416
|
}
|
|
354
417
|
stop(id) {
|
|
355
418
|
return this.ops.stop(id);
|
|
@@ -387,7 +450,7 @@ let OperatorController = class OperatorController {
|
|
|
387
450
|
// same as "no model": an empty string on a session means "leave the chat alone".
|
|
388
451
|
body?.model,
|
|
389
452
|
// The same for the review: absent means each session keeps whatever it is set to.
|
|
390
|
-
body?.reviewModel, body?.name, body?.taskIds)
|
|
453
|
+
body?.reviewModel, body?.name, body?.taskIds, startsFrom(body?.starts))
|
|
391
454
|
.catch(fail);
|
|
392
455
|
}
|
|
393
456
|
stopBatch() {
|
|
@@ -922,6 +985,42 @@ __decorate([
|
|
|
922
985
|
__metadata("design:paramtypes", [String, Object]),
|
|
923
986
|
__metadata("design:returntype", Promise)
|
|
924
987
|
], OperatorController.prototype, "start", null);
|
|
988
|
+
__decorate([
|
|
989
|
+
Get('start/options'),
|
|
990
|
+
__param(0, Query('sessions')),
|
|
991
|
+
__metadata("design:type", Function),
|
|
992
|
+
__metadata("design:paramtypes", [String]),
|
|
993
|
+
__metadata("design:returntype", Promise)
|
|
994
|
+
], OperatorController.prototype, "startOptions", null);
|
|
995
|
+
__decorate([
|
|
996
|
+
Post('start/fetch'),
|
|
997
|
+
__param(0, Body()),
|
|
998
|
+
__metadata("design:type", Function),
|
|
999
|
+
__metadata("design:paramtypes", [Object]),
|
|
1000
|
+
__metadata("design:returntype", Promise)
|
|
1001
|
+
], OperatorController.prototype, "startFetch", null);
|
|
1002
|
+
__decorate([
|
|
1003
|
+
Get('start/failures'),
|
|
1004
|
+
__param(0, Query('unanswered')),
|
|
1005
|
+
__metadata("design:type", Function),
|
|
1006
|
+
__metadata("design:paramtypes", [String]),
|
|
1007
|
+
__metadata("design:returntype", Object)
|
|
1008
|
+
], OperatorController.prototype, "startFailures", null);
|
|
1009
|
+
__decorate([
|
|
1010
|
+
Get('start/failures/:id/report'),
|
|
1011
|
+
__param(0, Param('id')),
|
|
1012
|
+
__param(1, Res()),
|
|
1013
|
+
__metadata("design:type", Function),
|
|
1014
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
1015
|
+
__metadata("design:returntype", Promise)
|
|
1016
|
+
], OperatorController.prototype, "startFailureReport", null);
|
|
1017
|
+
__decorate([
|
|
1018
|
+
Get('terminal'),
|
|
1019
|
+
__param(0, Res()),
|
|
1020
|
+
__metadata("design:type", Function),
|
|
1021
|
+
__metadata("design:paramtypes", [Object]),
|
|
1022
|
+
__metadata("design:returntype", Promise)
|
|
1023
|
+
], OperatorController.prototype, "terminal", null);
|
|
925
1024
|
__decorate([
|
|
926
1025
|
Post('sessions/:id/stop'),
|
|
927
1026
|
__param(0, Param('id')),
|
|
@@ -29,6 +29,17 @@ import { suggestRunName } from '../session/runName.js';
|
|
|
29
29
|
import { buildStory } from '../session/story.js';
|
|
30
30
|
import { checkPlan } from '../plan/schema.js';
|
|
31
31
|
import { planBrief } from '../plan/brief.js';
|
|
32
|
+
/**
|
|
33
|
+
* A settings file or a policy lock that cannot be used: answered 409 with its own message on every route
|
|
34
|
+
* (see `server.ts`), so a start passes it on rather than answering it as a start that did not happen.
|
|
35
|
+
*/
|
|
36
|
+
function unusableConfig(e) {
|
|
37
|
+
return e instanceof SettingsUnusableError || e instanceof PolicyLockUnusableError;
|
|
38
|
+
}
|
|
39
|
+
/** `20261013-102231` from an ISO time, in this machine's time as the file's contents are, for a file name. */
|
|
40
|
+
function fileStamp(iso) {
|
|
41
|
+
return localTime(iso).replace(/[-:]/g, '').replace(/\..*$/, '').replace(' ', '-');
|
|
42
|
+
}
|
|
32
43
|
/** Windows paths: case and the slash direction do not make two folders. */
|
|
33
44
|
function sameFolder(a, b) {
|
|
34
45
|
const norm = (p) => p.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
|
|
@@ -36,7 +47,7 @@ function sameFolder(a, b) {
|
|
|
36
47
|
}
|
|
37
48
|
import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
|
|
38
49
|
import { readInterruption } from '../session/interruption.js';
|
|
39
|
-
import { branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
|
|
50
|
+
import { baseRefOf, branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
|
|
40
51
|
import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
|
|
41
52
|
import { makeAuthorizer } from '../exec/authorizer.js';
|
|
42
53
|
import { pickFolder } from './folderPicker.js';
|
|
@@ -48,7 +59,11 @@ import { composeHandoff } from '../session/handoff.js';
|
|
|
48
59
|
import { planSync } from '../vcs/syncCommand.js';
|
|
49
60
|
import { planPrepare, prepareFromRemote } from '../vcs/prepareFromRemote.js';
|
|
50
61
|
import { askpassProgram } from '../vcs/remoteAuth.js';
|
|
51
|
-
import { appendRunLog, appendSessionLog } from '../session/runLog.js';
|
|
62
|
+
import { appendRunLog, appendSessionLog, readRunLog } from '../session/runLog.js';
|
|
63
|
+
import { applyStartChoice, fetchForStart, startOptions, withChoice } from '../vcs/startChoice.js';
|
|
64
|
+
import { StartFailures, failureReport, refOf } from '../session/startFailure.js';
|
|
65
|
+
import { formatTerminal, localTime, terminalLines, terminalLogFile } from '../log/terminal.js';
|
|
66
|
+
import { redactSecrets } from '../exec/redaction.js';
|
|
52
67
|
import { workingDirFor, isWorkingDirProblem } from '../exec/workDir.js';
|
|
53
68
|
import { contractConflictsWithFixes, contractFixPatch, describeContractChange } from '../orchestrator/contract.js';
|
|
54
69
|
import { checkRefusalForOperator, lineRefusal } from '../orchestrator/taskRunner.js';
|
|
@@ -62,7 +77,8 @@ import { botVersion } from '../config/version.js';
|
|
|
62
77
|
import { computeMetrics } from '../session/metrics.js';
|
|
63
78
|
import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
|
|
64
79
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
65
|
-
import { settingsOf } from './settings.js';
|
|
80
|
+
import { settingsOf, SettingsUnusableError } from './settings.js';
|
|
81
|
+
import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
|
|
66
82
|
/** Why a session set aside on the Sessions page does not run: one sentence for every start that refuses it. */
|
|
67
83
|
const INACTIVE_REASON = 'the session is inactive; make it active on the Sessions page to run it';
|
|
68
84
|
/**
|
|
@@ -195,6 +211,8 @@ let OperatorService = class OperatorService {
|
|
|
195
211
|
settings = settingsOf(this.layout);
|
|
196
212
|
store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
|
|
197
213
|
bus = new EventBus();
|
|
214
|
+
/** Starts that did not get as far as a first task, for the page to show and offer as a file. */
|
|
215
|
+
startFailures = new StartFailures();
|
|
198
216
|
running = new Map();
|
|
199
217
|
waiting = new Map();
|
|
200
218
|
/** The batch in progress, or the last one that finished. At most one ever exists. */
|
|
@@ -433,6 +451,17 @@ let OperatorService = class OperatorService {
|
|
|
433
451
|
merged.baseBranch = merged.baseBranch.trim();
|
|
434
452
|
if (merged.existingBranch !== undefined)
|
|
435
453
|
merged.existingBranch = merged.existingBranch.trim();
|
|
454
|
+
/*
|
|
455
|
+
* The session page names local branches: a start or a branch changed there is a local one, and
|
|
456
|
+
* the remote the start window chose it from last time no longer applies (see `baseRemote`).
|
|
457
|
+
*/
|
|
458
|
+
const pointMoved = (patch.vcs.startFrom !== undefined && patch.vcs.startFrom !== s.vcs?.startFrom) ||
|
|
459
|
+
(patch.vcs.baseBranch !== undefined && patch.vcs.baseBranch.trim() !== (s.vcs?.baseBranch ?? '').trim()) ||
|
|
460
|
+
(patch.vcs.existingBranch !== undefined && patch.vcs.existingBranch.trim() !== (s.vcs?.existingBranch ?? '').trim());
|
|
461
|
+
if (pointMoved && patch.vcs.baseRemote === undefined)
|
|
462
|
+
delete merged.baseRemote;
|
|
463
|
+
if (merged.baseRemote !== undefined && !merged.baseRemote.trim())
|
|
464
|
+
delete merged.baseRemote;
|
|
436
465
|
// Only the two known fields, and absent when it says what absent says: reject, asking first.
|
|
437
466
|
const dirty = dirtyPolicy(merged);
|
|
438
467
|
if (dirty.policy === 'reject')
|
|
@@ -479,6 +508,7 @@ let OperatorService = class OperatorService {
|
|
|
479
508
|
const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
|
|
480
509
|
(merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
|
|
481
510
|
(merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
|
|
511
|
+
(merged.baseRemote ?? '') !== (s.vcs?.baseRemote ?? '') ||
|
|
482
512
|
(merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
|
|
483
513
|
merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
|
|
484
514
|
if (startChanged) {
|
|
@@ -957,24 +987,47 @@ let OperatorService = class OperatorService {
|
|
|
957
987
|
* Starts the session's queued tasks in the background. Returns immediately; progress
|
|
958
988
|
* arrives on the event stream. One run at a time, of this session or any other.
|
|
959
989
|
*/
|
|
960
|
-
async start(sessionId, mode = 'confirm', name
|
|
990
|
+
async start(sessionId, mode = 'confirm', name,
|
|
991
|
+
/** Where the session starts, as chosen in the start window: see `vcs/startChoice.ts`. Absent keeps its settings. */
|
|
992
|
+
starts) {
|
|
993
|
+
const pressedAt = new Date().toISOString();
|
|
961
994
|
// One conversation at a time is not a policy, it is the browser profile: a second run
|
|
962
995
|
// does not get a second browser, it gets an error about a closed one. Whatever has the
|
|
963
996
|
// browser now — a batch, another session, a read of the models — the start is refused where
|
|
964
997
|
// the reason can still be read, rather than three minutes later in a stack trace.
|
|
965
998
|
const claim = this.claimBrowser({ kind: 'run', sessionId });
|
|
966
999
|
if ('refused' in claim)
|
|
967
|
-
return {
|
|
1000
|
+
return await this.notStarted({ stage: 'refused', message: claim.refused, sessionIds: [sessionId], pressedAt });
|
|
1001
|
+
let runId;
|
|
968
1002
|
try {
|
|
969
1003
|
// A run nobody named is named after what it is a run of, rather than left to show up in the
|
|
970
1004
|
// register as "Unnamed run" beside five others of the same description.
|
|
971
1005
|
// Set aside on the Sessions page: said first, before the preflight judges a session that will not run.
|
|
972
1006
|
if ((await this.store.getSession(sessionId))?.active === false) {
|
|
973
1007
|
this.releaseBrowser(claim.holder);
|
|
974
|
-
return {
|
|
1008
|
+
return await this.notStarted({ stage: 'refused', message: INACTIVE_REASON, sessionIds: [sessionId], pressedAt });
|
|
975
1009
|
}
|
|
976
1010
|
const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
|
|
977
1011
|
const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
|
|
1012
|
+
runId = runGroup.id;
|
|
1013
|
+
/*
|
|
1014
|
+
* What refuses this start whatever branch is chosen, asked before the choice moves anything: a local
|
|
1015
|
+
* branch made or brought up to its remote, and the session's settings, stayed changed after a start
|
|
1016
|
+
* refused for an unattended run this machine does not allow (review of 2026-10-13).
|
|
1017
|
+
*/
|
|
1018
|
+
const before = await this.store.getSession(sessionId);
|
|
1019
|
+
const earlyRefusal = !before ? 'no such session' : queuedToRun(before).length === 0 ? 'no queued tasks' : await this.unattendedRefusal(mode);
|
|
1020
|
+
if (earlyRefusal) {
|
|
1021
|
+
this.releaseBrowser(claim.holder);
|
|
1022
|
+
// No run log for a session that does not exist: the id is the caller's, and may be anything.
|
|
1023
|
+
return await this.notStarted({ stage: 'refused', message: earlyRefusal, sessionIds: [sessionId], pressedAt, ...(before ? { runId } : {}) });
|
|
1024
|
+
}
|
|
1025
|
+
// The branch the session starts from, as the start window chose it, before the preflight judges it.
|
|
1026
|
+
const unusable = await this.applyStartChoices(runGroup.id, [sessionId], starts);
|
|
1027
|
+
if (unusable) {
|
|
1028
|
+
this.releaseBrowser(claim.holder);
|
|
1029
|
+
return await this.notStarted({ stage: 'branch', message: unusable, sessionIds: [sessionId], pressedAt, runId });
|
|
1030
|
+
}
|
|
978
1031
|
// A run of one is still a run, and it records the same thing a batch does, so that going
|
|
979
1032
|
// back and starting again from a task works the same whether one session was started or six.
|
|
980
1033
|
// Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
|
|
@@ -982,12 +1035,12 @@ let OperatorService = class OperatorService {
|
|
|
982
1035
|
const notReady = await this.runPreflight(runGroup.id, [sessionId]);
|
|
983
1036
|
if (notReady) {
|
|
984
1037
|
this.releaseBrowser(claim.holder);
|
|
985
|
-
return {
|
|
1038
|
+
return await this.notStarted({ stage: 'preflight', message: notReady, sessionIds: [sessionId], pressedAt, runId });
|
|
986
1039
|
}
|
|
987
|
-
const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
|
|
1040
|
+
const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true, pressedAt });
|
|
988
1041
|
if (!begun.started) {
|
|
989
1042
|
this.releaseBrowser(claim.holder);
|
|
990
|
-
return {
|
|
1043
|
+
return await this.notStarted({ stage: 'refused', message: begun.reason ?? 'not started', sessionIds: [sessionId], pressedAt, runId });
|
|
991
1044
|
}
|
|
992
1045
|
// The window closes before the run's promise settles (see `runSession`), so the browser is
|
|
993
1046
|
// free again exactly when it is.
|
|
@@ -996,8 +1049,156 @@ let OperatorService = class OperatorService {
|
|
|
996
1049
|
}
|
|
997
1050
|
catch (e) {
|
|
998
1051
|
this.releaseBrowser(claim.holder);
|
|
999
|
-
|
|
1052
|
+
if (unusableConfig(e))
|
|
1053
|
+
throw e;
|
|
1054
|
+
// Said with the error itself, and on record with the console of those moments, rather than as a bare 500.
|
|
1055
|
+
console.error('[start] the session could not be started:', e.stack ?? e.message);
|
|
1056
|
+
return await this.notStarted({ stage: 'error', message: e.message, sessionIds: [sessionId], pressedAt, runId });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* A start that did not happen, put on record (`session/startFailure.ts`) and in the run's log when it
|
|
1061
|
+
* had a run, and said on the console, so the terminal shows it as well. `answered`: the reply to the
|
|
1062
|
+
* press carries it; false for one found after the start was accepted, which every page then shows.
|
|
1063
|
+
*/
|
|
1064
|
+
async notStarted(f) {
|
|
1065
|
+
const sessions = [];
|
|
1066
|
+
for (const id of f.sessionIds) {
|
|
1067
|
+
const s = await this.store.getSession(id).catch(() => null);
|
|
1068
|
+
sessions.push({ id, name: s?.name ?? id });
|
|
1069
|
+
}
|
|
1070
|
+
const recorded = this.startFailures.record({
|
|
1071
|
+
pressedAt: f.pressedAt,
|
|
1072
|
+
stage: f.stage,
|
|
1073
|
+
message: f.message,
|
|
1074
|
+
...(f.runId ? { runId: f.runId } : {}),
|
|
1075
|
+
sessions,
|
|
1076
|
+
answered: f.answered !== false,
|
|
1077
|
+
});
|
|
1078
|
+
console.warn(`[start] the work did not start (${f.stage}${f.runId ? `, run ${f.runId}` : ''}): ${f.message}`);
|
|
1079
|
+
if (f.runId) {
|
|
1080
|
+
const cfg = await this.settings.load().catch(() => null);
|
|
1081
|
+
if (cfg) {
|
|
1082
|
+
await appendRunLog(cfg.resolved.runsDir, f.runId, [
|
|
1083
|
+
{ at: recorded.at, type: 'start-failed', message: f.message, data: { stage: f.stage, failure: recorded.id, sessions: f.sessionIds } },
|
|
1084
|
+
]).catch(() => undefined);
|
|
1085
|
+
}
|
|
1000
1086
|
}
|
|
1087
|
+
return { started: false, reason: f.message, failure: refOf(recorded) };
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Writes the start window's choices onto the sessions of a run, or says why one cannot be used.
|
|
1091
|
+
*
|
|
1092
|
+
* Every choice is checked first, with nothing moved (`dry`), so a choice that cannot be used for one
|
|
1093
|
+
* session leaves every other session and every branch as it was; then each is written, and a local
|
|
1094
|
+
* branch made from, or brought up to, its remote one where the choice was to work on it. A session
|
|
1095
|
+
* whose start is fixed keeps it. Recorded in the run's log and the session's.
|
|
1096
|
+
*/
|
|
1097
|
+
async applyStartChoices(runId, sessionIds, starts) {
|
|
1098
|
+
if (!starts || typeof starts !== 'object')
|
|
1099
|
+
return null;
|
|
1100
|
+
const wanted = sessionIds.filter((id) => starts[id]);
|
|
1101
|
+
if (wanted.length === 0)
|
|
1102
|
+
return null;
|
|
1103
|
+
for (const id of wanted) {
|
|
1104
|
+
const s = await this.store.getSession(id);
|
|
1105
|
+
if (!s)
|
|
1106
|
+
continue;
|
|
1107
|
+
if (this.running.has(id))
|
|
1108
|
+
return `"${s.name}" is running; its start cannot be changed now.`;
|
|
1109
|
+
const checked = await applyStartChoice(s, starts[id], { dry: true });
|
|
1110
|
+
if (!checked.ok)
|
|
1111
|
+
return `"${s.name}": ${checked.problem}`;
|
|
1112
|
+
}
|
|
1113
|
+
const cfg = await this.settings.load();
|
|
1114
|
+
const entries = [];
|
|
1115
|
+
for (const id of wanted) {
|
|
1116
|
+
const s = await this.store.getSession(id);
|
|
1117
|
+
if (!s)
|
|
1118
|
+
continue;
|
|
1119
|
+
const done = await applyStartChoice(s, starts[id]);
|
|
1120
|
+
if (!done.ok)
|
|
1121
|
+
return `"${s.name}": ${done.problem}`;
|
|
1122
|
+
if (!done.applied)
|
|
1123
|
+
continue;
|
|
1124
|
+
const applied = done.applied;
|
|
1125
|
+
await this.store.updateSession(id, (x) => {
|
|
1126
|
+
if (!x.vcs)
|
|
1127
|
+
return;
|
|
1128
|
+
x.vcs = withChoice(x.vcs, applied.vcs);
|
|
1129
|
+
if (applied.clearStart) {
|
|
1130
|
+
x.vcsBaseCommit = undefined;
|
|
1131
|
+
x.vcsStart = undefined;
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
const message = `${applied.said}${applied.note ? `; ${applied.note}` : ''}${applied.clearStart ? '; the start prepared before (a starting snapshot) no longer applies' : ''}`;
|
|
1135
|
+
entries.push({ at: new Date().toISOString(), type: 'start-chosen', message, data: { session: id, choice: starts[id], ...(applied.note ? { note: applied.note } : {}) } });
|
|
1136
|
+
this.bus.publish({ sessionId: id, type: 'start-chosen', level: 'info', message, data: { runId, choice: starts[id] } });
|
|
1137
|
+
await appendSessionLog(cfg.resolved.runsDir, id, { type: 'start-chosen', message, data: { runId, choice: starts[id] } }).catch(() => undefined);
|
|
1138
|
+
}
|
|
1139
|
+
await appendRunLog(cfg.resolved.runsDir, runId, entries).catch(() => undefined);
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
/** The start window's question for these sessions: their starts and the branches of their repositories. */
|
|
1143
|
+
async startChoices(sessionIds) {
|
|
1144
|
+
await this.init();
|
|
1145
|
+
const sessions = [];
|
|
1146
|
+
for (const id of [...new Set(sessionIds.map((x) => x.trim()).filter(Boolean))]) {
|
|
1147
|
+
const s = await this.store.getSession(id);
|
|
1148
|
+
if (s)
|
|
1149
|
+
sessions.push(s);
|
|
1150
|
+
}
|
|
1151
|
+
return await startOptions(sessions);
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Fetches branches of a remote for the start window, where the operator is looking — so through Git's
|
|
1155
|
+
* own password window when the remote asks (see `vcs/remoteAuth.ts`). Only for a folder this bot works
|
|
1156
|
+
* in; only remote-tracking branches move.
|
|
1157
|
+
*/
|
|
1158
|
+
async startFetch(dir, remote, branches) {
|
|
1159
|
+
await this.init();
|
|
1160
|
+
await this.knownProjectFolder(dir, 'it is not fetched from here');
|
|
1161
|
+
return await fetchForStart(resolve(dir.trim()), remote.trim(), branches, await askpassProgram());
|
|
1162
|
+
}
|
|
1163
|
+
/** The starts that did not get as far as a first task, newest first: see `session/startFailure.ts`. */
|
|
1164
|
+
recentStartFailures(unansweredOnly) {
|
|
1165
|
+
return this.startFailures.recent({ unansweredOnly }).slice(0, 10).map(refOf);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* One of them as a file: what failed and where, the run's record, the sessions' events from the press,
|
|
1169
|
+
* and the console lines from the press to a few seconds after — secret-shaped text taken out.
|
|
1170
|
+
*/
|
|
1171
|
+
async startFailureReport(id) {
|
|
1172
|
+
await this.init();
|
|
1173
|
+
const f = this.startFailures.get(id);
|
|
1174
|
+
if (!f)
|
|
1175
|
+
throw new Error('That failed start is not known to this process (was the bot restarted since?). Its console is still in the terminal log: System → "Download what the terminal showed".');
|
|
1176
|
+
const cfg = await this.settings.load();
|
|
1177
|
+
const from = new Date(new Date(f.pressedAt).getTime() - 2_000).toISOString();
|
|
1178
|
+
const to = new Date(new Date(f.at).getTime() + 5_000).toISOString();
|
|
1179
|
+
const events = f.sessions.flatMap((s) => this.bus.recent(s.id)).filter((e) => e.at >= from).sort((a, b) => a.at.localeCompare(b.at));
|
|
1180
|
+
const text = failureReport(f, {
|
|
1181
|
+
runLog: await readRunLog(cfg.resolved.runsDir, f.runId),
|
|
1182
|
+
events,
|
|
1183
|
+
terminal: terminalLines({ from, to, max: 2_000 }),
|
|
1184
|
+
terminalFile: terminalLogFile(),
|
|
1185
|
+
version: botVersion(),
|
|
1186
|
+
});
|
|
1187
|
+
return { fileName: `copilot-operator-not-started-${fileStamp(f.at)}.txt`, content: redactSecrets(text, cfg.report.redactPatterns) };
|
|
1188
|
+
}
|
|
1189
|
+
/** What the bot's console showed: the latest lines kept in memory, as a file. See `log/terminal.ts`. */
|
|
1190
|
+
async terminalReport() {
|
|
1191
|
+
const cfg = await this.settings.load();
|
|
1192
|
+
const lines = terminalLines({ max: 5_000 });
|
|
1193
|
+
const file = terminalLogFile();
|
|
1194
|
+
const head = [
|
|
1195
|
+
"copilot-operator — what the terminal showed (the bot's console)",
|
|
1196
|
+
`Version: ${botVersion()}`,
|
|
1197
|
+
`${lines.length} line(s), the latest kept in memory${file ? `; everything since the bot started is in ${file}` : ''}.`,
|
|
1198
|
+
'',
|
|
1199
|
+
'',
|
|
1200
|
+
].join('\n');
|
|
1201
|
+
return { fileName: `copilot-operator-terminal-${fileStamp(new Date().toISOString())}.txt`, content: redactSecrets(`${head}${formatTerminal(lines)}\n`, cfg.report.redactPatterns) };
|
|
1001
1202
|
}
|
|
1002
1203
|
/**
|
|
1003
1204
|
* The same thing `start` does, but it also hands back a promise that settles when the run
|
|
@@ -1063,7 +1264,8 @@ let OperatorService = class OperatorService {
|
|
|
1063
1264
|
* nothing else.
|
|
1064
1265
|
*/
|
|
1065
1266
|
const authorizer = this.webAuthorizer(policy, controller.signal);
|
|
1066
|
-
|
|
1267
|
+
const startedAt = new Date().toISOString();
|
|
1268
|
+
this.running.set(sessionId, { controller, startedAt, mode, policy });
|
|
1067
1269
|
this.bus.publish({ sessionId, type: 'run-requested', level: 'info', message: `starting in ${mode} mode` });
|
|
1068
1270
|
// Said at the start of every run, in both modes, because it is the one fact about this tool a
|
|
1069
1271
|
// data-protection team needs and the one an operator forgets: the reports leave the machine.
|
|
@@ -1122,10 +1324,27 @@ let OperatorService = class OperatorService {
|
|
|
1122
1324
|
},
|
|
1123
1325
|
},
|
|
1124
1326
|
})
|
|
1125
|
-
.then(async (outcome) =>
|
|
1327
|
+
.then(async (outcome) => {
|
|
1328
|
+
const tally = { ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) };
|
|
1329
|
+
// Refused at its turn in a run that was going: nothing of it began, and nobody was answered with it.
|
|
1330
|
+
if (outcome.refused && tally.ran === 0) {
|
|
1331
|
+
await this.notStarted({ stage: 'at-turn', message: outcome.refused, sessionIds: [sessionId], pressedAt: how.pressedAt ?? startedAt, ...(runGroup ? { runId: runGroup.id } : {}), answered: false });
|
|
1332
|
+
}
|
|
1333
|
+
return tally;
|
|
1334
|
+
})
|
|
1126
1335
|
.catch(async (e) => {
|
|
1127
1336
|
this.bus.publish({ sessionId, type: 'run-failed', level: 'error', message: e.message });
|
|
1128
|
-
|
|
1337
|
+
const tally = { ...(await this.tally(sessionId, queuedIds)), error: e.message };
|
|
1338
|
+
/*
|
|
1339
|
+
* Failed before any task of it began — the browser, the profile, the sign-in, the model: the start
|
|
1340
|
+
* was answered "started" already, so the failure is shown on every page, with the console (2026-10-13).
|
|
1341
|
+
*/
|
|
1342
|
+
// Not when the operator stopped it: that is an answer, not a failure to show.
|
|
1343
|
+
if (tally.ran === 0 && !controller.signal.aborted) {
|
|
1344
|
+
console.error('[start] the run ended before its first task:', e.stack ?? e.message);
|
|
1345
|
+
await this.notStarted({ stage: how.laterInRun ? 'at-turn' : 'run', message: e.message, sessionIds: [sessionId], pressedAt: how.pressedAt ?? startedAt, ...(runGroup ? { runId: runGroup.id } : {}), answered: false });
|
|
1346
|
+
}
|
|
1347
|
+
return tally;
|
|
1129
1348
|
})
|
|
1130
1349
|
.finally(() => {
|
|
1131
1350
|
this.running.delete(sessionId);
|
|
@@ -1239,14 +1458,17 @@ let OperatorService = class OperatorService {
|
|
|
1239
1458
|
* ticked in the register. A session none of whose chosen tasks is queued is skipped; the
|
|
1240
1459
|
* tasks nobody chose stay queued exactly as they were. Absent means every queued task.
|
|
1241
1460
|
*/
|
|
1242
|
-
taskIds
|
|
1461
|
+
taskIds,
|
|
1462
|
+
/** Where each session starts, as chosen in the start window: see `vcs/startChoice.ts`. Absent keeps their settings. */
|
|
1463
|
+
starts) {
|
|
1464
|
+
const pressedAt = new Date().toISOString();
|
|
1243
1465
|
const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
|
|
1244
1466
|
// Before anything is waited for (see `browser`): a second batch, a session running on its own
|
|
1245
1467
|
// — one of these or any other — and a read of the models all have the browser already.
|
|
1246
1468
|
const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
|
|
1247
1469
|
if ('refused' in claim)
|
|
1248
|
-
return {
|
|
1249
|
-
return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
|
|
1470
|
+
return await this.notStarted({ stage: 'refused', message: claim.refused, sessionIds: wanted, pressedAt });
|
|
1471
|
+
return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds, { starts, pressedAt });
|
|
1250
1472
|
}
|
|
1251
1473
|
/**
|
|
1252
1474
|
* `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
|
|
@@ -1254,16 +1476,18 @@ let OperatorService = class OperatorService {
|
|
|
1254
1476
|
* refusal. "Run again from here" claims it before it moves anything and starts through this, so
|
|
1255
1477
|
* one claim covers the restore, the requeue and the run.
|
|
1256
1478
|
*/
|
|
1257
|
-
async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
|
|
1479
|
+
async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds, how = {}) {
|
|
1258
1480
|
const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
|
|
1481
|
+
const pressedAt = how.pressedAt ?? new Date().toISOString();
|
|
1259
1482
|
let handedOn = false;
|
|
1483
|
+
// The batch's id first, so the preflight is recorded under the run it decides about.
|
|
1484
|
+
const batchId = newId('b-');
|
|
1485
|
+
const refuse = (stage, message, runId) => this.notStarted({ stage, message, sessionIds: wanted, pressedAt, ...(runId ? { runId } : {}) });
|
|
1260
1486
|
try {
|
|
1261
1487
|
await this.init();
|
|
1262
1488
|
if (wanted.length === 0)
|
|
1263
|
-
return
|
|
1489
|
+
return await refuse('refused', 'no sessions were selected');
|
|
1264
1490
|
// Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
|
|
1265
|
-
// The batch's id first, so the preflight is recorded under the run it decides about.
|
|
1266
|
-
const batchId = newId('b-');
|
|
1267
1491
|
/*
|
|
1268
1492
|
* Sessions set aside on the Sessions page are left out before the preflight: judged there, an inactive
|
|
1269
1493
|
* session's contradiction or version-control problem was the answer to the press — and could refuse
|
|
@@ -1280,18 +1504,45 @@ let OperatorService = class OperatorService {
|
|
|
1280
1504
|
active.push(id);
|
|
1281
1505
|
}
|
|
1282
1506
|
if (active.length === 0)
|
|
1283
|
-
return
|
|
1507
|
+
return await refuse('refused', `${inactive.map((n) => `"${n}"`).join(', ')}: ${INACTIVE_REASON}`);
|
|
1508
|
+
/*
|
|
1509
|
+
* What refuses the batch whatever branch is chosen, asked before the choices move anything (review of
|
|
1510
|
+
* 2026-10-13): nothing queued among the chosen tasks, an unattended run this machine does not allow.
|
|
1511
|
+
*/
|
|
1512
|
+
let anyQueued = false;
|
|
1513
|
+
for (const id of active) {
|
|
1514
|
+
const s = await this.store.getSession(id);
|
|
1515
|
+
if (s && queuedToRun(s, onlyTasks).length > 0)
|
|
1516
|
+
anyQueued = true;
|
|
1517
|
+
}
|
|
1518
|
+
if (!anyQueued)
|
|
1519
|
+
return await refuse('refused', 'none of the selected sessions has a queued task', batchId);
|
|
1520
|
+
const blockedEarly = await this.unattendedRefusal(mode);
|
|
1521
|
+
if (blockedEarly)
|
|
1522
|
+
return await refuse('refused', blockedEarly, batchId);
|
|
1523
|
+
// The branch each session starts from, as the start window chose it, before the preflight judges it.
|
|
1524
|
+
const unusable = await this.applyStartChoices(batchId, active, how.starts);
|
|
1525
|
+
if (unusable)
|
|
1526
|
+
return await refuse('branch', unusable, batchId);
|
|
1284
1527
|
const notReady = await this.runPreflight(batchId, active, onlyTasks);
|
|
1285
1528
|
if (notReady)
|
|
1286
|
-
return
|
|
1529
|
+
return await refuse('preflight', notReady, batchId);
|
|
1287
1530
|
const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name, batchId);
|
|
1288
1531
|
if (!begun.batch)
|
|
1289
|
-
return
|
|
1532
|
+
return await refuse('refused', begun.reason, batchId);
|
|
1533
|
+
begun.batch.pressedAt = pressedAt;
|
|
1290
1534
|
this.batch = begun.batch;
|
|
1291
1535
|
handedOn = true;
|
|
1292
1536
|
void this.runBatch(begun.batch, holder);
|
|
1293
1537
|
return { started: true, batch: this.batchState() };
|
|
1294
1538
|
}
|
|
1539
|
+
catch (e) {
|
|
1540
|
+
if (unusableConfig(e))
|
|
1541
|
+
throw e;
|
|
1542
|
+
// Said with the error itself, and on record with the console of those moments, rather than as a bare 400.
|
|
1543
|
+
console.error('[start] the run could not be started:', e.stack ?? e.message);
|
|
1544
|
+
return await refuse('error', e.message, batchId);
|
|
1545
|
+
}
|
|
1295
1546
|
finally {
|
|
1296
1547
|
if (!handedOn)
|
|
1297
1548
|
this.releaseBrowser(holder);
|
|
@@ -1527,6 +1778,19 @@ let OperatorService = class OperatorService {
|
|
|
1527
1778
|
level: 'error',
|
|
1528
1779
|
message: `the browser could not be opened: ${e.message}`,
|
|
1529
1780
|
});
|
|
1781
|
+
/*
|
|
1782
|
+
* The run had been accepted, so the press was answered "started": this is shown on every page
|
|
1783
|
+
* until it is closed, with what the console printed (2026-10-13). The stack is printed for it.
|
|
1784
|
+
*/
|
|
1785
|
+
console.error('[start] the browser could not be opened:', e.stack ?? e.message);
|
|
1786
|
+
await this.notStarted({
|
|
1787
|
+
stage: 'browser',
|
|
1788
|
+
message: `the browser could not be opened: ${e.message}`,
|
|
1789
|
+
sessionIds: batch.sessions.map((x) => x.sessionId),
|
|
1790
|
+
pressedAt: batch.pressedAt ?? batch.startedAt,
|
|
1791
|
+
runId: batch.id,
|
|
1792
|
+
answered: false,
|
|
1793
|
+
});
|
|
1530
1794
|
return;
|
|
1531
1795
|
}
|
|
1532
1796
|
try {
|
|
@@ -1597,6 +1861,7 @@ let OperatorService = class OperatorService {
|
|
|
1597
1861
|
// switched the batch with it (see `setRunMode`). The hold is this batch's own.
|
|
1598
1862
|
const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
|
|
1599
1863
|
shouldPause: () => batch.pausing,
|
|
1864
|
+
laterInRun: batch.sessions.some((x) => x.ran > 0),
|
|
1600
1865
|
});
|
|
1601
1866
|
if (!begun.started) {
|
|
1602
1867
|
entry.state = 'skipped';
|
|
@@ -2346,13 +2611,15 @@ let OperatorService = class OperatorService {
|
|
|
2346
2611
|
const branch = how === 'existing-branch' ? session.vcs?.existingBranch?.trim() : session.vcs?.baseBranch?.trim() || 'main';
|
|
2347
2612
|
if (!branch)
|
|
2348
2613
|
return 'unknown';
|
|
2614
|
+
// A remote branch chosen in the start window: its remote-tracking branch, as that window fetched it.
|
|
2615
|
+
const ref = how === 'branch' ? baseRefOf(session.vcs).ref : `refs/heads/${branch}`;
|
|
2349
2616
|
// Brought up to its remote first: what that brings is known only then.
|
|
2350
|
-
if (session.vcs?.updateFromRemote !== false) {
|
|
2617
|
+
if (session.vcs?.updateFromRemote !== false && !(how === 'branch' && session.vcs?.baseRemote)) {
|
|
2351
2618
|
const remotes = await git(repo, ['remote']);
|
|
2352
2619
|
if (!remotes.ok || remotes.stdout.trim() !== '')
|
|
2353
2620
|
return 'unknown';
|
|
2354
2621
|
}
|
|
2355
|
-
const tip = await git(repo, ['rev-parse', '--verify', '--quiet',
|
|
2622
|
+
const tip = await git(repo, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
2356
2623
|
return tip.ok && tip.stdout ? { ref: tip.stdout } : 'unknown';
|
|
2357
2624
|
}
|
|
2358
2625
|
/**
|
package/dist/src/api/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { pruneRuns } from '../session/retention.js';
|
|
|
22
22
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
23
23
|
import { existsSync } from 'node:fs';
|
|
24
24
|
import { installLayout } from '../config/layout.js';
|
|
25
|
+
import { captureTerminal } from '../log/terminal.js';
|
|
25
26
|
/**
|
|
26
27
|
* A settings file, or a policy lock, that cannot be used, answered the same way on every route: 409,
|
|
27
28
|
* with the message that names the file and says what to do (see `SettingsUnusableError` and
|
|
@@ -50,6 +51,11 @@ export async function startApi(opts = {}) {
|
|
|
50
51
|
const PORT = opts.port ?? Number(process.env.COP_API_PORT ?? 4000);
|
|
51
52
|
const WEB_ORIGIN = opts.webOrigin ?? process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
|
|
52
53
|
const log = opts.quiet ? () => undefined : (line) => console.log(line);
|
|
54
|
+
/*
|
|
55
|
+
* What the console shows from here on is copied for the page to offer as a file (see
|
|
56
|
+
* `log/terminal.ts`): kept in memory now, written to the data folder once its permissions are set.
|
|
57
|
+
*/
|
|
58
|
+
captureTerminal();
|
|
53
59
|
const app = await NestFactory.create(AppModule, { logger: opts.quiet ? ['error', 'warn'] : ['error', 'warn', 'log'] });
|
|
54
60
|
// A plan pasted from a chat carries every task's text at once, which the 100 KB default
|
|
55
61
|
// refuses with a message about entity size that says nothing about plans.
|
|
@@ -96,6 +102,7 @@ export async function startApi(opts = {}) {
|
|
|
96
102
|
const acl = secureDataDir(dataDir);
|
|
97
103
|
if (!acl.ok)
|
|
98
104
|
throw new Error(`copilot-operator api will not start: ${acl.reason}`);
|
|
105
|
+
captureTerminal(join(dataDir, 'logs'));
|
|
99
106
|
const token = await ensureApiToken(dataDir);
|
|
100
107
|
// The dev UI's origin, and this process's own for the interface it serves itself.
|
|
101
108
|
const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
|