copilot-operator 0.1.7 → 0.1.9
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/main.js +2 -104
- package/dist/src/api/operator.controller.js +18 -1
- package/dist/src/api/operator.service.js +30 -11
- package/dist/src/api/server.js +114 -0
- package/dist/src/config/schema.js +5 -0
- package/dist/src/exec/checks.js +17 -0
- package/dist/src/orchestrator/progress.js +106 -0
- package/dist/src/orchestrator/taskRunner.js +110 -10
- package/dist/src/plan/brief.js +6 -4
- package/dist/src/plan/importPlan.js +1 -0
- package/dist/src/plan/schema.js +7 -0
- package/dist/src/plan/systemGuide.js +67 -12
- package/dist/src/session/compose.js +25 -20
- package/dist/src/session/exports.js +5 -0
- package/dist/src/session/handoff.js +42 -0
- package/dist/src/session/interruption.js +104 -0
- package/dist/src/session/metrics.js +95 -0
- package/dist/src/session/store.js +35 -5
- package/dist/src/session/story.js +1 -1
- package/dist/src/transport/chatTransport.js +32 -0
- package/dist/src/vcs/contentIntegrity.js +174 -0
- package/dist/src/vcs/git.js +42 -0
- package/dist/src/vcs/scope.js +129 -0
- package/dist/src/vcs/taskVcs.js +43 -2
- package/dist/web/404.html +4 -3
- package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +1 -0
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
- package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-be366b35f4cc97ce.js +1 -0
- package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-cabc3876a8111edd.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +1 -0
- package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
- package/dist/web/appearance.html +4 -3
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +4 -3
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +4 -3
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +4 -3
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +4 -3
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +4 -3
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +4 -3
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +4 -3
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +4 -3
- package/dist/web/system.txt +12 -12
- package/package.json +13 -4
- package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
- package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
- package/dist/web/_next/static/chunks/867-4aa80f5a32311bce.js +0 -1
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-2251664c0abe3f11.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
- package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
- package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-9517dd74c6d00f85.js +0 -1
- package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
- /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_ssgManifest.js +0 -0
package/dist/src/api/main.js
CHANGED
|
@@ -7,109 +7,7 @@
|
|
|
7
7
|
* commands on this machine. Exposing it on a network interface would hand that to anyone who
|
|
8
8
|
* could reach the port.
|
|
9
9
|
*/
|
|
10
|
-
import '
|
|
11
|
-
import { NestFactory } from '@nestjs/core';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { AppModule } from './app.module.js';
|
|
14
|
-
import { OperatorService } from './operator.service.js';
|
|
15
|
-
import { ensureApiToken, localApiGuard } from './security.js';
|
|
16
|
-
import { secureDataDir } from './dataAcl.js';
|
|
17
|
-
import { pruneRuns } from '../session/retention.js';
|
|
18
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
20
|
-
import { installLayout } from '../config/layout.js';
|
|
21
|
-
const PORT = Number(process.env.COP_API_PORT ?? 4000);
|
|
22
|
-
const WEB_ORIGIN = process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
|
|
23
|
-
async function main() {
|
|
24
|
-
const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] });
|
|
25
|
-
// A plan pasted from a chat carries every task's text at once, which the 100 KB default
|
|
26
|
-
// refuses with a message about entity size that says nothing about plans.
|
|
27
|
-
app.useBodyParser('json', { limit: '5mb' });
|
|
28
|
-
app.enableCors({
|
|
29
|
-
origin: [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1')],
|
|
30
|
-
/*
|
|
31
|
-
* The one header the page has to be able to read.
|
|
32
|
-
*
|
|
33
|
-
* A browser hands script only a handful of response headers across origins, and
|
|
34
|
-
* `content-disposition` is not among them unless it is named here. The UI is on
|
|
35
|
-
* `localhost:3210` and this is on `127.0.0.1:4000`, so every fetch is cross-origin: without
|
|
36
|
-
* this, a download built from a blob cannot find out what the file is called, and the
|
|
37
|
-
* carefully composed `copilot-operator-bundle-2-tasks-2-sessions-<stamp>.json` arrives as
|
|
38
|
-
* whatever the client guessed. The plain `<a download>` exports never noticed, because the
|
|
39
|
-
* browser reads the header itself for those and never shows it to the page.
|
|
40
|
-
*/
|
|
41
|
-
exposedHeaders: ['content-disposition'],
|
|
42
|
-
});
|
|
43
|
-
app.setGlobalPrefix('api');
|
|
44
|
-
/*
|
|
45
|
-
* Who may drive this. Mounted before routing so it covers every route including the SSE stream,
|
|
46
|
-
* and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
|
|
47
|
-
* was never the whole answer — a page in the operator's ordinary browser and any other process
|
|
48
|
-
* on the machine were both callers all along.
|
|
49
|
-
*/
|
|
50
|
-
const layout = installLayout();
|
|
51
|
-
const dataDir = layout.dataDir;
|
|
52
|
-
await mkdir(dataDir, { recursive: true });
|
|
53
|
-
if (layout.mode === 'package') {
|
|
54
|
-
/*
|
|
55
|
-
* The records folder sits inside the project, so it must be invisible to the project's git:
|
|
56
|
-
* version control refuses to branch on a tree with untracked files, and the records are not
|
|
57
|
-
* the project's work. A `.gitignore` of `*` inside the folder says so without touching the
|
|
58
|
-
* project's own .gitignore.
|
|
59
|
-
*/
|
|
60
|
-
const ignore = join(layout.homeDir, '.gitignore');
|
|
61
|
-
if (!existsSync(ignore))
|
|
62
|
-
await writeFile(ignore, '*\n', 'utf8');
|
|
63
|
-
}
|
|
64
|
-
// Before the token is read: a token any local account can read guards nothing. See `dataAcl.ts`.
|
|
65
|
-
const acl = secureDataDir(dataDir);
|
|
66
|
-
if (!acl.ok) {
|
|
67
|
-
console.error(`copilot-operator api will not start: ${acl.reason}`);
|
|
68
|
-
process.exit(1);
|
|
69
|
-
}
|
|
70
|
-
const token = await ensureApiToken(dataDir);
|
|
71
|
-
// The dev UI's origin, and this process's own for the interface it serves itself.
|
|
72
|
-
const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
|
|
73
|
-
app.use(localApiGuard({ token, port: PORT, allowedOrigins, servesInterface: layout.webDir !== null }));
|
|
74
|
-
if (layout.webDir) {
|
|
75
|
-
// The prebuilt interface, from the package: one process, one port, no dev server.
|
|
76
|
-
app.useStaticAssets(layout.webDir, { extensions: ['html'], index: 'index.html' });
|
|
77
|
-
}
|
|
78
|
-
await app.listen(PORT, '127.0.0.1');
|
|
79
|
-
// Close whatever the previous process left open before anyone can look at it, rather than
|
|
80
|
-
// waiting for the first request to trigger it.
|
|
81
|
-
const ops = app.get(OperatorService);
|
|
82
|
-
await ops.bootstrap();
|
|
83
|
-
/*
|
|
84
|
-
* runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
|
|
85
|
-
* as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
|
|
86
|
-
* parent folder had. Narrowed the same way, and pruned to the configured retention.
|
|
87
|
-
*/
|
|
88
|
-
const cfg = await ops.settings.load();
|
|
89
|
-
await mkdir(cfg.resolved.runsDir, { recursive: true });
|
|
90
|
-
const runsAcl = secureDataDir(cfg.resolved.runsDir);
|
|
91
|
-
if (!runsAcl.ok) {
|
|
92
|
-
console.error(`copilot-operator api will not start: ${runsAcl.reason}`);
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
95
|
-
for (const [name, dir] of [['data', dataDir], ['runs', cfg.resolved.runsDir]]) {
|
|
96
|
-
if (/onedrive/i.test(dir) || /^\\\\/.test(dir)) {
|
|
97
|
-
console.warn(` warning: the ${name} folder (${dir}) is under OneDrive or on a network share; keep the bot's records on a local disk (COP_DATA_DIR, runsDir)`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
|
|
101
|
-
if (pruned.length > 0)
|
|
102
|
-
console.log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
|
|
103
|
-
if (layout.webDir) {
|
|
104
|
-
console.log(`copilot-operator is running for ${layout.projectRoot}`);
|
|
105
|
-
console.log(` open http://127.0.0.1:${PORT}/ in your browser`);
|
|
106
|
-
console.log(` records are kept in ${layout.homeDir}`);
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
console.log(`copilot-operator api listening on http://127.0.0.1:${PORT}/api (web origin ${WEB_ORIGIN})`);
|
|
110
|
-
console.log(` requests need the token in ${join(dataDir, 'api-token')}; npm start hands it to the UI`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
10
|
+
import { startApi } from './server.js';
|
|
113
11
|
/*
|
|
114
12
|
* A stray rejection must not end a run.
|
|
115
13
|
*
|
|
@@ -126,7 +24,7 @@ process.on('unhandledRejection', (reason) => {
|
|
|
126
24
|
process.on('uncaughtException', (error) => {
|
|
127
25
|
console.error('[api] uncaught exception (the run continues):', error.stack ?? error.message);
|
|
128
26
|
});
|
|
129
|
-
|
|
27
|
+
startApi().catch((e) => {
|
|
130
28
|
console.error(e.message);
|
|
131
29
|
process.exit(1);
|
|
132
30
|
});
|
|
@@ -10,7 +10,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
10
10
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
|
-
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, Sse, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
13
|
+
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Query, Res, Sse, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
14
14
|
import { Observable } from 'rxjs';
|
|
15
15
|
import { OperatorService } from './operator.service.js';
|
|
16
16
|
function fail(e) {
|
|
@@ -37,6 +37,13 @@ function contentDisposition(fileName) {
|
|
|
37
37
|
}
|
|
38
38
|
let OperatorController = class OperatorController {
|
|
39
39
|
ops;
|
|
40
|
+
/*
|
|
41
|
+
* Named explicitly rather than read from the parameter's type. Nest normally learns what to inject
|
|
42
|
+
* from the metadata `tsc` emits for a decorated class, and that metadata exists only when the code
|
|
43
|
+
* was compiled by `tsc`: run through tsx or any other esbuild-based loader — which is how every
|
|
44
|
+
* check in test/ runs, including the ones that start this API — the type is gone and `ops` arrived
|
|
45
|
+
* as undefined, so every route failed with a TypeError. With the token named, both ways work.
|
|
46
|
+
*/
|
|
40
47
|
constructor(ops) {
|
|
41
48
|
this.ops = ops;
|
|
42
49
|
}
|
|
@@ -491,6 +498,9 @@ let OperatorController = class OperatorController {
|
|
|
491
498
|
tasks() {
|
|
492
499
|
return this.ops.taskRegistry();
|
|
493
500
|
}
|
|
501
|
+
metrics() {
|
|
502
|
+
return this.ops.metrics();
|
|
503
|
+
}
|
|
494
504
|
// --- helpers for the UI -----------------------------------------------------------------
|
|
495
505
|
dirs(root, gitignore) {
|
|
496
506
|
if (!root)
|
|
@@ -1030,6 +1040,12 @@ __decorate([
|
|
|
1030
1040
|
__metadata("design:paramtypes", []),
|
|
1031
1041
|
__metadata("design:returntype", Promise)
|
|
1032
1042
|
], OperatorController.prototype, "tasks", null);
|
|
1043
|
+
__decorate([
|
|
1044
|
+
Get('metrics'),
|
|
1045
|
+
__metadata("design:type", Function),
|
|
1046
|
+
__metadata("design:paramtypes", []),
|
|
1047
|
+
__metadata("design:returntype", Promise)
|
|
1048
|
+
], OperatorController.prototype, "metrics", null);
|
|
1033
1049
|
__decorate([
|
|
1034
1050
|
Get('dirs'),
|
|
1035
1051
|
__param(0, Query('root')),
|
|
@@ -1054,6 +1070,7 @@ __decorate([
|
|
|
1054
1070
|
], OperatorController.prototype, "preview", null);
|
|
1055
1071
|
OperatorController = __decorate([
|
|
1056
1072
|
Controller(),
|
|
1073
|
+
__param(0, Inject(OperatorService)),
|
|
1057
1074
|
__metadata("design:paramtypes", [OperatorService])
|
|
1058
1075
|
], OperatorController);
|
|
1059
1076
|
export { OperatorController };
|
|
@@ -36,6 +36,7 @@ function sameFolder(a, b) {
|
|
|
36
36
|
return a.trim() !== '' && norm(a) === norm(b);
|
|
37
37
|
}
|
|
38
38
|
import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
|
|
39
|
+
import { readInterruption } from '../session/interruption.js';
|
|
39
40
|
import { commitInterrupted, repoDirOf, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
|
|
40
41
|
import { changedFilesBetween, fileAt, gitAvailable, repoUnusableReason } from '../vcs/git.js';
|
|
41
42
|
import { makeAuthorizer } from '../exec/authorizer.js';
|
|
@@ -44,7 +45,9 @@ import { pickFolder } from './folderPicker.js';
|
|
|
44
45
|
import { findEdgeUsingProfile } from '../transport/profileLock.js';
|
|
45
46
|
import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } from '../exec/isolation.js';
|
|
46
47
|
import { unattendedPrecondition } from '../exec/policy.js';
|
|
47
|
-
import {
|
|
48
|
+
import { createTransport } from '../transport/chatTransport.js';
|
|
49
|
+
import { composeHandoff } from '../session/handoff.js';
|
|
50
|
+
import { computeMetrics } from '../session/metrics.js';
|
|
48
51
|
import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
|
|
49
52
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
50
53
|
import { Settings } from './settings.js';
|
|
@@ -101,19 +104,28 @@ let OperatorService = class OperatorService {
|
|
|
101
104
|
if (!session || !task)
|
|
102
105
|
return;
|
|
103
106
|
const vcs = await commitInterrupted(session, task, task.reason ?? 'interrupted', this.bus);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
const cfg = await this.settings.load();
|
|
108
|
+
// Where it had got to, from its run folder, so "Continue" can tell the chat exactly that.
|
|
109
|
+
const interruption = task.runId ? await readInterruption(join(cfg.resolved.runsDir, task.runId)) : null;
|
|
110
|
+
const settled = await this.store.updateSession(sessionId, (s) => {
|
|
111
|
+
const t = s.tasks.find((x) => x.id === taskId);
|
|
112
|
+
if (!t)
|
|
113
|
+
return;
|
|
114
|
+
if (interruption)
|
|
115
|
+
t.interruption = interruption;
|
|
116
|
+
if (vcs?.commit) {
|
|
109
117
|
t.vcs = vcs;
|
|
110
118
|
t.reason = `${t.reason ?? ''} What it had changed is committed on ${vcs.branch} as ${vcs.commit?.slice(0, 8)}.`.trim();
|
|
111
|
-
}
|
|
112
|
-
|
|
119
|
+
}
|
|
120
|
+
// The same fixed-shape ending a task the runner finished gets; the steps the bot never
|
|
121
|
+
// reached are the interruption's own record of them.
|
|
122
|
+
t.handoff = composeHandoff(t, (interruption?.steps ?? [])
|
|
123
|
+
.filter((x) => x.state !== 'finished')
|
|
124
|
+
.map((x) => ({ command: x.command ?? `step ${x.id}`, why: x.state === 'cut' ? 'cut off when the bot stopped' : 'not reached before the bot stopped' })));
|
|
125
|
+
});
|
|
113
126
|
const ended = settled.tasks.find((t) => t.id === taskId);
|
|
114
127
|
if (!ended)
|
|
115
128
|
return;
|
|
116
|
-
const cfg = await this.settings.load();
|
|
117
129
|
await writeAttemptRecord(settled, ended, cfg.resolved.runsDir, exportMachine(cfg));
|
|
118
130
|
}
|
|
119
131
|
/** Runs the startup work now, so it does not wait for the first request. */
|
|
@@ -314,7 +326,7 @@ let OperatorService = class OperatorService {
|
|
|
314
326
|
* the text it ran with, and only then is the new text applied. Editing in place would leave
|
|
315
327
|
* the old attempt's summary sitting under a question that was never asked.
|
|
316
328
|
*/
|
|
317
|
-
/** "Continue" on a task that stopped
|
|
329
|
+
/** "Continue" on a task that stopped before it finished — its limit, the bot stopping, the operator: same chat, same branch, fresh count. */
|
|
318
330
|
async continueTask(sessionId, taskId) {
|
|
319
331
|
await this.init();
|
|
320
332
|
const task = await this.store.continueTask(sessionId, taskId);
|
|
@@ -1947,7 +1959,7 @@ let OperatorService = class OperatorService {
|
|
|
1947
1959
|
throw new Error('The model list is already being read.');
|
|
1948
1960
|
this.readingModels = true;
|
|
1949
1961
|
const cfg = await this.settings.load();
|
|
1950
|
-
const transport =
|
|
1962
|
+
const transport = createTransport({
|
|
1951
1963
|
profileDir: cfg.resolved.profileDir,
|
|
1952
1964
|
transportDir: join(cfg.resolved.runsDir, '_models'),
|
|
1953
1965
|
chatUrl: cfg.copilot.url,
|
|
@@ -1978,6 +1990,11 @@ let OperatorService = class OperatorService {
|
|
|
1978
1990
|
* what is still ahead; doing the flattening here keeps that split honest, because the
|
|
1979
1991
|
* queue position is only knowable from the session's own list.
|
|
1980
1992
|
*/
|
|
1993
|
+
/** How well the bot is doing, added up from every task on record. See `session/metrics.ts`. */
|
|
1994
|
+
async metrics() {
|
|
1995
|
+
await this.init();
|
|
1996
|
+
return computeMetrics(await this.store.listSessions());
|
|
1997
|
+
}
|
|
1981
1998
|
async taskRegistry() {
|
|
1982
1999
|
await this.init();
|
|
1983
2000
|
const sessions = await this.store.listSessions();
|
|
@@ -2017,9 +2034,11 @@ let OperatorService = class OperatorService {
|
|
|
2017
2034
|
deviations: t.deviations?.length || undefined,
|
|
2018
2035
|
disputes: t.disputes?.length || undefined,
|
|
2019
2036
|
readOnly: t.readOnly || undefined,
|
|
2037
|
+
scope: t.scope?.length ? t.scope : undefined,
|
|
2020
2038
|
autoRetries: t.autoRetries || undefined,
|
|
2021
2039
|
attempt: t.attempt,
|
|
2022
2040
|
changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
|
|
2041
|
+
interrupted: t.status === 'aborted' && !!t.interruption ? true : undefined,
|
|
2023
2042
|
attempts: (t.attempts ?? []).map((a, n) => {
|
|
2024
2043
|
const from = a.startedAt ? Date.parse(a.startedAt) : undefined;
|
|
2025
2044
|
const to = a.finishedAt ? Date.parse(a.finishedAt) : undefined;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API server: routes, the guard in front of them, the data folder's permissions, recovery of
|
|
3
|
+
* whatever the previous process left running. See `main.ts` for the process around it.
|
|
4
|
+
*/
|
|
5
|
+
import 'reflect-metadata';
|
|
6
|
+
import { NestFactory } from '@nestjs/core';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { AppModule } from './app.module.js';
|
|
9
|
+
import { OperatorService } from './operator.service.js';
|
|
10
|
+
import { ensureApiToken, localApiGuard } from './security.js';
|
|
11
|
+
import { secureDataDir } from './dataAcl.js';
|
|
12
|
+
import { pruneRuns } from '../session/retention.js';
|
|
13
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { installLayout } from '../config/layout.js';
|
|
16
|
+
/**
|
|
17
|
+
* Everything `main` used to do, as a function that returns the running server instead of owning the
|
|
18
|
+
* process. `main.ts` calls it and turns a failure into an exit code; the end-to-end checks call it
|
|
19
|
+
* with a scripted chat in place (see `transport/chatTransport.ts`) and close it when they are done.
|
|
20
|
+
* A refusal to start is thrown rather than ending the process, so a check that trips one is told why.
|
|
21
|
+
*/
|
|
22
|
+
export async function startApi(opts = {}) {
|
|
23
|
+
const PORT = opts.port ?? Number(process.env.COP_API_PORT ?? 4000);
|
|
24
|
+
const WEB_ORIGIN = opts.webOrigin ?? process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
|
|
25
|
+
const log = opts.quiet ? () => undefined : (line) => console.log(line);
|
|
26
|
+
const app = await NestFactory.create(AppModule, { logger: opts.quiet ? ['error', 'warn'] : ['error', 'warn', 'log'] });
|
|
27
|
+
// A plan pasted from a chat carries every task's text at once, which the 100 KB default
|
|
28
|
+
// refuses with a message about entity size that says nothing about plans.
|
|
29
|
+
app.useBodyParser('json', { limit: '5mb' });
|
|
30
|
+
app.enableCors({
|
|
31
|
+
origin: [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1')],
|
|
32
|
+
/*
|
|
33
|
+
* The one header the page has to be able to read.
|
|
34
|
+
*
|
|
35
|
+
* A browser hands script only a handful of response headers across origins, and
|
|
36
|
+
* `content-disposition` is not among them unless it is named here. The UI is on
|
|
37
|
+
* `localhost:3210` and this is on `127.0.0.1:4000`, so every fetch is cross-origin: without
|
|
38
|
+
* this, a download built from a blob cannot find out what the file is called, and the
|
|
39
|
+
* carefully composed `copilot-operator-bundle-2-tasks-2-sessions-<stamp>.json` arrives as
|
|
40
|
+
* whatever the client guessed. The plain `<a download>` exports never noticed, because the
|
|
41
|
+
* browser reads the header itself for those and never shows it to the page.
|
|
42
|
+
*/
|
|
43
|
+
exposedHeaders: ['content-disposition'],
|
|
44
|
+
});
|
|
45
|
+
app.setGlobalPrefix('api');
|
|
46
|
+
/*
|
|
47
|
+
* Who may drive this. Mounted before routing so it covers every route including the SSE stream,
|
|
48
|
+
* and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
|
|
49
|
+
* was never the whole answer — a page in the operator's ordinary browser and any other process
|
|
50
|
+
* on the machine were both callers all along.
|
|
51
|
+
*/
|
|
52
|
+
const layout = installLayout();
|
|
53
|
+
const dataDir = layout.dataDir;
|
|
54
|
+
const webDir = opts.webDir ?? layout.webDir;
|
|
55
|
+
await mkdir(dataDir, { recursive: true });
|
|
56
|
+
if (layout.mode === 'package') {
|
|
57
|
+
/*
|
|
58
|
+
* The records folder sits inside the project, so it must be invisible to the project's git:
|
|
59
|
+
* version control refuses to branch on a tree with untracked files, and the records are not
|
|
60
|
+
* the project's work. A `.gitignore` of `*` inside the folder says so without touching the
|
|
61
|
+
* project's own .gitignore.
|
|
62
|
+
*/
|
|
63
|
+
const ignore = join(layout.homeDir, '.gitignore');
|
|
64
|
+
if (!existsSync(ignore))
|
|
65
|
+
await writeFile(ignore, '*\n', 'utf8');
|
|
66
|
+
}
|
|
67
|
+
// Before the token is read: a token any local account can read guards nothing. See `dataAcl.ts`.
|
|
68
|
+
const acl = secureDataDir(dataDir);
|
|
69
|
+
if (!acl.ok)
|
|
70
|
+
throw new Error(`copilot-operator api will not start: ${acl.reason}`);
|
|
71
|
+
const token = await ensureApiToken(dataDir);
|
|
72
|
+
// The dev UI's origin, and this process's own for the interface it serves itself.
|
|
73
|
+
const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
|
|
74
|
+
app.use(localApiGuard({ token, port: PORT, allowedOrigins, servesInterface: webDir !== null }));
|
|
75
|
+
if (webDir) {
|
|
76
|
+
// The prebuilt interface, from the package: one process, one port, no dev server.
|
|
77
|
+
app.useStaticAssets(webDir, { extensions: ['html'], index: 'index.html' });
|
|
78
|
+
}
|
|
79
|
+
await app.listen(PORT, '127.0.0.1');
|
|
80
|
+
// Close whatever the previous process left open before anyone can look at it, rather than
|
|
81
|
+
// waiting for the first request to trigger it.
|
|
82
|
+
const ops = app.get(OperatorService);
|
|
83
|
+
await ops.bootstrap();
|
|
84
|
+
/*
|
|
85
|
+
* runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
|
|
86
|
+
* as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
|
|
87
|
+
* parent folder had. Narrowed the same way, and pruned to the configured retention.
|
|
88
|
+
*/
|
|
89
|
+
const cfg = await ops.settings.load();
|
|
90
|
+
await mkdir(cfg.resolved.runsDir, { recursive: true });
|
|
91
|
+
const runsAcl = secureDataDir(cfg.resolved.runsDir);
|
|
92
|
+
if (!runsAcl.ok) {
|
|
93
|
+
await app.close();
|
|
94
|
+
throw new Error(`copilot-operator api will not start: ${runsAcl.reason}`);
|
|
95
|
+
}
|
|
96
|
+
for (const [name, dir] of [['data', dataDir], ['runs', cfg.resolved.runsDir]]) {
|
|
97
|
+
if (/onedrive/i.test(dir) || /^\\\\/.test(dir)) {
|
|
98
|
+
console.warn(` warning: the ${name} folder (${dir}) is under OneDrive or on a network share; keep the bot's records on a local disk (COP_DATA_DIR, runsDir)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
|
|
102
|
+
if (pruned.length > 0)
|
|
103
|
+
log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
|
|
104
|
+
if (layout.webDir) {
|
|
105
|
+
log(`copilot-operator is running for ${layout.projectRoot}`);
|
|
106
|
+
log(` open http://127.0.0.1:${PORT}/ in your browser`);
|
|
107
|
+
log(` records are kept in ${layout.homeDir}`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
log(`copilot-operator api listening on http://127.0.0.1:${PORT}/api (web origin ${WEB_ORIGIN})`);
|
|
111
|
+
log(` requests need the token in ${join(dataDir, 'api-token')}; npm start hands it to the UI`);
|
|
112
|
+
}
|
|
113
|
+
return { app, port: PORT, token, dataDir, close: () => app.close() };
|
|
114
|
+
}
|
|
@@ -370,6 +370,11 @@ export const RunConfigSchema = z.object({
|
|
|
370
370
|
* of turns" is not.
|
|
371
371
|
*/
|
|
372
372
|
maxStalledIterations: z.number().int().positive().default(2),
|
|
373
|
+
/*
|
|
374
|
+
* Rounds in a row that end with the same error while no file changes, before the task is
|
|
375
|
+
* stopped as blocked with that diagnosis (orchestrator/progress.ts). Only with a repository.
|
|
376
|
+
*/
|
|
377
|
+
maxNoProgressRounds: z.number().int().min(2).default(3),
|
|
373
378
|
/**
|
|
374
379
|
* How many times a task may be sent back because an independent review found problems.
|
|
375
380
|
*
|
package/dist/src/exec/checks.js
CHANGED
|
@@ -21,6 +21,7 @@ import { runStep } from './runner.js';
|
|
|
21
21
|
import { resolveShell } from './shells.js';
|
|
22
22
|
import { repoState, workingTreePaths } from '../vcs/git.js';
|
|
23
23
|
import { findSuspicious, suspiciousDetail } from '../vcs/commitHygiene.js';
|
|
24
|
+
import { integrityDetail, scanChanges } from '../vcs/contentIntegrity.js';
|
|
24
25
|
/**
|
|
25
26
|
* The check the runner adds on its own whenever it is about to commit.
|
|
26
27
|
*
|
|
@@ -28,6 +29,10 @@ import { findSuspicious, suspiciousDetail } from '../vcs/commitHygiene.js';
|
|
|
28
29
|
* work: it is what a commit should never carry, whatever the task was.
|
|
29
30
|
*/
|
|
30
31
|
export const COMMIT_CLEAN_CHECK = { name: 'nothing installed, built, logged or secret is committed', expect: 'commit-clean' };
|
|
32
|
+
/** Its sibling for what is inside the files: encoding damage, stray bytes, credentials. See `contentIntegrity.ts`. */
|
|
33
|
+
export const CONTENT_CLEAN_CHECK = { name: 'the text written is clean: encoding, line endings, no credentials', expect: 'content-clean' };
|
|
34
|
+
/** The checks the runner adds by itself before a commit, rather than ones a plan wrote. */
|
|
35
|
+
export const RUNNER_CHECK_KINDS = new Set(['commit-clean', 'content-clean']);
|
|
31
36
|
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
32
37
|
/** How much of a command's output travels back to the chat with a failure. */
|
|
33
38
|
const OUTPUT_KEPT = 4000;
|
|
@@ -63,6 +68,18 @@ export async function runCheck(check, index, opts) {
|
|
|
63
68
|
? pass('nothing in the working tree looks like tool output or secrets')
|
|
64
69
|
: fail(suspiciousDetail(found), { output: found.map((s) => `${s.path}\t${s.reason}`).join('\n') });
|
|
65
70
|
}
|
|
71
|
+
if (check.expect === 'content-clean') {
|
|
72
|
+
const dir = (opts.repoDir ?? '').trim();
|
|
73
|
+
if (!dir)
|
|
74
|
+
return pass('no repository is set for this session, so nothing is committed');
|
|
75
|
+
const state = await repoState(dir).catch(() => null);
|
|
76
|
+
if (!state?.isRepo)
|
|
77
|
+
return pass(`${dir} is not a git repository, so nothing is committed`);
|
|
78
|
+
const found = await scanChanges(dir, await workingTreePaths(dir), opts.baseCommit);
|
|
79
|
+
return found.length === 0
|
|
80
|
+
? pass('the changed files are clean UTF-8 text with one kind of line ending and no credentials')
|
|
81
|
+
: fail(integrityDetail(found), { output: found.map((f) => `${f.path}\t${f.kind}\t${f.detail}`).join('\n') });
|
|
82
|
+
}
|
|
66
83
|
/*
|
|
67
84
|
* Where this check really runs, decided once. The command runs here and a file check's relative
|
|
68
85
|
* path is resolved against it — the same folder for both, so the path confined is the path read.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a task is still getting anywhere, judged from what happened rather than from what the
|
|
3
|
+
* chat says about it.
|
|
4
|
+
*
|
|
5
|
+
* The runner already stops two kinds of loop: the same command sent again with the same result
|
|
6
|
+
* (`maxCommandRepeats`), and a whole round of steps it refused (`maxStalledIterations`). What those
|
|
7
|
+
* miss is the loop in which every round looks new: a different command each time that fails the
|
|
8
|
+
* same way while no file changes; changes made in one round and undone in the next, over and over;
|
|
9
|
+
* "done" said again with the same checks failing and nothing touched since they were reported. Each
|
|
10
|
+
* of those ran until the iteration limit and ended "limit reached" with a reason that explained
|
|
11
|
+
* nothing. Here each is recognised and ends the task `blocked` with a diagnosis saying which one it
|
|
12
|
+
* was and what the evidence is, so the operator knows what to change before running it again.
|
|
13
|
+
*
|
|
14
|
+
* "No file changed" is read from the working tree (`treeFingerprint` in git.ts), so these signals
|
|
15
|
+
* are only on when the session works in a git repository. Without one this watches nothing and
|
|
16
|
+
* the older guards still apply.
|
|
17
|
+
*/
|
|
18
|
+
/** The failure a round ended with, reduced to what stays the same when only the wording changes. */
|
|
19
|
+
export function failureSignature(results) {
|
|
20
|
+
if (results.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
// A round in which anything ran and succeeded made progress of some kind.
|
|
23
|
+
if (results.some((r) => r.outcome === 'completed' && r.exitCode === 0))
|
|
24
|
+
return null;
|
|
25
|
+
const failed = results.filter((r) => r.outcome === 'completed' && r.exitCode !== 0);
|
|
26
|
+
if (failed.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
return failed
|
|
29
|
+
.map((r) => {
|
|
30
|
+
const text = (r.stderr.trim() || r.stdout.trim()).slice(-600);
|
|
31
|
+
// Numbers are masked (times, line numbers, process ids) and space collapsed: a different
|
|
32
|
+
// command that dies of the same error still reads the same.
|
|
33
|
+
return text.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
|
|
34
|
+
})
|
|
35
|
+
.sort()
|
|
36
|
+
.join('\n');
|
|
37
|
+
}
|
|
38
|
+
export class ProgressWatch {
|
|
39
|
+
limits;
|
|
40
|
+
trees = [];
|
|
41
|
+
oscillations = 0;
|
|
42
|
+
lastFailure = null;
|
|
43
|
+
sameFailure = 1;
|
|
44
|
+
lastChecks = null;
|
|
45
|
+
constructor(limits) {
|
|
46
|
+
this.limits = limits;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* After a round of steps. `tree` is the working tree's fingerprint after it, or null when there is
|
|
50
|
+
* no repository to read. Returns why the task should stop, or null to go on.
|
|
51
|
+
*/
|
|
52
|
+
afterRound(tree, results) {
|
|
53
|
+
if (tree === null)
|
|
54
|
+
return null;
|
|
55
|
+
const previous = this.trees.at(-1);
|
|
56
|
+
const beforeThat = this.trees.at(-2);
|
|
57
|
+
if (beforeThat !== undefined && tree === beforeThat && tree !== previous) {
|
|
58
|
+
this.oscillations += 1;
|
|
59
|
+
if (this.oscillations >= this.limits.oscillations) {
|
|
60
|
+
return (`no progress: the files went back to how they were two rounds earlier ${this.oscillations} times — ` +
|
|
61
|
+
'changes made in one round and undone in the next. The task was stopped rather than left to run out of iterations. ' +
|
|
62
|
+
'The two approaches it switches between are in the iteration reports; decide between them in the prompt.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.trees.push(tree);
|
|
66
|
+
const signature = failureSignature(results);
|
|
67
|
+
if (signature === null) {
|
|
68
|
+
this.lastFailure = null;
|
|
69
|
+
this.sameFailure = 1;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (this.lastFailure && this.lastFailure.signature === signature && this.lastFailure.tree === tree) {
|
|
73
|
+
this.sameFailure += 1;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
this.sameFailure = 1;
|
|
77
|
+
}
|
|
78
|
+
this.lastFailure = { signature, tree };
|
|
79
|
+
if (this.sameFailure >= this.limits.noProgress) {
|
|
80
|
+
const example = signature.split('\n')[0].slice(-240);
|
|
81
|
+
return (`no progress: ${this.sameFailure} rounds in a row ended with the same error and no file changed, ` +
|
|
82
|
+
`though the commands differed. The error: "${example}". The task was stopped rather than left to run out of iterations; ` +
|
|
83
|
+
'the cause is probably outside what the chat can change — read the last report.');
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* After "done" was answered with failing checks. Returns why the task should stop when this is the
|
|
89
|
+
* same failure as last time with nothing changed in between.
|
|
90
|
+
*/
|
|
91
|
+
afterFailedChecks(tree, failing) {
|
|
92
|
+
if (tree === null)
|
|
93
|
+
return null;
|
|
94
|
+
const key = failing
|
|
95
|
+
.map((f) => `${f.name}\u0000${f.detail.toLowerCase().replace(/\d+/g, '#')}`)
|
|
96
|
+
.sort()
|
|
97
|
+
.join('\n');
|
|
98
|
+
const same = this.lastChecks && this.lastChecks.key === key && this.lastChecks.tree === tree;
|
|
99
|
+
this.lastChecks = { key, tree };
|
|
100
|
+
if (!same)
|
|
101
|
+
return null;
|
|
102
|
+
return (`no progress: "done" was said again with the same ${failing.length} check(s) failing ` +
|
|
103
|
+
`(${failing.map((f) => f.name).join(', ')}) and no file changed since they were reported. ` +
|
|
104
|
+
'The task was stopped rather than spend its remaining check rounds the same way.');
|
|
105
|
+
}
|
|
106
|
+
}
|