@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
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './agent-access.gate.js';
|
|
16
16
|
import type { IRoutineWritePolicy } from './routine-write-policy.js';
|
|
17
17
|
import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
18
|
+
import { CAN_WRITE_CLAUSE, defaultBranchWriteVerdicts } from '../tool-helpers/index.js';
|
|
18
19
|
import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
|
|
19
20
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
20
21
|
import { GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
@@ -965,6 +966,28 @@ export function registerWorkspaceTools(
|
|
|
965
966
|
|
|
966
967
|
/** A repo-relative path in the workspace-relative form every tool speaks. */
|
|
967
968
|
const toWs = (rel: string): string => (rel ? `${kbDirName}/${rel}` : kbDirName);
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* `canWrite` for workspace paths, as `list_files` and `read_file` report it:
|
|
972
|
+
* whether the caller may commit a change there directly on the DEFAULT
|
|
973
|
+
* branch, whichever branch the call read — one batch per call (see
|
|
974
|
+
* `defaultBranchWriteVerdicts`). The repository folder itself is judged as
|
|
975
|
+
* the repository root; a path outside the repository never lands on the
|
|
976
|
+
* default branch, so it is `false`.
|
|
977
|
+
*/
|
|
978
|
+
const canWriteAt = async (ctx: ToolContext, wsPaths: string[]): Promise<Map<string, boolean>> => {
|
|
979
|
+
const relOf = (p: string): string | null => {
|
|
980
|
+
const norm = p.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
981
|
+
return norm === kbDirName ? '' : toKbRelative(norm, kbDirName);
|
|
982
|
+
};
|
|
983
|
+
const rels = wsPaths.map(relOf);
|
|
984
|
+
const verdicts = await defaultBranchWriteVerdicts(
|
|
985
|
+
{ accessControl, defaultWorkspaceId: () => kb.defaultWorkspaceId() },
|
|
986
|
+
ctx,
|
|
987
|
+
rels.filter((r): r is string => r !== null),
|
|
988
|
+
);
|
|
989
|
+
return new Map(wsPaths.map((p, i) => [p, rels[i] !== null && verdicts.get(rels[i]!) === true]));
|
|
990
|
+
};
|
|
968
991
|
const sourceToWs = (s: AccessDecisionSource | null) => (s ? { ...s, path: toWs(s.path) } : null);
|
|
969
992
|
|
|
970
993
|
/**
|
|
@@ -1823,7 +1846,19 @@ export function registerWorkspaceTools(
|
|
|
1823
1846
|
* hook, the extraction and the not-found message all have to match, and a
|
|
1824
1847
|
* copy drifts on the first change to any of them.
|
|
1825
1848
|
*/
|
|
1826
|
-
const readForTool: ReadForTool = async (branch, p, ctx) =>
|
|
1849
|
+
const readForTool: ReadForTool = async (branch, p, ctx) => (await readServed(branch, p, ctx)).result;
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* {@link readForTool}, also saying, at the guide's name, whether a file of
|
|
1853
|
+
* the knowledge base's own answered first on THIS branch (`guide`: `own`)
|
|
1854
|
+
* or the platform's guide answered alone (`alone`); `null` for any other
|
|
1855
|
+
* path. `read_file` needs it to judge `canWrite` there (see its handler).
|
|
1856
|
+
*/
|
|
1857
|
+
const readServed = async (
|
|
1858
|
+
branch: string,
|
|
1859
|
+
p: string,
|
|
1860
|
+
ctx: ToolContext,
|
|
1861
|
+
): Promise<{ result: ReadResult; guide: 'own' | 'alone' | null }> => {
|
|
1827
1862
|
// The guide's name at the repository root answers with the platform's
|
|
1828
1863
|
// guide, which is text the code owns and every agent may read: no gate
|
|
1829
1864
|
// and no read hook for it. A file the knowledge base keeps under that
|
|
@@ -1832,7 +1867,8 @@ export function registerWorkspaceTools(
|
|
|
1832
1867
|
// guide an earlier release wrote to disk (still on a draft, say) is
|
|
1833
1868
|
// recognised by its header and not served a second time.
|
|
1834
1869
|
if (agentGuide && isAgentGuidePath(toKbRelative(p, kbDirName) ?? '')) {
|
|
1835
|
-
|
|
1870
|
+
const { text, own } = await guideServed(branch, ctx, p);
|
|
1871
|
+
return { result: { kind: 'text', text }, guide: own ? 'own' : 'alone' };
|
|
1836
1872
|
}
|
|
1837
1873
|
await notifyAgentRead(agentAccessGate, ctx, branch, p);
|
|
1838
1874
|
await assertCanRead(readGateFor(branch, ctx), p);
|
|
@@ -1841,20 +1877,39 @@ export function registerWorkspaceTools(
|
|
|
1841
1877
|
// the access gate and the read hook above — a document read is still a
|
|
1842
1878
|
// KB read. ONE registry dispatch picks the reader by extension.
|
|
1843
1879
|
const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
|
|
1844
|
-
return readers.readerFor(p).read(bytes, p);
|
|
1880
|
+
return { result: await readers.readerFor(p).read(bytes, p), guide: null };
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Whether the DEFAULT branch has a file of the knowledge base's own at the
|
|
1885
|
+
* guide's name that this caller may read — not a copy of the platform's
|
|
1886
|
+
* guide an earlier release wrote. Telling the two apart reads the file, so
|
|
1887
|
+
* that branch's read hook is asked first, as {@link ownGuideFile} does —
|
|
1888
|
+
* after the gate, before the bytes. A refusal, like any failure, is
|
|
1889
|
+
* `false`, so `canWrite` fails closed.
|
|
1890
|
+
*/
|
|
1891
|
+
const ownGuideOnDefault = async (ctx: ToolContext, p: string): Promise<boolean> => {
|
|
1892
|
+
try {
|
|
1893
|
+
const fs = await ctx.getFilesystem(kb.defaultBranch);
|
|
1894
|
+
if (!(await ownGuideReadable(fs, kb.defaultBranch, ctx, p))) return false;
|
|
1895
|
+
await notifyAgentRead(agentAccessGate, ctx, kb.defaultBranch, p);
|
|
1896
|
+
return await isOwnEntryStill(fs, p);
|
|
1897
|
+
} catch {
|
|
1898
|
+
return false;
|
|
1899
|
+
}
|
|
1845
1900
|
};
|
|
1846
1901
|
|
|
1847
1902
|
mount({
|
|
1848
1903
|
name: 'read_file',
|
|
1849
1904
|
gated: true,
|
|
1850
1905
|
description:
|
|
1851
|
-
'Read a workspace file as text. Returns `{ path, content }`.
|
|
1852
|
-
'
|
|
1853
|
-
'
|
|
1854
|
-
'
|
|
1855
|
-
'for
|
|
1856
|
-
'
|
|
1857
|
-
|
|
1906
|
+
'Read a workspace file as text. Returns `{ path, content, canWrite }`. Per the shared content rule: text as ' +
|
|
1907
|
+
'text, documents and email as extracted text, an image as the picture, anything else as a one-line ' +
|
|
1908
|
+
'description. Also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: it ' +
|
|
1909
|
+
'belongs to no workspace, so `branch` is ignored and no `canWrite` comes back. `offset`/`limit` slice the ' +
|
|
1910
|
+
'content (characters; bytes for a spill ref; ignored for an image): page through large files and ' +
|
|
1911
|
+
'spills, never read multi-MB whole. `canWrite`: may you change this file directly on the default branch. ' +
|
|
1912
|
+
CAN_WRITE_CLAUSE,
|
|
1858
1913
|
inputs: {
|
|
1859
1914
|
type: 'object',
|
|
1860
1915
|
properties: {
|
|
@@ -1869,7 +1924,16 @@ export function registerWorkspaceTools(
|
|
|
1869
1924
|
},
|
|
1870
1925
|
outputs: {
|
|
1871
1926
|
type: 'object',
|
|
1872
|
-
properties: {
|
|
1927
|
+
properties: {
|
|
1928
|
+
path: str('The path that was read (echoes the input).'),
|
|
1929
|
+
content: str('File (or spill) content, sliced if offset/limit were given.'),
|
|
1930
|
+
canWrite: {
|
|
1931
|
+
type: 'boolean',
|
|
1932
|
+
description:
|
|
1933
|
+
'Whether you may commit a change to this file directly on the default branch, under that branch\'s access ' +
|
|
1934
|
+
'rules — the same answer whichever branch you read. Absent for a spill ref.',
|
|
1935
|
+
},
|
|
1936
|
+
},
|
|
1873
1937
|
required: ['path', 'content'],
|
|
1874
1938
|
},
|
|
1875
1939
|
write: false,
|
|
@@ -1886,7 +1950,19 @@ export function registerWorkspaceTools(
|
|
|
1886
1950
|
? content.slice(start, limit !== undefined ? start + limit : undefined)
|
|
1887
1951
|
: content;
|
|
1888
1952
|
};
|
|
1889
|
-
const
|
|
1953
|
+
const branch = a.branch as string;
|
|
1954
|
+
const { result, guide } = await readServed(branch, p, ctx);
|
|
1955
|
+
// Judged after the read, so a path the caller may not read is refused
|
|
1956
|
+
// before anything is said about writing it. At the guide's name this
|
|
1957
|
+
// answers for the knowledge base's own file there, as `file_stat` does:
|
|
1958
|
+
// where the platform's guide stands alone there is nothing to change —
|
|
1959
|
+
// `false`, whether nothing is there or a file the caller may not read.
|
|
1960
|
+
// Like every verdict here, that is decided on the DEFAULT branch, not
|
|
1961
|
+
// the one read: a draft that deleted or added the file must not move
|
|
1962
|
+
// the answer about what can land directly.
|
|
1963
|
+
const ownOnDefault =
|
|
1964
|
+
guide === null || (branch === kb.defaultBranch ? guide === 'own' : await ownGuideOnDefault(ctx, p));
|
|
1965
|
+
const canWrite = ownOnDefault && (await canWriteAt(ctx, [p])).get(p) === true;
|
|
1890
1966
|
// Images return the picture itself as an MCP image content block, so a
|
|
1891
1967
|
// multimodal model SEES it. The handler returns the `McpImageResult`
|
|
1892
1968
|
// sentinel; the MCP result shaping (`toCallToolResult` in
|
|
@@ -1898,14 +1974,17 @@ export function registerWorkspaceTools(
|
|
|
1898
1974
|
// content blocks before any client could try to validate it, so the
|
|
1899
1975
|
// schema keeps describing the text path it has always described.
|
|
1900
1976
|
// `offset`/`limit` are meaningless on a picture and are ignored.
|
|
1977
|
+
// The picture's answer has no fields beside it, so its `canWrite` rides
|
|
1978
|
+
// in the text note that accompanies it.
|
|
1901
1979
|
if (result.kind === 'image') {
|
|
1902
|
-
|
|
1980
|
+
const verdict = `canWrite: ${canWrite}`;
|
|
1981
|
+
return mcpImageResult(result.data, result.mimeType, result.note ? `${result.note} (${verdict})` : verdict);
|
|
1903
1982
|
}
|
|
1904
1983
|
// Text and refusals alike land in `content` — a refusal (corrupt
|
|
1905
1984
|
// document, unreadable binary, oversized image) IS the file's honest
|
|
1906
1985
|
// textual answer, sliced like any other content.
|
|
1907
1986
|
const content = result.kind === 'text' ? result.text : result.message;
|
|
1908
|
-
return { path: p, content: slice(content) };
|
|
1987
|
+
return { path: p, content: slice(content), canWrite };
|
|
1909
1988
|
},
|
|
1910
1989
|
});
|
|
1911
1990
|
|
|
@@ -1925,10 +2004,14 @@ export function registerWorkspaceTools(
|
|
|
1925
2004
|
* node is indistinguishable from an absent one on every other read.
|
|
1926
2005
|
*/
|
|
1927
2006
|
/** What a read of the guide's path answers: the guide, after the knowledge base's own readable file when it has one. */
|
|
1928
|
-
const guideAt = async (branch: string, ctx: ToolContext, p: string): Promise<string> =>
|
|
2007
|
+
const guideAt = async (branch: string, ctx: ToolContext, p: string): Promise<string> =>
|
|
2008
|
+
(await guideServed(branch, ctx, p)).text;
|
|
2009
|
+
|
|
2010
|
+
/** {@link guideAt}, also saying whether a file of the knowledge base's own came first. */
|
|
2011
|
+
const guideServed = async (branch: string, ctx: ToolContext, p: string): Promise<{ text: string; own: boolean }> => {
|
|
1929
2012
|
const guide = await agentGuide!();
|
|
1930
2013
|
const own = await ownGuideFile(branch, ctx, p);
|
|
1931
|
-
return own === null ? guide : withPlatformGuideAppended(own, guide);
|
|
2014
|
+
return own === null ? { text: guide, own: false } : { text: withPlatformGuideAppended(own, guide), own: true };
|
|
1932
2015
|
};
|
|
1933
2016
|
|
|
1934
2017
|
const ownGuideFile = async (branch: string, ctx: ToolContext, p: string): Promise<string | null> => {
|
|
@@ -2008,7 +2091,8 @@ export function registerWorkspaceTools(
|
|
|
2008
2091
|
name: 'list_files',
|
|
2009
2092
|
gated: true,
|
|
2010
2093
|
description:
|
|
2011
|
-
`List a directory. Returns \`{ path, entries: [{ name, type, size
|
|
2094
|
+
`List a directory. Returns \`{ path, entries: [{ name, type, size?, canWrite }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it. ` +
|
|
2095
|
+
`\`canWrite\`: may you change that file, or add a file directly inside that folder, directly on the default branch. ${CAN_WRITE_CLAUSE}`,
|
|
2012
2096
|
inputs: {
|
|
2013
2097
|
type: 'object',
|
|
2014
2098
|
properties: {
|
|
@@ -2028,8 +2112,19 @@ export function registerWorkspaceTools(
|
|
|
2028
2112
|
description: 'Directory entries.',
|
|
2029
2113
|
items: {
|
|
2030
2114
|
type: 'object',
|
|
2031
|
-
properties: {
|
|
2032
|
-
|
|
2115
|
+
properties: {
|
|
2116
|
+
name: str('Entry name.'),
|
|
2117
|
+
type: str('`file` or `directory`.'),
|
|
2118
|
+
size: int('Size in bytes (files only).'),
|
|
2119
|
+
canWrite: {
|
|
2120
|
+
type: 'boolean',
|
|
2121
|
+
description:
|
|
2122
|
+
'Whether you may commit directly on the default branch, under that branch\'s access rules: for a ' +
|
|
2123
|
+
'file, a change to it; for a folder, a new file directly inside it, by that folder\'s own rules. ' +
|
|
2124
|
+
'The same answer whichever branch you listed.',
|
|
2125
|
+
},
|
|
2126
|
+
},
|
|
2127
|
+
required: ['name', 'type', 'canWrite'],
|
|
2033
2128
|
},
|
|
2034
2129
|
},
|
|
2035
2130
|
},
|
|
@@ -2042,7 +2137,12 @@ export function registerWorkspaceTools(
|
|
|
2042
2137
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
2043
2138
|
const entries = withoutPlaceholder((await fs.readdir(dir || '.')) as DirEntry[]);
|
|
2044
2139
|
const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
|
|
2045
|
-
|
|
2140
|
+
// One batch for the listing. A folder is judged at its own path, whose
|
|
2141
|
+
// rules (its `access.md` and those above it) are what a file added
|
|
2142
|
+
// directly inside it would get.
|
|
2143
|
+
const wsPathOf = (e: DirEntry): string => (dir ? `${dir}/${e.name}` : e.name);
|
|
2144
|
+
const verdicts = await canWriteAt(ctx, filtered.map(wsPathOf));
|
|
2145
|
+
return { path: a.path ?? '', entries: filtered.map((e) => ({ ...e, canWrite: verdicts.get(wsPathOf(e)) === true })) };
|
|
2046
2146
|
},
|
|
2047
2147
|
});
|
|
2048
2148
|
|
|
@@ -34,3 +34,27 @@ export function branchForWorkspaceId(workspaceId: string): string {
|
|
|
34
34
|
return workspaceId;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Canonicalize a workspace id so every row keyed on one — a queued save, a
|
|
40
|
+
* file lock — and every reader of those rows key on the SAME string.
|
|
41
|
+
*
|
|
42
|
+
* The id reaches `enqueue` and a lock's `acquire` via a route `:id` path
|
|
43
|
+
* param, which Express URL-decodes — so a slashed feature branch arrives as `alice/feature`. But the
|
|
44
|
+
* worker claims per `WorkspaceService.knownWorkspaces()`, whose ids are
|
|
45
|
+
* `encodeURIComponent(branch)` → `alice%2Ffeature`. Those strings differ, so
|
|
46
|
+
* `WHERE workspace_id = …` never matched and the row was never drained —
|
|
47
|
+
* silently stranding every human save on a feature branch (protected branches
|
|
48
|
+
* have no `/`, so encoded == decoded and they were unaffected).
|
|
49
|
+
*
|
|
50
|
+
* `encodeURIComponent(decodeURIComponent(id))` is idempotent and collapses both
|
|
51
|
+
* forms to the encoded id `knownWorkspaces()` uses. A malformed `%` sequence
|
|
52
|
+
* (which `decodeURIComponent` would throw on) is left untouched.
|
|
53
|
+
*/
|
|
54
|
+
export function canonicalWorkspaceId(id: string): string {
|
|
55
|
+
try {
|
|
56
|
+
return encodeURIComponent(decodeURIComponent(id));
|
|
57
|
+
} catch {
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
}
|