@bevel-software/platform-core-backend 0.28.0 → 0.29.0
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/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +7 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -14
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +11 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +13 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +5 -2
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +36 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +208 -85
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +7 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +2 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +13 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +24 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +10 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +106 -20
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/kb-template/access.md +2 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +29 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +7 -1
- package/src/core/create-core-services.ts +27 -21
- package/src/core/lifecycle.ts +24 -12
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
- package/src/modules/access/access-control.interface.ts +5 -2
- package/src/modules/access/access-control.service.ts +236 -95
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/index.ts +6 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/git.service.ts +23 -0
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
- package/src/modules/workspace/index.ts +14 -0
- package/src/modules/workspace/workspace.tools.ts +120 -20
- package/src/shared/workspace-id.ts +24 -0
|
@@ -23,6 +23,28 @@
|
|
|
23
23
|
* never be keyed on a path no file verb would accept. See
|
|
24
24
|
* `canonicalFileIdentity`.
|
|
25
25
|
*
|
|
26
|
+
* Workspace identity works the same way. The lock routes pass the `:id`
|
|
27
|
+
* path param, which Express URL-decodes, so a slashed branch's workspace
|
|
28
|
+
* arrives as `alice/feature`; the deletion gate and the git status probe ask
|
|
29
|
+
* with the encoded `alice%2Ffeature`. Every method keys rows on
|
|
30
|
+
* `canonicalWorkspaceId`, so both spellings are one workspace and a held file
|
|
31
|
+
* is seen whichever spelling asks.
|
|
32
|
+
*
|
|
33
|
+
* Unlike raw path spellings (below), rows written under the decoded workspace id before this
|
|
34
|
+
* landed are still honoured until they go: every read, heartbeat and release
|
|
35
|
+
* also matches that spelling, and an acquire is refused while a live row
|
|
36
|
+
* under it holds the same file. Otherwise a lock held across the upgrade
|
|
37
|
+
* would be invisible for up to its TTL — to the deletion gate and to a second
|
|
38
|
+
* editor alike. Nothing new is ever written under it, so once the last such
|
|
39
|
+
* row has expired or been released the extra spelling matches nothing.
|
|
40
|
+
*
|
|
41
|
+
* This covers rows LEFT by the previous version, not rows it is still
|
|
42
|
+
* writing: a previous-version process never looks at a canonical row, so no
|
|
43
|
+
* check here could make the two contend while both run. They never do — one
|
|
44
|
+
* server process owns the workspaces directory (see `branchLifecycle` in the
|
|
45
|
+
* workflow service; this service's deletion gate is in-process for the same
|
|
46
|
+
* reason), and an upgrade stops the old process before the new one starts.
|
|
47
|
+
*
|
|
26
48
|
* There is no transition handling for rows written under a raw spelling
|
|
27
49
|
* before this landed: such a row is now unreachable by name and expires on
|
|
28
50
|
* its own TTL. For one deploy an in-flight edit's lock can linger up to the
|
|
@@ -35,12 +57,13 @@
|
|
|
35
57
|
* bounded by concurrent editors, not history, so lazy GC is fine.
|
|
36
58
|
*/
|
|
37
59
|
|
|
38
|
-
import { and, eq, gt, lte } from 'drizzle-orm';
|
|
60
|
+
import { and, eq, gt, inArray, lte } from 'drizzle-orm';
|
|
39
61
|
import type { Database } from '../database/connection.js';
|
|
40
62
|
import { fileLocks } from '../database/schema.js';
|
|
41
63
|
import type { AcquireLockResult, AuthUser, FileLock } from '@bevel-software/platform-shared';
|
|
42
64
|
import { WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
43
65
|
import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
|
|
66
|
+
import { branchForWorkspaceId, canonicalWorkspaceId } from '../../shared/workspace-id.js';
|
|
44
67
|
|
|
45
68
|
/**
|
|
46
69
|
* Lock lifetime without a heartbeat. The client is expected to heartbeat
|
|
@@ -51,6 +74,17 @@ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
|
|
|
51
74
|
*/
|
|
52
75
|
const LOCK_TTL_MS = 60_000;
|
|
53
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The workspace ids a lock row of canonical workspace `workspaceId` can sit
|
|
79
|
+
* under: the canonical one every write uses, then — for a slashed branch —
|
|
80
|
+
* the decoded one rows were written under before workspace ids were
|
|
81
|
+
* canonicalised. See the module comment.
|
|
82
|
+
*/
|
|
83
|
+
function workspaceSpellings(workspaceId: string): string[] {
|
|
84
|
+
const legacy = branchForWorkspaceId(workspaceId);
|
|
85
|
+
return legacy === workspaceId ? [workspaceId] : [workspaceId, legacy];
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
function rowToFileLock(row: typeof fileLocks.$inferSelect): FileLock {
|
|
55
89
|
return {
|
|
56
90
|
branch: row.branch,
|
|
@@ -125,13 +159,14 @@ export class FileLockService {
|
|
|
125
159
|
* (or vice versa).
|
|
126
160
|
*/
|
|
127
161
|
async acquire(
|
|
128
|
-
|
|
162
|
+
rawWorkspaceId: string,
|
|
129
163
|
branch: string,
|
|
130
164
|
rawPath: string,
|
|
131
165
|
user: AuthUser,
|
|
132
166
|
opts?: { coordination?: boolean },
|
|
133
167
|
): Promise<AcquireLockResult> {
|
|
134
168
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
169
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
135
170
|
if (this.closing.has(branch)) {
|
|
136
171
|
throw new WorkflowValidationError(`"${branch}" is being deleted, so "${targetPath}" cannot be held for editing.`, {
|
|
137
172
|
kind: 'branch-being-deleted',
|
|
@@ -190,6 +225,25 @@ export class FileLockService {
|
|
|
190
225
|
// `heartbeat()` (which has its own same-user UPDATE), and the
|
|
191
226
|
// editor's save flow detects "I already hold it" via `getLock` in
|
|
192
227
|
// `workspace.routes.withLock` rather than re-acquiring.
|
|
228
|
+
// A live row under the pre-canonical spelling holds this file as surely
|
|
229
|
+
// as one under the canonical id; the upsert below cannot see it.
|
|
230
|
+
const legacy = workspaceSpellings(workspaceId).slice(1);
|
|
231
|
+
if (legacy.length > 0) {
|
|
232
|
+
const [held] = await this.db
|
|
233
|
+
.select()
|
|
234
|
+
.from(fileLocks)
|
|
235
|
+
.where(
|
|
236
|
+
and(
|
|
237
|
+
inArray(fileLocks.workspaceId, legacy),
|
|
238
|
+
eq(fileLocks.branch, branch),
|
|
239
|
+
eq(fileLocks.path, targetPath),
|
|
240
|
+
gt(fileLocks.expiresAt, now),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
.limit(1);
|
|
244
|
+
if (held) return { acquired: false, lock: rowToFileLock(held) };
|
|
245
|
+
}
|
|
246
|
+
|
|
193
247
|
const mode = opts?.coordination ? 'coordination' : 'edit';
|
|
194
248
|
const upsertResult = await this.db
|
|
195
249
|
.insert(fileLocks)
|
|
@@ -255,12 +309,13 @@ export class FileLockService {
|
|
|
255
309
|
* current holder.
|
|
256
310
|
*/
|
|
257
311
|
async heartbeat(
|
|
258
|
-
|
|
312
|
+
rawWorkspaceId: string,
|
|
259
313
|
branch: string,
|
|
260
314
|
rawPath: string,
|
|
261
315
|
user: AuthUser,
|
|
262
316
|
): Promise<FileLock> {
|
|
263
317
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
318
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
264
319
|
const now = new Date();
|
|
265
320
|
const expiresAt = new Date(now.getTime() + LOCK_TTL_MS);
|
|
266
321
|
// The expiry guard (`expiresAt > now`) matters because an expired
|
|
@@ -279,7 +334,7 @@ export class FileLockService {
|
|
|
279
334
|
.set({ lastHeartbeatAt: now, expiresAt })
|
|
280
335
|
.where(
|
|
281
336
|
and(
|
|
282
|
-
|
|
337
|
+
inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
|
|
283
338
|
eq(fileLocks.branch, branch),
|
|
284
339
|
eq(fileLocks.path, targetPath),
|
|
285
340
|
eq(fileLocks.holderUserId, user.id),
|
|
@@ -302,17 +357,18 @@ export class FileLockService {
|
|
|
302
357
|
* over by someone else — that's not an error from the caller's POV).
|
|
303
358
|
*/
|
|
304
359
|
async release(
|
|
305
|
-
|
|
360
|
+
rawWorkspaceId: string,
|
|
306
361
|
branch: string,
|
|
307
362
|
rawPath: string,
|
|
308
363
|
user: AuthUser,
|
|
309
364
|
): Promise<void> {
|
|
310
365
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
366
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
311
367
|
await this.db
|
|
312
368
|
.delete(fileLocks)
|
|
313
369
|
.where(
|
|
314
370
|
and(
|
|
315
|
-
|
|
371
|
+
inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
|
|
316
372
|
eq(fileLocks.branch, branch),
|
|
317
373
|
eq(fileLocks.path, targetPath),
|
|
318
374
|
eq(fileLocks.holderUserId, user.id),
|
|
@@ -326,25 +382,30 @@ export class FileLockService {
|
|
|
326
382
|
* are purged here lazily.
|
|
327
383
|
*/
|
|
328
384
|
async get(
|
|
329
|
-
|
|
385
|
+
rawWorkspaceId: string,
|
|
330
386
|
branch: string,
|
|
331
387
|
rawPath: string,
|
|
332
388
|
): Promise<FileLock | null> {
|
|
333
389
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
390
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
334
391
|
const rows = await this.db
|
|
335
392
|
.select()
|
|
336
393
|
.from(fileLocks)
|
|
337
394
|
.where(
|
|
338
395
|
and(
|
|
339
|
-
|
|
396
|
+
inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
|
|
340
397
|
eq(fileLocks.branch, branch),
|
|
341
398
|
eq(fileLocks.path, targetPath),
|
|
342
399
|
),
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
400
|
+
);
|
|
401
|
+
// At most one row per spelling; the canonical one first.
|
|
402
|
+
rows.sort((x, y) => Number(x.workspaceId !== workspaceId) - Number(y.workspaceId !== workspaceId));
|
|
403
|
+
let live: (typeof rows)[number] | null = null;
|
|
404
|
+
for (const row of rows) {
|
|
405
|
+
if (row.expiresAt.getTime() > Date.now()) {
|
|
406
|
+
live ??= row;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
348
409
|
// Lazy GC — drop the stale row so the next `acquire` doesn't have
|
|
349
410
|
// to bypass it. Bind the delete to this exact row's `expiresAt` so
|
|
350
411
|
// a concurrent acquire that wrote a fresh row in the gap between
|
|
@@ -356,15 +417,14 @@ export class FileLockService {
|
|
|
356
417
|
.delete(fileLocks)
|
|
357
418
|
.where(
|
|
358
419
|
and(
|
|
359
|
-
eq(fileLocks.workspaceId, workspaceId),
|
|
420
|
+
eq(fileLocks.workspaceId, row.workspaceId),
|
|
360
421
|
eq(fileLocks.branch, branch),
|
|
361
422
|
eq(fileLocks.path, targetPath),
|
|
362
423
|
eq(fileLocks.expiresAt, row.expiresAt),
|
|
363
424
|
),
|
|
364
425
|
);
|
|
365
|
-
return null;
|
|
366
426
|
}
|
|
367
|
-
return rowToFileLock(
|
|
427
|
+
return live ? rowToFileLock(live) : null;
|
|
368
428
|
}
|
|
369
429
|
|
|
370
430
|
/**
|
|
@@ -406,11 +466,12 @@ export class FileLockService {
|
|
|
406
466
|
* the "missed lock-release commit" the loud warning is for. Expired rows
|
|
407
467
|
* don't count (their holder is gone; they explain nothing).
|
|
408
468
|
*/
|
|
409
|
-
async hasAnyActive(
|
|
469
|
+
async hasAnyActive(rawWorkspaceId: string): Promise<boolean> {
|
|
470
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
410
471
|
const rows = await this.db
|
|
411
472
|
.select({ path: fileLocks.path })
|
|
412
473
|
.from(fileLocks)
|
|
413
|
-
.where(and(
|
|
474
|
+
.where(and(inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)), gt(fileLocks.expiresAt, new Date())))
|
|
414
475
|
.limit(1);
|
|
415
476
|
return rows.length > 0;
|
|
416
477
|
}
|
|
@@ -100,3 +100,45 @@ describe('GitService.readFileAtRef', () => {
|
|
|
100
100
|
).rejects.toThrow();
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
describe('GitService.commitAtRef', () => {
|
|
105
|
+
let root: string;
|
|
106
|
+
const workspaceId = 'ws-commit-at-ref-1';
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-commit-at-ref-'));
|
|
110
|
+
});
|
|
111
|
+
afterEach(async () => {
|
|
112
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function makeSvc(workspaceDir: string) {
|
|
116
|
+
return new GitService(
|
|
117
|
+
stubWorkspaceService(workspaceId, workspaceDir),
|
|
118
|
+
new WorkflowHooks(),
|
|
119
|
+
testKbContext({ kbDirName: PROCESS_MAP_DIR }),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('answers the commit a ref names, and null for a ref that names none', async () => {
|
|
124
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
125
|
+
const svc = makeSvc(workspaceDir);
|
|
126
|
+
// Unborn HEAD: a repository with no commit yet names none.
|
|
127
|
+
expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBeNull();
|
|
128
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles: {}\n');
|
|
129
|
+
await runGit(repo, ['add', 'roles.yaml']);
|
|
130
|
+
await runGit(repo, ['commit', '-m', 'add roles']);
|
|
131
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repo });
|
|
132
|
+
expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBe(stdout.trim());
|
|
133
|
+
expect(await svc.commitAtRef(workspaceId, 'origin/does-not-exist')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('THROWS on a failure that is not "names no commit", such as no repository there', async () => {
|
|
137
|
+
// The workspace folder exists; the clone inside it does not. Git fails
|
|
138
|
+
// with "not a git repository", which must not read as an empty one.
|
|
139
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
140
|
+
await fs.mkdir(path.join(workspaceDir, PROCESS_MAP_DIR), { recursive: true });
|
|
141
|
+
const svc = makeSvc(workspaceDir);
|
|
142
|
+
await expect(svc.commitAtRef(workspaceId, 'HEAD')).rejects.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -4017,6 +4017,29 @@ export class GitService implements IGitService {
|
|
|
4017
4017
|
return stdout.split('\0').filter((line) => line.length > 0);
|
|
4018
4018
|
}
|
|
4019
4019
|
|
|
4020
|
+
/**
|
|
4021
|
+
* The commit `ref` names right now, or null when it names none (an unborn
|
|
4022
|
+
* `HEAD`, a ref that does not exist). A caller that reads several things
|
|
4023
|
+
* "at HEAD" pins the commit once with this and asks each read at that
|
|
4024
|
+
* commit, so a commit landing between the reads cannot hand it one
|
|
4025
|
+
* snapshot's files with another's rules.
|
|
4026
|
+
*
|
|
4027
|
+
* Null is ONLY "names no commit": `rev-parse --verify --quiet` says that
|
|
4028
|
+
* with exit status 1 and nothing on stderr. Any other failure — no
|
|
4029
|
+
* repository there, git itself failing, a deadline — is thrown, so a
|
|
4030
|
+
* caller cannot read a broken clone as an empty one.
|
|
4031
|
+
*/
|
|
4032
|
+
async commitAtRef(workspaceId: string, ref: string): Promise<string | null> {
|
|
4033
|
+
const cwd = await this.repoDir(workspaceId);
|
|
4034
|
+
try {
|
|
4035
|
+
const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
4036
|
+
return stdout.trim() || null;
|
|
4037
|
+
} catch (err) {
|
|
4038
|
+
if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && !(err.stderr ?? '').trim()) return null;
|
|
4039
|
+
throw err;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4020
4043
|
/**
|
|
4021
4044
|
* A clean `git status` for a path has two honest readings (already
|
|
4022
4045
|
* committed; a queued re-apply of something that landed) and one dishonest
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the workflow module offers the other modules: the addresses of its
|
|
3
|
+
* change requests, so a module that lands a proposal can hand back a link
|
|
4
|
+
* built by the one rule — rather than each spelling the route for itself.
|
|
5
|
+
* The services are wired by the composition root.
|
|
6
|
+
*/
|
|
7
|
+
export { CHANGE_REQUEST_URL_NOTE, changeRequestLink, changeRequestLinkBase } from './git/change-request-link.js';
|
|
@@ -41,6 +41,7 @@ const log = logger('pending-commits');
|
|
|
41
41
|
import type { Database } from '../database/connection.js';
|
|
42
42
|
import { canonicalEmail } from '../../shared/email-identity.js';
|
|
43
43
|
import { pendingCommits } from '../database/schema.js';
|
|
44
|
+
import { canonicalWorkspaceId } from '../../shared/workspace-id.js';
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Transient-failure budget per recovery cycle. After this many consecutive
|
|
@@ -80,29 +81,9 @@ export const N_RECOVERY = 3;
|
|
|
80
81
|
*/
|
|
81
82
|
export const BACKOFF_MS = [1_000, 5_000, 30_000];
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*
|
|
87
|
-
* The id reaches `enqueue` via a route `:id` path param, which Express
|
|
88
|
-
* URL-decodes — so a slashed feature branch arrives as `alice/feature`. But the
|
|
89
|
-
* worker claims per `WorkspaceService.knownWorkspaces()`, whose ids are
|
|
90
|
-
* `encodeURIComponent(branch)` → `alice%2Ffeature`. Those strings differ, so
|
|
91
|
-
* `WHERE workspace_id = …` never matched and the row was never drained —
|
|
92
|
-
* silently stranding every human save on a feature branch (protected branches
|
|
93
|
-
* have no `/`, so encoded == decoded and they were unaffected).
|
|
94
|
-
*
|
|
95
|
-
* `encodeURIComponent(decodeURIComponent(id))` is idempotent and collapses both
|
|
96
|
-
* forms to the encoded id `knownWorkspaces()` uses. A malformed `%` sequence
|
|
97
|
-
* (which `decodeURIComponent` would throw on) is left untouched.
|
|
98
|
-
*/
|
|
99
|
-
export function canonicalWorkspaceId(id: string): string {
|
|
100
|
-
try {
|
|
101
|
-
return encodeURIComponent(decodeURIComponent(id));
|
|
102
|
-
} catch {
|
|
103
|
-
return id;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
84
|
+
// Lives in `shared/workspace-id.ts`, beside the encoding it canonicalises to;
|
|
85
|
+
// re-exported for this module's existing importers.
|
|
86
|
+
export { canonicalWorkspaceId };
|
|
106
87
|
|
|
107
88
|
export interface PendingCommit {
|
|
108
89
|
id: string;
|
|
@@ -40,6 +40,7 @@ import type { WorkflowEventBus } from './event-bus.js';
|
|
|
40
40
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
41
41
|
import { assertValidRelativePath } from '../kb-fs/branch-name.js';
|
|
42
42
|
import { domainErrorBody } from '../../shared/http-errors.js';
|
|
43
|
+
import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
|
|
43
44
|
import '../auth/auth.middleware.js'; // Express Request augmentation
|
|
44
45
|
|
|
45
46
|
function toHttpError(
|
|
@@ -501,6 +502,8 @@ export function createWorkflowRoutes(
|
|
|
501
502
|
// answer for that same input, and `toHttpError` passes it through unchanged.
|
|
502
503
|
// Nothing is canonicalised at this layer on purpose: a route-level fix would
|
|
503
504
|
// leave any other caller of the service coordinating on its own identity.
|
|
505
|
+
// (The status read canonicalises once for its read gate only; the lookup
|
|
506
|
+
// still takes the caller's spelling.)
|
|
504
507
|
|
|
505
508
|
router.post('/workspace/:id/workflow/locks', async (req, res) => {
|
|
506
509
|
const user = await requireUser(req, res);
|
|
@@ -589,7 +592,8 @@ export function createWorkflowRoutes(
|
|
|
589
592
|
});
|
|
590
593
|
|
|
591
594
|
router.get('/workspace/:id/workflow/locks', async (req, res) => {
|
|
592
|
-
|
|
595
|
+
const user = await requireUser(req, res);
|
|
596
|
+
if (!user) return;
|
|
593
597
|
const branch = typeof req.query.branch === 'string' ? req.query.branch : '';
|
|
594
598
|
const targetPath = typeof req.query.path === 'string' ? req.query.path : '';
|
|
595
599
|
if (!branch || !targetPath) {
|
|
@@ -597,6 +601,25 @@ export function createWorkflowRoutes(
|
|
|
597
601
|
return;
|
|
598
602
|
}
|
|
599
603
|
try {
|
|
604
|
+
// A lock names who is editing the file, so a caller who may not read
|
|
605
|
+
// the file is refused before the lookup: the answer is the same whether
|
|
606
|
+
// or not it is held. The gate judges the canonical spelling, which is
|
|
607
|
+
// the one the lock is found under (`./kb//x.md` would otherwise slip
|
|
608
|
+
// past the KB-prefix test), and a refused spelling keeps the file
|
|
609
|
+
// verbs' status. A path without the KB prefix is not exempted as
|
|
610
|
+
// non-KB: the git layer also takes the repo-relative form (`GTM/x.md`
|
|
611
|
+
// for `knowledge-base/GTM/x.md`, see `requireReadPermission`), so it is
|
|
612
|
+
// judged as the repository file it would name.
|
|
613
|
+
const canonical = canonicalFileIdentity(targetPath);
|
|
614
|
+
const readable = await accessControl.canRead(
|
|
615
|
+
req.params.id,
|
|
616
|
+
user.email,
|
|
617
|
+
toKbRelative(canonical, kbDirName) ?? canonical,
|
|
618
|
+
);
|
|
619
|
+
if (!readable) {
|
|
620
|
+
res.status(403).json({ error: `You don't have permission to read "${targetPath}".` });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
600
623
|
res.json({ lock: await workflow.getLock(req.params.id, branch, targetPath) });
|
|
601
624
|
} catch (err) {
|
|
602
625
|
const { status, body } = toHttpError(err);
|
|
@@ -542,7 +542,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
542
542
|
// One line per deletion, whoever asked: the commit is what a restore
|
|
543
543
|
// starts from, and for a deletion made in the app or by the cleanup this
|
|
544
544
|
// line is the only trace there is.
|
|
545
|
-
// By id
|
|
545
|
+
// By id: log lines carry no email addresses.
|
|
546
546
|
const actor = opts?.systemCleanup ? 'system' : user.id;
|
|
547
547
|
log.info(`branch deleted by ${actor}: "${name}" at ${lastCommit ?? '(no commit)'}`);
|
|
548
548
|
// Retire the deleted branch's own workspace clone, best-effort. A stale
|
|
@@ -629,6 +629,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
629
629
|
*/
|
|
630
630
|
private async closeIfEmpty(
|
|
631
631
|
number: number,
|
|
632
|
+
// `actor` is a user id or `system`, never an address: it goes into a log line.
|
|
632
633
|
opts: { reason: string; actor: string; workspaceId?: string; fresh?: boolean },
|
|
633
634
|
): Promise<{ base: string } | null> {
|
|
634
635
|
const summary = await this.prs.getPr(number);
|
|
@@ -3360,7 +3361,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
3360
3361
|
* Returns true when THIS call closed it.
|
|
3361
3362
|
*/
|
|
3362
3363
|
async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
|
|
3363
|
-
const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.
|
|
3364
|
+
const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.id });
|
|
3364
3365
|
if (!closed) return false;
|
|
3365
3366
|
await this.retireMergedSourceBranch(number, closed.base, user);
|
|
3366
3367
|
return true;
|
|
@@ -48,12 +48,18 @@ const allowAll = {
|
|
|
48
48
|
canReadBatch: async (_w: string, _u: string, paths: string[]) =>
|
|
49
49
|
new Map(paths.map((p) => [p, true])),
|
|
50
50
|
// The verbs `file_stat` and the move/delete preflight ask about. Allowing
|
|
51
|
-
// everything
|
|
52
|
-
//
|
|
51
|
+
// everything keeps these tests about what they test rather than about
|
|
52
|
+
// access.
|
|
53
53
|
canWrite: async () => true,
|
|
54
|
+
canWriteBatch: async (_w: string, _u: string, paths: string[]) =>
|
|
55
|
+
new Map(paths.map((p) => [p, true])),
|
|
54
56
|
canDownload: async () => true,
|
|
55
57
|
canOwner: async () => true,
|
|
56
|
-
|
|
58
|
+
// The `canWrite` the read tools report is judged at the default branch's
|
|
59
|
+
// HEAD, so a write-scope caller here gets a COMPUTED `true` — never the
|
|
60
|
+
// fail-closed `false` of a missing method or an unresolvable ref.
|
|
61
|
+
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, paths: string[]) =>
|
|
62
|
+
new Map(paths.map((p) => [p, true])),
|
|
57
63
|
// The `after` half of a move's or copy's preview. Allowing everything here
|
|
58
64
|
// keeps it the same answer as the four gates above; what the relocation of
|
|
59
65
|
// a folder's `access.md` files actually does to it is exercised against
|
|
@@ -372,7 +378,7 @@ async function isDeadOrZombie(pid: number): Promise<boolean> {
|
|
|
372
378
|
describe('workspace file primitives', () => {
|
|
373
379
|
it('read_file returns the content', async () => {
|
|
374
380
|
const base = await start();
|
|
375
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
|
|
381
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
|
|
376
382
|
});
|
|
377
383
|
|
|
378
384
|
it('write_file then read_file round-trips', async () => {
|
|
@@ -505,7 +511,7 @@ describe('workspace file primitives', () => {
|
|
|
505
511
|
// that same text into an agent: a leading slash names the same path.
|
|
506
512
|
it('every path input accepts a leading slash as the same workspace path', async () => {
|
|
507
513
|
const base = await start();
|
|
508
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
|
|
514
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
|
|
509
515
|
expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `/${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
|
|
510
516
|
await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/b.md`, content: 'fresh' });
|
|
511
517
|
await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/c.md`, content: 'batch' });
|
|
@@ -1117,14 +1123,14 @@ describe('write modes and per-path outcomes', () => {
|
|
|
1117
1123
|
describe("the agent guide at the guide's name", () => {
|
|
1118
1124
|
const GUIDE = `${KB_DIR}/AGENTS.md`;
|
|
1119
1125
|
const read = (base: string, p = GUIDE, extra: Record<string, unknown> = {}) =>
|
|
1120
|
-
post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string }>);
|
|
1126
|
+
post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string; canWrite?: boolean }>);
|
|
1121
1127
|
const statOf = (base: string, p = GUIDE) =>
|
|
1122
1128
|
post(`${base}/api/agent/tools/file_stat`, { path: p }).then((r) => r.json() as Promise<Record<string, unknown>>);
|
|
1123
1129
|
|
|
1124
1130
|
it('answers with the guide when the knowledge base has no file of that name', async () => {
|
|
1125
1131
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
1126
1132
|
const base = await start();
|
|
1127
|
-
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
|
|
1133
|
+
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
|
|
1128
1134
|
// By the root-anchored and the prefix-less spellings too, like any path.
|
|
1129
1135
|
expect((await read(base, `/${GUIDE}`)).content).toBe('THE PLATFORM GUIDE\n');
|
|
1130
1136
|
expect((await read(base, 'AGENTS.md')).content).toBe('THE PLATFORM GUIDE\n');
|
|
@@ -1145,7 +1151,9 @@ describe("the agent guide at the guide's name", () => {
|
|
|
1145
1151
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
1146
1152
|
const base = await start();
|
|
1147
1153
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nWrite tickets in the present tense.\n');
|
|
1148
|
-
const { content } = await read(base);
|
|
1154
|
+
const { content, canWrite } = await read(base);
|
|
1155
|
+
// Its own file is a file like any other: the write verdict is the rules' answer for it.
|
|
1156
|
+
expect(canWrite).toBe(true);
|
|
1149
1157
|
expect(content.startsWith('# Acme\n\nWrite tickets in the present tense.\n\n---\n')).toBe(true);
|
|
1150
1158
|
expect(content.endsWith('\n\nTHE PLATFORM GUIDE\n')).toBe(true);
|
|
1151
1159
|
expect(content).toContain("The text above is this knowledge base's own conventions file.");
|
|
@@ -1155,7 +1163,7 @@ describe("the agent guide at the guide's name", () => {
|
|
|
1155
1163
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
1156
1164
|
const base = await start();
|
|
1157
1165
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
|
|
1158
|
-
expect(
|
|
1166
|
+
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
|
|
1159
1167
|
});
|
|
1160
1168
|
|
|
1161
1169
|
it("never tells a caller who may not read the knowledge base's own file that it exists", async () => {
|
|
@@ -1199,7 +1207,7 @@ describe("the agent guide at the guide's name", () => {
|
|
|
1199
1207
|
};
|
|
1200
1208
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
|
|
1201
1209
|
vanish = true;
|
|
1202
|
-
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
|
|
1210
|
+
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
|
|
1203
1211
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
|
|
1204
1212
|
vanish = true;
|
|
1205
1213
|
expect(await statOf(base)).toMatchObject({ platformGuide: true });
|
|
@@ -2211,7 +2219,7 @@ describe('images', () => {
|
|
|
2211
2219
|
const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/photo.jpg` })).json()) as ImageSentinel;
|
|
2212
2220
|
expect(body.mimeType).toBe('image/jpeg');
|
|
2213
2221
|
expect(body.data).toBe(bytes.toString('base64'));
|
|
2214
|
-
expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes]`);
|
|
2222
|
+
expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes] (canWrite: true)`);
|
|
2215
2223
|
});
|
|
2216
2224
|
|
|
2217
2225
|
it('read_file refuses an image over 3.5 MiB raw with the downscale message, not a sentinel', async () => {
|
|
@@ -2237,6 +2245,7 @@ describe('images', () => {
|
|
|
2237
2245
|
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/icon.svg` })).json()).toEqual({
|
|
2238
2246
|
path: `${KB_DIR}/icon.svg`,
|
|
2239
2247
|
content: svg,
|
|
2248
|
+
canWrite: true,
|
|
2240
2249
|
});
|
|
2241
2250
|
});
|
|
2242
2251
|
|
|
@@ -2776,6 +2785,7 @@ describe('the tools place an unprefixed path inside the repository', () => {
|
|
|
2776
2785
|
expect(await (await tool(base, 'read_file', { path: 'Notes.md' })).json()).toEqual({
|
|
2777
2786
|
path: `${KB_DIR}/Notes.md`,
|
|
2778
2787
|
content: 'hello\nworld\n',
|
|
2788
|
+
canWrite: true,
|
|
2779
2789
|
});
|
|
2780
2790
|
expect(await (await tool(base, 'file_stat', { path: 'Notes.md' })).json()).toMatchObject({ type: 'file' });
|
|
2781
2791
|
const edited = await tool(base, 'edit_file', { path: 'Notes.md', old_string: 'world', new_string: 'earth' });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the workspace module offers the other modules: how a file's bytes are
|
|
3
|
+
* read and told apart — the reader registry `read_file` dispatches on, and
|
|
4
|
+
* the raw content type a file is served with — so a module that serves a
|
|
5
|
+
* file answers exactly as a read of it would. The services are wired by the
|
|
6
|
+
* composition root.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
FileReaderRegistry,
|
|
10
|
+
type FileKind,
|
|
11
|
+
type FileReader,
|
|
12
|
+
type ReadResult,
|
|
13
|
+
} from './file-readers/file-reader.js';
|
|
14
|
+
export { OCTET_STREAM, rawExtensionOf, rawMimeFor } from './file-readers/raw-mime.js';
|