copilot-operator 0.1.8 → 0.1.10
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 +19 -14
- 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/contract.js +58 -0
- package/dist/src/orchestrator/progress.js +106 -0
- package/dist/src/orchestrator/taskRunner.js +167 -11
- package/dist/src/plan/brief.js +2 -0
- package/dist/src/plan/importPlan.js +1 -0
- package/dist/src/plan/schema.js +28 -1
- package/dist/src/plan/systemGuide.js +61 -6
- package/dist/src/protocol/parser.js +98 -5
- package/dist/src/session/compose.js +1 -1
- package/dist/src/session/exports.js +6 -0
- package/dist/src/session/handoff.js +42 -0
- package/dist/src/session/metrics.js +95 -0
- package/dist/src/session/store.js +58 -10
- package/dist/src/transport/chatTransport.js +32 -0
- package/dist/src/transport/copilotTransport.js +18 -0
- package/dist/src/vcs/contentIntegrity.js +174 -0
- package/dist/src/vcs/git.js +42 -0
- package/dist/src/vcs/scope.js +132 -0
- package/dist/src/vcs/taskVcs.js +43 -2
- package/dist/web/404.html +4 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
- package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.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-0f88c46dcd9dd52c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-dd3ebf37d29dce4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-93ab518db67e52a2.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.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 +5 -4
- 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 +16 -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-3d4ee08c5f9f0e71.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-dfcbd748f454d2d7.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.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-a82e5e801dabc423.js +0 -1
- package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_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 };
|
|
@@ -17,9 +17,9 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { installLayout } from '../config/layout.js';
|
|
20
|
-
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW } from '../session/store.js';
|
|
20
|
+
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
|
|
21
21
|
import { EventBus } from '../session/events.js';
|
|
22
|
-
import { newId
|
|
22
|
+
import { 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';
|
|
@@ -45,7 +45,9 @@ import { pickFolder } from './folderPicker.js';
|
|
|
45
45
|
import { findEdgeUsingProfile } from '../transport/profileLock.js';
|
|
46
46
|
import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } from '../exec/isolation.js';
|
|
47
47
|
import { unattendedPrecondition } from '../exec/policy.js';
|
|
48
|
-
import {
|
|
48
|
+
import { createTransport } from '../transport/chatTransport.js';
|
|
49
|
+
import { composeHandoff } from '../session/handoff.js';
|
|
50
|
+
import { computeMetrics } from '../session/metrics.js';
|
|
49
51
|
import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
|
|
50
52
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
51
53
|
import { Settings } from './settings.js';
|
|
@@ -115,6 +117,11 @@ let OperatorService = class OperatorService {
|
|
|
115
117
|
t.vcs = vcs;
|
|
116
118
|
t.reason = `${t.reason ?? ''} What it had changed is committed on ${vcs.branch} as ${vcs.commit?.slice(0, 8)}.`.trim();
|
|
117
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' })));
|
|
118
125
|
});
|
|
119
126
|
const ended = settled.tasks.find((t) => t.id === taskId);
|
|
120
127
|
if (!ended)
|
|
@@ -296,16 +303,7 @@ let OperatorService = class OperatorService {
|
|
|
296
303
|
return await this.store.updateTask(sessionId, taskId, (t) => {
|
|
297
304
|
if (t.status !== 'queued')
|
|
298
305
|
throw new Error('Only a queued task can be edited.');
|
|
299
|
-
|
|
300
|
-
t.title = patch.title;
|
|
301
|
-
if (patch.level2 !== undefined)
|
|
302
|
-
t.level2 = patch.level2;
|
|
303
|
-
if (patch.prompt !== undefined)
|
|
304
|
-
t.prompt = patch.prompt;
|
|
305
|
-
if (patch.vcsPlan !== undefined)
|
|
306
|
-
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
307
|
-
if (patch.checks !== undefined)
|
|
308
|
-
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
306
|
+
applyTaskPatch(t, patch);
|
|
309
307
|
});
|
|
310
308
|
}
|
|
311
309
|
async deleteTask(sessionId, taskId) {
|
|
@@ -1952,7 +1950,7 @@ let OperatorService = class OperatorService {
|
|
|
1952
1950
|
throw new Error('The model list is already being read.');
|
|
1953
1951
|
this.readingModels = true;
|
|
1954
1952
|
const cfg = await this.settings.load();
|
|
1955
|
-
const transport =
|
|
1953
|
+
const transport = createTransport({
|
|
1956
1954
|
profileDir: cfg.resolved.profileDir,
|
|
1957
1955
|
transportDir: join(cfg.resolved.runsDir, '_models'),
|
|
1958
1956
|
chatUrl: cfg.copilot.url,
|
|
@@ -1983,6 +1981,11 @@ let OperatorService = class OperatorService {
|
|
|
1983
1981
|
* what is still ahead; doing the flattening here keeps that split honest, because the
|
|
1984
1982
|
* queue position is only knowable from the session's own list.
|
|
1985
1983
|
*/
|
|
1984
|
+
/** How well the bot is doing, added up from every task on record. See `session/metrics.ts`. */
|
|
1985
|
+
async metrics() {
|
|
1986
|
+
await this.init();
|
|
1987
|
+
return computeMetrics(await this.store.listSessions());
|
|
1988
|
+
}
|
|
1986
1989
|
async taskRegistry() {
|
|
1987
1990
|
await this.init();
|
|
1988
1991
|
const sessions = await this.store.listSessions();
|
|
@@ -2022,6 +2025,8 @@ let OperatorService = class OperatorService {
|
|
|
2022
2025
|
deviations: t.deviations?.length || undefined,
|
|
2023
2026
|
disputes: t.disputes?.length || undefined,
|
|
2024
2027
|
readOnly: t.readOnly || undefined,
|
|
2028
|
+
scope: t.scope?.length ? t.scope : undefined,
|
|
2029
|
+
stopCode: t.stopCode,
|
|
2025
2030
|
autoRetries: t.autoRetries || undefined,
|
|
2026
2031
|
attempt: t.attempt,
|
|
2027
2032
|
changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : 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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a task can be satisfied at all, decided before anything is sent to the chat.
|
|
3
|
+
*
|
|
4
|
+
* A task is a contract: a prompt, the checks that say when it is done, and what it may change
|
|
5
|
+
* (`readOnly`, `scope`). The three are written at different times — a plan, an edit, a new prompt on
|
|
6
|
+
* a finished task — and they can end up contradicting each other: an audit made read-only that
|
|
7
|
+
* still carries the old "the file now says X" check; a task scoped to its tests that must change
|
|
8
|
+
* the config to pass. Run as it is, such a task spends its rounds and ends blocked on a check the
|
|
9
|
+
* chat could never have met. This names the contradiction instead, before the first message.
|
|
10
|
+
*
|
|
11
|
+
* Only what can be known without running anything is judged: the flags against each other, and
|
|
12
|
+
* the file checks against the tree as it is now. A file check that already passes needs no change;
|
|
13
|
+
* one that fails needs its file changed, which a read-only task, or a scope that leaves the file
|
|
14
|
+
* out, forbids. Command checks are not judged: whether a command will pass depends on work the
|
|
15
|
+
* task is allowed to do elsewhere.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
19
|
+
import { inScope } from '../vcs/scope.js';
|
|
20
|
+
async function fileCheckPassesNow(check, cwd) {
|
|
21
|
+
const path = resolve(cwd, (check.cwd ?? '').trim() || '.', (check.file ?? '').trim());
|
|
22
|
+
const info = await stat(path).catch(() => null);
|
|
23
|
+
const exists = !!info?.isFile();
|
|
24
|
+
if (check.expect === 'file-exists')
|
|
25
|
+
return { passes: exists, path };
|
|
26
|
+
if (check.expect === 'file-missing')
|
|
27
|
+
return { passes: !exists, path };
|
|
28
|
+
const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
|
|
29
|
+
return { passes: exists && text.includes(check.value ?? ''), path };
|
|
30
|
+
}
|
|
31
|
+
/** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
|
|
32
|
+
export async function contractConflicts(task, cwd, repoDir) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const scope = task.scope ?? [];
|
|
35
|
+
if (task.readOnly && scope.length > 0) {
|
|
36
|
+
out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
|
|
37
|
+
}
|
|
38
|
+
if (!task.readOnly && scope.length === 0)
|
|
39
|
+
return out;
|
|
40
|
+
const checks = [
|
|
41
|
+
...(task.checks ?? []),
|
|
42
|
+
...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check),
|
|
43
|
+
].filter((c) => c.expect === 'file-exists' || c.expect === 'file-missing' || c.expect === 'file-contains');
|
|
44
|
+
for (const check of checks) {
|
|
45
|
+
const { passes, path } = await fileCheckPassesNow(check, cwd);
|
|
46
|
+
if (passes)
|
|
47
|
+
continue;
|
|
48
|
+
const rel = relative(resolve(repoDir), path).replace(/\\/g, '/');
|
|
49
|
+
const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
50
|
+
if (task.readOnly) {
|
|
51
|
+
out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
|
|
52
|
+
}
|
|
53
|
+
else if (inside && !inScope(rel, scope)) {
|
|
54
|
+
out.push(`The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|