draftgo-cli 4.0.24 → 4.0.26

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 (88) hide show
  1. package/README.md +23 -37
  2. package/package.json +3 -5
  3. package/resources/skill/SKILL.md +9 -5
  4. package/resources/skill/manifest.json +2 -5
  5. package/resources/skill/references/ai.md +41 -0
  6. package/resources/skill/references/app-api.md +2 -50
  7. package/resources/skill/references/architecture.md +1 -1
  8. package/resources/skill/references/chat-sdk.md +29 -37
  9. package/resources/skill/references/checkout.md +4 -4
  10. package/resources/skill/references/data.md +0 -46
  11. package/resources/skill/references/delivery.md +3 -3
  12. package/resources/skill/references/diagnostics.md +10 -11
  13. package/resources/skill/references/frontend.md +23 -20
  14. package/resources/skill/references/mcp.md +4 -14
  15. package/resources/skill/references/methods.md +15 -68
  16. package/resources/skill/references/modules.md +23 -44
  17. package/resources/skill/references/runtime.md +3 -20
  18. package/resources/skill/story/SKILL.md +2 -2
  19. package/src/apiContractCache.js +14 -6
  20. package/src/cli.js +0 -7
  21. package/src/commandRegistry.js +0 -6
  22. package/src/commands/api.js +87 -17
  23. package/src/commands/apiKey.js +2 -6
  24. package/src/commands/autoPush.js +15 -51
  25. package/src/commands/capabilities.js +22 -15
  26. package/src/commands/check.js +19 -53
  27. package/src/commands/checkout.js +1 -4
  28. package/src/commands/clean.js +1 -1
  29. package/src/commands/commit.js +1 -4
  30. package/src/commands/components.js +12 -8
  31. package/src/commands/conflict.js +4 -6
  32. package/src/commands/conflicts.js +1 -2
  33. package/src/commands/connect.js +0 -8
  34. package/src/commands/delete.js +15 -11
  35. package/src/commands/deploy.js +64 -26
  36. package/src/commands/diff.js +1 -4
  37. package/src/commands/group.js +2 -3
  38. package/src/commands/help.js +22 -43
  39. package/src/commands/init.js +13 -6
  40. package/src/commands/local.js +4 -1
  41. package/src/commands/map.js +138 -23
  42. package/src/commands/reconcile.js +1 -15
  43. package/src/commands/role.js +1 -2
  44. package/src/commands/status.js +12 -40
  45. package/src/commands/update.js +18 -24
  46. package/src/commands/verify.js +8 -7
  47. package/src/commands/worklog.js +11 -5
  48. package/src/contractCompatibility.js +10 -2
  49. package/src/localRuntime/compose.js +41 -27
  50. package/src/localRuntime/detect.js +6 -6
  51. package/src/localRuntime/index.js +47 -47
  52. package/src/localRuntime/services.js +2 -39
  53. package/src/mcp/client.js +99 -134
  54. package/src/mcp/parallel.js +25 -2
  55. package/src/mcp/protocol.js +38 -9
  56. package/src/mcp/tools.js +10 -19
  57. package/src/projectConfig.js +1 -4
  58. package/src/{workspaceHealth.js → projectHealth.js} +5 -5
  59. package/src/projectMap.js +1 -1
  60. package/src/runtimeFiles.js +2 -1
  61. package/src/worklog.js +3 -2
  62. package/src/worktree/backend.js +127 -15
  63. package/src/worktree/index.js +64 -22
  64. package/src/worktree/locks.js +52 -0
  65. package/src/worktree/manifest.js +18 -4
  66. package/src/worktree/status.js +4 -2
  67. package/resources/custom-service-sdk/ai.go +0 -520
  68. package/resources/custom-service-sdk/ai_test.go +0 -156
  69. package/resources/custom-service-sdk/auth_test.go +0 -56
  70. package/resources/custom-service-sdk/billing.go +0 -596
  71. package/resources/custom-service-sdk/billing_test.go +0 -150
  72. package/resources/custom-service-sdk/go.mod +0 -3
  73. package/resources/custom-service-sdk/manifest.json +0 -77
  74. package/resources/custom-service-sdk/platform.go +0 -352
  75. package/resources/custom-service-sdk/platform_logger_test.go +0 -24
  76. package/resources/custom-service-sdk/registration_test.go +0 -39
  77. package/resources/custom-service-sdk/resources.go +0 -247
  78. package/resources/custom-service-sdk/resources_billing_test.go +0 -115
  79. package/resources/custom-service-sdk/resources_files_test.go +0 -57
  80. package/resources/custom-service-sdk/resources_scope_test.go +0 -92
  81. package/resources/custom-service-sdk/sdk.go +0 -209
  82. package/resources/skill/references/aihub.md +0 -116
  83. package/resources/skill/references/custom-services.md +0 -201
  84. package/src/commands/customService.js +0 -95
  85. package/src/commands/dataRange.js +0 -33
  86. package/src/commands/grant.js +0 -29
  87. package/src/commands/space.js +0 -41
  88. package/src/customServices.js +0 -484
@@ -10,6 +10,12 @@ const { normalizeSha256 } = require('./streams');
10
10
  const METADATA_TOOL = 'draftgo_resource_get_metadata';
11
11
  const MAX_ERROR_BYTES = 64 * 1024;
12
12
  const MAX_JSON_BYTES = 1024 * 1024;
13
+ const MAX_CHECKOUT_JSON_BYTES = 64 * 1024 * 1024;
14
+ const FINAL_OPERATIONS = Object.freeze({
15
+ pages: Object.freeze({ checkout: 'listPageCheckout', commit: 'updatePageCommit', contentType: 'text/html; charset=utf-8' }),
16
+ navigations: Object.freeze({ checkout: 'listNavigationCheckout', commit: 'updateNavigationCommit', contentType: 'text/html; charset=utf-8' }),
17
+ docs: Object.freeze({ checkout: 'listContentArticleCheckout', commit: 'updateContentArticleCommit', contentType: 'text/html; charset=utf-8', jsonCommit: true }),
18
+ });
13
19
 
14
20
  function isObject(value) {
15
21
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -139,6 +145,68 @@ function firstValue(...values) {
139
145
  return values.find((value) => value !== undefined && value !== null && value !== '');
140
146
  }
141
147
 
148
+ function operationPath(config, template, resourceId) {
149
+ const encoded = encodeURIComponent(String(resourceId));
150
+ const relative = String(template || '').replace(/\{[^}]+\}/, encoded);
151
+ if (!relative.startsWith('/api/')) {
152
+ throw new WorktreeError('INVALID_WORKFLOW_PATH', 'DraftGo checkout workflow returned an invalid API path.');
153
+ }
154
+ return new URL(relative, `${config.server.replace(/\/+$/, '')}/`).toString();
155
+ }
156
+
157
+ async function finalMetadata(config, resourceType, resourceId, session, options = {}) {
158
+ // Loaded lazily to avoid the API command's dependency on this backend module.
159
+ const { registryRevision, describeOperation } = require('../commands/api');
160
+ const { TOOL_NAMES, callStructured } = require('../mcp/tools');
161
+ const type = canonicalResourceType(resourceType);
162
+ const spec = FINAL_OPERATIONS[type];
163
+ const revision = await registryRevision(session, spec.checkout);
164
+ const checkout = await describeOperation(options.projectDir || process.cwd(), config, session, spec.checkout, revision);
165
+ const commit = await describeOperation(options.projectDir || process.cwd(), config, session, spec.commit, revision);
166
+ const checkoutOperation = checkout && checkout.operation || {};
167
+ const commitOperation = commit && commit.operation || {};
168
+ const checkoutResult = await callStructured(session, TOOL_NAMES.apiCall, {
169
+ operation_id: spec.checkout,
170
+ registry_revision: revision,
171
+ path: { id: Number(resourceId) },
172
+ }, options);
173
+ const workflow = checkoutResult && checkoutResult.workflow || {};
174
+ if (workflow.required !== true || workflow.workflow !== 'checkout_commit') {
175
+ throw new WorktreeError('INVALID_WORKFLOW_DESCRIPTOR', 'DraftGo did not return a checkout/commit workflow descriptor.');
176
+ }
177
+ const checkoutURL = operationPath(config, checkoutOperation.path || workflow.path, resourceId);
178
+ const response = await (options.fetch || fetch)(checkoutURL, {
179
+ method: String(checkoutOperation.method || 'GET').toUpperCase(),
180
+ headers: authHeaders(config, { Accept: 'application/json' }),
181
+ signal: options.signal,
182
+ redirect: 'error',
183
+ });
184
+ if (!response.ok) throw await errorForResponse(response, config.token || config.sat);
185
+ const raw = await readBounded(response, MAX_CHECKOUT_JSON_BYTES, true);
186
+ const value = parseJsonText(raw, 'DraftGo checkout endpoint');
187
+ const version = firstValue(value.content_version, value.revision);
188
+ const content = typeof value.content === 'string' ? value.content : null;
189
+ const contentURL = firstValue(value.content_url, checkoutURL);
190
+ const contentType = type === 'docs' ? spec.contentType : spec.contentType;
191
+ const size = content == null ? null : Buffer.byteLength(content, 'utf8');
192
+ return normalizeMetadata(config, type, resourceId, {
193
+ resource_type: type,
194
+ resource_id: String(resourceId),
195
+ title: value.title || '', route: value.route || null, code: value.code || null, slug: value.slug || null,
196
+ content_type: contentType,
197
+ file_extension: '.html',
198
+ content_size: size,
199
+ content_hash: value.content_hash,
200
+ ...(type === 'docs' ? { base_revision: version } : { base_version: version }),
201
+ etag: value.content_hash,
202
+ download_url: contentURL,
203
+ commit_url: operationPath(config, commitOperation.path, resourceId),
204
+ commit_method: String(commitOperation.method || 'PUT').toUpperCase(),
205
+ inline_content: content,
206
+ json_commit: spec.jsonCommit === true,
207
+ });
208
+ }
209
+
142
210
  function normalizeUrl(config, value, purpose) {
143
211
  if (!value) return null;
144
212
  let url;
@@ -219,7 +287,23 @@ async function resolveMetadata(config, resourceType, resourceId, options = {}) {
219
287
  if (typeof client.listAllTools === 'function') {
220
288
  const tools = options.tools || await client.listAllTools(options);
221
289
  const found = tools.find((tool) => tool && toolNameMatches(String(tool.name || ''), METADATA_TOOL));
222
- if (!found) throw new WorktreeError('MCP_TOOL_UNAVAILABLE', `${METADATA_TOOL} is not available.`);
290
+ if (!found) {
291
+ const { TOOL_NAMES, openToolSession } = require('../mcp/tools');
292
+ const session = await openToolSession(config, [
293
+ TOOL_NAMES.apiSearch, TOOL_NAMES.apiDescribe, TOOL_NAMES.apiCall,
294
+ ], { ...options, client });
295
+ return finalMetadata(config, resourceType, resourceId, session, options);
296
+ }
297
+ toolName = found.name;
298
+ } else if (Array.isArray(options.tools)) {
299
+ const found = options.tools.find((tool) => tool && toolNameMatches(String(tool.name || ''), METADATA_TOOL));
300
+ if (!found) {
301
+ const { TOOL_NAMES, openToolSession } = require('../mcp/tools');
302
+ const session = await openToolSession(config, [
303
+ TOOL_NAMES.apiSearch, TOOL_NAMES.apiDescribe, TOOL_NAMES.apiCall,
304
+ ], { ...options, client });
305
+ return finalMetadata(config, resourceType, resourceId, session, options);
306
+ }
223
307
  toolName = found.name;
224
308
  }
225
309
  const result = await client.toolsCall(toolName, {
@@ -233,21 +317,26 @@ function authHeaders(config, extra = {}) {
233
317
  return { Authorization: `Bearer ${config.token || config.sat}`, ...extra };
234
318
  }
235
319
 
236
- async function readBounded(response, maximum = MAX_ERROR_BYTES) {
320
+ async function readBounded(response, maximum = MAX_ERROR_BYTES, rejectOverflow = false) {
237
321
  if (!response.body) return '';
238
322
  const reader = response.body.getReader();
239
323
  const chunks = [];
240
324
  let size = 0;
241
- let ended = false;
242
- while (size < maximum) {
243
- const { done, value } = await reader.read();
244
- if (done) { ended = true; break; }
245
- const chunk = Buffer.from(value);
246
- const remaining = maximum - size;
247
- chunks.push(chunk.subarray(0, remaining));
248
- size += Math.min(chunk.length, remaining);
249
- }
250
- if (!ended) await reader.cancel().catch(() => {});
325
+ try {
326
+ while (true) {
327
+ const { done, value } = await reader.read();
328
+ if (done) break;
329
+ const chunk = Buffer.from(value);
330
+ if (size + chunk.length > maximum) {
331
+ if (rejectOverflow) throw new WorktreeError('RESPONSE_TOO_LARGE',
332
+ 'DraftGo response exceeds the explicit ' + maximum + '-byte limit; local content was preserved.', { max_bytes: maximum });
333
+ chunks.push(chunk.subarray(0, maximum - size));
334
+ break;
335
+ }
336
+ chunks.push(chunk);
337
+ size += chunk.length;
338
+ }
339
+ } finally { await reader.cancel().catch(() => {}); }
251
340
  return Buffer.concat(chunks).toString('utf8');
252
341
  }
253
342
 
@@ -264,10 +353,15 @@ async function errorForResponse(response, token) {
264
353
  }
265
354
 
266
355
  async function download(config, metadata, options = {}) {
356
+ const raw = metadata.raw && typeof metadata.raw === 'object' ? metadata.raw : {};
357
+ if (typeof raw.inline_content === 'string') {
358
+ return new Response(raw.inline_content, { headers: { 'content-type': metadata.content_type } });
359
+ }
267
360
  const response = await (options.fetch || fetch)(metadata.download_url, {
268
361
  method: 'GET',
269
362
  headers: authHeaders(config, { Accept: '*/*' }),
270
363
  signal: options.signal,
364
+ redirect: 'error',
271
365
  });
272
366
  if (!response.ok) throw await errorForResponse(response, config.token || config.sat);
273
367
  return response;
@@ -294,15 +388,31 @@ async function commit(config, metadata, localPath, current, options = {}) {
294
388
  if (metadata.base_version != null) headers['DraftGo-Base-Version'] = String(metadata.base_version);
295
389
  if (metadata.base_revision != null) headers['DraftGo-Base-Revision'] = String(metadata.base_revision);
296
390
 
391
+ let body;
392
+ let duplex;
393
+ if (metadata.raw && metadata.raw.json_commit === true) {
394
+ const content = fs.readFileSync(localPath, 'utf8');
395
+ body = JSON.stringify({
396
+ content,
397
+ ...(metadata.base_revision != null ? { base_revision: metadata.base_revision } : {}),
398
+ ...(metadata.base_revision == null && metadata.content_hash ? { base_hash: metadata.content_hash } : {}),
399
+ });
400
+ headers['Content-Type'] = 'application/json';
401
+ headers['Content-Length'] = String(Buffer.byteLength(body, 'utf8'));
402
+ } else {
403
+ body = fs.createReadStream(localPath);
404
+ duplex = 'half';
405
+ }
297
406
  const response = await (options.fetch || fetch)(metadata.commit_url, {
298
407
  method: metadata.commit_method,
299
408
  headers,
300
- body: fs.createReadStream(localPath),
301
- duplex: 'half',
409
+ body,
410
+ ...(duplex ? { duplex } : {}),
302
411
  signal: options.signal,
412
+ redirect: 'error',
303
413
  });
304
414
  if (!response.ok) throw await errorForResponse(response, config.token || config.sat);
305
- const text = await readBounded(response, MAX_JSON_BYTES);
415
+ const text = await readBounded(response, MAX_JSON_BYTES, true);
306
416
  if (!text.trim()) return {};
307
417
  return parseJsonText(text, 'DraftGo commit endpoint');
308
418
  }
@@ -314,6 +424,8 @@ function nodeReadable(response) {
314
424
 
315
425
  module.exports = {
316
426
  METADATA_TOOL,
427
+ readBounded,
428
+ MAX_CHECKOUT_JSON_BYTES,
317
429
  toolNameMatches,
318
430
  unwrapToolResult,
319
431
  unwrapProtectedResult,
@@ -33,6 +33,8 @@ const {
33
33
  const { validateContentFile } = require('./validate');
34
34
  const { inspectEntry, openMetadataSession, sameVersion } = require('./status');
35
35
 
36
+ const { withResourceLocks } = require('./locks');
37
+ const { mapBounded } = require('../mcp/parallel');
36
38
  const CONFLICT_SCHEMA_VERSION = 1;
37
39
 
38
40
  function backendFor(options) {
@@ -93,17 +95,18 @@ function assertMetadataIdentity(metadata, resourceType, resourceId) {
93
95
  }
94
96
  }
95
97
 
96
- async function checkoutResources(projectDir, resourceType, resourceIds, options = {}) {
98
+ async function checkoutResourcesUnlocked(projectDir, resourceType, resourceIds, options = {}) {
97
99
  const canonical = canonicalResourceType(resourceType);
98
100
  const ids = ensureIds(resourceIds);
99
101
  const config = options.config || loadProjectConfig(projectDir);
100
102
  const backend = backendFor(options);
101
103
  const session = await createSession(config, options);
102
104
  const manifest = loadManifest(projectDir);
103
- const settled = await Promise.allSettled(ids.map(async (resourceId) => {
105
+ const settled = await mapBounded(ids, async (resourceId) => {
104
106
  const metadata = await backend.resolveMetadata(config, canonical, resourceId, {
105
107
  ...options,
106
108
  ...session,
109
+ projectDir,
107
110
  clientInitialized: true,
108
111
  });
109
112
  assertMetadataIdentity(metadata, canonical, resourceId);
@@ -158,7 +161,7 @@ async function checkoutResources(projectDir, resourceType, resourceIds, options
158
161
  updated_by: metadata.updated_by,
159
162
  };
160
163
  return entry;
161
- }));
164
+ }, { ...options, settled: true });
162
165
  const results = settled.filter((item) => item.status === 'fulfilled').map((item) => item.value);
163
166
  for (const entry of results) manifest.entries[entryKey(canonical, entry.resource_id)] = entry;
164
167
  if (results.length) await saveManifest(projectDir, manifest);
@@ -211,6 +214,7 @@ async function writeConflict(projectDir, entry, error, config, backend, options,
211
214
  const remoteMetadata = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
212
215
  ...options,
213
216
  ...session,
217
+ projectDir,
214
218
  clientInitialized: true,
215
219
  });
216
220
  assertMetadataIdentity(remoteMetadata, entry.resource_type, entry.resource_id);
@@ -270,9 +274,7 @@ function unresolvedConflict(projectDir, resourceType, resourceId) {
270
274
  }
271
275
  }
272
276
 
273
- async function commitResources(projectDir, resourceType, resourceIds, options = {}) {
274
- const canonical = canonicalResourceType(resourceType);
275
- const ids = ensureIds(resourceIds);
277
+ async function commitBatchUnlocked(projectDir, targets, options = {}) {
276
278
  const config = options.config || loadProjectConfig(projectDir);
277
279
  const backend = backendFor(options);
278
280
  const session = await createSession(config, options);
@@ -287,8 +289,8 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
287
289
  };
288
290
 
289
291
  // Resolve and validate every target before the first remote write. Different
290
- // resources are independent, so preflight can run without a client-side cap.
291
- const preflight = await Promise.all(ids.map(async (resourceId) => {
292
+ // resources are independent; bound concurrency to avoid exhausting connections.
293
+ const preflight = await mapBounded(targets, async ({ resource_type: canonical, resource_id: resourceId }) => {
292
294
  try {
293
295
  const entry = getEntry(manifest, canonical, resourceId);
294
296
  if (!entry) {
@@ -320,6 +322,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
320
322
  const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
321
323
  ...options,
322
324
  ...session,
325
+ projectDir,
323
326
  clientInitialized: true,
324
327
  });
325
328
  assertMetadataIdentity(fresh, canonical, resourceId);
@@ -359,22 +362,21 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
359
362
  details: error.details || {},
360
363
  } };
361
364
  }
362
- }));
365
+ }, options);
363
366
  for (const item of preflight) {
364
367
  if (item.plan) plans.push(item.plan);
365
368
  else preflightFailures.push(item.failure);
366
369
  }
367
370
 
368
371
  if (preflightFailures.length) {
369
- const failedIds = new Set(preflightFailures.map((item) => String(item.resource_id)));
370
- const notStarted = ids.filter((id) => !failedIds.has(String(id))).map((resourceId) => ({
371
- resource_type: canonical,
372
- resource_id: resourceId,
372
+ const failedIds = new Set(preflightFailures.map((item) => entryKey(item.resource_type, item.resource_id)));
373
+ const notStarted = targets.filter((item) => !failedIds.has(entryKey(item.resource_type, item.resource_id))).map((item) => ({
374
+ ...item,
373
375
  status: 'not_started',
374
376
  phase: 'preflight',
375
377
  }));
376
378
  for (const result of [...preflightFailures, ...notStarted]) report(result);
377
- if (ids.length === 1 && preflightFailures.length === 1) {
379
+ if (targets.length === 1 && preflightFailures.length === 1) {
378
380
  const original = new WorktreeError(
379
381
  preflightFailures[0].code,
380
382
  preflightFailures[0].message,
@@ -391,6 +393,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
391
393
 
392
394
  async function commitPlan(plan) {
393
395
  const { resourceId, entry, localPath, current, fresh } = plan;
396
+ const canonical = entry.resource_type;
394
397
  if (current.hash === entry.base_hash) {
395
398
  return { resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash };
396
399
  }
@@ -430,6 +433,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
430
433
  const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
431
434
  ...options,
432
435
  ...session,
436
+ projectDir,
433
437
  clientInitialized: true,
434
438
  });
435
439
  assertMetadataIdentity(confirmed, canonical, resourceId);
@@ -476,7 +480,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
476
480
  }
477
481
  }
478
482
 
479
- const settled = await Promise.allSettled(plans.map(async (plan) => {
483
+ const settled = await mapBounded(plans, async (plan) => {
480
484
  try {
481
485
  const result = await commitPlan(plan);
482
486
  report(result);
@@ -485,16 +489,16 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
485
489
  report(error.batchResult);
486
490
  throw error;
487
491
  }
488
- }));
492
+ }, { ...options, settled: true });
489
493
  const committed = results.filter((item) => item.status === 'committed');
490
494
  if (committed.length) await saveManifest(projectDir, manifest);
491
495
  const failures = settled.filter((item) => item.status === 'rejected');
492
496
  if (failures.length) {
493
497
  const first = failures[0].reason;
494
- const inputOrder = new Map(ids.map((id, index) => [String(id), index]));
498
+ const inputOrder = new Map(targets.map((item, index) => [entryKey(item.resource_type, item.resource_id), index]));
495
499
  const ordered = (items) => [...items].sort((left, right) => (
496
- (inputOrder.get(String(left.resource_id)) ?? Number.MAX_SAFE_INTEGER)
497
- - (inputOrder.get(String(right.resource_id)) ?? Number.MAX_SAFE_INTEGER)
500
+ (inputOrder.get(entryKey(left.resource_type, left.resource_id)) ?? Number.MAX_SAFE_INTEGER)
501
+ - (inputOrder.get(entryKey(right.resource_type, right.resource_id)) ?? Number.MAX_SAFE_INTEGER)
498
502
  ));
499
503
  first.details = {
500
504
  ...(first.details || {}),
@@ -509,7 +513,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
509
513
  return results;
510
514
  }
511
515
 
512
- async function reconcileResources(projectDir, resourceType, resourceIds, options = {}) {
516
+ async function reconcileResourcesUnlocked(projectDir, resourceType, resourceIds, options = {}) {
513
517
  const canonical = canonicalResourceType(resourceType);
514
518
  const ids = ensureIds(resourceIds);
515
519
  const config = options.config || loadProjectConfig(projectDir);
@@ -533,7 +537,7 @@ async function reconcileResources(projectDir, resourceType, resourceIds, options
533
537
  );
534
538
  }
535
539
  const remote = await backend.resolveMetadata(config, canonical, resourceId, {
536
- ...options, ...session, clientInitialized: true,
540
+ ...options, ...session, projectDir, clientInitialized: true,
537
541
  });
538
542
  assertMetadataIdentity(remote, canonical, resourceId);
539
543
  const status = await inspectEntry(projectDir, entry, remote);
@@ -654,7 +658,7 @@ function showConflict(projectDir, resourceType, resourceId) {
654
658
  return match;
655
659
  }
656
660
 
657
- async function resolveConflict(projectDir, resourceType, resourceId) {
661
+ async function resolveConflictUnlocked(projectDir, resourceType, resourceId) {
658
662
  const canonical = canonicalResourceType(resourceType);
659
663
  const manifest = loadManifest(projectDir);
660
664
  const entry = getEntry(manifest, canonical, resourceId);
@@ -695,7 +699,45 @@ async function resolveConflict(projectDir, resourceType, resourceId) {
695
699
  return { ...resolved, manifest_path: relativePath(projectDir, manifestFile) };
696
700
  }
697
701
 
702
+ function normalizedTargets(targets) {
703
+ const unique = new Map();
704
+ for (const target of targets) {
705
+ const resource_type = canonicalResourceType(target.resource_type);
706
+ const resource_id = ensureIds([target.resource_id])[0];
707
+ unique.set(entryKey(resource_type, resource_id), { resource_type, resource_id });
708
+ }
709
+ if (!unique.size) throw new WorktreeError('INVALID_RESOURCE_ID', 'At least one resource is required.');
710
+ return [...unique.values()];
711
+ }
712
+
713
+ function commitBatch(projectDir, targets, options = {}) {
714
+ const normalized = normalizedTargets(targets);
715
+ return withResourceLocks(projectDir, normalized.map((item) => entryKey(item.resource_type, item.resource_id)),
716
+ () => commitBatchUnlocked(projectDir, normalized, options));
717
+ }
718
+ function commitResources(projectDir, resourceType, resourceIds, options = {}) {
719
+ return commitBatch(projectDir, ensureIds(resourceIds).map((resource_id) => ({ resource_type: resourceType, resource_id })), options);
720
+ }
721
+ function checkoutResources(projectDir, resourceType, resourceIds, options = {}) {
722
+ const type = canonicalResourceType(resourceType);
723
+ const ids = ensureIds(resourceIds);
724
+ return withResourceLocks(projectDir, ids.map((id) => entryKey(type, id)),
725
+ () => checkoutResourcesUnlocked(projectDir, type, ids, options));
726
+ }
727
+ function reconcileResources(projectDir, resourceType, resourceIds, options = {}) {
728
+ const type = canonicalResourceType(resourceType);
729
+ const ids = ensureIds(resourceIds);
730
+ return withResourceLocks(projectDir, ids.map((id) => entryKey(type, id)),
731
+ () => reconcileResourcesUnlocked(projectDir, type, ids, options));
732
+ }
733
+ function resolveConflict(projectDir, resourceType, resourceId) {
734
+ const type = canonicalResourceType(resourceType);
735
+ return withResourceLocks(projectDir, [entryKey(type, resourceId)],
736
+ () => resolveConflictUnlocked(projectDir, type, resourceId));
737
+ }
738
+
698
739
  module.exports = {
740
+ commitBatch,
699
741
  CONFLICT_SCHEMA_VERSION,
700
742
  worktreePaths,
701
743
  conflictPaths,
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { acquireLock, releaseLock } = require('../worklog');
7
+ const { WorktreeError } = require('./errors');
8
+
9
+ // Reuse owner-token locks, but never expire an active long-running transfer.
10
+ function lockFile(file) {
11
+ const lockPath = `${file}.lock`;
12
+ try {
13
+ const token = fs.readFileSync(lockPath, 'utf8').trim();
14
+ const pid = Number(token.split(':')[0]);
15
+ if (Number.isSafeInteger(pid) && pid > 0) {
16
+ try { process.kill(pid, 0); } catch (error) {
17
+ if (error.code === 'ESRCH' && fs.readFileSync(lockPath, 'utf8').trim() === token) fs.rmSync(lockPath);
18
+ }
19
+ }
20
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
21
+ try { return acquireLock(file, { lockTimeoutMs: 0, lockStaleMs: Number.MAX_SAFE_INTEGER }); }
22
+ catch (error) {
23
+ if (error.code !== 'WORKLOG_LOCK_TIMEOUT') throw error;
24
+ throw new WorktreeError('WORKTREE_LOCKED', 'Another process owns this worktree resource; retry after it finishes.', { lock_path: lockPath });
25
+ }
26
+ }
27
+
28
+ async function withResourceLocks(projectDir, keys, action) {
29
+ const locks = [];
30
+ try {
31
+ for (const key of [...new Set(keys)].sort()) {
32
+ const name = crypto.createHash('sha256').update(key).digest('hex');
33
+ locks.push(lockFile(path.join(projectDir, '.draftgo', 'worktree', '.locks', name)));
34
+ }
35
+ return await action();
36
+ } finally { for (const lock of locks.reverse()) releaseLock(lock); }
37
+ }
38
+
39
+ async function withManifestLock(projectDir, action) {
40
+ const file = path.join(projectDir, '.draftgo', 'worktree', 'manifest.json');
41
+ const started = Date.now();
42
+ let lock;
43
+ while (!lock) {
44
+ try { lock = lockFile(file); } catch (error) {
45
+ if (error.code !== 'WORKTREE_LOCKED' || Date.now() - started >= 10000) throw error;
46
+ await new Promise((resolve) => setTimeout(resolve, 15));
47
+ }
48
+ }
49
+ try { return await action(); } finally { releaseLock(lock); }
50
+ }
51
+
52
+ module.exports = { withResourceLocks, withManifestLock };
@@ -6,6 +6,8 @@ const { WorktreeError } = require('./errors');
6
6
  const { entryKey } = require('./types');
7
7
  const { writeJsonAtomic } = require('./streams');
8
8
 
9
+ const { withManifestLock } = require('./locks');
10
+ const snapshots = new WeakMap();
9
11
  const MANIFEST_SCHEMA_VERSION = 1;
10
12
 
11
13
  function manifestPath(projectDir) {
@@ -13,7 +15,9 @@ function manifestPath(projectDir) {
13
15
  }
14
16
 
15
17
  function emptyManifest() {
16
- return { schema_version: MANIFEST_SCHEMA_VERSION, entries: {} };
18
+ const value = { schema_version: MANIFEST_SCHEMA_VERSION, entries: {} };
19
+ snapshots.set(value, {});
20
+ return value;
17
21
  }
18
22
 
19
23
  function loadManifest(projectDir) {
@@ -29,6 +33,7 @@ function loadManifest(projectDir) {
29
33
  || !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
30
34
  throw new WorktreeError('INVALID_WORKTREE_MANIFEST', 'Unsupported or malformed checkout manifest.');
31
35
  }
36
+ snapshots.set(parsed, JSON.parse(JSON.stringify(parsed.entries)));
32
37
  return parsed;
33
38
  }
34
39
 
@@ -58,9 +63,18 @@ function absolutePath(projectDir, relative) {
58
63
  }
59
64
 
60
65
  async function saveManifest(projectDir, manifest) {
61
- manifest.schema_version = MANIFEST_SCHEMA_VERSION;
62
- manifest.updated_at = new Date().toISOString();
63
- await writeJsonAtomic(manifestPath(projectDir), manifest);
66
+ const before = snapshots.get(manifest) || {};
67
+ await withManifestLock(projectDir, async () => {
68
+ const fresh = loadManifest(projectDir);
69
+ for (const key of new Set([...Object.keys(before), ...Object.keys(manifest.entries)])) {
70
+ if (JSON.stringify(before[key]) === JSON.stringify(manifest.entries[key])) continue;
71
+ if (manifest.entries[key] === undefined) delete fresh.entries[key];
72
+ else fresh.entries[key] = manifest.entries[key];
73
+ }
74
+ fresh.updated_at = new Date().toISOString();
75
+ await writeJsonAtomic(manifestPath(projectDir), fresh);
76
+ snapshots.set(manifest, JSON.parse(JSON.stringify(manifest.entries)));
77
+ });
64
78
  }
65
79
 
66
80
  module.exports = {
@@ -102,14 +102,16 @@ async function inspectRemoteCheckouts(projectDir, options = {}) {
102
102
  ? { client: options.client || {}, tools: options.tools || [] }
103
103
  : await openMetadataSession(config, options);
104
104
  const entries = options.entries || Object.values(manifest.entries);
105
- return Promise.all(entries.map(async (entry) => {
105
+ const { mapBounded } = require('../mcp/parallel');
106
+ return mapBounded(entries, async (entry) => {
106
107
  const remote = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
107
108
  ...options,
108
109
  ...session,
110
+ projectDir,
109
111
  clientInitialized: true,
110
112
  });
111
113
  return inspectEntry(projectDir, entry, remote);
112
- }));
114
+ }, options);
113
115
  }
114
116
 
115
117
  module.exports = {