draftgo-cli 3.0.48 → 3.0.49

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.
@@ -5,6 +5,10 @@ const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
6
  const log = require('../logger');
7
7
  const { configPath, loadProjectConfig } = require('../projectConfig');
8
+ const { loadManifest, getEntry } = require('../worktree/manifest');
9
+ const { canonicalResourceType } = require('../worktree/types');
10
+ const { resolveMetadata } = require('../worktree/backend');
11
+ const { inspectEntry, openMetadataSession } = require('../worktree/status');
8
12
 
9
13
  const UI_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte']);
10
14
 
@@ -77,6 +81,7 @@ function redactUrlTokens(message) {
77
81
  }
78
82
 
79
83
  function executableCandidates() {
84
+ const explicit = process.env.DRAFTGO_BROWSER_PATH ? [process.env.DRAFTGO_BROWSER_PATH] : [];
80
85
  if (process.platform === 'win32') {
81
86
  const roots = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean);
82
87
  const rels = [
@@ -84,23 +89,47 @@ function executableCandidates() {
84
89
  ['Google', 'Chrome', 'Application', 'chrome.exe'],
85
90
  ['Chromium', 'Application', 'chrome.exe'],
86
91
  ];
87
- return roots.flatMap((root) => rels.map((parts) => path.join(root, ...parts)));
92
+ return [...explicit, ...roots.flatMap((root) => rels.map((parts) => path.join(root, ...parts)))];
88
93
  }
89
94
  if (process.platform === 'darwin') {
90
- return [
95
+ return [...explicit,
91
96
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
92
97
  '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
93
98
  '/Applications/Chromium.app/Contents/MacOS/Chromium',
94
99
  ];
95
100
  }
96
- return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/microsoft-edge', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
101
+ return [...explicit, '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/microsoft-edge', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
97
102
  }
98
103
 
99
- async function launchBrowser(chromium, requested) {
104
+ function playwrightCacheCandidates() {
105
+ const root = process.env.PLAYWRIGHT_BROWSERS_PATH
106
+ || (process.platform === 'win32'
107
+ ? path.join(process.env.LOCALAPPDATA || '', 'ms-playwright')
108
+ : path.join(process.env.HOME || '', '.cache', 'ms-playwright'));
109
+ if (!root || !fs.existsSync(root)) return [];
110
+ const candidates = [];
111
+ const visit = (directory, depth) => {
112
+ if (depth > 3) return;
113
+ let entries;
114
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
115
+ for (const entry of entries) {
116
+ const absolute = path.join(directory, entry.name);
117
+ if (entry.isFile() && /^(?:chrome(?:-headless-shell)?|chromium|msedge|headless_shell)(?:\.exe)?$/i.test(entry.name)) candidates.push(absolute);
118
+ else if (entry.isDirectory()) visit(absolute, depth + 1);
119
+ }
120
+ };
121
+ visit(root, 0);
122
+ return candidates;
123
+ }
124
+
125
+ async function launchBrowser(chromium, requested, requestedPath) {
100
126
  const attempts = [];
127
+ if (requestedPath) attempts.push({ executablePath: requestedPath, label: `path:${requestedPath}` });
101
128
  if (requested && requested !== 'chromium') attempts.push({ channel: requested });
102
- for (const executablePath of executableCandidates().filter((candidate) => fs.existsSync(candidate))) {
103
- attempts.push({ executablePath });
129
+ for (const executablePath of [...executableCandidates(), ...playwrightCacheCandidates()].filter((candidate) => fs.existsSync(candidate))) {
130
+ if (!attempts.some((attempt) => attempt.executablePath === executablePath)) {
131
+ attempts.push({ executablePath, label: `path:${executablePath}` });
132
+ }
104
133
  }
105
134
  if (!requested || requested === 'chromium') attempts.push({});
106
135
  for (const channel of ['msedge', 'chrome']) {
@@ -108,14 +137,42 @@ async function launchBrowser(chromium, requested) {
108
137
  }
109
138
 
110
139
  let lastError = null;
140
+ const attempted = [];
111
141
  for (const options of attempts) {
112
142
  try {
113
- return await chromium.launch({ headless: true, ...options });
143
+ const { label, ...launchOptions } = options;
144
+ const browser = await chromium.launch({ headless: true, ...launchOptions });
145
+ return { browser, selected: label || options.channel || 'playwright-managed', attempted };
114
146
  } catch (err) {
115
147
  lastError = err;
148
+ attempted.push(`${options.label || options.channel || 'playwright-managed'}: ${String(err.message || err).split('\n')[0]}`);
116
149
  }
117
150
  }
118
- throw new Error(`未找到可用的 Chromium/Chrome/Edge。${lastError ? ` ${lastError.message.split('\n')[0]}` : ''}`);
151
+ throw new Error(`未找到可用的 Chromium/Chrome/Edge。请使用 --browser-path <executable> DRAFTGO_BROWSER_PATH。`
152
+ + `尝试记录:${attempted.join('; ')}`
153
+ + (lastError ? `;最后错误:${lastError.message.split('\n')[0]}` : ''));
154
+ }
155
+
156
+ function parseResourceSpec(value) {
157
+ const raw = String(value || '');
158
+ const separator = raw.indexOf(':');
159
+ if (separator <= 0 || separator === raw.length - 1) {
160
+ throw new Error('--resource 格式必须是 <pages|nav|docs>:<id>。');
161
+ }
162
+ return { resourceType: canonicalResourceType(raw.slice(0, separator)), resourceId: raw.slice(separator + 1) };
163
+ }
164
+
165
+ async function verifyRemoteResource(projectDir, spec) {
166
+ const { resourceType, resourceId } = parseResourceSpec(spec);
167
+ const entry = getEntry(loadManifest(projectDir), resourceType, resourceId);
168
+ if (!entry) throw new Error(`${resourceType} ${resourceId} 未 checkout。`);
169
+ const config = loadProjectConfig(projectDir);
170
+ const session = await openMetadataSession(config);
171
+ const remote = await resolveMetadata(config, resourceType, resourceId, {
172
+ ...session,
173
+ clientInitialized: true,
174
+ });
175
+ return inspectEntry(projectDir, entry, remote);
119
176
  }
120
177
 
121
178
  async function verifyUi(projectDir, positional, flags = {}) {
@@ -136,7 +193,9 @@ async function verifyUi(projectDir, positional, flags = {}) {
136
193
  log.err('--mobile-check 只支持 auto、always、never。');
137
194
  return 1;
138
195
  }
139
- const decision = decideMobileCheck(projectDir, mode);
196
+ const decision = flags.resource
197
+ ? { run: true, reason: `resource=${flags.resource}` }
198
+ : decideMobileCheck(projectDir, mode);
140
199
  if (!decision.run) {
141
200
  log.ok(`跳过 UI smoke check:${decision.reason}`);
142
201
  return 0;
@@ -169,7 +228,26 @@ async function verifyUi(projectDir, positional, flags = {}) {
169
228
 
170
229
  let browser;
171
230
  try {
172
- browser = await launchBrowser(chromium, flags.browser && String(flags.browser));
231
+ if (flags.resource) {
232
+ const resource = await verifyRemoteResource(projectDir, flags.resource);
233
+ log.info(`UI source: remote committed ${resource.resource_type} ${resource.resource_id}`);
234
+ log.info(`Remote version: ${resource.remote_version || '-'}; remote hash: ${resource.remote_hash || '-'}`);
235
+ if (['local_modified', 'diverged'].includes(resource.state)) {
236
+ log.warn(`Local worktree is not committed (${resource.state}); verify-ui will test the remote version.`);
237
+ }
238
+ if (!resource.local_matches_remote && resource.state !== 'local_modified') {
239
+ throw new Error(`Local and remote content differ (${resource.state}); UI verification stopped.`);
240
+ }
241
+ } else {
242
+ log.info('UI source: remote URL response; no local worktree version was asserted.');
243
+ }
244
+ const launch = await launchBrowser(
245
+ chromium,
246
+ flags.browser && String(flags.browser),
247
+ flags['browser-path'] && String(flags['browser-path']),
248
+ );
249
+ browser = launch.browser;
250
+ log.info(`Browser: ${launch.selected}`);
173
251
  const page = await browser.newPage({ viewport: { width, height } });
174
252
  const consoleErrors = [];
175
253
  const pageErrors = [];
@@ -240,3 +318,7 @@ module.exports.gitChangedFiles = gitChangedFiles;
240
318
  module.exports.isUiFile = isUiFile;
241
319
  module.exports.configuredUiUrl = configuredUiUrl;
242
320
  module.exports.redactUrlTokens = redactUrlTokens;
321
+ module.exports.executableCandidates = executableCandidates;
322
+ module.exports.playwrightCacheCandidates = playwrightCacheCandidates;
323
+ module.exports.parseResourceSpec = parseResourceSpec;
324
+ module.exports.verifyRemoteResource = verifyRemoteResource;
package/src/mcp/client.js CHANGED
@@ -225,16 +225,30 @@ class DraftGoMcpClient {
225
225
  }
226
226
 
227
227
  async initialize(options = {}) {
228
- const result = await this.request('initialize', {
229
- protocolVersion: options.protocolVersion || this.protocolVersion,
230
- capabilities: options.capabilities || {},
231
- clientInfo: options.clientInfo || {
232
- name: 'draftgo-cli',
233
- version: pkg.version,
234
- },
235
- }, options);
228
+ if (typeof options.onStage === 'function') options.onStage('initialize', 'started');
229
+ let result;
230
+ try {
231
+ result = await this.request('initialize', {
232
+ protocolVersion: options.protocolVersion || this.protocolVersion,
233
+ capabilities: options.capabilities || {},
234
+ clientInfo: options.clientInfo || {
235
+ name: 'draftgo-cli',
236
+ version: pkg.version,
237
+ },
238
+ }, options);
239
+ } catch (error) {
240
+ if (typeof options.onStage === 'function') options.onStage('initialize', 'failed');
241
+ throw error;
242
+ }
243
+ if (typeof options.onStage === 'function') options.onStage('initialize', 'succeeded', result);
236
244
  if (result && result.protocolVersion) this.protocolVersion = result.protocolVersion;
237
- await this.notify('notifications/initialized', undefined, options);
245
+ try {
246
+ await this.notify('notifications/initialized', undefined, options);
247
+ } catch (error) {
248
+ if (typeof options.onStage === 'function') options.onStage('initialized', 'failed');
249
+ throw error;
250
+ }
251
+ if (typeof options.onStage === 'function') options.onStage('initialized', 'succeeded');
238
252
  return result;
239
253
  }
240
254
 
@@ -279,7 +293,15 @@ class DraftGoMcpClient {
279
293
 
280
294
  async testConnection(options = {}) {
281
295
  const initialized = await this.initialize(options);
282
- const tools = await this.listAllTools(options);
296
+ if (typeof options.onStage === 'function') options.onStage('tools/list', 'started');
297
+ let tools;
298
+ try {
299
+ tools = await this.listAllTools(options);
300
+ } catch (error) {
301
+ if (typeof options.onStage === 'function') options.onStage('tools/list', 'failed');
302
+ throw error;
303
+ }
304
+ if (typeof options.onStage === 'function') options.onStage('tools/list', 'succeeded', { count: tools.length });
283
305
  const requiredTools = options.requiredTools || REQUIRED_DRAFTGO_TOOLS;
284
306
  const missing = requiredTools.filter((expected) => !tools.some((tool) =>
285
307
  tool && typeof tool.name === 'string' && toolMatches(tool.name, expected)));
@@ -325,21 +347,32 @@ class DraftGoMcpClient {
325
347
  },
326
348
  ];
327
349
  const runPlan = (plan, startIndex = 0) => allWithAbort(plan.map((entry, offset) =>
328
- async (queryOptions) => ({
350
+ async (queryOptions) => {
351
+ if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'started');
352
+ let result;
353
+ try {
354
+ result = await this.toolsCall(
355
+ entry.tool.name,
356
+ options.toolArguments && startIndex + offset === 0
357
+ ? options.toolArguments
358
+ : entry.args,
359
+ queryOptions,
360
+ );
361
+ } catch (error) {
362
+ if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'failed');
363
+ throw error;
364
+ }
365
+ if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'succeeded');
366
+ return ({
329
367
  label: entry.label,
330
368
  canonical: entry.canonical,
331
369
  name: entry.tool.name,
332
370
  arguments: options.toolArguments && startIndex + offset === 0
333
371
  ? options.toolArguments
334
372
  : entry.args,
335
- result: await this.toolsCall(
336
- entry.tool.name,
337
- options.toolArguments && startIndex + offset === 0
338
- ? options.toolArguments
339
- : entry.args,
340
- queryOptions,
341
- ),
342
- })), options);
373
+ result,
374
+ });
375
+ }), options);
343
376
  const tested = await runPlan(defaultPlan);
344
377
  if (!hasDiagnosticOverrides) {
345
378
  let discovery = tested.find((entry) => entry.label === 'api:db_meta');
package/src/projectMap.js CHANGED
@@ -148,8 +148,13 @@ function analyzeProject(projectDir) {
148
148
  }
149
149
  if (!fs.existsSync(base)) {
150
150
  errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base is missing (${entry.base_path}).`);
151
- } else if (sha256File(base) !== entry.base_hash) {
152
- errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest.`);
151
+ } else {
152
+ const actualBaseHash = sha256File(base);
153
+ if (actualBaseHash !== entry.base_hash) {
154
+ errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest `
155
+ + `(manifest=${entry.base_hash}, base=${actualBaseHash}). `
156
+ + `Run \`draftgo check --remote\` to classify the mismatch before replacing files.`);
157
+ }
153
158
  }
154
159
 
155
160
  const type = mediaType(entry.content_type);
@@ -15,7 +15,14 @@ const {
15
15
  resourceFileName,
16
16
  safeIdSegment,
17
17
  } = require('./types');
18
- const { streamToFiles, hashFile, copyFileAtomic, writeJsonAtomic, normalizeSha256 } = require('./streams');
18
+ const {
19
+ streamToFiles,
20
+ hashFile,
21
+ copyFileAtomic,
22
+ writeJsonAtomic,
23
+ normalizeSha256,
24
+ tempPathFor,
25
+ } = require('./streams');
19
26
  const {
20
27
  loadManifest,
21
28
  getEntry,
@@ -24,6 +31,7 @@ const {
24
31
  saveManifest,
25
32
  } = require('./manifest');
26
33
  const { validateContentFile } = require('./validate');
34
+ const { inspectEntry, openMetadataSession, sameVersion } = require('./status');
27
35
 
28
36
  const CONFLICT_SCHEMA_VERSION = 1;
29
37
 
@@ -248,36 +256,148 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
248
256
  const session = await createSession(config, options);
249
257
  const manifest = loadManifest(projectDir);
250
258
  const results = [];
259
+ const plans = [];
260
+ const preflightFailures = [];
261
+
262
+ const report = (result) => {
263
+ results.push(result);
264
+ if (typeof options.onStatus === 'function') options.onStatus(result);
265
+ };
251
266
 
267
+ // Resolve and validate every target before the first remote write. This prevents
268
+ // a late local or stale-version error from causing an avoidable partial batch.
252
269
  for (const resourceId of ids) {
253
- const entry = getEntry(manifest, canonical, resourceId);
254
- if (!entry) {
255
- throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
256
- }
257
- if (entry.server !== config.server) {
258
- throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
270
+ try {
271
+ const entry = getEntry(manifest, canonical, resourceId);
272
+ if (!entry) {
273
+ throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
274
+ }
275
+ if (entry.server !== config.server) {
276
+ throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
277
+ }
278
+ if (unresolvedConflict(projectDir, canonical, resourceId)) {
279
+ throw new WorktreeError(
280
+ 'UNRESOLVED_CONFLICT',
281
+ `${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
282
+ );
283
+ }
284
+
285
+ const localPath = absolutePath(projectDir, entry.local_path);
286
+ const current = await hashFile(localPath);
287
+ const basePath = absolutePath(projectDir, entry.base_path);
288
+ const base = await hashFile(basePath);
289
+ if (base.hash !== entry.base_hash) {
290
+ throw new WorktreeError(
291
+ 'BASE_HASH_MISMATCH',
292
+ `${canonical} ${resourceId} base hash differs from its manifest; run draftgo check --remote.`,
293
+ { manifest_hash: entry.base_hash, base_hash: base.hash },
294
+ );
295
+ }
296
+ if (current.hash !== entry.base_hash) validateContentFile(localPath, entry.content_type);
297
+
298
+ const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
299
+ ...options,
300
+ ...session,
301
+ clientInitialized: true,
302
+ });
303
+ assertMetadataIdentity(fresh, canonical, resourceId);
304
+ const remoteMatchesBase = fresh.content_hash === entry.base_hash && sameVersion(entry, fresh);
305
+ if (!remoteMatchesBase) {
306
+ const state = await inspectEntry(projectDir, entry, fresh);
307
+ throw new WorktreeError(
308
+ state.local_matches_remote ? 'COMMITTED_UNRECORDED' : 'REMOTE_VERSION_CHANGED',
309
+ state.local_matches_remote
310
+ ? `${canonical} ${resourceId} already equals remote; run ${state.recommendation}.`
311
+ : `${canonical} ${resourceId} changed remotely; inspect with draftgo check --remote before committing.`,
312
+ state,
313
+ );
314
+ }
315
+ plans.push({ resourceId, entry, localPath, current, fresh });
316
+ } catch (error) {
317
+ if (error.code === 'REMOTE_VERSION_CHANGED') {
318
+ const entry = getEntry(manifest, canonical, resourceId);
319
+ try {
320
+ const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
321
+ error = new WorktreeError(
322
+ 'RESOURCE_VERSION_CONFLICT',
323
+ `${canonical} ${resourceId} changed remotely; conflict materials were preserved during preflight.`,
324
+ conflict,
325
+ );
326
+ } catch (conflictError) {
327
+ error = conflictError;
328
+ }
329
+ }
330
+ preflightFailures.push({
331
+ resource_type: canonical,
332
+ resource_id: resourceId,
333
+ status: 'failed',
334
+ phase: 'preflight',
335
+ code: error.code || 'PREFLIGHT_FAILED',
336
+ message: error.message,
337
+ details: error.details || {},
338
+ });
259
339
  }
260
- if (unresolvedConflict(projectDir, canonical, resourceId)) {
261
- throw new WorktreeError(
262
- 'UNRESOLVED_CONFLICT',
263
- `${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
340
+ }
341
+
342
+ if (preflightFailures.length) {
343
+ const failedIds = new Set(preflightFailures.map((item) => String(item.resource_id)));
344
+ const notStarted = ids.filter((id) => !failedIds.has(String(id))).map((resourceId) => ({
345
+ resource_type: canonical,
346
+ resource_id: resourceId,
347
+ status: 'not_started',
348
+ phase: 'preflight',
349
+ }));
350
+ for (const result of [...preflightFailures, ...notStarted]) report(result);
351
+ if (ids.length === 1 && preflightFailures.length === 1) {
352
+ const original = new WorktreeError(
353
+ preflightFailures[0].code,
354
+ preflightFailures[0].message,
355
+ preflightFailures[0].details,
264
356
  );
357
+ throw original;
265
358
  }
359
+ throw new WorktreeError(
360
+ 'BATCH_PREFLIGHT_FAILED',
361
+ `Commit preflight failed for ${preflightFailures.length} resource(s); no remote content was changed.`,
362
+ { completed: [], failed: preflightFailures, not_started: notStarted },
363
+ );
364
+ }
266
365
 
267
- const localPath = absolutePath(projectDir, entry.local_path);
268
- const current = await hashFile(localPath);
366
+ function throwBatchFailure(error, index, resourceId, remoteChangePossible = false) {
367
+ const failed = {
368
+ resource_type: canonical,
369
+ resource_id: resourceId,
370
+ status: 'failed',
371
+ phase: 'upload',
372
+ code: error.code || 'COMMIT_FAILED',
373
+ message: error.message,
374
+ remote_change_possible: remoteChangePossible,
375
+ };
376
+ const notStarted = plans.slice(index + 1).map((plan) => ({
377
+ resource_type: canonical,
378
+ resource_id: plan.resourceId,
379
+ status: 'not_started',
380
+ phase: 'upload',
381
+ }));
382
+ report(failed);
383
+ for (const item of notStarted) report(item);
384
+ error.details = {
385
+ ...(error.details || {}),
386
+ batch: {
387
+ completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
388
+ failed: [failed],
389
+ not_started: notStarted,
390
+ },
391
+ };
392
+ throw error;
393
+ }
394
+
395
+ for (let index = 0; index < plans.length; index += 1) {
396
+ const { resourceId, entry, localPath, current, fresh } = plans[index];
269
397
  if (current.hash === entry.base_hash) {
270
- results.push({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
398
+ report({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
271
399
  continue;
272
400
  }
273
- validateContentFile(localPath, entry.content_type);
274
-
275
- const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
276
- ...options,
277
- ...session,
278
- clientInitialized: true,
279
- });
280
- assertMetadataIdentity(fresh, canonical, resourceId);
281
401
  const commitMetadata = {
282
402
  ...fresh,
283
403
  content_type: entry.content_type,
@@ -290,55 +410,147 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
290
410
  try {
291
411
  responsePayload = await backend.commit(config, commitMetadata, localPath, current, options);
292
412
  } catch (error) {
293
- if (!isVersionConflict(error)) throw error;
294
- const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
295
- throw new WorktreeError(
296
- 'RESOURCE_VERSION_CONFLICT',
297
- `${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
298
- conflict,
299
- );
413
+ let failure = error;
414
+ const versionConflict = isVersionConflict(error);
415
+ if (versionConflict) {
416
+ const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
417
+ failure = new WorktreeError(
418
+ 'RESOURCE_VERSION_CONFLICT',
419
+ `${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
420
+ conflict,
421
+ );
422
+ }
423
+ throwBatchFailure(failure, index, resourceId, !versionConflict);
300
424
  }
301
425
 
302
- let committed = commitVersion(normalizeCommitPayload(responsePayload));
303
- if (!committed.hash || (committed.version == null && committed.revision == null)) {
304
- const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
305
- ...options,
306
- ...session,
307
- clientInitialized: true,
426
+ try {
427
+ let committed = commitVersion(normalizeCommitPayload(responsePayload));
428
+ if (!committed.hash || (committed.version == null && committed.revision == null)) {
429
+ const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
430
+ ...options,
431
+ ...session,
432
+ clientInitialized: true,
433
+ });
434
+ assertMetadataIdentity(confirmed, canonical, resourceId);
435
+ committed = {
436
+ version: confirmed.base_version,
437
+ revision: confirmed.base_revision,
438
+ etag: confirmed.etag,
439
+ hash: confirmed.content_hash,
440
+ updated_at: confirmed.updated_at,
441
+ updated_by: confirmed.updated_by,
442
+ };
443
+ }
444
+ if (committed.hash && committed.hash !== current.hash) {
445
+ throw new WorktreeError(
446
+ 'HASH_MISMATCH',
447
+ 'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
448
+ { local_hash: current.hash, remote_hash: committed.hash },
449
+ );
450
+ }
451
+
452
+ await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
453
+ entry.base_hash = current.hash;
454
+ entry.content_size = current.size;
455
+ entry.base_version = committed.version;
456
+ entry.base_revision = committed.revision;
457
+ entry.base_etag = committed.etag;
458
+ entry.updated_at = committed.updated_at;
459
+ entry.updated_by = committed.updated_by;
460
+ entry.committed_at = new Date().toISOString();
461
+ await saveManifest(projectDir, manifest);
462
+ report({
463
+ resource_type: canonical,
464
+ resource_id: resourceId,
465
+ status: 'committed',
466
+ hash: current.hash,
467
+ base_version: entry.base_version,
468
+ base_revision: entry.base_revision,
308
469
  });
309
- assertMetadataIdentity(confirmed, canonical, resourceId);
310
- committed = {
311
- version: confirmed.base_version,
312
- revision: confirmed.base_revision,
313
- etag: confirmed.etag,
314
- hash: confirmed.content_hash,
315
- updated_at: confirmed.updated_at,
316
- updated_by: confirmed.updated_by,
317
- };
470
+ } catch (error) {
471
+ throwBatchFailure(error, index, resourceId, true);
472
+ }
473
+ }
474
+ return results;
475
+ }
476
+
477
+ async function reconcileResources(projectDir, resourceType, resourceIds, options = {}) {
478
+ const canonical = canonicalResourceType(resourceType);
479
+ const ids = ensureIds(resourceIds);
480
+ const config = options.config || loadProjectConfig(projectDir);
481
+ const backend = backendFor(options);
482
+ const manifest = loadManifest(projectDir);
483
+ const session = options.backend && typeof options.backend.resolveMetadata === 'function'
484
+ ? { client: options.client || {}, tools: options.tools || [] }
485
+ : await openMetadataSession(config, options);
486
+ const results = [];
487
+
488
+ for (const resourceId of ids) {
489
+ const entry = getEntry(manifest, canonical, resourceId);
490
+ if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
491
+ if (entry.server !== config.server) {
492
+ throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
318
493
  }
319
- if (committed.hash && committed.hash !== current.hash) {
494
+ if (unresolvedConflict(projectDir, canonical, resourceId)) {
320
495
  throw new WorktreeError(
321
- 'HASH_MISMATCH',
322
- 'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
323
- { local_hash: current.hash, remote_hash: committed.hash },
496
+ 'UNRESOLVED_CONFLICT',
497
+ `${canonical} ${resourceId} has an unresolved conflict; use conflict resolve instead of reconcile.`,
498
+ );
499
+ }
500
+ const remote = await backend.resolveMetadata(config, canonical, resourceId, {
501
+ ...options, ...session, clientInitialized: true,
502
+ });
503
+ assertMetadataIdentity(remote, canonical, resourceId);
504
+ const status = await inspectEntry(projectDir, entry, remote);
505
+ if (!status.local_matches_remote) {
506
+ throw new WorktreeError(
507
+ 'RECONCILE_UNSAFE',
508
+ `${canonical} ${resourceId} local content does not equal remote; refusing to advance metadata.`,
509
+ status,
510
+ );
511
+ }
512
+ if (entry.content_type !== remote.content_type || entry.file_extension !== remote.file_extension) {
513
+ throw new WorktreeError(
514
+ 'RECONCILE_FORMAT_CHANGED',
515
+ `${canonical} ${resourceId} remote content format changed; use checkout after preserving local work.`,
324
516
  );
325
517
  }
326
518
 
327
- await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
328
- entry.base_hash = current.hash;
329
- entry.content_size = current.size;
330
- entry.base_version = committed.version;
331
- entry.base_revision = committed.revision;
332
- entry.base_etag = committed.etag;
333
- entry.updated_at = committed.updated_at;
334
- entry.updated_by = committed.updated_by;
335
- entry.committed_at = new Date().toISOString();
519
+ const response = await backend.download(config, remote, options);
520
+ const basePath = absolutePath(projectDir, entry.base_path);
521
+ const verifiedRemote = tempPathFor(basePath);
522
+ let verified;
523
+ try {
524
+ verified = await streamToFiles(response.body, [verifiedRemote], {
525
+ expectedHash: remote.content_hash,
526
+ expectedSize: remote.content_size,
527
+ });
528
+ const localAfterDownload = await hashFile(absolutePath(projectDir, entry.local_path));
529
+ if (localAfterDownload.hash !== verified.hash) {
530
+ throw new WorktreeError(
531
+ 'RECONCILE_LOCAL_CHANGED',
532
+ `${canonical} ${resourceId} local content changed during reconcile; manifest was not advanced.`,
533
+ { local_hash: localAfterDownload.hash, remote_hash: verified.hash },
534
+ );
535
+ }
536
+ await copyFileAtomic(verifiedRemote, basePath, { expectedHash: verified.hash });
537
+ } finally {
538
+ await fs.promises.rm(verifiedRemote, { force: true }).catch(() => {});
539
+ }
540
+ entry.base_hash = verified.hash;
541
+ entry.content_size = verified.size;
542
+ entry.base_version = remote.base_version;
543
+ entry.base_revision = remote.base_revision;
544
+ entry.base_etag = remote.etag;
545
+ entry.updated_at = remote.updated_at;
546
+ entry.updated_by = remote.updated_by;
547
+ entry.reconciled_at = new Date().toISOString();
336
548
  await saveManifest(projectDir, manifest);
337
549
  results.push({
338
550
  resource_type: canonical,
339
551
  resource_id: resourceId,
340
- status: 'committed',
341
- hash: current.hash,
552
+ status: 'reconciled',
553
+ hash: verified.hash,
342
554
  base_version: entry.base_version,
343
555
  base_revision: entry.base_revision,
344
556
  });
@@ -454,6 +666,7 @@ module.exports = {
454
666
  conflictPaths,
455
667
  checkoutResources,
456
668
  commitResources,
669
+ reconcileResources,
457
670
  diffResource,
458
671
  listConflicts,
459
672
  showConflict,