copilot-operator 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -14
- package/dist/src/api/operator.controller.js +31 -42
- package/dist/src/api/operator.service.js +663 -367
- package/dist/src/api/saveToDesktop.js +1 -1
- package/dist/src/api/security.js +40 -10
- package/dist/src/api/server.js +75 -12
- package/dist/src/api/settings.js +156 -15
- package/dist/src/cli.js +108 -97
- package/dist/src/config/handWritten.js +36 -0
- package/dist/src/config/layout.js +13 -4
- package/dist/src/config/lockedPolicy.js +56 -22
- package/dist/src/config/schema.js +13 -84
- package/dist/src/context/desktopDir.js +44 -0
- package/dist/src/exec/authorizer.js +9 -1
- package/dist/src/exec/checks.js +240 -23
- package/dist/src/exec/dangerous.js +29 -5
- package/dist/src/exec/network.js +105 -28
- package/dist/src/exec/redaction.js +52 -9
- package/dist/src/exec/reportFile.js +4 -5
- package/dist/src/exec/runner.js +146 -13
- package/dist/src/exec/shells.js +41 -6
- package/dist/src/exec/workDir.js +4 -4
- package/dist/src/orchestrator/derivedChecks.js +1 -0
- package/dist/src/orchestrator/review.js +25 -3
- package/dist/src/orchestrator/taskRunner.js +476 -163
- package/dist/src/plan/brief.js +37 -44
- package/dist/src/plan/importPlan.js +15 -7
- package/dist/src/plan/schema.js +64 -29
- package/dist/src/plan/script.js +0 -15
- package/dist/src/plan/systemGuide.js +2 -177
- package/dist/src/protocol/parser.js +5 -1
- package/dist/src/session/debugExport.js +1 -1
- package/dist/src/session/exportRecord.js +1 -1
- package/dist/src/session/exports.js +49 -9
- package/dist/src/session/store.js +215 -80
- package/dist/src/vcs/scope.js +29 -6
- package/dist/src/vcs/taskVcs.js +78 -10
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
- package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
- package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
- package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +18 -10
- package/prompts/level1.md +2 -1
- package/prompts/organisation.bg.md +0 -1
- package/prompts/organisation.en.md +0 -1
- package/dist/src/context/contextFiles.js +0 -295
- package/dist/src/context/desktopMirror.js +0 -173
- package/dist/src/context/projectMirror.js +0 -432
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
- package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
|
@@ -149,14 +149,7 @@ export function buildPlanExport(scope) {
|
|
|
149
149
|
...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
|
|
150
150
|
},
|
|
151
151
|
review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
|
|
152
|
-
|
|
153
|
-
enabled: s.mirror.enabled,
|
|
154
|
-
rootDir: s.mirror.rootDir,
|
|
155
|
-
includeDirs: s.mirror.includeDirs,
|
|
156
|
-
excludeDirs: s.mirror.excludeDirs,
|
|
157
|
-
respectGitignore: s.mirror.respectGitignore ?? true,
|
|
158
|
-
includeEnvFiles: s.mirror.includeEnvFiles ?? false,
|
|
159
|
-
},
|
|
152
|
+
...(s.projectDir ? { projectDir: s.projectDir } : {}),
|
|
160
153
|
tasks: tasks.map((t) => {
|
|
161
154
|
const p = splitPrompt(t.prompt);
|
|
162
155
|
const task = { title: t.title, prompt: p.prompt, expected: p.expected };
|
|
@@ -269,6 +262,54 @@ export function taskAtAttempt(task, attempt) {
|
|
|
269
262
|
export function withTask(session, task) {
|
|
270
263
|
return { ...session, tasks: session.tasks.map((t) => (t.id === task.id ? task : t)) };
|
|
271
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* The task as it ended in one run: the last of its attempts that ran in it, live or archived, or
|
|
267
|
+
* null when none did. Several attempts can share a run (a retry in a fresh chat is one); the last
|
|
268
|
+
* is how the run left it, with the ones before it as its history.
|
|
269
|
+
*/
|
|
270
|
+
export function taskInRun(task, runId) {
|
|
271
|
+
if (task.runGroup?.id === runId)
|
|
272
|
+
return task;
|
|
273
|
+
const attempts = task.attempts ?? [];
|
|
274
|
+
for (let i = attempts.length - 1; i >= 0; i -= 1) {
|
|
275
|
+
if (attempts[i]?.runGroup?.id === runId)
|
|
276
|
+
return taskAtAttempt(task, i + 1);
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* One run's sessions, with each task as it ended in that run, and only the tasks that were part of it.
|
|
282
|
+
*
|
|
283
|
+
* A run is a record of what happened, and the task on the record moves on after it: "run again"
|
|
284
|
+
* queues it as a new attempt, and another run may finish it. Taken as it is now, a task that
|
|
285
|
+
* failed in the run read as queued, its failure only among the earlier attempts, and the run's
|
|
286
|
+
* counts said nothing failed in it; once a later run had stamped it, the task was not in the
|
|
287
|
+
* first run's export at all. So each task is taken at its attempt in the run (`taskInRun`), and
|
|
288
|
+
* the three views describe the run whichever is asked for. A task the run was asked to do and
|
|
289
|
+
* never reached has no such attempt; it is still the run's, as it stands, through the run's own
|
|
290
|
+
* record on the session.
|
|
291
|
+
*/
|
|
292
|
+
export function runScope(sessions, runId, label) {
|
|
293
|
+
const chosen = new Map();
|
|
294
|
+
const asInRun = sessions.map((s) => {
|
|
295
|
+
let session = s;
|
|
296
|
+
const ids = new Set();
|
|
297
|
+
for (const t of s.tasks) {
|
|
298
|
+
const ran = taskInRun(t, runId);
|
|
299
|
+
if (ran) {
|
|
300
|
+
ids.add(t.id);
|
|
301
|
+
if (ran !== t)
|
|
302
|
+
session = withTask(session, ran);
|
|
303
|
+
}
|
|
304
|
+
else if (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)) {
|
|
305
|
+
ids.add(t.id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
chosen.set(s.id, ids);
|
|
309
|
+
return session;
|
|
310
|
+
});
|
|
311
|
+
return { sessions: asInRun, taskFilter: (s, t) => !!chosen.get(s.id)?.has(t.id), label };
|
|
312
|
+
}
|
|
272
313
|
/** What the runner export says about the machine, from the configuration in force. */
|
|
273
314
|
export function exportMachine(cfg) {
|
|
274
315
|
return {
|
|
@@ -540,7 +581,6 @@ async function botTask(session, task, runsDir) {
|
|
|
540
581
|
fromReviews: task.reviewChecks,
|
|
541
582
|
},
|
|
542
583
|
processes: { leftovers: task.leftovers, reaped: events.filter((e) => e.type === 'processes-reaped').map(trimmed), notOurs: events.filter((e) => e.type === 'processes-not-ours').map(trimmed) },
|
|
543
|
-
mirror: events.filter((e) => String(e.type).startsWith('mirror')).map(trimmed),
|
|
544
584
|
review: {
|
|
545
585
|
verdict: task.review?.verdict,
|
|
546
586
|
rounds: task.review?.rounds,
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* JSON files, not a database. A single operator on one machine gets nothing from a
|
|
5
5
|
* database except a dependency, and files can be read, diffed and backed up with nothing
|
|
6
6
|
* installed. Writes go through a temp file and a rename, so a crash mid-write cannot leave a
|
|
7
|
-
* half-written session behind
|
|
7
|
+
* half-written session behind, and the writes of one file wait their turn (see `inTurn`), so two
|
|
8
|
+
* at once cannot fail each other or undo each other's change.
|
|
8
9
|
*/
|
|
9
10
|
import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises';
|
|
10
11
|
import { existsSync } from 'node:fs';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
11
13
|
import { join, resolve } from 'node:path';
|
|
12
14
|
import { isContinuable, newId, tidyVcsPlan } from './model.js';
|
|
13
15
|
/** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
|
|
@@ -34,14 +36,27 @@ export const DEFAULT_VCS = {
|
|
|
34
36
|
commitOnFinish: true,
|
|
35
37
|
branchPrefix: 'cop/',
|
|
36
38
|
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Points a new session that names no folder at the default project, the one on the Project page.
|
|
41
|
+
*
|
|
42
|
+
* The one rule for every way a session is made: on the Sessions page and by importing a plan. The
|
|
43
|
+
* import used to skip it, so a plan's session with version control off and no `projectDir` — the
|
|
44
|
+
* brief's own example has one — kept an empty folder and ran its commands in `execution.cwd`,
|
|
45
|
+
* somewhere the operator never chose, while the same session made by hand worked in their project.
|
|
46
|
+
*
|
|
47
|
+
* Both fields, because they answer different questions about the same folder: where the work is
|
|
48
|
+
* and where the branches go. Version control is not switched on by being filled in. A session that
|
|
49
|
+
* names either keeps what it named and gets nothing here: a plan's repository with the default
|
|
50
|
+
* project beside it as its folder would say the work is in two places.
|
|
51
|
+
*/
|
|
52
|
+
export function applyDefaultProject(session, defaultDir) {
|
|
53
|
+
const dir = defaultDir.trim();
|
|
54
|
+
if (!dir || session.projectDir.trim() || session.vcs?.repoDir.trim())
|
|
55
|
+
return;
|
|
56
|
+
session.projectDir = dir;
|
|
57
|
+
if (session.vcs)
|
|
58
|
+
session.vcs.repoDir = dir;
|
|
59
|
+
}
|
|
45
60
|
/**
|
|
46
61
|
* Applies an edit to a task: the one place the rule about a new intent lives.
|
|
47
62
|
*
|
|
@@ -114,10 +129,10 @@ export class SessionStore {
|
|
|
114
129
|
return { content: await readFile(this.defaultLevel1Path, 'utf8'), customised: false };
|
|
115
130
|
}
|
|
116
131
|
async setLevel1(content) {
|
|
117
|
-
await
|
|
132
|
+
await writeFileAtomically(this.level1Path, content);
|
|
118
133
|
}
|
|
119
134
|
async resetLevel1() {
|
|
120
|
-
await
|
|
135
|
+
await removeFile(this.level1Path);
|
|
121
136
|
}
|
|
122
137
|
// --- the organisation's part of the plan persona -------------------------------------
|
|
123
138
|
/**
|
|
@@ -136,10 +151,10 @@ export class SessionStore {
|
|
|
136
151
|
return { content: '', customised: false, example };
|
|
137
152
|
}
|
|
138
153
|
async setContext(kind, content) {
|
|
139
|
-
await
|
|
154
|
+
await writeFileAtomically(join(this.dir, `context-${kind}.md`), content);
|
|
140
155
|
}
|
|
141
156
|
async resetContext(kind) {
|
|
142
|
-
await
|
|
157
|
+
await removeFile(join(this.dir, `context-${kind}.md`));
|
|
143
158
|
}
|
|
144
159
|
// --- the model catalogue ----------------------------------------------------------------
|
|
145
160
|
/** What the chat's picker offered when it was last read, or null if it never was. */
|
|
@@ -152,7 +167,7 @@ export class SessionStore {
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
async saveModels(catalogue) {
|
|
155
|
-
await
|
|
170
|
+
await writeFileAtomically(this.modelsPath, JSON.stringify(catalogue, null, 2));
|
|
156
171
|
}
|
|
157
172
|
// --- level 2 presets ------------------------------------------------------------------
|
|
158
173
|
async listPresets() {
|
|
@@ -168,15 +183,23 @@ export class SessionStore {
|
|
|
168
183
|
}
|
|
169
184
|
return out;
|
|
170
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Saves a preset under the name it can later be deleted by.
|
|
188
|
+
*
|
|
189
|
+
* Stripping the characters a file name cannot hold is not enough on its own: a leading dot and
|
|
190
|
+
* ".." survive it, and `safePresetName` refuses both, so ".env" and "a..b" were saved and then
|
|
191
|
+
* could never be deleted. The name goes through the same rule as the delete, the way a persona's
|
|
192
|
+
* always has, so a name that could not be removed is refused here instead.
|
|
193
|
+
*/
|
|
171
194
|
async savePreset(name, content) {
|
|
172
195
|
const safe = name.replace(/[^\p{L}\p{N}._ -]/gu, '').trim();
|
|
173
196
|
if (!safe)
|
|
174
197
|
throw new Error('Preset name is empty after removing unsafe characters.');
|
|
175
|
-
await
|
|
198
|
+
await writeFileAtomically(join(this.presetsDir, `${safePresetName(safe)}.md`), content);
|
|
176
199
|
return { name: safe, content, updatedAt: new Date().toISOString() };
|
|
177
200
|
}
|
|
178
201
|
async deletePreset(name) {
|
|
179
|
-
await
|
|
202
|
+
await removeFile(await namedFile(this.presetsDir, name));
|
|
180
203
|
}
|
|
181
204
|
// --- named personas -------------------------------------------------------------------
|
|
182
205
|
//
|
|
@@ -202,11 +225,11 @@ export class SessionStore {
|
|
|
202
225
|
if (!safe)
|
|
203
226
|
throw new Error('The persona name is empty after removing unsafe characters.');
|
|
204
227
|
await mkdir(this.personasDir, { recursive: true });
|
|
205
|
-
await
|
|
228
|
+
await writeFileAtomically(join(this.personasDir, `${safePresetName(safe)}.md`), content);
|
|
206
229
|
return { name: safe, content, updatedAt: new Date().toISOString() };
|
|
207
230
|
}
|
|
208
231
|
async deletePersona(name) {
|
|
209
|
-
await
|
|
232
|
+
await removeFile(await namedFile(this.personasDir, name));
|
|
210
233
|
}
|
|
211
234
|
// --- sessions -------------------------------------------------------------------------
|
|
212
235
|
/**
|
|
@@ -238,10 +261,27 @@ export class SessionStore {
|
|
|
238
261
|
return b.id.localeCompare(a.id);
|
|
239
262
|
});
|
|
240
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* The session of this id, or null when there is none.
|
|
266
|
+
*
|
|
267
|
+
* An id that could not be a session's file name — a path, `..`, anything `safeName` refuses —
|
|
268
|
+
* names no session, and is answered as one that does not exist. It used to throw here, so every
|
|
269
|
+
* route that only reads a session (the session itself, a task's log, story or files, a start)
|
|
270
|
+
* answered a path for an id with a 500 rather than the 404 or "no such session" a missing one
|
|
271
|
+
* gets. A write or a delete of such an id is still refused outright: those go through
|
|
272
|
+
* `sessionPath` themselves.
|
|
273
|
+
*/
|
|
241
274
|
async getSession(id) {
|
|
242
|
-
|
|
275
|
+
let path;
|
|
276
|
+
try {
|
|
277
|
+
path = this.sessionPath(id);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return await this.readSession(path);
|
|
243
283
|
}
|
|
244
|
-
async createSession(name,
|
|
284
|
+
async createSession(name, projectDir = '') {
|
|
245
285
|
const session = {
|
|
246
286
|
id: newId(),
|
|
247
287
|
name: name.trim() || 'untitled',
|
|
@@ -252,26 +292,38 @@ export class SessionStore {
|
|
|
252
292
|
// behaved and the safer of the two.
|
|
253
293
|
onFailure: 'stop',
|
|
254
294
|
vcs: { ...DEFAULT_VCS },
|
|
255
|
-
|
|
295
|
+
projectDir: projectDir.trim(),
|
|
256
296
|
tasks: [],
|
|
257
297
|
};
|
|
258
298
|
await this.saveSession(session);
|
|
259
299
|
return session;
|
|
260
300
|
}
|
|
261
301
|
async saveSession(session) {
|
|
262
|
-
await
|
|
302
|
+
await writeFileAtomically(this.sessionPath(session.id), JSON.stringify(session, null, 2));
|
|
263
303
|
}
|
|
264
304
|
async deleteSession(id) {
|
|
265
|
-
await
|
|
305
|
+
await removeFile(this.sessionPath(id));
|
|
266
306
|
}
|
|
267
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Applies a change under a fresh read, so two writers cannot clobber each other.
|
|
309
|
+
*
|
|
310
|
+
* The read, the change and the write happen in the file's turn, with nothing else of this
|
|
311
|
+
* process writing the session in between. A fresh read alone did not keep that promise: Stop
|
|
312
|
+
* writes "stopping" at the moment it answers an approval, and the runner it has just woken
|
|
313
|
+
* writes the task at the same moment. Both read the session before either had written, so
|
|
314
|
+
* whichever wrote second put back what the first had changed, or, when both renamed at once,
|
|
315
|
+
* one of them failed and took the stop or the task down with it.
|
|
316
|
+
*/
|
|
268
317
|
async updateSession(id, mutate) {
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
318
|
+
const path = this.sessionPath(id);
|
|
319
|
+
return await inTurn(path, async () => {
|
|
320
|
+
const s = await this.readSession(path);
|
|
321
|
+
if (!s)
|
|
322
|
+
throw new Error(`Session ${id} does not exist.`);
|
|
323
|
+
mutate(s);
|
|
324
|
+
await replaceFile(path, JSON.stringify(s, null, 2));
|
|
325
|
+
return s;
|
|
326
|
+
});
|
|
275
327
|
}
|
|
276
328
|
async addTask(sessionId, input) {
|
|
277
329
|
const vcsPlan = tidyVcsPlan(input.vcsPlan);
|
|
@@ -465,33 +517,41 @@ export class SessionStore {
|
|
|
465
517
|
*/
|
|
466
518
|
async recoverInterrupted() {
|
|
467
519
|
const recovered = [];
|
|
468
|
-
for (const
|
|
469
|
-
|
|
470
|
-
const busySession = session.status !== 'idle';
|
|
471
|
-
if (stuck.length === 0 && !busySession)
|
|
520
|
+
for (const listed of await this.listSessions()) {
|
|
521
|
+
if (listed.status === 'idle' && !listed.tasks.some((t) => ACTIVE_STATUSES.includes(t.status)))
|
|
472
522
|
continue;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
523
|
+
// Read again in the file's turn, like any other change, so a write that landed since the
|
|
524
|
+
// list was read is kept rather than replaced by the copy in the list.
|
|
525
|
+
const path = this.sessionPath(listed.id);
|
|
526
|
+
await inTurn(path, async () => {
|
|
527
|
+
const session = await this.readSession(path);
|
|
528
|
+
if (!session)
|
|
529
|
+
return;
|
|
530
|
+
for (const t of session.tasks.filter((x) => ACTIVE_STATUSES.includes(x.status))) {
|
|
531
|
+
t.status = 'aborted';
|
|
532
|
+
t.finishedAt = t.finishedAt ?? new Date().toISOString();
|
|
533
|
+
t.reason =
|
|
534
|
+
'The bot stopped while this task was in progress (the program was closed, the machine went off, or it crashed), so it never finished. ' +
|
|
535
|
+
'Its work so far is kept; "Continue in the same chat" carries it on where it stopped.';
|
|
536
|
+
recovered.push({ sessionId: session.id, taskId: t.id, title: t.title });
|
|
537
|
+
}
|
|
538
|
+
session.status = 'idle';
|
|
539
|
+
await replaceFile(path, JSON.stringify(session, null, 2));
|
|
540
|
+
});
|
|
483
541
|
}
|
|
484
542
|
return recovered;
|
|
485
543
|
}
|
|
486
544
|
// --- helpers --------------------------------------------------------------------------
|
|
487
545
|
/**
|
|
488
|
-
* Reads one session file, filling in
|
|
546
|
+
* Reads one session file, filling in fields added after it was written. Sessions are
|
|
489
547
|
* long-lived JSON on disk, so a new option must never come back as `undefined`.
|
|
490
548
|
*/
|
|
491
549
|
async readSession(path) {
|
|
492
550
|
try {
|
|
493
551
|
const s = JSON.parse(await readFile(path, 'utf8'));
|
|
494
|
-
|
|
552
|
+
// Written before the project-files feature was removed: its root is the project folder.
|
|
553
|
+
s.projectDir = (s.projectDir ?? s.mirror?.rootDir ?? '').trim();
|
|
554
|
+
delete s.mirror;
|
|
495
555
|
s.vcs = { ...DEFAULT_VCS, ...(s.vcs ?? {}) };
|
|
496
556
|
// Sessions written before the queue could be told what to do on a failure behave the
|
|
497
557
|
// way they always did: the chain stops.
|
|
@@ -502,44 +562,119 @@ export class SessionStore {
|
|
|
502
562
|
return null;
|
|
503
563
|
}
|
|
504
564
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
565
|
+
sessionPath(id) {
|
|
566
|
+
return join(this.sessionsDir, `${safeName(id)}.json`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// --- writing a file -------------------------------------------------------------------------
|
|
570
|
+
/**
|
|
571
|
+
* The work queued on each file, by the file's full path: the tail of its chain.
|
|
572
|
+
*
|
|
573
|
+
* Kept for the module, not for one store, because every store in a process writes the same
|
|
574
|
+
* files: the API has one, but a check or the terminal may make another over the same folder,
|
|
575
|
+
* and a queue each would let their writes meet again.
|
|
576
|
+
*/
|
|
577
|
+
const queued = new Map();
|
|
578
|
+
/**
|
|
579
|
+
* Runs `work` once everything queued earlier on the same file has finished, failed or not.
|
|
580
|
+
*
|
|
581
|
+
* Every write of the store goes through here, and `updateSession` holds the turn across its read
|
|
582
|
+
* as well, as does a write worked out from the file (see `writeFileAtomically`), so a change is
|
|
583
|
+
* always made to what the last write left. `work` must not wait for another turn on the same file,
|
|
584
|
+
* which would wait for itself; nothing here does, and a session's `mutate` is synchronous, so it
|
|
585
|
+
* cannot.
|
|
586
|
+
*
|
|
587
|
+
* In this process only: another process writing the same file at the same time is not waited
|
|
588
|
+
* for. The one that might, a run from the terminal, makes a session of its own and writes only that.
|
|
589
|
+
*/
|
|
590
|
+
function inTurn(path, work) {
|
|
591
|
+
const full = resolve(path);
|
|
592
|
+
// Windows treats `A.json` and `a.json` as one file, so they share one queue there.
|
|
593
|
+
const key = process.platform === 'win32' ? full.toLowerCase() : full;
|
|
594
|
+
const run = (queued.get(key) ?? Promise.resolve()).then(work);
|
|
595
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
596
|
+
queued.set(key, tail);
|
|
597
|
+
// Forgotten once nothing is queued behind it, so the map holds only the files being written.
|
|
598
|
+
void tail.then(() => {
|
|
599
|
+
if (queued.get(key) === tail)
|
|
600
|
+
queued.delete(key);
|
|
601
|
+
});
|
|
602
|
+
return run;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Writes a file whole, in its turn: a crash, or another write of the same file, cannot leave it
|
|
606
|
+
* half written or lose either write. For any file the program keeps, not only the store's.
|
|
607
|
+
*
|
|
608
|
+
* `content` may be a function instead, for a write worked out from what the file holds: it is
|
|
609
|
+
* called in the file's turn, so what it reads is what the last write left, and nothing else of this
|
|
610
|
+
* process writes the file until its answer has landed. Read first and written here, a change of one
|
|
611
|
+
* setting put back whatever another had changed in between. If it throws, nothing is written. Like
|
|
612
|
+
* any work in a turn, it must not wait for another turn on the same file (see `inTurn`).
|
|
613
|
+
*/
|
|
614
|
+
export async function writeFileAtomically(path, content) {
|
|
615
|
+
await inTurn(path, async () => replaceFile(path, typeof content === 'string' ? content : await content()));
|
|
616
|
+
}
|
|
617
|
+
/** Removes a file in its turn, so a delete and a write of it land in the order they were asked for. */
|
|
618
|
+
async function removeFile(path) {
|
|
619
|
+
await inTurn(path, () => rm(path, { force: true }));
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Write through a temp file and a rename, and keep trying when Windows says no. Not queued: the
|
|
623
|
+
* caller holds the file's turn (see `inTurn`).
|
|
624
|
+
*
|
|
625
|
+
* The rename is what makes the write atomic: a crash halfway through leaves the temp file
|
|
626
|
+
* behind and the real one untouched. On Windows it is also the step that fails, because
|
|
627
|
+
* renaming onto a destination that any other process has open is refused outright — and this
|
|
628
|
+
* file is read constantly, by the register polling every few seconds, by the sessions list,
|
|
629
|
+
* and by whatever virus scanner has decided to look inside a JSON file that just changed.
|
|
630
|
+
*
|
|
631
|
+
* The failure is EPERM and it is transient: the handle closes microseconds later. Letting it
|
|
632
|
+
* through unretried meant a task dying mid-run with a message about a temp file, which is
|
|
633
|
+
* what happened — a scaffold that had finished three tasks was killed by a file lock on the
|
|
634
|
+
* fourth. So it is retried, briefly and with a growing pause, and only then given up on.
|
|
635
|
+
*
|
|
636
|
+
* Each write has a temp file of its own. One name per process was shared by every write of the
|
|
637
|
+
* same file, so when two overlapped, the first rename took the temp file the second was about to
|
|
638
|
+
* rename, and the second failed with ENOENT — reported as a lock it was not.
|
|
639
|
+
*/
|
|
640
|
+
async function replaceFile(path, content) {
|
|
641
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
642
|
+
await writeFile(tmp, content, 'utf8');
|
|
643
|
+
const backoffMs = [10, 25, 60, 120, 250, 500];
|
|
644
|
+
for (let attempt = 0;; attempt += 1) {
|
|
645
|
+
try {
|
|
646
|
+
await rename(tmp, path);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
const code = e.code ?? '';
|
|
651
|
+
const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
652
|
+
if (!transient || attempt >= backoffMs.length) {
|
|
653
|
+
// The temp file is no use to anyone now, and leaving one per failed write behind
|
|
654
|
+
// would slowly fill the folder with them.
|
|
655
|
+
await rm(tmp, { force: true }).catch(() => undefined);
|
|
656
|
+
throw new Error(transient
|
|
657
|
+
? `Could not save ${path}: ${code}. Something else is holding the file open — an editor, a sync client or a virus scanner.`
|
|
658
|
+
: `Could not save ${path}: ${e.message}`);
|
|
539
659
|
}
|
|
660
|
+
await new Promise((r) => setTimeout(r, backoffMs[attempt]));
|
|
540
661
|
}
|
|
541
662
|
}
|
|
542
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* The file a preset or persona of this name is kept in, for removing it.
|
|
666
|
+
*
|
|
667
|
+
* A name `safePresetName` accepts, or one that is already a file in the folder. The save did not
|
|
668
|
+
* always apply that rule, so a preset called ".env" or "a..b" may be on disk from before; without
|
|
669
|
+
* the second way it is listed and can never be deleted. A name matched against the folder's own
|
|
670
|
+
* listing is a file name, which cannot hold a separator, so it cannot reach outside the folder.
|
|
671
|
+
*/
|
|
672
|
+
async function namedFile(dir, name) {
|
|
673
|
+
const onDisk = await readdir(dir).catch(() => []);
|
|
674
|
+
if (onDisk.includes(`${name}.md`))
|
|
675
|
+
return join(dir, `${name}.md`);
|
|
676
|
+
return join(dir, `${safePresetName(name)}.md`);
|
|
677
|
+
}
|
|
543
678
|
/**
|
|
544
679
|
* A session id as it may appear in a file name, or an error.
|
|
545
680
|
*
|
package/dist/src/vcs/scope.js
CHANGED
|
@@ -15,13 +15,27 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Patterns are repository-relative and use `/`: `tests/e2e/editor.spec.ts` is one file,
|
|
17
17
|
* `tests/e2e/` or `tests/e2e/**` is everything under that folder, `*` matches within one folder
|
|
18
|
-
* and `**` across folders.
|
|
18
|
+
* and `**` across folders. A pattern is read the way a path is: `\` is `/`, a `./` or `/` in front
|
|
19
|
+
* and a `//` or `/./` inside change nothing, `src/../docs` is `docs`, and `.`, `./` or `/` alone is
|
|
20
|
+
* the whole repository. Matching ignores case, as Windows does.
|
|
19
21
|
*/
|
|
20
22
|
import { readdir, rm, rmdir } from 'node:fs/promises';
|
|
21
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
23
|
+
import { dirname, join, posix, resolve, sep } from 'node:path';
|
|
22
24
|
import { git, workingTreePaths } from './git.js';
|
|
25
|
+
/**
|
|
26
|
+
* A path or pattern in the one spelling the matching expects: `/` between folders, nothing in front,
|
|
27
|
+
* and no empty, `.` or `..` folder anywhere in it. A plan writes the same folder as `docs`, `./docs`,
|
|
28
|
+
* `/docs`, `.\docs` or `src/../docs`, and they must all cover the same files: each spelling the
|
|
29
|
+
* matching did not expect covered nothing, so every change the task made under it was put back.
|
|
30
|
+
* Cleaning only the front left `src//docs` and `docs/.` the same way, so the whole path is resolved
|
|
31
|
+
* here, as a path would be. The repository itself (`.`, `./`, `/`) comes out as the empty string.
|
|
32
|
+
*/
|
|
33
|
+
function normalise(p) {
|
|
34
|
+
const n = posix.normalize(p.replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
35
|
+
return n === '.' || n === './' ? '' : n;
|
|
36
|
+
}
|
|
23
37
|
function toRegExp(pattern) {
|
|
24
|
-
let p = pattern.trim()
|
|
38
|
+
let p = normalise(pattern.trim());
|
|
25
39
|
// A folder, written with or without `**`, is everything under it.
|
|
26
40
|
if (p.endsWith('/'))
|
|
27
41
|
p += '**';
|
|
@@ -52,11 +66,20 @@ function toRegExp(pattern) {
|
|
|
52
66
|
export function inScope(path, scope) {
|
|
53
67
|
if (scope.length === 0)
|
|
54
68
|
return true;
|
|
55
|
-
const p = path
|
|
69
|
+
const p = normalise(path);
|
|
56
70
|
return scope.some((pattern) => {
|
|
57
|
-
|
|
71
|
+
// A blank entry names nothing, so it covers nothing. It must not read as the repository below,
|
|
72
|
+
// which it would, since normalising it gives the same empty string as `.` does.
|
|
73
|
+
if (pattern.trim() === '')
|
|
74
|
+
return false;
|
|
75
|
+
const clean = normalise(pattern.trim()).replace(/\/+$/, '');
|
|
76
|
+
// `.`, `./` or `/` is the repository itself, so the whole project: a plan writes it to mean
|
|
77
|
+
// anywhere, and read as a file named `.`, or as no path at all, it covered nothing and every
|
|
78
|
+
// change the task made was put back.
|
|
79
|
+
if (clean === '')
|
|
80
|
+
return true;
|
|
58
81
|
// A plain folder name without a slash still means the folder: `pages` covers `pages/Home.ts`.
|
|
59
|
-
if (
|
|
82
|
+
if (!/[*?]/.test(clean) && p.toLowerCase().startsWith(`${clean.toLowerCase()}/`))
|
|
60
83
|
return true;
|
|
61
84
|
return toRegExp(pattern).test(p);
|
|
62
85
|
});
|