@thxgg/steward 0.1.23 → 0.1.25
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/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/9ce7f1bc-d5e2-47bf-8026-f4910c257b2e.json +1 -0
- package/.output/server/chunks/_/prd-service.mjs.map +1 -1
- package/.output/server/chunks/build/styles.mjs +2 -2
- package/.output/server/chunks/nitro/nitro.mjs +873 -571
- package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
- package/.output/server/package.json +1 -1
- package/README.md +55 -9
- package/bin/prd +1 -1
- package/dist/host/src/api/repo-context.js +19 -2
- package/dist/host/src/index.js +10 -0
- package/dist/host/src/prompts.js +60 -36
- package/dist/host/src/sync.js +201 -0
- package/dist/server/utils/db.js +64 -0
- package/dist/server/utils/prd-state.js +24 -2
- package/dist/server/utils/repos.js +12 -2
- package/dist/server/utils/state-migration.js +4 -3
- package/dist/server/utils/sync-apply.js +380 -0
- package/dist/server/utils/sync-export.js +183 -0
- package/dist/server/utils/sync-identity.js +231 -0
- package/dist/server/utils/sync-inspect.js +103 -0
- package/dist/server/utils/sync-merge.js +579 -0
- package/dist/server/utils/sync-schema.js +100 -0
- package/docs/MCP.md +15 -4
- package/package.json +1 -1
- package/.output/public/_nuxt/builds/meta/bd99c09c-d991-4bcb-8c66-ab2088e1da03.json +0 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { dbAll } from './db.js';
|
|
3
|
+
import { getRepos } from './repos.js';
|
|
4
|
+
import { parseStoredProgressFile, parseTasksFile } from './state-schema.js';
|
|
5
|
+
import { createSyncFieldHashes, parseSyncBundle } from './sync-schema.js';
|
|
6
|
+
import { ensureRepoSyncMetaForRepos } from './sync-identity.js';
|
|
7
|
+
function normalizePath(path) {
|
|
8
|
+
return resolve(path);
|
|
9
|
+
}
|
|
10
|
+
function createRepoIndex(localRepos) {
|
|
11
|
+
const byRepoId = new Map();
|
|
12
|
+
const byPath = new Map();
|
|
13
|
+
const bySyncKey = new Map();
|
|
14
|
+
const byFingerprint = new Map();
|
|
15
|
+
for (const repo of localRepos) {
|
|
16
|
+
byRepoId.set(repo.repoId, repo);
|
|
17
|
+
byPath.set(normalizePath(repo.repoPath), repo);
|
|
18
|
+
bySyncKey.set(repo.syncKey, repo);
|
|
19
|
+
const fingerprintKey = `${repo.fingerprintKind}:${repo.fingerprint}`;
|
|
20
|
+
const current = byFingerprint.get(fingerprintKey);
|
|
21
|
+
if (current) {
|
|
22
|
+
current.push(repo);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
byFingerprint.set(fingerprintKey, [repo]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { byRepoId, byPath, bySyncKey, byFingerprint };
|
|
29
|
+
}
|
|
30
|
+
function parseLocalJson(rawValue, parseValue, fallback) {
|
|
31
|
+
if (rawValue === null) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(rawValue);
|
|
36
|
+
try {
|
|
37
|
+
return parseValue(parsed);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return fallback(parsed);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return rawValue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function toClock(primary, fallback, hasValue) {
|
|
48
|
+
if (primary) {
|
|
49
|
+
return primary;
|
|
50
|
+
}
|
|
51
|
+
if (hasValue) {
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function compareIsoTimestamps(a, b) {
|
|
57
|
+
if (a === b) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (!a && !b) {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (!a) {
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
if (!b) {
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
const aEpoch = Date.parse(a);
|
|
70
|
+
const bEpoch = Date.parse(b);
|
|
71
|
+
if (Number.isFinite(aEpoch) && Number.isFinite(bEpoch) && aEpoch !== bEpoch) {
|
|
72
|
+
return aEpoch > bEpoch ? 1 : -1;
|
|
73
|
+
}
|
|
74
|
+
return a.localeCompare(b);
|
|
75
|
+
}
|
|
76
|
+
function compareHashes(a, b) {
|
|
77
|
+
const left = a || '';
|
|
78
|
+
const right = b || '';
|
|
79
|
+
return left.localeCompare(right);
|
|
80
|
+
}
|
|
81
|
+
function decideFieldMerge(params) {
|
|
82
|
+
const { localClock, incomingClock, localHash, incomingHash } = params;
|
|
83
|
+
const valueChanged = (localHash || '') !== (incomingHash || '');
|
|
84
|
+
const clockChanged = localClock !== incomingClock;
|
|
85
|
+
const conflict = valueChanged && localHash !== null && incomingHash !== null;
|
|
86
|
+
if (!valueChanged && !clockChanged) {
|
|
87
|
+
return {
|
|
88
|
+
winner: 'local',
|
|
89
|
+
reason: 'equal_value',
|
|
90
|
+
localClock,
|
|
91
|
+
incomingClock,
|
|
92
|
+
localHash,
|
|
93
|
+
incomingHash,
|
|
94
|
+
changed: false,
|
|
95
|
+
valueChanged,
|
|
96
|
+
clockChanged,
|
|
97
|
+
conflict
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (incomingClock && localClock) {
|
|
101
|
+
const timestampComparison = compareIsoTimestamps(incomingClock, localClock);
|
|
102
|
+
if (timestampComparison > 0) {
|
|
103
|
+
return {
|
|
104
|
+
winner: 'incoming',
|
|
105
|
+
reason: 'incoming_newer_clock',
|
|
106
|
+
localClock,
|
|
107
|
+
incomingClock,
|
|
108
|
+
localHash,
|
|
109
|
+
incomingHash,
|
|
110
|
+
changed: valueChanged || clockChanged,
|
|
111
|
+
valueChanged,
|
|
112
|
+
clockChanged,
|
|
113
|
+
conflict
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (timestampComparison < 0) {
|
|
117
|
+
return {
|
|
118
|
+
winner: 'local',
|
|
119
|
+
reason: 'local_newer_clock',
|
|
120
|
+
localClock,
|
|
121
|
+
incomingClock,
|
|
122
|
+
localHash,
|
|
123
|
+
incomingHash,
|
|
124
|
+
changed: false,
|
|
125
|
+
valueChanged,
|
|
126
|
+
clockChanged,
|
|
127
|
+
conflict
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (incomingClock && !localClock) {
|
|
132
|
+
return {
|
|
133
|
+
winner: 'incoming',
|
|
134
|
+
reason: 'incoming_has_clock',
|
|
135
|
+
localClock,
|
|
136
|
+
incomingClock,
|
|
137
|
+
localHash,
|
|
138
|
+
incomingHash,
|
|
139
|
+
changed: valueChanged || clockChanged,
|
|
140
|
+
valueChanged,
|
|
141
|
+
clockChanged,
|
|
142
|
+
conflict
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
else if (!incomingClock && localClock) {
|
|
146
|
+
return {
|
|
147
|
+
winner: 'local',
|
|
148
|
+
reason: 'local_has_clock',
|
|
149
|
+
localClock,
|
|
150
|
+
incomingClock,
|
|
151
|
+
localHash,
|
|
152
|
+
incomingHash,
|
|
153
|
+
changed: false,
|
|
154
|
+
valueChanged,
|
|
155
|
+
clockChanged,
|
|
156
|
+
conflict
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const hashComparison = compareHashes(incomingHash, localHash);
|
|
160
|
+
if (hashComparison > 0) {
|
|
161
|
+
return {
|
|
162
|
+
winner: 'incoming',
|
|
163
|
+
reason: 'incoming_hash_tiebreak',
|
|
164
|
+
localClock,
|
|
165
|
+
incomingClock,
|
|
166
|
+
localHash,
|
|
167
|
+
incomingHash,
|
|
168
|
+
changed: valueChanged || clockChanged,
|
|
169
|
+
valueChanged,
|
|
170
|
+
clockChanged,
|
|
171
|
+
conflict
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (hashComparison < 0) {
|
|
175
|
+
return {
|
|
176
|
+
winner: 'local',
|
|
177
|
+
reason: 'local_hash_tiebreak',
|
|
178
|
+
localClock,
|
|
179
|
+
incomingClock,
|
|
180
|
+
localHash,
|
|
181
|
+
incomingHash,
|
|
182
|
+
changed: false,
|
|
183
|
+
valueChanged,
|
|
184
|
+
clockChanged,
|
|
185
|
+
conflict
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
winner: 'local',
|
|
190
|
+
reason: 'equal_value',
|
|
191
|
+
localClock,
|
|
192
|
+
incomingClock,
|
|
193
|
+
localHash,
|
|
194
|
+
incomingHash,
|
|
195
|
+
changed: false,
|
|
196
|
+
valueChanged,
|
|
197
|
+
clockChanged,
|
|
198
|
+
conflict
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function buildStateKey(repoId, slug) {
|
|
202
|
+
return `${repoId}:${slug}`;
|
|
203
|
+
}
|
|
204
|
+
function resolveMappedRepo(incomingRepoSyncKey, incomingRepoMeta, repoIndex, repoMap) {
|
|
205
|
+
const mappedValue = repoMap[incomingRepoSyncKey];
|
|
206
|
+
if (typeof mappedValue === 'string' && mappedValue.trim().length > 0) {
|
|
207
|
+
const target = mappedValue.trim();
|
|
208
|
+
const byId = repoIndex.byRepoId.get(target)
|
|
209
|
+
|| repoIndex.byPath.get(normalizePath(target))
|
|
210
|
+
|| repoIndex.bySyncKey.get(target);
|
|
211
|
+
if (!byId) {
|
|
212
|
+
return {
|
|
213
|
+
incomingRepoSyncKey,
|
|
214
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
215
|
+
source: 'unresolved',
|
|
216
|
+
reason: 'map_target_not_found'
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
incomingRepoSyncKey,
|
|
221
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
222
|
+
localRepoId: byId.repoId,
|
|
223
|
+
localRepoPath: byId.repoPath,
|
|
224
|
+
localRepoSyncKey: byId.syncKey,
|
|
225
|
+
source: 'map'
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const bySyncKey = repoIndex.bySyncKey.get(incomingRepoSyncKey);
|
|
229
|
+
if (bySyncKey) {
|
|
230
|
+
return {
|
|
231
|
+
incomingRepoSyncKey,
|
|
232
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
233
|
+
localRepoId: bySyncKey.repoId,
|
|
234
|
+
localRepoPath: bySyncKey.repoPath,
|
|
235
|
+
localRepoSyncKey: bySyncKey.syncKey,
|
|
236
|
+
source: 'sync_key'
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (!incomingRepoMeta?.fingerprint || !incomingRepoMeta.fingerprintKind) {
|
|
240
|
+
return {
|
|
241
|
+
incomingRepoSyncKey,
|
|
242
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
243
|
+
source: 'unresolved',
|
|
244
|
+
reason: incomingRepoMeta ? 'no_match' : 'unknown_repo_metadata'
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const fingerprintKey = `${incomingRepoMeta.fingerprintKind}:${incomingRepoMeta.fingerprint}`;
|
|
248
|
+
const matches = repoIndex.byFingerprint.get(fingerprintKey) || [];
|
|
249
|
+
if (matches.length === 1) {
|
|
250
|
+
const matchedRepo = matches[0];
|
|
251
|
+
return {
|
|
252
|
+
incomingRepoSyncKey,
|
|
253
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
254
|
+
localRepoId: matchedRepo.repoId,
|
|
255
|
+
localRepoPath: matchedRepo.repoPath,
|
|
256
|
+
localRepoSyncKey: matchedRepo.syncKey,
|
|
257
|
+
source: 'fingerprint'
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (matches.length > 1) {
|
|
261
|
+
return {
|
|
262
|
+
incomingRepoSyncKey,
|
|
263
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
264
|
+
source: 'unresolved',
|
|
265
|
+
reason: 'fingerprint_ambiguous'
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
incomingRepoSyncKey,
|
|
270
|
+
...(incomingRepoMeta?.name ? { incomingRepoName: incomingRepoMeta.name } : {}),
|
|
271
|
+
source: 'unresolved',
|
|
272
|
+
reason: 'no_match'
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async function loadLocalStateRows(repoIds) {
|
|
276
|
+
if (repoIds.length === 0) {
|
|
277
|
+
return new Map();
|
|
278
|
+
}
|
|
279
|
+
const placeholders = repoIds.map(() => '?').join(', ');
|
|
280
|
+
const rows = await dbAll(`
|
|
281
|
+
SELECT
|
|
282
|
+
repo_id,
|
|
283
|
+
slug,
|
|
284
|
+
tasks_json,
|
|
285
|
+
progress_json,
|
|
286
|
+
notes_md,
|
|
287
|
+
updated_at,
|
|
288
|
+
tasks_updated_at,
|
|
289
|
+
progress_updated_at,
|
|
290
|
+
notes_updated_at
|
|
291
|
+
FROM prd_states
|
|
292
|
+
WHERE repo_id IN (${placeholders})
|
|
293
|
+
`, repoIds);
|
|
294
|
+
const byKey = new Map();
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
byKey.set(buildStateKey(row.repo_id, row.slug), row);
|
|
297
|
+
}
|
|
298
|
+
return byKey;
|
|
299
|
+
}
|
|
300
|
+
async function loadLocalArchiveRows(repoIds) {
|
|
301
|
+
if (repoIds.length === 0) {
|
|
302
|
+
return new Map();
|
|
303
|
+
}
|
|
304
|
+
const placeholders = repoIds.map(() => '?').join(', ');
|
|
305
|
+
const rows = await dbAll(`
|
|
306
|
+
SELECT repo_id, slug, archived_at
|
|
307
|
+
FROM prd_archives
|
|
308
|
+
WHERE repo_id IN (${placeholders})
|
|
309
|
+
`, repoIds);
|
|
310
|
+
const byKey = new Map();
|
|
311
|
+
for (const row of rows) {
|
|
312
|
+
byKey.set(buildStateKey(row.repo_id, row.slug), row);
|
|
313
|
+
}
|
|
314
|
+
return byKey;
|
|
315
|
+
}
|
|
316
|
+
function getIncomingRepoMeta(bundle, repoSyncKey) {
|
|
317
|
+
const repo = bundle.repos.find((entry) => entry.repoSyncKey === repoSyncKey);
|
|
318
|
+
if (!repo) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
name: repo.name,
|
|
323
|
+
fingerprint: repo.fingerprint,
|
|
324
|
+
fingerprintKind: repo.fingerprintKind
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function parseLocalTasks(row) {
|
|
328
|
+
return parseLocalJson(row.tasks_json, parseTasksFile, (value) => value);
|
|
329
|
+
}
|
|
330
|
+
function parseLocalProgress(row, tasks) {
|
|
331
|
+
return parseLocalJson(row.progress_json, (value) => parseStoredProgressFile(value, {
|
|
332
|
+
totalTasksHint: tasks && typeof tasks === 'object' && !Array.isArray(tasks)
|
|
333
|
+
? Array.isArray(tasks.tasks)
|
|
334
|
+
? (tasks.tasks.length)
|
|
335
|
+
: undefined
|
|
336
|
+
: undefined,
|
|
337
|
+
prdNameFallback: row.slug
|
|
338
|
+
}), (value) => value);
|
|
339
|
+
}
|
|
340
|
+
function buildStateRowPlan(params) {
|
|
341
|
+
const { row, mapping, localStateRows } = params;
|
|
342
|
+
if (!mapping.localRepoId || mapping.source === 'unresolved') {
|
|
343
|
+
return {
|
|
344
|
+
repoSyncKey: row.repoSyncKey,
|
|
345
|
+
slug: row.slug,
|
|
346
|
+
action: 'unresolved',
|
|
347
|
+
mappingSource: mapping.source,
|
|
348
|
+
...(mapping.reason ? { reason: mapping.reason } : {}),
|
|
349
|
+
updateFields: [],
|
|
350
|
+
conflictFields: []
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const stateKey = buildStateKey(mapping.localRepoId, row.slug);
|
|
354
|
+
const localRow = localStateRows.get(stateKey);
|
|
355
|
+
if (!localRow) {
|
|
356
|
+
return {
|
|
357
|
+
repoSyncKey: row.repoSyncKey,
|
|
358
|
+
slug: row.slug,
|
|
359
|
+
action: 'insert',
|
|
360
|
+
localRepoId: mapping.localRepoId,
|
|
361
|
+
...(mapping.localRepoPath ? { localRepoPath: mapping.localRepoPath } : {}),
|
|
362
|
+
mappingSource: mapping.source,
|
|
363
|
+
updateFields: ['tasks', 'progress', 'notes'],
|
|
364
|
+
conflictFields: []
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const localTasks = parseLocalTasks(localRow);
|
|
368
|
+
const localProgress = parseLocalProgress(localRow, localTasks);
|
|
369
|
+
const localNotes = localRow.notes_md;
|
|
370
|
+
const localClocks = {
|
|
371
|
+
tasksUpdatedAt: toClock(localRow.tasks_updated_at, localRow.updated_at, localTasks !== null),
|
|
372
|
+
progressUpdatedAt: toClock(localRow.progress_updated_at, localRow.updated_at, localProgress !== null),
|
|
373
|
+
notesUpdatedAt: toClock(localRow.notes_updated_at, localRow.updated_at, localNotes !== null)
|
|
374
|
+
};
|
|
375
|
+
const localHashes = createSyncFieldHashes({
|
|
376
|
+
tasks: localTasks,
|
|
377
|
+
progress: localProgress,
|
|
378
|
+
notes: localNotes
|
|
379
|
+
});
|
|
380
|
+
const incomingHashes = createSyncFieldHashes({
|
|
381
|
+
tasks: row.tasks,
|
|
382
|
+
progress: row.progress,
|
|
383
|
+
notes: row.notes
|
|
384
|
+
});
|
|
385
|
+
const tasksDecision = decideFieldMerge({
|
|
386
|
+
localClock: localClocks.tasksUpdatedAt,
|
|
387
|
+
incomingClock: row.clocks.tasksUpdatedAt,
|
|
388
|
+
localHash: localHashes.tasksHash,
|
|
389
|
+
incomingHash: incomingHashes.tasksHash
|
|
390
|
+
});
|
|
391
|
+
const progressDecision = decideFieldMerge({
|
|
392
|
+
localClock: localClocks.progressUpdatedAt,
|
|
393
|
+
incomingClock: row.clocks.progressUpdatedAt,
|
|
394
|
+
localHash: localHashes.progressHash,
|
|
395
|
+
incomingHash: incomingHashes.progressHash
|
|
396
|
+
});
|
|
397
|
+
const notesDecision = decideFieldMerge({
|
|
398
|
+
localClock: localClocks.notesUpdatedAt,
|
|
399
|
+
incomingClock: row.clocks.notesUpdatedAt,
|
|
400
|
+
localHash: localHashes.notesHash,
|
|
401
|
+
incomingHash: incomingHashes.notesHash
|
|
402
|
+
});
|
|
403
|
+
const updateFields = [];
|
|
404
|
+
const conflictFields = [];
|
|
405
|
+
if (tasksDecision.changed) {
|
|
406
|
+
updateFields.push('tasks');
|
|
407
|
+
}
|
|
408
|
+
if (progressDecision.changed) {
|
|
409
|
+
updateFields.push('progress');
|
|
410
|
+
}
|
|
411
|
+
if (notesDecision.changed) {
|
|
412
|
+
updateFields.push('notes');
|
|
413
|
+
}
|
|
414
|
+
if (tasksDecision.conflict) {
|
|
415
|
+
conflictFields.push('tasks');
|
|
416
|
+
}
|
|
417
|
+
if (progressDecision.conflict) {
|
|
418
|
+
conflictFields.push('progress');
|
|
419
|
+
}
|
|
420
|
+
if (notesDecision.conflict) {
|
|
421
|
+
conflictFields.push('notes');
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
repoSyncKey: row.repoSyncKey,
|
|
425
|
+
slug: row.slug,
|
|
426
|
+
action: updateFields.length > 0 ? 'update' : 'skip',
|
|
427
|
+
localRepoId: mapping.localRepoId,
|
|
428
|
+
...(mapping.localRepoPath ? { localRepoPath: mapping.localRepoPath } : {}),
|
|
429
|
+
mappingSource: mapping.source,
|
|
430
|
+
updateFields,
|
|
431
|
+
conflictFields,
|
|
432
|
+
fieldDecisions: {
|
|
433
|
+
tasks: tasksDecision,
|
|
434
|
+
progress: progressDecision,
|
|
435
|
+
notes: notesDecision
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function buildArchiveRowPlan(params) {
|
|
440
|
+
const { row, mapping, localArchiveRows } = params;
|
|
441
|
+
if (!mapping.localRepoId || mapping.source === 'unresolved') {
|
|
442
|
+
return {
|
|
443
|
+
repoSyncKey: row.repoSyncKey,
|
|
444
|
+
slug: row.slug,
|
|
445
|
+
action: 'unresolved',
|
|
446
|
+
mappingSource: mapping.source,
|
|
447
|
+
...(mapping.reason ? { reason: mapping.reason } : {})
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const archiveKey = buildStateKey(mapping.localRepoId, row.slug);
|
|
451
|
+
const localRow = localArchiveRows.get(archiveKey);
|
|
452
|
+
if (!localRow) {
|
|
453
|
+
return {
|
|
454
|
+
repoSyncKey: row.repoSyncKey,
|
|
455
|
+
slug: row.slug,
|
|
456
|
+
action: 'insert',
|
|
457
|
+
localRepoId: mapping.localRepoId,
|
|
458
|
+
...(mapping.localRepoPath ? { localRepoPath: mapping.localRepoPath } : {}),
|
|
459
|
+
mappingSource: mapping.source
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const incomingIsNewer = compareIsoTimestamps(row.archivedAt, localRow.archived_at) > 0;
|
|
463
|
+
return {
|
|
464
|
+
repoSyncKey: row.repoSyncKey,
|
|
465
|
+
slug: row.slug,
|
|
466
|
+
action: incomingIsNewer ? 'update' : 'skip',
|
|
467
|
+
localRepoId: mapping.localRepoId,
|
|
468
|
+
...(mapping.localRepoPath ? { localRepoPath: mapping.localRepoPath } : {}),
|
|
469
|
+
mappingSource: mapping.source
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
export async function planSyncMerge(bundleInput, options = {}) {
|
|
473
|
+
const bundle = parseSyncBundle(bundleInput);
|
|
474
|
+
const localRepos = await getRepos();
|
|
475
|
+
const repoMetaById = await ensureRepoSyncMetaForRepos(localRepos);
|
|
476
|
+
const identities = localRepos.map((repo) => {
|
|
477
|
+
const meta = repoMetaById.get(repo.id);
|
|
478
|
+
if (!meta) {
|
|
479
|
+
throw new Error(`Missing sync metadata for local repository ${repo.id}`);
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
repoId: repo.id,
|
|
483
|
+
repoPath: repo.path,
|
|
484
|
+
syncKey: meta.syncKey,
|
|
485
|
+
fingerprint: meta.fingerprint,
|
|
486
|
+
fingerprintKind: meta.fingerprintKind
|
|
487
|
+
};
|
|
488
|
+
});
|
|
489
|
+
const repoIndex = createRepoIndex(identities);
|
|
490
|
+
const incomingRepoKeys = new Set();
|
|
491
|
+
for (const repo of bundle.repos) {
|
|
492
|
+
incomingRepoKeys.add(repo.repoSyncKey);
|
|
493
|
+
}
|
|
494
|
+
for (const row of bundle.states) {
|
|
495
|
+
incomingRepoKeys.add(row.repoSyncKey);
|
|
496
|
+
}
|
|
497
|
+
for (const row of bundle.archives) {
|
|
498
|
+
incomingRepoKeys.add(row.repoSyncKey);
|
|
499
|
+
}
|
|
500
|
+
const mappingResults = Array.from(incomingRepoKeys)
|
|
501
|
+
.sort((a, b) => a.localeCompare(b))
|
|
502
|
+
.map((repoSyncKey) => {
|
|
503
|
+
return resolveMappedRepo(repoSyncKey, getIncomingRepoMeta(bundle, repoSyncKey), repoIndex, options.repoMap || {});
|
|
504
|
+
});
|
|
505
|
+
const mappingByIncomingKey = new Map(mappingResults.map((result) => [result.incomingRepoSyncKey, result]));
|
|
506
|
+
const mappedRepoIds = Array.from(new Set(mappingResults
|
|
507
|
+
.map((result) => result.localRepoId)
|
|
508
|
+
.filter((value) => typeof value === 'string' && value.length > 0)));
|
|
509
|
+
const [localStateRows, localArchiveRows] = await Promise.all([
|
|
510
|
+
loadLocalStateRows(mappedRepoIds),
|
|
511
|
+
loadLocalArchiveRows(mappedRepoIds)
|
|
512
|
+
]);
|
|
513
|
+
const statePlans = bundle.states.map((row) => {
|
|
514
|
+
const mapping = mappingByIncomingKey.get(row.repoSyncKey) || {
|
|
515
|
+
incomingRepoSyncKey: row.repoSyncKey,
|
|
516
|
+
source: 'unresolved',
|
|
517
|
+
reason: 'unknown_repo_metadata'
|
|
518
|
+
};
|
|
519
|
+
return buildStateRowPlan({
|
|
520
|
+
row,
|
|
521
|
+
mapping,
|
|
522
|
+
localStateRows
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
const archivePlans = bundle.archives.map((row) => {
|
|
526
|
+
const mapping = mappingByIncomingKey.get(row.repoSyncKey) || {
|
|
527
|
+
incomingRepoSyncKey: row.repoSyncKey,
|
|
528
|
+
source: 'unresolved',
|
|
529
|
+
reason: 'unknown_repo_metadata'
|
|
530
|
+
};
|
|
531
|
+
return buildArchiveRowPlan({
|
|
532
|
+
row,
|
|
533
|
+
mapping,
|
|
534
|
+
localArchiveRows
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
const summary = {
|
|
538
|
+
repos: {
|
|
539
|
+
mapped: mappingResults.filter((result) => result.source !== 'unresolved').length,
|
|
540
|
+
unresolved: mappingResults.filter((result) => result.source === 'unresolved').length
|
|
541
|
+
},
|
|
542
|
+
states: {
|
|
543
|
+
insert: statePlans.filter((row) => row.action === 'insert').length,
|
|
544
|
+
update: statePlans.filter((row) => row.action === 'update').length,
|
|
545
|
+
skip: statePlans.filter((row) => row.action === 'skip').length,
|
|
546
|
+
unresolved: statePlans.filter((row) => row.action === 'unresolved').length,
|
|
547
|
+
conflicts: statePlans.reduce((sum, row) => sum + row.conflictFields.length, 0)
|
|
548
|
+
},
|
|
549
|
+
archives: {
|
|
550
|
+
insert: archivePlans.filter((row) => row.action === 'insert').length,
|
|
551
|
+
update: archivePlans.filter((row) => row.action === 'update').length,
|
|
552
|
+
skip: archivePlans.filter((row) => row.action === 'skip').length,
|
|
553
|
+
unresolved: archivePlans.filter((row) => row.action === 'unresolved').length
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
return {
|
|
557
|
+
bundle: {
|
|
558
|
+
bundleId: bundle.bundleId,
|
|
559
|
+
formatVersion: bundle.formatVersion,
|
|
560
|
+
sourceDeviceId: bundle.sourceDeviceId,
|
|
561
|
+
createdAt: bundle.createdAt
|
|
562
|
+
},
|
|
563
|
+
mappings: mappingResults,
|
|
564
|
+
states: statePlans,
|
|
565
|
+
archives: archivePlans,
|
|
566
|
+
summary
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
export async function planSyncMergeJson(jsonPayload, options = {}) {
|
|
570
|
+
let parsed;
|
|
571
|
+
try {
|
|
572
|
+
parsed = JSON.parse(jsonPayload);
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
576
|
+
throw new Error(`Invalid bundle JSON: ${message}`);
|
|
577
|
+
}
|
|
578
|
+
return await planSyncMerge(parsed, options);
|
|
579
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const SYNC_BUNDLE_TYPE = 'steward-sync-bundle';
|
|
4
|
+
export const SYNC_BUNDLE_FORMAT_VERSION = 1;
|
|
5
|
+
const prdSlugSchema = z.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/);
|
|
6
|
+
const syncRepoSchema = z.object({
|
|
7
|
+
repoSyncKey: z.string().trim().min(1),
|
|
8
|
+
name: z.string().trim().min(1),
|
|
9
|
+
pathHint: z.string().trim().min(1).optional(),
|
|
10
|
+
fingerprint: z.string().trim().min(1),
|
|
11
|
+
fingerprintKind: z.string().trim().min(1)
|
|
12
|
+
});
|
|
13
|
+
const syncFieldClocksSchema = z.object({
|
|
14
|
+
tasksUpdatedAt: z.string().nullable(),
|
|
15
|
+
progressUpdatedAt: z.string().nullable(),
|
|
16
|
+
notesUpdatedAt: z.string().nullable()
|
|
17
|
+
});
|
|
18
|
+
const syncFieldHashesSchema = z.object({
|
|
19
|
+
tasksHash: z.string().nullable(),
|
|
20
|
+
progressHash: z.string().nullable(),
|
|
21
|
+
notesHash: z.string().nullable()
|
|
22
|
+
});
|
|
23
|
+
const syncStateRowSchema = z.object({
|
|
24
|
+
repoSyncKey: z.string().trim().min(1),
|
|
25
|
+
slug: prdSlugSchema,
|
|
26
|
+
tasks: z.unknown().nullable(),
|
|
27
|
+
progress: z.unknown().nullable(),
|
|
28
|
+
notes: z.string().nullable(),
|
|
29
|
+
clocks: syncFieldClocksSchema,
|
|
30
|
+
hashes: syncFieldHashesSchema
|
|
31
|
+
});
|
|
32
|
+
const syncArchiveRowSchema = z.object({
|
|
33
|
+
repoSyncKey: z.string().trim().min(1),
|
|
34
|
+
slug: prdSlugSchema,
|
|
35
|
+
archivedAt: z.string().trim().min(1)
|
|
36
|
+
});
|
|
37
|
+
const syncBundleSchema = z.object({
|
|
38
|
+
type: z.literal(SYNC_BUNDLE_TYPE),
|
|
39
|
+
formatVersion: z.literal(SYNC_BUNDLE_FORMAT_VERSION),
|
|
40
|
+
bundleId: z.string().trim().min(1),
|
|
41
|
+
createdAt: z.string().trim().min(1),
|
|
42
|
+
sourceDeviceId: z.string().trim().min(1),
|
|
43
|
+
stewardVersion: z.string().trim().min(1),
|
|
44
|
+
repos: z.array(syncRepoSchema),
|
|
45
|
+
states: z.array(syncStateRowSchema),
|
|
46
|
+
archives: z.array(syncArchiveRowSchema)
|
|
47
|
+
});
|
|
48
|
+
function canonicalize(value) {
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return value.map((entry) => canonicalize(entry));
|
|
51
|
+
}
|
|
52
|
+
if (!value || typeof value !== 'object') {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
const objectValue = value;
|
|
56
|
+
const keys = Object.keys(objectValue).sort((a, b) => a.localeCompare(b));
|
|
57
|
+
const result = {};
|
|
58
|
+
for (const key of keys) {
|
|
59
|
+
result[key] = canonicalize(objectValue[key]);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
export function toCanonicalJson(value) {
|
|
64
|
+
return JSON.stringify(canonicalize(value));
|
|
65
|
+
}
|
|
66
|
+
export function hashCanonicalValue(value) {
|
|
67
|
+
return createHash('sha256').update(toCanonicalJson(value)).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
export function hashNullableCanonicalValue(value) {
|
|
70
|
+
if (value === null || value === undefined) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return hashCanonicalValue(value);
|
|
74
|
+
}
|
|
75
|
+
export function createSyncFieldHashes(fields) {
|
|
76
|
+
return {
|
|
77
|
+
tasksHash: hashNullableCanonicalValue(fields.tasks),
|
|
78
|
+
progressHash: hashNullableCanonicalValue(fields.progress),
|
|
79
|
+
notesHash: hashNullableCanonicalValue(fields.notes)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function parseSyncBundle(value) {
|
|
83
|
+
return syncBundleSchema.parse(value);
|
|
84
|
+
}
|
|
85
|
+
export function validateSyncBundle(value) {
|
|
86
|
+
const parsed = syncBundleSchema.safeParse(value);
|
|
87
|
+
if (parsed.success) {
|
|
88
|
+
return { success: true };
|
|
89
|
+
}
|
|
90
|
+
const issue = parsed.error.issues[0];
|
|
91
|
+
if (!issue) {
|
|
92
|
+
return { success: false, error: 'Invalid sync bundle payload' };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
error: issue.path.length > 0
|
|
97
|
+
? `${issue.path.join('.')}: ${issue.message}`
|
|
98
|
+
: issue.message
|
|
99
|
+
};
|
|
100
|
+
}
|