@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.
Files changed (135) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/core/catalog-revision.d.ts.map +1 -1
  4. package/dist/core/catalog-revision.js +4 -2
  5. package/dist/core/catalog-revision.js.map +1 -1
  6. package/dist/core/compose-embed.d.ts +35 -0
  7. package/dist/core/compose-embed.d.ts.map +1 -0
  8. package/dist/core/compose-embed.js +14 -0
  9. package/dist/core/compose-embed.js.map +1 -0
  10. package/dist/core/core-ports.d.ts +10 -0
  11. package/dist/core/core-ports.d.ts.map +1 -1
  12. package/dist/core/core-ports.js.map +1 -1
  13. package/dist/core/create-core-server.d.ts.map +1 -1
  14. package/dist/core/create-core-server.js +7 -1
  15. package/dist/core/create-core-server.js.map +1 -1
  16. package/dist/core/create-core-services.d.ts +1 -1
  17. package/dist/core/create-core-services.d.ts.map +1 -1
  18. package/dist/core/create-core-services.js +25 -14
  19. package/dist/core/create-core-services.js.map +1 -1
  20. package/dist/core/lifecycle.d.ts +11 -6
  21. package/dist/core/lifecycle.d.ts.map +1 -1
  22. package/dist/core/lifecycle.js +13 -6
  23. package/dist/core/lifecycle.js.map +1 -1
  24. package/dist/modules/access/access-control.service.d.ts +36 -14
  25. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.js +145 -43
  27. package/dist/modules/access/access-control.service.js.map +1 -1
  28. package/dist/modules/auth/index.d.ts +15 -0
  29. package/dist/modules/auth/index.d.ts.map +1 -0
  30. package/dist/modules/auth/index.js +20 -0
  31. package/dist/modules/auth/index.js.map +1 -0
  32. package/dist/modules/embed/embed.interface.d.ts +22 -0
  33. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  34. package/dist/modules/embed/embed.service.d.ts +28 -14
  35. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.js +38 -17
  37. package/dist/modules/embed/embed.service.js.map +1 -1
  38. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  39. package/dist/modules/embed/embed.tools.js +10 -2
  40. package/dist/modules/embed/embed.tools.js.map +1 -1
  41. package/dist/modules/kb-fs/index.d.ts +7 -0
  42. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  43. package/dist/modules/kb-fs/index.js +2 -0
  44. package/dist/modules/kb-fs/index.js.map +1 -0
  45. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  46. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  47. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  48. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  49. package/dist/modules/skills/skills.tools.d.ts +8 -1
  50. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.js +115 -11
  52. package/dist/modules/skills/skills.tools.js.map +1 -1
  53. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  54. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  55. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  56. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  57. package/dist/modules/tool-helpers/index.d.ts +1 -0
  58. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/index.js +1 -0
  60. package/dist/modules/tool-helpers/index.js.map +1 -1
  61. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  62. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.js +70 -18
  64. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  65. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  66. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  67. package/dist/modules/workflow/git/git.service.js +24 -0
  68. package/dist/modules/workflow/git/git.service.js.map +1 -1
  69. package/dist/modules/workflow/index.d.ts +8 -0
  70. package/dist/modules/workflow/index.d.ts.map +1 -0
  71. package/dist/modules/workflow/index.js +8 -0
  72. package/dist/modules/workflow/index.js.map +1 -0
  73. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  74. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  75. package/dist/modules/workflow/pending-commits.service.js +4 -24
  76. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  77. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  78. package/dist/modules/workflow/workflow.routes.js +20 -1
  79. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  80. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  81. package/dist/modules/workflow/workflow.service.js +5 -3
  82. package/dist/modules/workflow/workflow.service.js.map +1 -1
  83. package/dist/modules/workspace/index.d.ts +10 -0
  84. package/dist/modules/workspace/index.d.ts.map +1 -0
  85. package/dist/modules/workspace/index.js +10 -0
  86. package/dist/modules/workspace/index.js.map +1 -0
  87. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  88. package/dist/modules/workspace/workspace.tools.js +106 -20
  89. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  90. package/dist/shared/workspace-id.d.ts +17 -0
  91. package/dist/shared/workspace-id.d.ts.map +1 -1
  92. package/dist/shared/workspace-id.js +24 -0
  93. package/dist/shared/workspace-id.js.map +1 -1
  94. package/mcp-app/page.html +142 -8
  95. package/package.json +3 -3
  96. package/src/core/__tests__/compose-embed.test.ts +65 -0
  97. package/src/core/__tests__/lifecycle.test.ts +29 -26
  98. package/src/core/catalog-revision.ts +4 -2
  99. package/src/core/compose-embed.ts +50 -0
  100. package/src/core/core-ports.ts +10 -0
  101. package/src/core/create-core-server.ts +7 -1
  102. package/src/core/create-core-services.ts +27 -21
  103. package/src/core/lifecycle.ts +24 -12
  104. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  105. package/src/modules/access/access-control.service.ts +162 -46
  106. package/src/modules/auth/index.ts +29 -0
  107. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  108. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  109. package/src/modules/embed/embed.interface.ts +24 -0
  110. package/src/modules/embed/embed.service.ts +44 -19
  111. package/src/modules/embed/embed.tools.ts +10 -2
  112. package/src/modules/kb-fs/index.ts +6 -0
  113. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  114. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  115. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  116. package/src/modules/skills/skills.contract.ts +4 -0
  117. package/src/modules/skills/skills.tools.ts +120 -10
  118. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  119. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  120. package/src/modules/tool-helpers/index.ts +5 -0
  121. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  122. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  123. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  124. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  125. package/src/modules/workflow/file-lock.service.ts +79 -18
  126. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  127. package/src/modules/workflow/git/git.service.ts +23 -0
  128. package/src/modules/workflow/index.ts +7 -0
  129. package/src/modules/workflow/pending-commits.service.ts +4 -23
  130. package/src/modules/workflow/workflow.routes.ts +24 -1
  131. package/src/modules/workflow/workflow.service.ts +3 -2
  132. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  133. package/src/modules/workspace/index.ts +14 -0
  134. package/src/modules/workspace/workspace.tools.ts +120 -20
  135. 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
- return { kind: 'text', text: await guideAt(branch, ctx, p) };
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 }`. What comes back for a document, an email file, an image ' +
1852
- 'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
1853
- 'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
1854
- 'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
1855
- 'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
1856
- 'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
1857
- 'workspace, so `branch` is ignored for it.',
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: { path: str('The path that was read (echoes the input).'), content: str('File (or spill) content, sliced if offset/limit were given.') },
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 result = await readForTool(a.branch as string, p, ctx);
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
- return mcpImageResult(result.data, result.mimeType, result.note);
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? }] }\`. 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.`,
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: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes (files only).') },
2032
- required: ['name', 'type'],
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
- return { path: a.path ?? '', entries: filtered };
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
+ }