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.
- package/README.md +23 -37
- package/package.json +3 -5
- package/resources/skill/SKILL.md +9 -5
- package/resources/skill/manifest.json +2 -5
- package/resources/skill/references/ai.md +41 -0
- package/resources/skill/references/app-api.md +2 -50
- package/resources/skill/references/architecture.md +1 -1
- package/resources/skill/references/chat-sdk.md +29 -37
- package/resources/skill/references/checkout.md +4 -4
- package/resources/skill/references/data.md +0 -46
- package/resources/skill/references/delivery.md +3 -3
- package/resources/skill/references/diagnostics.md +10 -11
- package/resources/skill/references/frontend.md +23 -20
- package/resources/skill/references/mcp.md +4 -14
- package/resources/skill/references/methods.md +15 -68
- package/resources/skill/references/modules.md +23 -44
- package/resources/skill/references/runtime.md +3 -20
- package/resources/skill/story/SKILL.md +2 -2
- package/src/apiContractCache.js +14 -6
- package/src/cli.js +0 -7
- package/src/commandRegistry.js +0 -6
- package/src/commands/api.js +87 -17
- package/src/commands/apiKey.js +2 -6
- package/src/commands/autoPush.js +15 -51
- package/src/commands/capabilities.js +22 -15
- package/src/commands/check.js +19 -53
- package/src/commands/checkout.js +1 -4
- package/src/commands/clean.js +1 -1
- package/src/commands/commit.js +1 -4
- package/src/commands/components.js +12 -8
- package/src/commands/conflict.js +4 -6
- package/src/commands/conflicts.js +1 -2
- package/src/commands/connect.js +0 -8
- package/src/commands/delete.js +15 -11
- package/src/commands/deploy.js +64 -26
- package/src/commands/diff.js +1 -4
- package/src/commands/group.js +2 -3
- package/src/commands/help.js +22 -43
- package/src/commands/init.js +13 -6
- package/src/commands/local.js +4 -1
- package/src/commands/map.js +138 -23
- package/src/commands/reconcile.js +1 -15
- package/src/commands/role.js +1 -2
- package/src/commands/status.js +12 -40
- package/src/commands/update.js +18 -24
- package/src/commands/verify.js +8 -7
- package/src/commands/worklog.js +11 -5
- package/src/contractCompatibility.js +10 -2
- package/src/localRuntime/compose.js +41 -27
- package/src/localRuntime/detect.js +6 -6
- package/src/localRuntime/index.js +47 -47
- package/src/localRuntime/services.js +2 -39
- package/src/mcp/client.js +99 -134
- package/src/mcp/parallel.js +25 -2
- package/src/mcp/protocol.js +38 -9
- package/src/mcp/tools.js +10 -19
- package/src/projectConfig.js +1 -4
- package/src/{workspaceHealth.js → projectHealth.js} +5 -5
- package/src/projectMap.js +1 -1
- package/src/runtimeFiles.js +2 -1
- package/src/worklog.js +3 -2
- package/src/worktree/backend.js +127 -15
- package/src/worktree/index.js +64 -22
- package/src/worktree/locks.js +52 -0
- package/src/worktree/manifest.js +18 -4
- package/src/worktree/status.js +4 -2
- package/resources/custom-service-sdk/ai.go +0 -520
- package/resources/custom-service-sdk/ai_test.go +0 -156
- package/resources/custom-service-sdk/auth_test.go +0 -56
- package/resources/custom-service-sdk/billing.go +0 -596
- package/resources/custom-service-sdk/billing_test.go +0 -150
- package/resources/custom-service-sdk/go.mod +0 -3
- package/resources/custom-service-sdk/manifest.json +0 -77
- package/resources/custom-service-sdk/platform.go +0 -352
- package/resources/custom-service-sdk/platform_logger_test.go +0 -24
- package/resources/custom-service-sdk/registration_test.go +0 -39
- package/resources/custom-service-sdk/resources.go +0 -247
- package/resources/custom-service-sdk/resources_billing_test.go +0 -115
- package/resources/custom-service-sdk/resources_files_test.go +0 -57
- package/resources/custom-service-sdk/resources_scope_test.go +0 -92
- package/resources/custom-service-sdk/sdk.go +0 -209
- package/resources/skill/references/aihub.md +0 -116
- package/resources/skill/references/custom-services.md +0 -201
- package/src/commands/customService.js +0 -95
- package/src/commands/dataRange.js +0 -33
- package/src/commands/grant.js +0 -29
- package/src/commands/space.js +0 -41
- package/src/customServices.js +0 -484
package/src/worktree/backend.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
301
|
-
duplex:
|
|
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,
|
package/src/worktree/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
291
|
-
const preflight = await
|
|
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) =>
|
|
370
|
-
const notStarted =
|
|
371
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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(
|
|
497
|
-
- (inputOrder.get(
|
|
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
|
|
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
|
|
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 };
|
package/src/worktree/manifest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 = {
|
package/src/worktree/status.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|