@bevel-software/platform-core-backend 0.28.1 → 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.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 +145 -43
- 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/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.atref-cache.test.ts +303 -25
- package/src/modules/access/access-control.service.ts +162 -46
- 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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
|
-
import { timingSafeStringEqual } from '../auth/
|
|
3
|
-
import { ACCOUNT_DEACTIVATED_MESSAGE } from '../auth/account-admission.js';
|
|
2
|
+
import { ACCOUNT_DEACTIVATED_MESSAGE, timingSafeStringEqual } from '../auth/index.js';
|
|
4
3
|
import jwt from 'jsonwebtoken';
|
|
5
4
|
import type { AuthUser, IGitService, IWorkflowService } from '@bevel-software/platform-shared';
|
|
6
5
|
import { suggestionsBranchPrefixFor } from '@bevel-software/platform-shared';
|
|
@@ -8,9 +7,9 @@ import { logger } from '../../shared/logging.js';
|
|
|
8
7
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
9
8
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
10
9
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
11
|
-
import { changeRequestLink, changeRequestLinkBase } from '../workflow/
|
|
12
|
-
import type { FileReaderRegistry } from '../workspace/
|
|
13
|
-
import type { WriteValidator } from '../kb-fs/
|
|
10
|
+
import { changeRequestLink, changeRequestLinkBase } from '../workflow/index.js';
|
|
11
|
+
import type { FileReaderRegistry } from '../workspace/index.js';
|
|
12
|
+
import type { WriteValidator } from '../kb-fs/index.js';
|
|
14
13
|
import type {
|
|
15
14
|
EmbedAuthPort,
|
|
16
15
|
EmbedFileView,
|
|
@@ -21,6 +20,7 @@ import type {
|
|
|
21
20
|
EmbedProposalResult,
|
|
22
21
|
EmbedSubject,
|
|
23
22
|
EmbedTokenResult,
|
|
23
|
+
EmbedViewer,
|
|
24
24
|
IEmbedService,
|
|
25
25
|
} from './embed.interface.js';
|
|
26
26
|
import {
|
|
@@ -35,16 +35,24 @@ import type { IAccountLinkService } from './account-link.service.js';
|
|
|
35
35
|
const log = logger('embed');
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* page
|
|
38
|
+
* Lifetime of a token `open_page` mints (`kind: user`). The token rides in
|
|
39
|
+
* the tool's result, so it sits in the chat transcript, and whoever holds it
|
|
40
|
+
* reads and edits that one file as the token's user until it expires. Five
|
|
41
|
+
* minutes (Razvan, 2026-10-09; one hour before, two before that): a token
|
|
42
|
+
* copied out of a transcript stops working almost at once. A view left open
|
|
43
|
+
* longer is not ended by it — the MCP App view asks its host to call
|
|
44
|
+
* `open_page` again when a call is refused for the token, and carries on with
|
|
45
|
+
* the fresh one (see `mcp-app/page.html`).
|
|
46
46
|
*/
|
|
47
|
-
export const
|
|
47
|
+
export const EMBED_USER_TOKEN_TTL_SECONDS = 5 * 60;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lifetime of a token the Atlassian connector mints through the shared-secret
|
|
51
|
+
* route (`kind: atlassian`). One hour, unchanged: the connector frames
|
|
52
|
+
* `/embed` directly and has no host to renew a token through, and its mint
|
|
53
|
+
* contract does not change (Decision 9 of the parent specification).
|
|
54
|
+
*/
|
|
55
|
+
export const EMBED_CONNECTOR_TOKEN_TTL_SECONDS = 60 * 60;
|
|
48
56
|
|
|
49
57
|
/** What the embed's config needs from the deployment's. */
|
|
50
58
|
export interface EmbedConfig {
|
|
@@ -148,7 +156,11 @@ export class EmbedService implements IEmbedService {
|
|
|
148
156
|
if (!this.authService.isEmailDomainAllowed(email ?? '')) {
|
|
149
157
|
throw new EmbedAccessError('Your email domain is not permitted to view this content');
|
|
150
158
|
}
|
|
151
|
-
|
|
159
|
+
const minted = await this.mint({ kind: 'atlassian', accountId: input.accountId }, input.reference);
|
|
160
|
+
// The connector's host sizes its frame to the content (the issue panel
|
|
161
|
+
// grows with the page), so its view is told to report its height; the
|
|
162
|
+
// MCP path says nothing, and its host's fixed reading pane stays.
|
|
163
|
+
return { ...minted, embedUrl: this.embedUrlFor(minted.token, { sizing: 'content' }) };
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
/**
|
|
@@ -195,13 +207,20 @@ export class EmbedService implements IEmbedService {
|
|
|
195
207
|
repoRelative,
|
|
196
208
|
slug: ref.slug,
|
|
197
209
|
};
|
|
198
|
-
const
|
|
210
|
+
const expiresIn =
|
|
211
|
+
subject.kind === 'user' ? EMBED_USER_TOKEN_TTL_SECONDS : EMBED_CONNECTOR_TOKEN_TTL_SECONDS;
|
|
212
|
+
const token = jwt.sign(claims, this.signingKey, { expiresIn });
|
|
199
213
|
return { token, embedUrl: this.embedUrlFor(token) };
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
/**
|
|
217
|
+
* The view's address — what a host frames. `sizing: 'content'` tells the
|
|
218
|
+
* view its host sizes the frame to the content and wants the height
|
|
219
|
+
* reported; absent, the host's fixed pane is kept.
|
|
220
|
+
*/
|
|
221
|
+
embedUrlFor(token: string, opts: { sizing?: 'content' } = {}): string {
|
|
222
|
+
const sizing = opts.sizing === 'content' ? '&sizing=content' : '';
|
|
223
|
+
return `${this.config.publicFrontendUrl}/embed?token=${encodeURIComponent(token)}${sizing}`;
|
|
205
224
|
}
|
|
206
225
|
|
|
207
226
|
/** A file's address in the app — what a link opens in a new tab. */
|
|
@@ -556,6 +575,12 @@ export class EmbedService implements IEmbedService {
|
|
|
556
575
|
return { claims, user, wsPath: this.wsPathFor(claims.repoRelative) };
|
|
557
576
|
}
|
|
558
577
|
|
|
578
|
+
async viewerOf(token: string): Promise<EmbedViewer> {
|
|
579
|
+
const claims = this.verifyToken(token);
|
|
580
|
+
const identity = await this.resolveIdentity(claims);
|
|
581
|
+
return { ...identity, repoRelative: claims.repoRelative };
|
|
582
|
+
}
|
|
583
|
+
|
|
559
584
|
/** Resolve the token's identity to a user + read/write verdicts. */
|
|
560
585
|
private async resolveIdentity(claims: EmbedClaims): Promise<ResolvedIdentity> {
|
|
561
586
|
const unresolved: ResolvedIdentity = { linked: false, user: null, canRead: false, canWrite: false };
|
|
@@ -104,6 +104,10 @@ export function registerEmbedTools(
|
|
|
104
104
|
type: 'object',
|
|
105
105
|
properties: {
|
|
106
106
|
path: { type: 'string', description: 'The path that was shown (echoes the input).' },
|
|
107
|
+
heading: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'The heading the view opens at (echoes the input). Absent when none was named.',
|
|
110
|
+
},
|
|
107
111
|
content: { type: 'string', description: "The page's text, exactly as `read_file` answers it." },
|
|
108
112
|
embedUrl: {
|
|
109
113
|
type: 'string',
|
|
@@ -163,12 +167,16 @@ export function registerEmbedTools(
|
|
|
163
167
|
result.kind === 'text' ? result.text : result.kind === 'image' ? result.note : result.message;
|
|
164
168
|
|
|
165
169
|
const appUrl = deps.appUrlFor(repoRelative, heading);
|
|
170
|
+
// The heading goes back beside the path: the view calls this tool again
|
|
171
|
+
// with both when its token runs out (see `mcp-app/page.html`), and a
|
|
172
|
+
// result is all it is told.
|
|
173
|
+
const echoed = heading ? { heading } : {};
|
|
166
174
|
// No view on a plain-http deployment (Decision 8): a host's https
|
|
167
175
|
// sandbox cannot load from it. The text and the app address are still
|
|
168
176
|
// the answer, and the tool says why there is no view rather than
|
|
169
177
|
// handing back an address no host can open.
|
|
170
178
|
if (!deps.canBeReached()) {
|
|
171
|
-
return { path: raw, content, appUrl, branch, note: HTTP_DEPLOYMENT_NOTE };
|
|
179
|
+
return { path: raw, ...echoed, content, appUrl, branch, note: HTTP_DEPLOYMENT_NOTE };
|
|
172
180
|
}
|
|
173
181
|
// Minted for the identity THIS MCP session authenticated — the
|
|
174
182
|
// signed-in user, or the owner of the connection key the agent carries.
|
|
@@ -178,7 +186,7 @@ export function registerEmbedTools(
|
|
|
178
186
|
userId: ctx.user.id,
|
|
179
187
|
reference: heading ? `${repoRelative}#${heading}` : repoRelative,
|
|
180
188
|
});
|
|
181
|
-
return { path: raw, content, embedUrl, appUrl, branch };
|
|
189
|
+
return { path: raw, ...echoed, content, embedUrl, appUrl, branch };
|
|
182
190
|
}),
|
|
183
191
|
);
|
|
184
192
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the kb-fs module offers the other modules. The filesystem and the
|
|
3
|
+
* locks are wired by the composition root; a module that writes through the
|
|
4
|
+
* workspace needs only the shape of the gate every write passes first.
|
|
5
|
+
*/
|
|
6
|
+
export type { WriteValidator } from './locking-filesystem.js';
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { DEFAULT_BRANCH, joinBranchFor } from '@bevel-software/platform-shared';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from '../join-request-jobs.service.js';
|
|
3
|
+
import { DrizzleQueryError } from 'drizzle-orm';
|
|
4
|
+
import { PluginJoinRequestJobs, JoinRequestNotReadyError, type JoinRequestJobsDeps } from '../index.js';
|
|
5
|
+
// The claim window is not part of the module's surface: a test that moves
|
|
6
|
+
// the clock past it reads the number the service itself uses.
|
|
7
|
+
import { CLAIM_STALE_AFTER_MS } from '../join-request-jobs.service.js';
|
|
9
8
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
10
9
|
import { FakeJoinRequestStore } from './fake-join-request-store.js';
|
|
11
10
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
@@ -146,6 +145,48 @@ describe('PluginJoinRequestJobs', () => {
|
|
|
146
145
|
expect(row.failureReason).not.toContain('ghp_secret');
|
|
147
146
|
});
|
|
148
147
|
|
|
148
|
+
it("logs a database failure as the database's reason, and shows the requester one stable sentence", async () => {
|
|
149
|
+
const h = harness();
|
|
150
|
+
// What Drizzle throws: its own error class, the SQL in the message, the
|
|
151
|
+
// driver's error as the cause.
|
|
152
|
+
h.workflow.openChangeRequest.mockRejectedValue(
|
|
153
|
+
new DrizzleQueryError(
|
|
154
|
+
'insert into "change_requests" ("id", "number", "title", "body", "source_branch", "target_branch", "author_id", "status", "created_at", "updated_at") values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
|
|
155
|
+
[],
|
|
156
|
+
new Error('relation "change_requests" does not exist'),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
await h.jobs.start(pendingRow(h.store));
|
|
160
|
+
|
|
161
|
+
// The operator gets the reason, not the wrapper's cut-off SQL…
|
|
162
|
+
const logged = consoleError.mock.calls.map((c) => String(c[0]));
|
|
163
|
+
// (`printable` renders the reason as a JSON string, so its own quotes come escaped.)
|
|
164
|
+
expect(logged).toContainEqual(expect.stringMatching(/failed: "relation \\"change_requests\\" does not exist"/));
|
|
165
|
+
expect(logged.some((l) => l.includes('Failed query'))).toBe(false);
|
|
166
|
+
// …and the requester's card names no table.
|
|
167
|
+
const [row] = h.store.all();
|
|
168
|
+
expect(row.status).toBe('failed');
|
|
169
|
+
expect(row.failureReason).toBe('the request could not be recorded; an admin can find the reason in the server log');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("shows the requester a wrapped failure's own words when it is not the database's", async () => {
|
|
173
|
+
const h = harness();
|
|
174
|
+
// The git work failing behind a wrapper of its own: the cause is what is
|
|
175
|
+
// logged, and what the requester reads, credentials stripped — the
|
|
176
|
+
// database's stable sentence would hide the detail they can act on.
|
|
177
|
+
h.workflow.openChangeRequest.mockRejectedValue(
|
|
178
|
+
new Error('opening the change request failed', {
|
|
179
|
+
cause: new Error('fatal: unable to access https://x:ghp_secret@github.com/acme/kb: could not resolve host'),
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
await h.jobs.start(pendingRow(h.store));
|
|
183
|
+
const [row] = h.store.all();
|
|
184
|
+
expect(row.status).toBe('failed');
|
|
185
|
+
expect(row.failureReason).toContain('unable to access');
|
|
186
|
+
expect(row.failureReason).not.toContain('ghp_secret');
|
|
187
|
+
expect(row.failureReason).not.toContain('could not be recorded');
|
|
188
|
+
});
|
|
189
|
+
|
|
149
190
|
it('fails the record rather than throwing when the plugin is gone', async () => {
|
|
150
191
|
const h = harness({ target: async () => null });
|
|
151
192
|
await expect(h.jobs.start(pendingRow(h.store))).resolves.toBeUndefined();
|
|
@@ -524,6 +565,32 @@ describe('PluginJoinRequestJobs', () => {
|
|
|
524
565
|
expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
|
|
525
566
|
});
|
|
526
567
|
|
|
568
|
+
it("a sweep that cannot read the table logs the database's reason, not the wrapper's cut-off SQL", async () => {
|
|
569
|
+
vi.useFakeTimers();
|
|
570
|
+
const warned = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
571
|
+
try {
|
|
572
|
+
const h = harness();
|
|
573
|
+
// What Drizzle throws: its own error class, the SQL in the message, the driver's error underneath.
|
|
574
|
+
const wrapped = new DrizzleQueryError(
|
|
575
|
+
'select "id", "requester_email", "requester_email_bidx", "requester_name", "plugin_key", "status", "failure_reason", "change_request_number", "claimed_at", "claim_token", "created_at" from "plugin_join_requests" where status = $1',
|
|
576
|
+
[],
|
|
577
|
+
new Error('Cannot use a pool after calling end on the pool'),
|
|
578
|
+
);
|
|
579
|
+
h.store.pending = async () => {
|
|
580
|
+
throw wrapped;
|
|
581
|
+
};
|
|
582
|
+
h.jobs.startSweeping(1000);
|
|
583
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
584
|
+
h.jobs.stopSweeping();
|
|
585
|
+
const lines = warned.mock.calls.map((c) => String(c[0]));
|
|
586
|
+
expect(lines).toHaveLength(1);
|
|
587
|
+
expect(lines[0]).toContain('could not look at the join requests still owed: "Cannot use a pool after calling end on the pool"');
|
|
588
|
+
expect(lines[0]).not.toContain('Failed query');
|
|
589
|
+
} finally {
|
|
590
|
+
warned.mockRestore();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
527
594
|
it('proceeds past a createBranch failure only when the branch is really there', async () => {
|
|
528
595
|
const h = harness();
|
|
529
596
|
const branch = joinBranchFor(ALI, 'Finance');
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ChangeRequestState,
|
|
5
5
|
type IWorkflowService,
|
|
6
6
|
} from '@bevel-software/platform-shared';
|
|
7
|
+
import { DrizzleQueryError } from 'drizzle-orm';
|
|
7
8
|
import { spliceGrant } from '../access-model/access-splice.js';
|
|
8
9
|
import { sanitizeError } from '../workflow/sanitize-error.js';
|
|
9
10
|
import { logger } from '../../shared/logging.js';
|
|
@@ -358,12 +359,12 @@ export class PluginJoinRequestJobs {
|
|
|
358
359
|
*/
|
|
359
360
|
startSweeping(intervalMs: number = SWEEP_INTERVAL_MS): void {
|
|
360
361
|
void this.sweep().catch((err: unknown) => {
|
|
361
|
-
log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(err))}`);
|
|
362
|
+
log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(underlying(err)))}`);
|
|
362
363
|
});
|
|
363
364
|
if (this.sweepTimer) return;
|
|
364
365
|
this.sweepTimer = setInterval(() => {
|
|
365
366
|
void this.sweep().catch((err: unknown) => {
|
|
366
|
-
log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(err))}`);
|
|
367
|
+
log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(underlying(err)))}`);
|
|
367
368
|
});
|
|
368
369
|
}, intervalMs);
|
|
369
370
|
// Never a reason for the process to stay alive: every row is durable, and
|
|
@@ -398,7 +399,7 @@ export class PluginJoinRequestJobs {
|
|
|
398
399
|
// wrap the whole attempt: the claim has to be refreshed for as long as
|
|
399
400
|
// the work runs, and the work is the part that can take minutes.
|
|
400
401
|
const live = await this.store.claim(record.id, CLAIM_STALE_AFTER_MS).catch((err: unknown) => {
|
|
401
|
-
log.warn(`could not claim a join request: ${loggable(sanitizeError(err))}`);
|
|
402
|
+
log.warn(`could not claim a join request: ${loggable(sanitizeError(underlying(err)))}`);
|
|
402
403
|
return null;
|
|
403
404
|
});
|
|
404
405
|
if (!live) {
|
|
@@ -426,7 +427,7 @@ export class PluginJoinRequestJobs {
|
|
|
426
427
|
if (!still) held = false;
|
|
427
428
|
return still ? 'held' : 'lost';
|
|
428
429
|
} catch (err: unknown) {
|
|
429
|
-
log.warn(`could not refresh a join-request claim: ${loggable(sanitizeError(err))}`);
|
|
430
|
+
log.warn(`could not refresh a join-request claim: ${loggable(sanitizeError(underlying(err)))}`);
|
|
430
431
|
return 'unknown';
|
|
431
432
|
}
|
|
432
433
|
};
|
|
@@ -453,7 +454,10 @@ export class PluginJoinRequestJobs {
|
|
|
453
454
|
if (state !== 'held') throw new ClaimLostError(state);
|
|
454
455
|
});
|
|
455
456
|
} catch (err) {
|
|
456
|
-
|
|
457
|
+
// The cause when there is one (see `underlying`), for the log; a
|
|
458
|
+
// database's reason, where a wrapper's cut-off column list is not.
|
|
459
|
+
const cause = underlying(err);
|
|
460
|
+
const reason = sanitizeError(cause);
|
|
457
461
|
// `pluginKey` is a persisted folder path, so it is escaped before it
|
|
458
462
|
// reaches a log line: a folder carrying control characters could
|
|
459
463
|
// otherwise write newlines of its own into the log.
|
|
@@ -473,22 +477,31 @@ export class PluginJoinRequestJobs {
|
|
|
473
477
|
// claim held by a process that never really started.
|
|
474
478
|
log.warn(`join request for ${key} postponed: ${loggable(reason)}`);
|
|
475
479
|
await this.store.release(record.id, live.claimToken).catch((releaseErr: unknown) => {
|
|
476
|
-
log.warn(`could not release that claim: ${loggable(sanitizeError(releaseErr))}`);
|
|
480
|
+
log.warn(`could not release that claim: ${loggable(sanitizeError(underlying(releaseErr)))}`);
|
|
477
481
|
});
|
|
478
482
|
return;
|
|
479
483
|
}
|
|
480
484
|
log.error(`join request for ${key} failed: ${loggable(reason)}`);
|
|
485
|
+
// What the requester reads on their card. The git work's own words
|
|
486
|
+
// are theirs to see (credentials stripped); a database's reason is
|
|
487
|
+
// not — it names tables and columns and is for the operator, who has
|
|
488
|
+
// it in the line above — so a database failure is recorded in one
|
|
489
|
+
// stable sentence. The database's failures are the ones the query
|
|
490
|
+
// layer wraps (`DrizzleQueryError`, its cause the driver's error);
|
|
491
|
+
// any other wrapped error keeps its own sanitised words, which may be
|
|
492
|
+
// what the requester needs to act on.
|
|
493
|
+
const shown = err instanceof DrizzleQueryError ? DATABASE_FAILURE_REASON : reason;
|
|
481
494
|
await this.store
|
|
482
495
|
// Named with the token, so this lands only if the row is still this
|
|
483
496
|
// process's. A worker that overran the window and had its row taken
|
|
484
497
|
// writes nothing here rather than stamping its failure on the attempt
|
|
485
498
|
// that replaced it — which would show the requester a failure for
|
|
486
499
|
// work that is, at that moment, succeeding.
|
|
487
|
-
.markFailed(record.id, live.claimToken,
|
|
500
|
+
.markFailed(record.id, live.claimToken, shown)
|
|
488
501
|
// Nothing left to do if even that write fails: the row stays
|
|
489
502
|
// `pending` and the next boot's sweep tries the whole thing again.
|
|
490
503
|
.catch((writeErr: unknown) => {
|
|
491
|
-
log.error(`could not record that failure: ${loggable(sanitizeError(writeErr))}`);
|
|
504
|
+
log.error(`could not record that failure: ${loggable(sanitizeError(underlying(writeErr)))}`);
|
|
492
505
|
});
|
|
493
506
|
} finally {
|
|
494
507
|
held = false;
|
|
@@ -669,6 +682,30 @@ function loggable(reason: string): string {
|
|
|
669
682
|
return printable(reason);
|
|
670
683
|
}
|
|
671
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The error behind a wrapped one. Drizzle reports a failed query as its own
|
|
687
|
+
* error whose message is the SQL and whose `cause` is the database's reason;
|
|
688
|
+
* the sanitizer caps a message at a couple of hundred characters, so logging
|
|
689
|
+
* the wrapper showed a cut-off column list and never the reason. The reason
|
|
690
|
+
* is what every LOG line this service writes needs — "the pool has been
|
|
691
|
+
* ended", "relation does not exist" — the sweep's warning, the claim and
|
|
692
|
+
* heartbeat warnings, the failure line. The requester's card is the one
|
|
693
|
+
* place it does not go: a wrapped failure is recorded there as
|
|
694
|
+
* `DATABASE_FAILURE_REASON`, since a table name is the operator's to read.
|
|
695
|
+
* An error with no cause (git's, the claim's own) is reported as it is.
|
|
696
|
+
*/
|
|
697
|
+
function underlying(err: unknown): unknown {
|
|
698
|
+
const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
|
|
699
|
+
return cause instanceof Error ? cause : err;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The sentence a requester's card carries when the database, not the git
|
|
704
|
+
* work, refused the request: stable, and naming no table or column. The
|
|
705
|
+
* database's own words go to the server log beside it.
|
|
706
|
+
*/
|
|
707
|
+
const DATABASE_FAILURE_REASON = 'the request could not be recorded; an admin can find the reason in the server log';
|
|
708
|
+
|
|
672
709
|
/** Node's "there is no such file" errors, and only those. */
|
|
673
710
|
function isFileAbsent(err: unknown): boolean {
|
|
674
711
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
@@ -154,7 +154,7 @@ describe('the skill tools read a branch when asked', () => {
|
|
|
154
154
|
skills: { name: string; unmerged?: boolean }[];
|
|
155
155
|
};
|
|
156
156
|
expect(listArgs).toEqual([['e@x', { branch: undefined }]]);
|
|
157
|
-
expect(body.skills).toEqual([{ name: 'rfi', description: 'RFI.', path: released.path }]);
|
|
157
|
+
expect(body.skills).toEqual([{ name: 'rfi', description: 'RFI.', path: released.path, canWrite: false }]);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
it('a blank branch is no branch at all: the released catalog answers', async () => {
|
|
@@ -52,6 +52,10 @@ export interface SkillSummary {
|
|
|
52
52
|
unmerged?: true;
|
|
53
53
|
/** With `unmerged`: the branch the skill was read from. */
|
|
54
54
|
branch?: string;
|
|
55
|
+
// No `canWrite` here, on purpose: whether the caller may change a skill
|
|
56
|
+
// directly is a per-caller verdict the agent tools add onto a COPY of each
|
|
57
|
+
// summary (`skills.tools.ts`). The catalog is cached across users, so a
|
|
58
|
+
// verdict stored on it would be served to the next caller.
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export interface Skill extends SkillSummary {
|
|
@@ -10,6 +10,7 @@ import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
|
10
10
|
import type { ISkillService } from './skills.contract.js';
|
|
11
11
|
import { EXTERNAL_KB_MANUAL_NAME } from '../tool-manuals/tool-manuals.contract.js';
|
|
12
12
|
import type { IAllowedToolsChecker } from './allowed-tools-check.js';
|
|
13
|
+
import { CAN_WRITE_CLAUSE, defaultBranchWriteVerdicts, type DefaultBranchWriteDeps } from '../tool-helpers/index.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The optional `branch` both skill tools declare. Optional on purpose, unlike
|
|
@@ -82,7 +83,57 @@ export function registerSkillsTools(
|
|
|
82
83
|
skillService: ISkillService,
|
|
83
84
|
/** When given, a loaded skill carries `warnings` for `allowed-tools` entries that name no visible tool. */
|
|
84
85
|
allowedTools?: IAllowedToolsChecker,
|
|
86
|
+
/**
|
|
87
|
+
* Where `canWrite` is judged: the access layer and the default branch's
|
|
88
|
+
* workspace. Without it every verdict is `false` — the safe answer, which
|
|
89
|
+
* sends the agent to a change request rather than a direct write.
|
|
90
|
+
*/
|
|
91
|
+
writeVerdicts?: DefaultBranchWriteDeps,
|
|
85
92
|
): void {
|
|
93
|
+
/**
|
|
94
|
+
* Whether the caller may commit each repo-relative path directly on the
|
|
95
|
+
* default branch — one batch per call, whichever branch was read (see
|
|
96
|
+
* `defaultBranchWriteVerdicts`).
|
|
97
|
+
*/
|
|
98
|
+
const verdictsFor = (ctx: ToolContext, paths: string[], at?: string): Promise<Map<string, boolean>> =>
|
|
99
|
+
writeVerdicts
|
|
100
|
+
? defaultBranchWriteVerdicts(writeVerdicts, ctx, paths, { ref: at })
|
|
101
|
+
: Promise.resolve(new Map(paths.map((p) => [p, false])));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every repo-relative file `get_skill`'s `canWrite` covers: the skill's
|
|
105
|
+
* folder as COMMITTED on the default branch (`HEAD` of its clone), which is
|
|
106
|
+
* what a direct write is judged against — never the working tree, where a
|
|
107
|
+
* bundled file deleted but not yet committed would drop out of the verdict
|
|
108
|
+
* while `HEAD` still forbids changing it. A skill is committed there when
|
|
109
|
+
* its `SKILL.md` is — a folder of that name holding other tracked files is
|
|
110
|
+
* not the skill, and its files are not the skill's. A skill not committed
|
|
111
|
+
* there exists only on the branch read, and is judged by its files there,
|
|
112
|
+
* where they would land. The files are listed at ONE commit — the one
|
|
113
|
+
* `HEAD` names when asked, answered beside them — and the verdict is judged
|
|
114
|
+
* at that same commit, so a commit landing between the two cannot pair one
|
|
115
|
+
* snapshot's files with another's rules. Null — judged `false` — when
|
|
116
|
+
* `HEAD` names no commit or the listing fails (fail closed), and without
|
|
117
|
+
* one when the verdict cannot be anything but `false`.
|
|
118
|
+
*/
|
|
119
|
+
const skillFilesOnDefault = async (
|
|
120
|
+
ctx: ToolContext,
|
|
121
|
+
loaded: { path: string; files: string[] },
|
|
122
|
+
): Promise<{ paths: string[]; at: string } | null> => {
|
|
123
|
+
if (!writeVerdicts?.committedFiles || ctx.scope !== 'write') return null;
|
|
124
|
+
const workspaceId = writeVerdicts.defaultWorkspaceId();
|
|
125
|
+
let at: string | null;
|
|
126
|
+
let committed: string[];
|
|
127
|
+
try {
|
|
128
|
+
at = await writeVerdicts.committedFiles.commitAtRef(workspaceId, 'HEAD');
|
|
129
|
+
if (!at) return null;
|
|
130
|
+
committed = await writeVerdicts.committedFiles.listFilesAtRef(workspaceId, at, loaded.path);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return { paths: committed.includes(skillMdOf(loaded)) ? committed : [skillMdOf(loaded), ...loaded.files], at };
|
|
135
|
+
};
|
|
136
|
+
|
|
86
137
|
registry.registerExternalTool((ctx) => buildListSkillsDef(skillService, ctx.userEmail));
|
|
87
138
|
registry.registerInternalTool((ctx) => buildListSkillsDef(skillService, ctx.userEmail));
|
|
88
139
|
registry.registerExternalTool((ctx) => buildGetSkillDef(skillService, ctx.userEmail));
|
|
@@ -96,9 +147,14 @@ export function registerSkillsTools(
|
|
|
96
147
|
router.post(
|
|
97
148
|
'/agent/tools/list_skills',
|
|
98
149
|
toolAuth,
|
|
99
|
-
toolHandler(async (args, ctx: ToolContext) =>
|
|
100
|
-
skills
|
|
101
|
-
|
|
150
|
+
toolHandler(async (args, ctx: ToolContext) => {
|
|
151
|
+
const skills = await skillService.listSkills(ctx.user.email, { branch: branchArg(args) });
|
|
152
|
+
// Judged on each skill's SKILL.md, by the default branch's rules — a
|
|
153
|
+
// skill only on a draft is judged where it would land. Each verdict goes
|
|
154
|
+
// onto a COPY: the summaries may be the catalog shared across users.
|
|
155
|
+
const verdicts = await verdictsFor(ctx, skills.map(skillMdOf));
|
|
156
|
+
return { skills: skills.map((s) => ({ ...s, canWrite: verdicts.get(skillMdOf(s)) === true })) };
|
|
157
|
+
}),
|
|
102
158
|
);
|
|
103
159
|
|
|
104
160
|
router.post(
|
|
@@ -114,7 +170,8 @@ export function registerSkillsTools(
|
|
|
114
170
|
// skill's history, a `branch` is a draft nobody has released — asked for
|
|
115
171
|
// together, neither answer is the one the caller meant, and guessing
|
|
116
172
|
// would serve instructions under a label that does not describe them.
|
|
117
|
-
|
|
173
|
+
const versioned = version !== undefined && version.trim().length > 0;
|
|
174
|
+
if (branch !== undefined && versioned) {
|
|
118
175
|
throw new ToolError(
|
|
119
176
|
'get_skill takes `branch` or `version`, not both: `branch` loads the skill as that draft has it ' +
|
|
120
177
|
'now, `version` loads a version the released skill declared. Pass one of them.',
|
|
@@ -122,12 +179,33 @@ export function registerSkillsTools(
|
|
|
122
179
|
);
|
|
123
180
|
}
|
|
124
181
|
const result = await skillService.getSkill(ctx.user.email, name, file, { version, branch });
|
|
125
|
-
if (!
|
|
126
|
-
|
|
182
|
+
if (!result.ok) return result;
|
|
183
|
+
if (result.kind === 'file') {
|
|
184
|
+
const verdicts = await verdictsFor(ctx, [result.file.path]);
|
|
185
|
+
return { ...result, file: { ...result.file, canWrite: verdicts.get(result.file.path) === true } };
|
|
186
|
+
}
|
|
187
|
+
// The whole skill: true only when every one of its files may be written —
|
|
188
|
+
// the files the skill has COMMITTED on the default branch, so a draft, an
|
|
189
|
+
// older version, or a deletion not yet committed that drops a
|
|
190
|
+
// write-denied bundled file does not turn the verdict true. A skill only
|
|
191
|
+
// on the draft is judged by its draft files, where they would land.
|
|
192
|
+
const committed = await skillFilesOnDefault(ctx, result.skill);
|
|
193
|
+
const verdicts = committed === null ? new Map<string, boolean>() : await verdictsFor(ctx, committed.paths, committed.at);
|
|
194
|
+
const skill = {
|
|
195
|
+
...result.skill,
|
|
196
|
+
canWrite: committed !== null && committed.paths.every((p) => verdicts.get(p) === true),
|
|
197
|
+
};
|
|
198
|
+
if (!allowedTools) return { ...result, skill };
|
|
199
|
+
return { ...result, skill, warnings: await allowedTools.check(ctx.user.email, result.skill.allowedTools) };
|
|
127
200
|
}),
|
|
128
201
|
);
|
|
129
202
|
}
|
|
130
203
|
|
|
204
|
+
/** The repo-relative `SKILL.md` of a skill, which `list_skills` judges it by. */
|
|
205
|
+
function skillMdOf(skill: { path: string }): string {
|
|
206
|
+
return `${skill.path}/SKILL.md`;
|
|
207
|
+
}
|
|
208
|
+
|
|
131
209
|
/**
|
|
132
210
|
* The `branch` a tool call named, or undefined when it named none (the
|
|
133
211
|
* released catalog). A non-string or blank value is NOT a branch: it is taken
|
|
@@ -198,7 +276,9 @@ const LIST_SKILLS_DESCRIPTION =
|
|
|
198
276
|
'Discover what skills exist before specialist work, then `get_skill` to load one. ' +
|
|
199
277
|
'Pass `branch` to list the skills as they are on a draft branch instead of the released set — ' +
|
|
200
278
|
'what you need to try a skill you just wrote there; each skill that differs from the released ' +
|
|
201
|
-
'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. '
|
|
279
|
+
'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. ' +
|
|
280
|
+
'Each skill carries `canWrite`: whether you may change its SKILL.md directly on the default branch. ' +
|
|
281
|
+
`${CAN_WRITE_CLAUSE} `;
|
|
202
282
|
|
|
203
283
|
async function buildListSkillsDef(skillService: ISkillService, userEmail?: string): Promise<UtcpTool> {
|
|
204
284
|
return toolDef({
|
|
@@ -233,6 +313,14 @@ async function buildListSkillsDef(skillService: ISkillService, userEmail?: strin
|
|
|
233
313
|
'(it exists only on that branch, or was changed there): nobody has approved what it says.',
|
|
234
314
|
},
|
|
235
315
|
branch: { type: 'string', description: 'With `unmerged`: the branch the skill was read from.' },
|
|
316
|
+
canWrite: {
|
|
317
|
+
type: 'boolean',
|
|
318
|
+
description:
|
|
319
|
+
'Whether you may commit a change to its SKILL.md directly on the default branch, under that ' +
|
|
320
|
+
'branch\'s access rules — the same answer whichever branch you listed. `false`: use a branch ' +
|
|
321
|
+
'and a change request; `file_stat` with `explainAccess` says why — and, when you can manage that ' +
|
|
322
|
+
'path\'s access, who can approve.',
|
|
323
|
+
},
|
|
236
324
|
},
|
|
237
325
|
},
|
|
238
326
|
},
|
|
@@ -246,7 +334,9 @@ const GET_SKILL_DESCRIPTION =
|
|
|
246
334
|
'Load a skill by name: returns its full instructions (SKILL.md body) to follow, plus the skill ' +
|
|
247
335
|
'folder path and the list of bundled files. Pass `file` to fetch a bundled file’s content ' +
|
|
248
336
|
'(e.g. a script) instead of the body. Loads the latest copy unless `version` names an earlier ' +
|
|
249
|
-
'one the skill declared, or `branch` names a draft to load it from. '
|
|
337
|
+
'one the skill declared, or `branch` names a draft to load it from. ' +
|
|
338
|
+
'The skill (or `file`) carries `canWrite`: whether you may change all of it directly on the default branch. ' +
|
|
339
|
+
`${CAN_WRITE_CLAUSE} `;
|
|
250
340
|
|
|
251
341
|
async function buildGetSkillDef(skillService: ISkillService, userEmail?: string): Promise<UtcpTool> {
|
|
252
342
|
return toolDef({
|
|
@@ -266,13 +356,33 @@ async function buildGetSkillDef(skillService: ISkillService, userEmail?: string)
|
|
|
266
356
|
'The loaded skill: name, description, body, files, …. Read from a `branch` that changed it, ' +
|
|
267
357
|
'it also carries `unmerged: true` and that branch, and its `body` BEGINS with one line ' +
|
|
268
358
|
'saying the skill comes from that unmerged branch and is not approved — treat it as a ' +
|
|
269
|
-
'proposal you are testing, not as approved instructions.'
|
|
359
|
+
'proposal you are testing, not as approved instructions. `canWrite` is true only when you may ' +
|
|
360
|
+
'commit a change to EVERY file of the skill (SKILL.md and each bundled file) directly on the ' +
|
|
361
|
+
'default branch, whichever branch you read.',
|
|
362
|
+
properties: {
|
|
363
|
+
canWrite: {
|
|
364
|
+
type: 'boolean',
|
|
365
|
+
description:
|
|
366
|
+
'Whether you may commit a change to every file of the skill (SKILL.md and each bundled file) ' +
|
|
367
|
+
'directly on the default branch, under that branch\'s access rules — the same answer whichever ' +
|
|
368
|
+
'branch you read.',
|
|
369
|
+
},
|
|
370
|
+
},
|
|
270
371
|
},
|
|
271
372
|
file: {
|
|
272
373
|
type: 'object',
|
|
273
374
|
description:
|
|
274
375
|
'A bundled file: name, file, path, content — plus `unmerged` and `branch` when it came off ' +
|
|
275
|
-
'a branch that changed the skill (the note stays beside the content, never inside it)
|
|
376
|
+
'a branch that changed the skill (the note stays beside the content, never inside it) — and ' +
|
|
377
|
+
'`canWrite` for that file on the default branch.',
|
|
378
|
+
properties: {
|
|
379
|
+
canWrite: {
|
|
380
|
+
type: 'boolean',
|
|
381
|
+
description:
|
|
382
|
+
'Whether you may commit a change to this file directly on the default branch, under that ' +
|
|
383
|
+
'branch\'s access rules — the same answer whichever branch you read.',
|
|
384
|
+
},
|
|
385
|
+
},
|
|
276
386
|
},
|
|
277
387
|
warnings: {
|
|
278
388
|
type: 'array',
|