@michelj/context-guard 0.4.4 → 0.6.1

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -0,0 +1,506 @@
1
+ // Shared by the workbench and Node service. Unknown stored fields are retained.
2
+ export const editableFields = ['title', 'purpose', 'kind', 'state', 'memoryDocument', 'memories', 'ideas', 'todos', 'bugs', 'messages', 'access', 'dormant', 'files', 'owns', 'proposal', 'isNew'];
3
+ // Transport JSON stays open/fixed/resolved plus Unfixable as an end state.
4
+ // `fixed` is agent-complete and still reviewable. Do not add deferred/wontfix as
5
+ // live write values; leftover stored ones stay closed.
6
+ export const WRITABLE_BUG_STATUSES = Object.freeze(['open', 'fixed', 'resolved', 'unfixable']);
7
+ export const CLOSED_BUG_STATUSES = Object.freeze(['resolved', 'unfixable', 'dormant', 'deferred', 'wontfix']);
8
+ export function isWritableBugStatus(status) {
9
+ return WRITABLE_BUG_STATUSES.includes(status);
10
+ }
11
+ export function isClosedBugStatus(status) {
12
+ return CLOSED_BUG_STATUSES.includes(status);
13
+ }
14
+ export class MapError extends Error {
15
+ constructor(code, message, status = 400, details = {}) { super(message); Object.assign(this, { code, status, details }); }
16
+ }
17
+ export const copy = value => JSON.parse(JSON.stringify(value));
18
+ export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
19
+ export function filterNodeAccess(document, grants, agentId, mode = 'write') {
20
+ if (!document?.root) return [];
21
+ const index = entries(document.root);
22
+ return grants.filter(id => {
23
+ const node = index.get(id)?.node;
24
+ if (!node) return false;
25
+ const rule = node.access?.find(item => item.agentId === agentId);
26
+ return !rule || rule.allow === 'write' || mode === 'read' && rule.allow === 'read';
27
+ });
28
+ }
29
+ const object = x => x && typeof x === 'object' && !Array.isArray(x);
30
+ const proposalBases = new Set(['new-module', 'new-interface', 'new-component', 'new-responsibility']);
31
+ function proposalPath(value) {
32
+ if (typeof value !== 'string') return '';
33
+ let file = value.trim().replaceAll('\\', '/');
34
+ while (file.startsWith('./')) file = file.slice(2);
35
+ const parts = file.split('/').filter(Boolean);
36
+ if (!file || file.length > 500 || file.startsWith('/') || file.startsWith('~') || /^[A-Za-z]:\//.test(file) || !parts.length || parts.some(part => part === '.' || part === '..')) return '';
37
+ return parts.join('/') + (file.endsWith('/') ? '/' : '');
38
+ }
39
+ function supportOnlyPath(file) {
40
+ const lower = file.toLowerCase(), parts = lower.split('/'), basename = parts.at(-1);
41
+ if (['test', 'tests', '__tests__', 'docs', 'doc', 'references', '.github'].includes(parts[0])) return true;
42
+ if (/^(readme|changelog|contributing|license|todo)(\.|$)/.test(basename) || basename === 'skill.md') return true;
43
+ if (/^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|package\.json)$/.test(basename)) return true;
44
+ if (/(^|[._-])(test|tests|spec)([._-]|$)/.test(basename)) return true;
45
+ return /(^|\/)([^/]*\.config\.[^/]+|[^/]*rc(?:\.[^/]+)?)$/.test(lower);
46
+ }
47
+ function validateAgentProposalNode(node, parentId) {
48
+ const title = String(node?.title || '').trim(), purpose = String(node?.purpose || '').trim();
49
+ const owns = Array.isArray(node?.owns) ? [...new Set(node.owns.map(proposalPath))] : [];
50
+ const evidence = (Array.isArray(node?.memories) ? node.memories : []).map(memory => memory?.proposalEvidence).find(object);
51
+ if (!title || title.length > 120 || !purpose || purpose.length > 500) throw new MapError('INVALID_PROPOSAL', 'Agent node proposals need a concise title and purpose');
52
+ if (!owns.length || owns.some(path => !path)) throw new MapError('INVALID_PROPOSAL', 'Agent node proposals need valid repo-relative owns paths');
53
+ if (!evidence) throw new MapError('INVALID_PROPOSAL', 'Agent node proposals need proposalEvidence');
54
+ const reason = String(evidence.reason || '').trim(), basis = String(evidence.basis || '').trim();
55
+ const files = Array.isArray(evidence.files) ? [...new Set(evidence.files.map(proposalPath))] : [];
56
+ if (String(evidence.parentId || '').trim() !== parentId) throw new MapError('INVALID_PROPOSAL', 'Proposal evidence parentId must match the create parent');
57
+ if (!reason || reason.length > 1000 || !proposalBases.has(basis)) throw new MapError('INVALID_PROPOSAL', 'Proposal evidence needs a valid basis and reason');
58
+ if (!files.length || files.some(path => !path) || files.every(supportOnlyPath)) throw new MapError('INVALID_PROPOSAL', 'Proposal evidence needs at least one implementation file');
59
+ if (files.some(file => !owns.some(owned => owned === file || file.startsWith(owned.endsWith('/') ? owned : `${owned}/`)))) throw new MapError('INVALID_PROPOSAL', 'Proposal evidence files must be covered by the proposed owns paths');
60
+ }
61
+ export function entries(root) {
62
+ const found = new Map();
63
+ function visit(node, parent = null, bucket = 'children', depth = 0) {
64
+ if (depth > 128 || found.size >= 10000) throw new MapError('INVALID_MAP', 'Map exceeds depth/node limits');
65
+ if (!object(node) || typeof node.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(node.id)) throw new MapError('INVALID_MAP', 'Invalid node ID');
66
+ if (found.has(node.id)) throw new MapError('INVALID_MAP', `Duplicate node: ${node.id}`);
67
+ found.set(node.id, { node, parent, bucket });
68
+ for (const key of ['children', '_inbox']) {
69
+ if (node[key] !== undefined && !Array.isArray(node[key])) throw new MapError('INVALID_MAP', `${node.id}.${key} must be an array`);
70
+ for (const child of node[key] || []) visit(child, node, key, depth + 1);
71
+ }
72
+ }
73
+ visit(root); return found;
74
+ }
75
+ export function validate(doc) {
76
+ if (object(doc) && doc.root === null && doc.bootstrap === 'pending' && !(doc.flows || []).length) return new Map();
77
+ if (!object(doc) || !object(doc.root)) throw new MapError('INVALID_MAP', 'Map requires a root node');
78
+ const index = entries(doc.root);
79
+ if (doc.unassigned_bugs !== undefined && !Array.isArray(doc.unassigned_bugs)) throw new MapError('INVALID_MAP', 'unassigned_bugs must be an array');
80
+ if ((doc.unassigned_bugs || []).some(item => !object(item))) throw new MapError('INVALID_MAP', 'Unassigned bug must be an object');
81
+ for (const { node } of index.values()) {
82
+ if (typeof node.title !== 'string' || node.title.length > 10000) throw new MapError('INVALID_MAP', `${node.id}: invalid title`);
83
+ if (node.purpose !== undefined && typeof node.purpose !== 'string') throw new MapError('INVALID_MAP', 'purpose must be text');
84
+ if (node.memoryDocument !== undefined && (typeof node.memoryDocument !== 'string' || node.memoryDocument.length > 12000)) {
85
+ throw new MapError('INVALID_MAP', `${node.id}: memory document must be text within 12000 characters`);
86
+ }
87
+ for (const key of ['memories', 'ideas', 'todos', 'bugs', 'messages', 'access', 'dormant', 'files', 'owns']) {
88
+ if (node[key] !== undefined && !Array.isArray(node[key])) throw new MapError('INVALID_MAP', `${key} must be an array`);
89
+ }
90
+ if (node.kind && !['module', 'work'].includes(node.kind)) throw new MapError('INVALID_MAP', 'Invalid kind');
91
+ if (node.state && !['dirty', 'untested', 'success', 'failed'].includes(node.state)) throw new MapError('INVALID_MAP', 'Invalid state');
92
+ if (node.proposal && !['proposed', 'accepted', 'cancelled'].includes(node.proposal)) throw new MapError('INVALID_MAP', 'Invalid proposal');
93
+ for (const item of [...(node.memories || []), ...(node.ideas || []), ...(node.todos || []), ...(node.bugs || [])]) {
94
+ if (!object(item)) throw new MapError('INVALID_MAP', 'Memory/idea/todo/bug must be an object');
95
+ const refs = Array.isArray(item.also) ? item.also : typeof item.also === 'string' ? item.also.split(/[,,]/).map(x => x.trim()).filter(Boolean) : [];
96
+ if (refs.some(id => !index.has(id))) throw new MapError('INVALID_REFERENCE', `${node.id}: missing also reference`);
97
+ }
98
+ for (const todo of node.todos || []) {
99
+ if (typeof todo.title !== 'string' || todo.title.length > 10000) throw new MapError('INVALID_MAP', `${node.id}: invalid TODO title`);
100
+ if (todo.status && !['pending', 'processing', 'done'].includes(todo.status)) throw new MapError('INVALID_MAP', `${node.id}: invalid TODO status`);
101
+ validateAttempts(todo, 'Todo');
102
+ }
103
+ for (const bug of node.bugs || []) validateAttempts(bug, 'Bug');
104
+ }
105
+ for (const bug of doc.unassigned_bugs || []) validateAttempts(bug, 'Bug');
106
+ for (const { node } of index.values()) {
107
+ for (const message of node.messages || []) if (!object(message) || typeof message.text !== 'string') throw new MapError('INVALID_MAP', 'Message must contain text');
108
+ const agents = new Set();
109
+ for (const rule of node.access || []) {
110
+ if (!object(rule) || typeof rule.agentId !== 'string' || !rule.agentId || !['read', 'write', 'none'].includes(rule.allow) || agents.has(rule.agentId)) throw new MapError('INVALID_MAP', 'Invalid or duplicate node access rule');
111
+ agents.add(rule.agentId);
112
+ }
113
+ }
114
+ for (const flow of doc.flows || []) {
115
+ if (!index.has(flow.from) || !index.has(flow.to)) throw new MapError('INVALID_REFERENCE', 'Flow endpoint is missing');
116
+ }
117
+ return index;
118
+ }
119
+
120
+ function validateAttempts(item, kind) {
121
+ if (item.attempts === undefined) return;
122
+ if (!Array.isArray(item.attempts) || !item.attempts.length) throw new MapError('INVALID_ATTEMPT', `${kind} attempts must be a nonempty list`);
123
+ for (const [index, attempt] of item.attempts.entries()) {
124
+ if (!object(attempt) || !['Confirmed', 'Refuted'].includes(attempt.status)) throw new MapError('INVALID_ATTEMPT', `${kind} attempt needs Confirmed or Refuted status`);
125
+ if (attempt.status === 'Refuted') {
126
+ const target = /^A([1-9]\d*)$/.exec(attempt.refutedBy || '');
127
+ if (!target || Number(target[1]) <= index + 1 || Number(target[1]) > item.attempts.length || !String(attempt.reason || '').trim()) {
128
+ throw new MapError('INVALID_ATTEMPT', `${kind} refutation needs a later attempt and reason`);
129
+ }
130
+ } else if (attempt.refutedBy !== undefined || attempt.reason !== undefined) throw new MapError('INVALID_ATTEMPT', `${kind} confirmed attempt cannot carry refutation fields`);
131
+ for (const field of ['reproduction', 'acceptance', 'cause', 'solution', 'resolution', 'event', 'eventSource']) {
132
+ if (attempt[field] !== undefined && typeof attempt[field] !== 'string') throw new MapError('INVALID_ATTEMPT', `${kind} ${field} must be text`);
133
+ }
134
+ if (attempt.codeIndex !== undefined && (!Array.isArray(attempt.codeIndex) || attempt.codeIndex.some(entry => !object(entry) || typeof entry.path !== 'string' || !entry.path || typeof entry.summary !== 'string'))) {
135
+ throw new MapError('INVALID_ATTEMPT', `${kind} code index is invalid`);
136
+ }
137
+ if (attempt.test !== undefined && (!object(attempt.test) || typeof attempt.test.summary !== 'string' || typeof attempt.test.content !== 'string')) {
138
+ throw new MapError('INVALID_ATTEMPT', `${kind} test evidence is invalid`);
139
+ }
140
+ if (attempt.sessionIds !== undefined && (!Array.isArray(attempt.sessionIds) || attempt.sessionIds.some(id => typeof id !== 'string' || !id.trim()))) {
141
+ throw new MapError('INVALID_ATTEMPT', `${kind} Session references are invalid`);
142
+ }
143
+ }
144
+ }
145
+
146
+ function relatedIds(node) {
147
+ const out = new Set();
148
+ const values = [node, ...(node.memories || []), ...(node.ideas || []), ...(node.todos || []), ...(node.bugs || [])];
149
+ for (const value of values) {
150
+ const refs = Array.isArray(value?.also)
151
+ ? value.also
152
+ : typeof value?.also === 'string'
153
+ ? value.also.split(/[,,]/).map(id => id.trim()).filter(Boolean)
154
+ : [];
155
+ refs.forEach(id => out.add(id));
156
+ }
157
+ return out;
158
+ }
159
+
160
+ export function assignmentScope(doc, nodeId) {
161
+ const index = validate(doc);
162
+ if (!index.has(nodeId)) throw new MapError('NOT_FOUND', `Node ${nodeId} is missing`, 404);
163
+ const related = new Set([nodeId]);
164
+ for (const flow of doc.flows || []) {
165
+ if (flow.from === nodeId) related.add(flow.to);
166
+ if (flow.to === nodeId) related.add(flow.from);
167
+ }
168
+ for (const [id, { node }] of index) {
169
+ const refs = relatedIds(node);
170
+ if (id === nodeId) refs.forEach(ref => related.add(ref));
171
+ if (refs.has(nodeId)) related.add(id);
172
+ }
173
+ const scope = new Set();
174
+ for (const id of related) {
175
+ let entry = index.get(id);
176
+ while (entry) {
177
+ if (!['cancelled', 'proposed'].includes(entry.node.proposal)) scope.add(entry.node.id);
178
+ entry = entry.parent ? index.get(entry.parent.id) : null;
179
+ }
180
+ }
181
+ return [...scope];
182
+ }
183
+
184
+ const assignedSessions = item => Array.isArray(item?.sessions)
185
+ ? [...new Set(item.sessions.map(value => String(value || '').trim()).filter(Boolean))]
186
+ : [];
187
+
188
+ export function workItemAssignedTo(item, sessionId) {
189
+ return Boolean(sessionId) && (assignedSessions(item).includes(sessionId) || String(item?.target_session || '').trim() === sessionId);
190
+ }
191
+
192
+ function visibleWorkItem(item, sessionId) {
193
+ const scoped = copy(item);
194
+ scoped.sessions = [sessionId];
195
+ if (Object.hasOwn(scoped, 'target_session')) scoped.target_session = sessionId;
196
+ if (scoped.dispatch?.session_id && scoped.dispatch.session_id !== sessionId) delete scoped.dispatch;
197
+ return scoped;
198
+ }
199
+
200
+ function scopedWorkItems(items, sessionId) {
201
+ return (items || []).filter(item => workItemAssignedTo(item, sessionId)).map(item => visibleWorkItem(item, sessionId));
202
+ }
203
+
204
+ export function scopeDocumentToSession(document, sessionId) {
205
+ const scoped = copy(document);
206
+ if (!scoped?.root || !sessionId) return scoped;
207
+ for (const { node } of entries(scoped.root).values()) {
208
+ if (Array.isArray(node.bugs)) node.bugs = scopedWorkItems(node.bugs, sessionId);
209
+ if (Array.isArray(node.todos)) node.todos = scopedWorkItems(node.todos, sessionId);
210
+ }
211
+ if (Array.isArray(scoped.unassigned_bugs)) scoped.unassigned_bugs = [];
212
+ return scoped;
213
+ }
214
+
215
+ function restoreWorkItems(existing = [], incoming = [], sessionId) {
216
+ const current = new Map(existing.map(item => [item?.id, item]));
217
+ const next = [];
218
+ const incomingIds = new Set(incoming.map(item => item?.id).filter(Boolean));
219
+ for (const item of existing) {
220
+ if (!workItemAssignedTo(item, sessionId)) next.push(copy(item));
221
+ else if (!incomingIds.has(item?.id)) {
222
+ const otherSessions = assignedSessions(item).filter(id => id !== sessionId);
223
+ if (otherSessions.length) next.push({ ...copy(item), sessions: otherSessions });
224
+ }
225
+ }
226
+ for (const item of incoming) {
227
+ const before = current.get(item?.id);
228
+ if (before && !workItemAssignedTo(before, sessionId)) throw new MapError('FORBIDDEN_WORK_ITEM', 'Session cannot replace a hidden work item', 403);
229
+ const otherSessions = assignedSessions(before).filter(id => id !== sessionId);
230
+ const keepsCurrent = !before || assignedSessions(item).includes(sessionId) || String(item?.target_session || '') === sessionId;
231
+ const restored = { ...copy(item), sessions: [...otherSessions, ...(keepsCurrent ? [sessionId] : [])] };
232
+ if (before && before.target_session && before.target_session !== sessionId && !keepsCurrent) restored.target_session = before.target_session;
233
+ else if (keepsCurrent && Object.hasOwn(item, 'target_session')) restored.target_session = sessionId;
234
+ if (before?.dispatch?.session_id && before.dispatch.session_id !== sessionId && !restored.dispatch) restored.dispatch = copy(before.dispatch);
235
+ next.push(restored);
236
+ }
237
+ return next;
238
+ }
239
+
240
+ function assignedBug(document, bugId, sessionId) {
241
+ if (!document?.root) return false;
242
+ for (const { node } of entries(document.root).values()) {
243
+ const bug = (node.bugs || []).find(item => item?.id === bugId);
244
+ if (bug) return workItemAssignedTo(bug, sessionId);
245
+ }
246
+ return false;
247
+ }
248
+
249
+ function assertOperations(operations) {
250
+ if (!Array.isArray(operations) || operations.length < 1 || operations.length > 2000) throw new MapError('INVALID_OPERATIONS', 'Expected 1–2000 operations');
251
+ }
252
+
253
+ export function restoreSessionWorkItemOperations(document, operations, sessionId) {
254
+ assertOperations(operations);
255
+ if (!sessionId) return copy(operations);
256
+ const index = document?.root ? entries(document.root) : new Map();
257
+ return operations.map(operation => {
258
+ const op = copy(operation);
259
+ const node = index.get(op.id)?.node;
260
+ if (op.type === 'update' && node) {
261
+ if (Array.isArray(op.fields?.bugs)) op.fields.bugs = restoreWorkItems(node.bugs, op.fields.bugs, sessionId);
262
+ if (Array.isArray(op.fields?.todos)) op.fields.todos = restoreWorkItems(node.todos, op.fields.todos, sessionId);
263
+ }
264
+ if (op.type === 'create') for (const key of ['bugs', 'todos']) if (Array.isArray(op.node?.[key])) {
265
+ op.node[key] = op.node[key].map(item => ({ ...item, sessions: [sessionId], ...(Object.hasOwn(item, 'target_session') ? { target_session: sessionId } : {}) }));
266
+ }
267
+ if (op.type === 'attach-bug') op.bug = { ...op.bug, sessions: [sessionId] };
268
+ if (op.type === 'update-bug' && !assignedBug(document, op.bug?.id, sessionId)) throw new MapError('FORBIDDEN_WORK_ITEM', 'Session can only update bugs assigned to it', 403);
269
+ return op;
270
+ });
271
+ }
272
+
273
+ export function scopeChangesToSession(result, document, sessionId) {
274
+ const visibleBugIds = new Set();
275
+ if (document?.root) for (const { node } of entries(document.root).values()) {
276
+ for (const bug of node.bugs || []) if (workItemAssignedTo(bug, sessionId)) visibleBugIds.add(bug.id);
277
+ }
278
+ const changes = (result.changes || []).map(change => {
279
+ const operations = (change.operations || []).flatMap(operation => {
280
+ const op = copy(operation);
281
+ if (op.type === 'attach-bug' || op.type === 'recover-bug') return workItemAssignedTo(op.bug, sessionId) ? [{ ...op, bug: visibleWorkItem(op.bug, sessionId) }] : [];
282
+ if (op.type === 'update-bug') return visibleBugIds.has(op.bug?.id) ? [op] : [];
283
+ if (op.type === 'update') for (const key of ['bugs', 'todos']) if (Array.isArray(op.fields?.[key])) op.fields[key] = scopedWorkItems(op.fields[key], sessionId);
284
+ if (op.type === 'create') for (const key of ['bugs', 'todos']) if (Array.isArray(op.node?.[key])) op.node[key] = scopedWorkItems(op.node[key], sessionId);
285
+ return [op];
286
+ });
287
+ return { ...change, operations, fields: (change.fields || []).filter(field => field !== 'unassigned_bugs') };
288
+ });
289
+ return { ...result, changes };
290
+ }
291
+ function checkFields(fields, allowed = editableFields) {
292
+ if (!object(fields) || Object.keys(fields).some(key => !allowed.includes(key))) throw new MapError('INVALID_FIELDS', 'Unsupported field');
293
+ }
294
+ export function applyOperations(document, operations, actor, grants = []) {
295
+ assertOperations(operations);
296
+ const doc = copy(document), resultIds = [];
297
+ const human = actor.kind === 'human';
298
+ const developer = actor.kind === 'developer';
299
+ const coordinator = actor.kind === 'coordinator';
300
+ const ideaWriter = human || coordinator;
301
+ const directStructure = human || developer || coordinator;
302
+ const allowed = node => directStructure || grants.includes(node.id) || (node.proposal === 'proposed' && node.proposedBy === actor.sessionId);
303
+ for (const op of operations) {
304
+ if (op.type === 'initialize') {
305
+ if (doc.root !== null || typeof op.project !== 'string' || !op.project.trim()) throw new MapError('INVALID_INITIALIZATION', 'Only an empty legacy pending map can be initialized');
306
+ checkFields(op.node, ['id', ...editableFields, 'children', '_inbox']);
307
+ if (!ideaWriter && Object.hasOwn(op.node, 'memoryDocument')) throw new MapError('FORBIDDEN', 'Only the workbench or Coordinator can write memory documents', 403);
308
+ if (!ideaWriter && op.node.ideas?.length) throw new MapError('FORBIDDEN', 'Only Coordinator can write Ideas', 403);
309
+ if (!human && op.node.access?.length) throw new MapError('FORBIDDEN', 'Agent cannot set node access', 403);
310
+ if (!human && (op.node.children?.length || op.node._inbox?.length)) throw new MapError('FORBIDDEN', 'Only the workbench can initialize a complete map', 403);
311
+ doc.project = op.project;
312
+ doc.root = { id: 'T0', title: op.project, kind: 'module', state: 'dirty', children: [], ...copy(op.node), origin: actor.kind, proposal: human ? 'accepted' : 'proposed', proposedBy: actor.sessionId };
313
+ doc.bootstrap = 'proposed'; resultIds.push(doc.root.id); continue;
314
+ }
315
+ if (!doc.root) throw new MapError('INITIALIZATION_REQUIRED', 'Initialize this legacy empty map before adding nodes');
316
+ const index = entries(doc.root);
317
+ const target = index.get(op.id);
318
+ if (op.type === 'create') {
319
+ const parent = index.get(op.parentId)?.node;
320
+ if (!parent) throw new MapError('NOT_FOUND', 'Parent is missing', 404);
321
+ checkFields(op.node, ['id', ...editableFields]);
322
+ if (!ideaWriter && Object.hasOwn(op.node, 'memoryDocument')) throw new MapError('FORBIDDEN', 'Only the workbench or Coordinator can write memory documents', 403);
323
+ if (!ideaWriter && op.node.ideas?.length) throw new MapError('FORBIDDEN', 'Only Coordinator can write Ideas', 403);
324
+ const id = op.node.id;
325
+ if (!id || index.has(id)) throw new MapError('DUPLICATE_ID', 'Node ID already exists or is empty', 409);
326
+ if (!directStructure) {
327
+ if (op.node.access?.length) throw new MapError('FORBIDDEN', 'Agent cannot set node access', 403);
328
+ validateAgentProposalNode(op.node, op.parentId);
329
+ const title = String(op.node.title).trim().toLocaleLowerCase();
330
+ const owns = new Set(op.node.owns.map(proposalPath));
331
+ for (const { node: existing } of index.values()) {
332
+ if (existing.proposal === 'cancelled') continue;
333
+ if (String(existing.title || '').trim().toLocaleLowerCase() === title) throw new MapError('DUPLICATE_PROPOSAL', 'A non-cancelled node already has this title', 409);
334
+ if (existing.proposal === 'proposed' && (existing.owns || []).some(file => owns.has(proposalPath(file)))) throw new MapError('DUPLICATE_PROPOSAL', 'A pending proposal already covers this path', 409);
335
+ }
336
+ }
337
+ const node = { title: '', kind: 'work', state: 'dirty', purpose: '', memories: [], ideas: [], todos: [], bugs: [], dormant: [], files: [], owns: [], children: [], ...copy(op.node), origin: actor.kind, proposedBy: actor.sessionId };
338
+ if (!directStructure) { node.proposal = 'proposed'; node.isNew = true; }
339
+ else { node.proposal ||= 'accepted'; node.isNew = node.proposal === 'proposed'; }
340
+ const children = parent.children ||= [];
341
+ if (op.order !== undefined && (!Number.isSafeInteger(op.order) || op.order < 0 || op.order > children.length)) throw new MapError('INVALID_ORDER', 'Sibling position is outside the list');
342
+ children.splice(op.order ?? children.length, 0, node); resultIds.push(id);
343
+ } else if (op.type === 'relation') {
344
+ const flows = doc.flows ||= [];
345
+ const position = op.legacyIndex === undefined ? flows.findIndex(flow => flow.id === op.id)
346
+ : Number.isSafeInteger(op.legacyIndex) && op.legacyIndex >= 0 && !flows[op.legacyIndex]?.id ? op.legacyIndex : -1;
347
+ const prior = flows[position];
348
+ if (!['create', 'update', 'delete'].includes(op.action)) throw new MapError('INVALID_OPERATION', 'Invalid relation action');
349
+ if (op.action === 'create' ? !!prior : !prior) throw new MapError('VERSION_CONFLICT', 'Relation no longer matches the operation', 409);
350
+ checkFields(op.fields || {}, ['from', 'to', 'label']);
351
+ const next = { ...prior, ...copy(op.fields || {}), id: op.id };
352
+ for (const id of new Set([prior?.from, prior?.to, next.from, next.to].filter(Boolean))) {
353
+ const node = index.get(id)?.node;
354
+ if (!node) throw new MapError('NOT_FOUND', 'Relation endpoint is missing', 404);
355
+ if (!allowed(node)) throw new MapError('FORBIDDEN', 'Relation endpoint is not authorized', 403);
356
+ resultIds.push(id);
357
+ }
358
+ if (op.action === 'create') flows.push(next);
359
+ else if (op.action === 'delete') flows.splice(position, 1);
360
+ else flows[position] = next;
361
+ } else if (op.type === 'document') {
362
+ if (!human) throw new MapError('FORBIDDEN', 'Only the workbench can change document metadata', 403);
363
+ checkFields(op.fields, ['bootstrap', 'flows', 'unassigned_bugs']); Object.assign(doc, copy(op.fields));
364
+ } else if (op.type === 'recover-bug') {
365
+ if (actor.kind !== 'recovery' || !actor.sessionId) throw new MapError('FORBIDDEN_RECOVERY', 'Bug recovery requires a verified original receipt', 403);
366
+ if (!target || !allowed(target.node) || !object(op.bug) || !/^B[0-9]+$/.test(op.bug.id || '')) throw new MapError('INVALID_BUG_RECOVERY', 'Invalid bug recovery', 403);
367
+ let existing = null;
368
+ for (const { node } of index.values()) {
369
+ const found = (node.bugs || []).find(item => item?.id === op.bug.id);
370
+ if (found) existing ||= found;
371
+ if (Array.isArray(node.bugs)) node.bugs = node.bugs.filter(item => item?.id !== op.bug.id);
372
+ }
373
+ const unassigned = (doc.unassigned_bugs || []).find(item => item?.id === op.bug.id);
374
+ existing ||= unassigned;
375
+ if (Array.isArray(doc.unassigned_bugs)) doc.unassigned_bugs = doc.unassigned_bugs.filter(item => item?.id !== op.bug.id);
376
+ const bug = { ...copy(existing || {}), ...copy(op.bug), sessions: [...new Set([...assignedSessions(existing), actor.sessionId])] };
377
+ (target.node.bugs ||= []).push(bug); resultIds.push(op.id);
378
+ } else if (op.type === 'attach-bug') {
379
+ // Compatibility operation only adds a bug stub; it cannot confirm or rewrite nodes.
380
+ if (!object(op.bug) || !/^B[0-9]+$/.test(op.bug.id || '')) throw new MapError('INVALID_BUG', 'Invalid bug');
381
+ if (op.bug.status !== undefined && !isWritableBugStatus(op.bug.status)) {
382
+ throw new MapError('INVALID_BUG', 'Bugs cannot be deferred; delete the record or close it as unfixable');
383
+ }
384
+ const list = target ? (target.node.bugs ||= []) : (doc.unassigned_bugs ||= []);
385
+ const existing = list.find(x => x.id === op.bug.id);
386
+ if (existing && !same(existing, op.bug)) throw new MapError('DUPLICATE_ID', 'Bug ID already exists', 409);
387
+ if (!existing) list.push(copy(op.bug));
388
+ resultIds.push(op.id || doc.root.id);
389
+ } else if (op.type === 'delete-work-item') {
390
+ if (!human && !coordinator) throw new MapError('FORBIDDEN', 'Only the workbench or Coordinator can delete TODO/Bug records', 403);
391
+ if (!['todo', 'bug'].includes(op.kind) || typeof op.itemId !== 'string' || !op.itemId || (op.nodeId !== undefined && (typeof op.nodeId !== 'string' || !op.nodeId))) {
392
+ throw new MapError('INVALID_OPERATION', 'Invalid work item deletion');
393
+ }
394
+ const field = `${op.kind}s`;
395
+ let owner = op.nodeId ? index.get(op.nodeId)?.node : null, ownerId = op.nodeId || '';
396
+ if (op.nodeId && !owner) throw new MapError('NOT_FOUND', `Node ${op.nodeId} is missing`, 404);
397
+ if (!owner) {
398
+ const matches = [...index.entries()].filter(([, entry]) => (entry.node[field] || []).some(item => item?.id === op.itemId));
399
+ if (matches.length > 1) throw new MapError('CONFLICT', `${op.kind} ${op.itemId} has multiple owners`, 409);
400
+ if (matches.length === 1) { ownerId = matches[0][0]; owner = matches[0][1].node; }
401
+ }
402
+ if (owner) {
403
+ const list = owner[field] || [], position = list.findIndex(item => item?.id === op.itemId);
404
+ if (position < 0) throw new MapError('NOT_FOUND', `${op.kind} ${op.itemId} is missing`, 404);
405
+ owner[field] = list.filter((_, current) => current !== position);
406
+ resultIds.push(ownerId);
407
+ } else if (op.kind === 'bug' && Array.isArray(doc.unassigned_bugs)) {
408
+ const position = doc.unassigned_bugs.findIndex(item => item?.id === op.itemId);
409
+ if (position < 0) throw new MapError('NOT_FOUND', `${op.kind} ${op.itemId} is missing`, 404);
410
+ doc.unassigned_bugs.splice(position, 1); resultIds.push(doc.root.id);
411
+ } else throw new MapError('NOT_FOUND', `${op.kind} ${op.itemId} is missing`, 404);
412
+ } else if (op.type === 'update-bug') {
413
+ if (!object(op.bug) || !/^B[0-9]+$/.test(op.bug.id || '') || !isWritableBugStatus(op.bug.status)) {
414
+ throw new MapError('INVALID_BUG', 'Bugs cannot be deferred; delete the record or close it as unfixable');
415
+ }
416
+ let found = null, owner = doc.root.id;
417
+ for (const [id, entry] of index) {
418
+ found = (entry.node.bugs || []).find(item => item.id === op.bug.id);
419
+ if (found) { owner = id; break; }
420
+ }
421
+ if (!found) found = (doc.unassigned_bugs || []).find(item => item.id === op.bug.id);
422
+ if (!found) throw new MapError('NOT_FOUND', `Bug ${op.bug.id} is missing`, 404);
423
+ found.status = op.bug.status; resultIds.push(owner);
424
+ } else {
425
+ if (!target) throw new MapError('NOT_FOUND', `Node ${op.id} is missing`, 404);
426
+ if (!allowed(target.node)) throw new MapError('FORBIDDEN', `Session is not authorized for ${op.id}`, 403);
427
+ if (op.type === 'update') {
428
+ checkFields(op.fields);
429
+ if (!ideaWriter && Object.hasOwn(op.fields, 'memoryDocument')) throw new MapError('FORBIDDEN', 'Only the workbench or Coordinator can write memory documents', 403);
430
+ if (!ideaWriter && Object.hasOwn(op.fields, 'ideas')) throw new MapError('FORBIDDEN', 'Only Coordinator can write Ideas', 403);
431
+ if (!human && ['proposal', 'isNew', 'access'].some(key => Object.hasOwn(op.fields, key))) throw new MapError('FORBIDDEN', 'Agent cannot confirm proposals or change node access', 403);
432
+ Object.assign(target.node, copy(op.fields));
433
+ } else if (op.type === 'move') {
434
+ const parent = index.get(op.parentId)?.node;
435
+ if (!target.parent || !parent || entries(target.node).has(parent.id)) throw new MapError('INVALID_MOVE', 'Missing parent, root move or tree cycle');
436
+ if (!allowed(parent)) throw new MapError('FORBIDDEN', 'Destination is not authorized', 403);
437
+ target.parent[target.bucket] = target.parent[target.bucket].filter(x => x.id !== op.id);
438
+ const children = parent.children ||= [];
439
+ if (op.order !== undefined && (!Number.isSafeInteger(op.order) || op.order < 0 || op.order > children.length)) throw new MapError('INVALID_ORDER', 'Sibling position is outside the list');
440
+ children.splice(op.order ?? children.length, 0, target.node);
441
+ } else if (op.type === 'delete') {
442
+ if ((!human && !coordinator) || !target.parent) throw new MapError('FORBIDDEN', 'Only the workbench or Coordinator can remove a non-root node', 403);
443
+ const removed = new Set(entries(target.node).keys());
444
+ target.parent[target.bucket] = target.parent[target.bucket].filter(x => x.id !== op.id);
445
+ if (Array.isArray(doc.flows)) doc.flows = doc.flows.filter(flow => !removed.has(flow.from) && !removed.has(flow.to));
446
+ for (const { node } of entries(doc.root).values()) for (const field of ['memories', 'ideas', 'todos', 'bugs']) {
447
+ for (const item of node[field] || []) if (Array.isArray(item.also)) item.also = item.also.filter(id => !removed.has(id));
448
+ }
449
+ for (const id of removed) resultIds.push(id);
450
+ } else throw new MapError('INVALID_OPERATION', 'Unknown operation type');
451
+ resultIds.push(op.id);
452
+ }
453
+ }
454
+ const layer = [...(doc.root.children || []), ...(doc.root._inbox || [])].filter(n => n.proposal !== 'cancelled');
455
+ if (layer.length) doc.bootstrap = layer.every(n => n.proposal !== 'proposed') ? 'ready' : 'proposed';
456
+ validate(doc); return { doc, resultIds: [...new Set(resultIds)] };
457
+ }
458
+ // Ignore canvas-only inbox expansion and absent empty arrays. Compare only fields
459
+ // the editor owns, so merely drawing a legacy map never rewrites its metadata.
460
+ export function diffTrees(before, after) {
461
+ const a = entries(before), b = entries(after), ops = [];
462
+ for (const [id, { node, parent }] of b) {
463
+ const old = a.get(id);
464
+ if (!old) {
465
+ const fields = Object.fromEntries(['id', ...editableFields].filter(k => node[k] !== undefined).map(k => [k, copy(node[k])]));
466
+ ops.push({ type: 'create', parentId: parent?.id, node: fields }); continue;
467
+ }
468
+ if (old.parent?.id !== parent?.id) ops.push({ type: 'move', id, parentId: parent?.id });
469
+ const fields = {};
470
+ for (const key of editableFields) {
471
+ if (node[key] === undefined) continue;
472
+ if (old.node[key] === undefined && (Array.isArray(node[key]) && !node[key].length || node[key] === '')) continue;
473
+ if (!same(old.node[key], node[key])) fields[key] = copy(node[key]);
474
+ }
475
+ if (Object.keys(fields).length) ops.push({ type: 'update', id, fields });
476
+ }
477
+ for (const [id, { parent }] of a) if (!b.has(id) && parent && b.has(parent.id)) ops.push({ type: 'delete', id });
478
+ // Reconstruct the child order after structural operations before emitting
479
+ // positional moves. This also handles inserts before existing siblings.
480
+ const lists = new Map([...a].map(([id, { node }]) => [id, (node.children || []).map(child => child.id)]));
481
+ const owners = new Map([...a].filter(([, item]) => item.parent && item.bucket === 'children').map(([id, item]) => [id, item.parent.id]));
482
+ const detach = id => {
483
+ const list = lists.get(owners.get(id));
484
+ if (list?.includes(id)) list.splice(list.indexOf(id), 1);
485
+ owners.delete(id);
486
+ };
487
+ for (const op of ops) {
488
+ if (op.type === 'delete') detach(op.id);
489
+ if (op.type === 'move' || op.type === 'create') {
490
+ const id = op.id || op.node.id;
491
+ detach(id);
492
+ if (!lists.has(op.parentId)) lists.set(op.parentId, []);
493
+ lists.get(op.parentId).push(id); owners.set(id, op.parentId);
494
+ if (!lists.has(id)) lists.set(id, []);
495
+ }
496
+ }
497
+ for (const [parentId, { node }] of b) {
498
+ const list = lists.get(parentId) || [];
499
+ for (const [order, child] of (node.children || []).entries()) {
500
+ if (list[order] === child.id) continue;
501
+ detach(child.id); list.splice(order, 0, child.id); owners.set(child.id, parentId);
502
+ ops.push({ type: 'move', id: child.id, parentId, order });
503
+ }
504
+ }
505
+ return ops;
506
+ }
@@ -0,0 +1,13 @@
1
+ import { MapError, validate } from './map-model.mjs';
2
+ export function validateMemory(value) {
3
+ if (!value || typeof value !== 'object' || !value.map) throw new MapError('INVALID_MEMORY', 'A map and records are required');
4
+ validate(value.map);
5
+ if (value.display !== undefined && (!value.display || typeof value.display !== 'object' || Array.isArray(value.display)
6
+ || Object.keys(value.display).some(key => !['name', 'platform'].includes(key))
7
+ || typeof value.display.name !== 'string' || !value.display.name.trim() || value.display.name.length > 200
8
+ || typeof value.display.platform !== 'string' || value.display.platform.length > 30)) throw new MapError('INVALID_MEMORY', 'Invalid Session display metadata');
9
+ if (!value.records || typeof value.records !== 'object' || Array.isArray(value.records)) throw new MapError('INVALID_MEMORY', 'records must be an object');
10
+ for (const [file, content] of Object.entries(value.records)) {
11
+ if (!/^(?:(?:sessions|bugs|fixes|tasks|cards)\/[A-Za-z0-9_.-]+\.md|(?:index|FIND|user-messages|architecture|l1-candidates)\.md|(?:preferences|bugs-index|bad-case-events|jump-index|owns-index|tasks-index|l1-candidates)\.json|sessions\.jsonl)$/.test(file) || file.split('/').includes('..') || typeof content !== 'string') throw new MapError('PRIVATE_PATH', 'Only development records may be uploaded; private/runtime files are excluded');
12
+ }
13
+ }
@@ -0,0 +1,112 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { atomicWrite, encode, hash, readJSON, withFileLock } from './io.mjs';
5
+ import { canonical, fail, payloadRules } from './protocol.mjs';
6
+
7
+ const CHUNK = 1024 * 1024;
8
+ // Authentication and binding checks belong to the caller, before any body is read.
9
+ export async function serveBlob(req, res, { blobs, principal, session, blobId }) {
10
+ if (req.method === 'PUT') {
11
+ const chunks = []; let size = 0;
12
+ for await (const chunk of req) {
13
+ size += chunk.length;
14
+ if (size > CHUNK) fail('TOO_LARGE', 'Chunk exceeds 1 MiB');
15
+ chunks.push(chunk);
16
+ }
17
+ const result = await blobs.put(principal, session, blobId, req.headers['content-range'], Buffer.concat(chunks));
18
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
19
+ return res.end(JSON.stringify(result));
20
+ }
21
+ if (req.method !== 'GET') fail('INVALID_ARGUMENT', 'Use PUT or GET');
22
+ const data = await blobs.read(principal, session, blobId, req.headers.range);
23
+ res.writeHead(req.headers.range ? 206 : 200, {
24
+ 'Content-Type': 'application/octet-stream', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store',
25
+ ETag: `"${data.sha256}"`, 'Accept-Ranges': 'bytes', 'Content-Length': data.bytes.length,
26
+ ...(req.headers.range ? { 'Content-Range': `bytes ${data.start}-${data.end}/${data.size}` } : {}),
27
+ });
28
+ return res.end(data.bytes);
29
+ }
30
+ // Private, content-addressed chunks are immutable. Metadata advances only after
31
+ // each chunk is fsynced. A lost reply can safely resend the same byte range.
32
+ export class ProtocolBlobs {
33
+ constructor(directory) { this.directory = path.resolve(directory); }
34
+ owner(principal, session) { return hash(canonical([principal.repositoryId, session.id, session.generation])); }
35
+ directoryFor(principal, session, blobId) {
36
+ if (!/^[a-f0-9]{64}$/.test(blobId)) fail('INVALID_ARGUMENT', 'Invalid blob ID');
37
+ return path.join(this.directory, this.owner(principal, session), blobId);
38
+ }
39
+ async register(principal, session, metadata) {
40
+ payloadRules['blob.put'](metadata);
41
+ const blobId = hash(canonical(metadata)), directory = this.directoryFor(principal, session, blobId);
42
+ return withFileLock(path.join(directory, 'lock'), async () => {
43
+ const file = path.join(directory, 'metadata.json');
44
+ let record = await readJSON(file, null);
45
+ if (!record) {
46
+ if (metadata.size === 0 && metadata.sha256 !== hash(Buffer.alloc(0))) fail('INVALID_ARGUMENT', 'Empty file hash mismatch');
47
+ record = { ...metadata, offset: 0, complete: metadata.size === 0, chunks: [] };
48
+ await atomicWrite(file, encode(record));
49
+ }
50
+ return { blobId, uploadPath: `/api/v2/blobs/${blobId}`, offset: record.offset };
51
+ });
52
+ }
53
+ async put(principal, session, blobId, range, bytes) {
54
+ const directory = this.directoryFor(principal, session, blobId);
55
+ const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(range || '');
56
+ if (!match || !Buffer.isBuffer(bytes) || bytes.length > CHUNK || bytes.length === 0) fail('INVALID_ARGUMENT', 'Expected a Content-Range and at most 1 MiB');
57
+ const [start, end, total] = match.slice(1).map(Number);
58
+ if (![start, end, total].every(Number.isSafeInteger) || end - start + 1 !== bytes.length || end >= total) fail('INVALID_ARGUMENT', 'Invalid byte range');
59
+ return withFileLock(path.join(directory, 'lock'), async () => {
60
+ const file = path.join(directory, 'metadata.json'), record = await readJSON(file, null);
61
+ if (!record) fail('NOT_FOUND', 'Blob is not registered in this Session');
62
+ if (total !== record.size) fail('CONFLICT', 'Blob length differs from registration');
63
+ const digest = hash(bytes), previous = record.chunks.find(c => c.start === start);
64
+ if (previous) {
65
+ if (previous.end !== end || previous.digest !== digest) fail('CONFLICT', 'Repeated chunk differs');
66
+ return { offset: record.offset, complete: record.complete };
67
+ }
68
+ if (start !== record.offset) fail('CONFLICT', 'Send the next contiguous range', { offset: record.offset });
69
+ const chunkFile = path.join(directory, `${start}-${digest}.chunk`);
70
+ if (end + 1 === record.size) {
71
+ const full = createHash('sha256');
72
+ for (const chunk of record.chunks) {
73
+ const data = await fs.readFile(path.join(directory, `${chunk.start}-${chunk.digest}.chunk`));
74
+ if (hash(data) !== chunk.digest) fail('UNAVAILABLE', 'Stored chunk is corrupt');
75
+ full.update(data);
76
+ }
77
+ full.update(bytes);
78
+ if (full.digest('hex') !== record.sha256) fail('CONFLICT', 'Completed file hash does not match registration');
79
+ record.complete = true;
80
+ }
81
+ await atomicWrite(chunkFile, bytes);
82
+ record.chunks.push({ start, end, digest }); record.offset = end + 1;
83
+ await atomicWrite(file, encode(record));
84
+ return { offset: record.offset, complete: record.complete };
85
+ });
86
+ }
87
+ async metadata(principal, session, blobId) {
88
+ const record = await readJSON(path.join(this.directoryFor(principal, session, blobId), 'metadata.json'), null);
89
+ if (!record?.complete) fail('NOT_FOUND', 'Blob is missing or incomplete');
90
+ return { downloadPath: `/api/v2/blobs/${blobId}`, size: record.size, sha256: record.sha256, mediaType: record.mediaType };
91
+ }
92
+ async read(principal, session, blobId, range) {
93
+ const directory = this.directoryFor(principal, session, blobId);
94
+ const metadata = await this.metadata(principal, session, blobId);
95
+ let start = 0, end = metadata.size - 1;
96
+ if (range) {
97
+ const match = /^bytes=(\d+)-(\d*)$/.exec(range);
98
+ if (!match) fail('INVALID_ARGUMENT', 'Use a single byte range');
99
+ start = Number(match[1]); end = match[2] ? Number(match[2]) : end;
100
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || end >= metadata.size) fail('INVALID_ARGUMENT', 'Range is outside the file');
101
+ }
102
+ const record = await readJSON(path.join(directory, 'metadata.json'));
103
+ const chunks = [];
104
+ for (const chunk of record.chunks) {
105
+ if (chunk.end < start || chunk.start > end) continue;
106
+ const bytes = await fs.readFile(path.join(directory, `${chunk.start}-${chunk.digest}.chunk`));
107
+ if (hash(bytes) !== chunk.digest) fail('UNAVAILABLE', 'Stored chunk is corrupt');
108
+ chunks.push(bytes.subarray(Math.max(0, start - chunk.start), Math.min(bytes.length, end - chunk.start + 1)));
109
+ }
110
+ return { bytes: Buffer.concat(chunks), start, end, ...metadata };
111
+ }
112
+ }