@orion-agents/orion-code 0.3.22 → 0.3.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/CHANGELOG.md +81 -3
- package/README.md +9 -5
- package/README.zh-CN.md +7 -4
- package/dist/harness/failure-attribution.d.ts +23 -0
- package/dist/harness/failure-attribution.js +103 -0
- package/dist/harness/verified-repair.d.ts +71 -0
- package/dist/harness/verified-repair.js +212 -0
- package/dist/product/paths.d.ts +20 -1
- package/dist/product/paths.js +293 -10
- package/dist/runtime/model-coordinator.d.ts +1 -0
- package/dist/runtime/model-coordinator.js +9 -2
- package/dist/runtime/release-receipts.d.ts +3 -3
- package/dist/runtime/release-receipts.js +29 -0
- package/dist/runtime/session-composer-control.js +5 -1
- package/dist/services/config-dir.d.ts +3 -2
- package/dist/services/config-dir.js +6 -29
- package/dist/services/repo-intelligence/index.d.ts +11 -0
- package/dist/services/repo-intelligence/index.js +20 -0
- package/dist/services/repo-intelligence/recall.d.ts +44 -0
- package/dist/services/repo-intelligence/recall.js +115 -0
- package/dist/services/repo-intelligence/symbol-index.d.ts +52 -0
- package/dist/services/repo-intelligence/symbol-index.js +258 -0
- package/dist/services/session-storage.d.ts +18 -2
- package/dist/services/session-storage.js +196 -60
- package/dist/services/workspace-registry.d.ts +9 -0
- package/dist/services/workspace-registry.js +13 -2
- package/dist/web/git-history-service.js +3 -0
- package/dist/web/git-read-model-service.d.ts +9 -0
- package/dist/web/git-read-model-service.js +35 -0
- package/dist/web/git-version-service.d.ts +8 -0
- package/dist/web/git-version-service.js +50 -26
- package/dist/web/protocol.d.ts +13 -0
- package/dist/web/workbench-controller.js +47 -2
- package/dist/web-client/assets/{FilesPanel-CKeXPofZ.js → FilesPanel-CdoCa8u0.js} +1 -1
- package/dist/web-client/assets/{GitPanel-DollPxv0.js → GitPanel-CRr_iIDz.js} +1 -1
- package/dist/web-client/assets/{ReviewPanel-CNADacIn.js → ReviewPanel-C1Cu2IRV.js} +1 -1
- package/dist/web-client/assets/{TerminalPanel-Cyns4uJt.js → TerminalPanel-BniCjatZ.js} +1 -1
- package/dist/web-client/assets/index-kQqA3li6.js +20 -0
- package/dist/web-client/assets/{useAutoNotice-BLADeFFj.js → useAutoNotice-DPXFWIwK.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/services/auto-fix/autoFixConfig.d.ts +0 -30
- package/dist/services/auto-fix/autoFixConfig.js +0 -78
- package/dist/services/auto-fix/autoFixHook.d.ts +0 -27
- package/dist/services/auto-fix/autoFixHook.js +0 -99
- package/dist/services/auto-fix/autoFixRunner.d.ts +0 -60
- package/dist/services/auto-fix/autoFixRunner.js +0 -207
- package/dist/services/auto-fix/index.d.ts +0 -7
- package/dist/services/auto-fix/index.js +0 -17
- package/dist/web-client/assets/index-BhGhORAX.js +0 -20
|
@@ -10,9 +10,11 @@ exports.SESSION_TAG_MAX_LENGTH = exports.SESSION_TAG_LIMIT = exports.SessionComp
|
|
|
10
10
|
exports.resolveProjectPath = resolveProjectPath;
|
|
11
11
|
exports.getProjectKey = getProjectKey;
|
|
12
12
|
exports.isSessionMetaFile = isSessionMetaFile;
|
|
13
|
+
exports.prepareSessionCatalogAsync = prepareSessionCatalogAsync;
|
|
13
14
|
exports.prepareSessionCompactSourceReceipt = prepareSessionCompactSourceReceipt;
|
|
14
15
|
exports.advanceSessionCompactSourceReceipt = advanceSessionCompactSourceReceipt;
|
|
15
16
|
exports.createSession = createSession;
|
|
17
|
+
exports.createSessionAsync = createSessionAsync;
|
|
16
18
|
exports.saveSessionMeta = saveSessionMeta;
|
|
17
19
|
exports.updateSessionEffort = updateSessionEffort;
|
|
18
20
|
exports.updateSessionComposerPreferences = updateSessionComposerPreferences;
|
|
@@ -219,7 +221,6 @@ function sanitizeTraceEvent(event) {
|
|
|
219
221
|
// ============================================================================
|
|
220
222
|
// Project helpers
|
|
221
223
|
// ============================================================================
|
|
222
|
-
const MAX_CACHE_SIZE = 256;
|
|
223
224
|
const SESSION_CATALOG_VERSION = 1;
|
|
224
225
|
let sessionCatalogCache = null;
|
|
225
226
|
function getSessionCatalogPath() {
|
|
@@ -228,63 +229,15 @@ function getSessionCatalogPath() {
|
|
|
228
229
|
function emptySessionCatalog() {
|
|
229
230
|
return { version: SESSION_CATALOG_VERSION, sessions: {} };
|
|
230
231
|
}
|
|
231
|
-
/**
|
|
232
|
-
* Evict the oldest entries when a Map exceeds MAX_CACHE_SIZE.
|
|
233
|
-
* Simple LRU approximation: delete the first (oldest) entries until
|
|
234
|
-
* the map is within bounds. Called after each insertion.
|
|
235
|
-
*/
|
|
236
|
-
function evictOldest(map, maxSize) {
|
|
237
|
-
while (map.size > maxSize) {
|
|
238
|
-
const firstKey = map.keys().next().value;
|
|
239
|
-
if (firstKey !== undefined)
|
|
240
|
-
map.delete(firstKey);
|
|
241
|
-
else
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
const resolvedProjectPathCache = new Map();
|
|
246
232
|
/**
|
|
247
233
|
* Resolve a working directory to the project identity used for session storage.
|
|
248
234
|
* Git repositories share sessions from the repository root; non-git folders use
|
|
249
235
|
* their real absolute path.
|
|
250
236
|
*/
|
|
251
237
|
function resolveProjectPath(cwd = process.cwd()) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return cached;
|
|
256
|
-
}
|
|
257
|
-
let resolvedPath = absolute;
|
|
258
|
-
if ((0, fs_1.existsSync)(absolute)) {
|
|
259
|
-
try {
|
|
260
|
-
const root = (0, child_process_1.execFileSync)('git', ['-C', absolute, 'rev-parse', '--show-toplevel'], {
|
|
261
|
-
encoding: 'utf-8',
|
|
262
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
263
|
-
}).trim();
|
|
264
|
-
if (root) {
|
|
265
|
-
resolvedPath = (0, fs_1.realpathSync)(root);
|
|
266
|
-
resolvedProjectPathCache.set(absolute, resolvedPath);
|
|
267
|
-
evictOldest(resolvedProjectPathCache, MAX_CACHE_SIZE);
|
|
268
|
-
return resolvedPath;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
catch (error) {
|
|
272
|
-
// Not a git worktree, or git is unavailable.
|
|
273
|
-
(0, debug_log_1.debugError)('session-storage.resolveGitRoot', error, absolute);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
try {
|
|
277
|
-
resolvedPath = (0, fs_1.realpathSync)(absolute);
|
|
278
|
-
}
|
|
279
|
-
catch (error) {
|
|
280
|
-
// Broken symlink or concurrent deletion; the literal path still works
|
|
281
|
-
// as a stable project key.
|
|
282
|
-
(0, debug_log_1.debugError)('session-storage.realpath', error, absolute);
|
|
283
|
-
resolvedPath = absolute;
|
|
284
|
-
}
|
|
285
|
-
resolvedProjectPathCache.set(absolute, resolvedPath);
|
|
286
|
-
evictOldest(resolvedProjectPathCache, MAX_CACHE_SIZE);
|
|
287
|
-
return resolvedPath;
|
|
238
|
+
// Storage helpers and Session identity must share the same invalidation
|
|
239
|
+
// boundary; a second permanent cache could miss a newly initialized repo.
|
|
240
|
+
return (0, config_dir_1.resolveProjectStoragePath)(cwd);
|
|
288
241
|
}
|
|
289
242
|
function getProjectKey(projectPath) {
|
|
290
243
|
return (0, config_dir_1.encodeProjectPath)(resolveProjectPath(projectPath));
|
|
@@ -307,8 +260,8 @@ function getGitBranch(projectPath) {
|
|
|
307
260
|
return undefined;
|
|
308
261
|
}
|
|
309
262
|
}
|
|
310
|
-
function normalizeSessionMeta(session) {
|
|
311
|
-
const projectPath = resolveProjectPath(session.projectPath);
|
|
263
|
+
function normalizeSessionMeta(session, persistedStorage) {
|
|
264
|
+
const projectPath = persistedStorage?.projectPath ?? resolveProjectPath(session.projectPath);
|
|
312
265
|
const startTime = session.startTime ?? Date.now();
|
|
313
266
|
const updatedAt = session.updatedAt ?? session.endTime ?? startTime;
|
|
314
267
|
return {
|
|
@@ -321,15 +274,20 @@ function normalizeSessionMeta(session) {
|
|
|
321
274
|
updatedAt,
|
|
322
275
|
updatedAtIso: session.updatedAtIso ?? new Date(updatedAt).toISOString(),
|
|
323
276
|
messageCount: session.messageCount ?? 0,
|
|
324
|
-
historySizeBytes: session.historySizeBytes ??
|
|
277
|
+
historySizeBytes: session.historySizeBytes ??
|
|
278
|
+
(persistedStorage
|
|
279
|
+
? (safeFileSize(persistedStorage.historyPath) ?? 0)
|
|
280
|
+
: computeSessionHistorySizeBytes({ id: session.id, projectPath })),
|
|
325
281
|
tokenCount: session.tokenCount ?? 0,
|
|
326
282
|
cost: session.cost ?? 0,
|
|
327
|
-
|
|
283
|
+
// Normalization is a read-model operation. Missing optional metadata must
|
|
284
|
+
// not spawn Git on every read/save of a non-repository Session.
|
|
285
|
+
gitBranch: session.gitBranch,
|
|
328
286
|
};
|
|
329
287
|
}
|
|
330
|
-
function tryNormalizeSessionMeta(session, sourcePath) {
|
|
288
|
+
function tryNormalizeSessionMeta(session, sourcePath, persistedStorage) {
|
|
331
289
|
try {
|
|
332
|
-
return normalizeSessionMeta(session);
|
|
290
|
+
return normalizeSessionMeta(session, persistedStorage);
|
|
333
291
|
}
|
|
334
292
|
catch (error) {
|
|
335
293
|
(0, debug_log_1.debugError)('session-storage.normalizeSessionMeta', error, sourcePath);
|
|
@@ -479,6 +437,109 @@ function updateSessionCatalog(update) {
|
|
|
479
437
|
return catalog;
|
|
480
438
|
}, { waitMs: 10000 });
|
|
481
439
|
}
|
|
440
|
+
function catalogSourceStamp(path) {
|
|
441
|
+
try {
|
|
442
|
+
const stat = (0, fs_1.statSync)(path);
|
|
443
|
+
return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (error.code === 'ENOENT')
|
|
447
|
+
return 'missing';
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function scanSessionCatalogAsync() {
|
|
452
|
+
const catalog = emptySessionCatalog();
|
|
453
|
+
const stamps = new Map();
|
|
454
|
+
const projectsDir = (0, config_dir_1.getProjectsDir)();
|
|
455
|
+
stamps.set(projectsDir, catalogSourceStamp(projectsDir));
|
|
456
|
+
if (!(0, fs_1.existsSync)(projectsDir))
|
|
457
|
+
return { catalog, stamps };
|
|
458
|
+
let sinceYield = 0;
|
|
459
|
+
for (const projectKey of await fs_1.promises.readdir(projectsDir)) {
|
|
460
|
+
const sessionsDir = (0, path_1.join)(projectsDir, projectKey, 'sessions');
|
|
461
|
+
stamps.set(sessionsDir, catalogSourceStamp(sessionsDir));
|
|
462
|
+
if (!(0, fs_1.existsSync)(sessionsDir))
|
|
463
|
+
continue;
|
|
464
|
+
for (const file of (await fs_1.promises.readdir(sessionsDir)).filter(isSessionMetaFile)) {
|
|
465
|
+
const path = (0, path_1.join)(sessionsDir, file);
|
|
466
|
+
// Stamp before reading. A concurrent atomic replacement between these
|
|
467
|
+
// steps must invalidate the scan rather than bless stale bytes.
|
|
468
|
+
stamps.set(path, catalogSourceStamp(path));
|
|
469
|
+
let raw;
|
|
470
|
+
try {
|
|
471
|
+
raw = JSON.parse(await fs_1.promises.readFile(path, 'utf8'));
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
(0, debug_log_1.debugError)('session-storage.scanCatalogAsync', error, path);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (!raw || typeof raw.id !== 'string' || !raw.id || !raw.projectPath)
|
|
478
|
+
continue;
|
|
479
|
+
if (typeof raw.projectPath !== 'string')
|
|
480
|
+
continue;
|
|
481
|
+
// Historical metadata already owns its persisted root. Rediscovering
|
|
482
|
+
// Git here could migrate it, or let an unrelated broken repo deny Host
|
|
483
|
+
// startup. Rebuild the index without probing any historical repository.
|
|
484
|
+
const normalized = tryNormalizeSessionMeta(raw, path, {
|
|
485
|
+
projectPath: (0, path_1.resolve)(raw.projectPath),
|
|
486
|
+
historyPath: path.slice(0, -'.json'.length) + '.jsonl',
|
|
487
|
+
});
|
|
488
|
+
if (normalized) {
|
|
489
|
+
const previous = catalog.sessions[normalized.id];
|
|
490
|
+
if (!previous ||
|
|
491
|
+
(normalized.updatedAt ?? normalized.startTime) >
|
|
492
|
+
(previous.updatedAt ?? previous.startTime)) {
|
|
493
|
+
catalog.sessions[normalized.id] = normalized;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (++sinceYield >= 16) {
|
|
497
|
+
sinceYield = 0;
|
|
498
|
+
await new Promise(resolveYield => setImmediate(resolveYield));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return { catalog, stamps };
|
|
503
|
+
}
|
|
504
|
+
/** Warm/rebuild the global read model without synchronous lock retry or scan. */
|
|
505
|
+
async function prepareSessionCatalogAsync(waitMs = 10000) {
|
|
506
|
+
(0, config_dir_1.ensureConfigDir)();
|
|
507
|
+
const path = getSessionCatalogPath();
|
|
508
|
+
if (readCachedSessionCatalog(path))
|
|
509
|
+
return;
|
|
510
|
+
const deadline = Date.now() + waitMs;
|
|
511
|
+
let scanned = await scanSessionCatalogAsync();
|
|
512
|
+
const scanChanged = new Error('Session Catalog sources changed during scan.');
|
|
513
|
+
while (true) {
|
|
514
|
+
try {
|
|
515
|
+
(0, file_lock_1.withFileLockSync)(path, () => {
|
|
516
|
+
// A writer may have published a newer Catalog while the scan ran.
|
|
517
|
+
if (readCachedSessionCatalog(path))
|
|
518
|
+
return;
|
|
519
|
+
for (const [sourcePath, stamp] of scanned.stamps) {
|
|
520
|
+
if (catalogSourceStamp(sourcePath) !== stamp)
|
|
521
|
+
throw scanChanged;
|
|
522
|
+
}
|
|
523
|
+
writeSessionCatalog(path, scanned.catalog);
|
|
524
|
+
}, { waitMs: 0 });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
if (Date.now() >= deadline)
|
|
529
|
+
throw error;
|
|
530
|
+
if (error === scanChanged) {
|
|
531
|
+
scanned = await scanSessionCatalogAsync();
|
|
532
|
+
}
|
|
533
|
+
else if (error instanceof Error &&
|
|
534
|
+
error.message === `Timed out waiting for file lock ${path}.lock`) {
|
|
535
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 10));
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
482
543
|
function parseHarnessSidecarFile(path) {
|
|
483
544
|
try {
|
|
484
545
|
const content = (0, fs_1.readFileSync)(path, 'utf-8');
|
|
@@ -913,8 +974,9 @@ function computeSessionHistorySizeBytes(session) {
|
|
|
913
974
|
// ============================================================================
|
|
914
975
|
/**
|
|
915
976
|
* 创建新会话
|
|
977
|
+
* @param options Optional pre-probed Git metadata. Supplying this object skips synchronous Git.
|
|
916
978
|
*/
|
|
917
|
-
function createSession(projectPath, model) {
|
|
979
|
+
function createSession(projectPath, model, options) {
|
|
918
980
|
(0, config_dir_1.ensureConfigDir)();
|
|
919
981
|
const canonicalProjectPath = resolveProjectPath(projectPath);
|
|
920
982
|
const now = Date.now();
|
|
@@ -929,13 +991,87 @@ function createSession(projectPath, model) {
|
|
|
929
991
|
updatedAt: now,
|
|
930
992
|
updatedAtIso: new Date(now).toISOString(),
|
|
931
993
|
messageCount: 0,
|
|
932
|
-
|
|
994
|
+
// Interactive Hosts can supply an asynchronously probed branch. Passing
|
|
995
|
+
// options (including an unavailable branch) deliberately skips the legacy
|
|
996
|
+
// synchronous Git probe; CLI/TUI callers retain their existing behavior.
|
|
997
|
+
gitBranch: options ? options.gitBranch : getGitBranch(canonicalProjectPath),
|
|
933
998
|
tokenCount: 0,
|
|
934
999
|
cost: 0,
|
|
935
1000
|
};
|
|
936
1001
|
saveSessionMeta(session);
|
|
937
1002
|
return session;
|
|
938
1003
|
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Web creation waits for cross-process metadata/catalog locks asynchronously.
|
|
1006
|
+
* Both locks are acquired before any Session bytes are written. The original
|
|
1007
|
+
* synchronous lock owner/recovery and atomic publication remain authoritative.
|
|
1008
|
+
*/
|
|
1009
|
+
async function createSessionAsync(projectPath, model, options = {}) {
|
|
1010
|
+
(0, config_dir_1.ensureConfigDir)();
|
|
1011
|
+
const deadline = Date.now() + (options.waitMs ?? 10000);
|
|
1012
|
+
options.beforeCommit?.();
|
|
1013
|
+
const storageIdentity = await (0, paths_1.resolveProjectStorageIdentityAsync)(projectPath);
|
|
1014
|
+
const canonicalProjectPath = storageIdentity.canonicalPath;
|
|
1015
|
+
await prepareSessionCatalogAsync(Math.max(0, deadline - Date.now()));
|
|
1016
|
+
const now = Date.now();
|
|
1017
|
+
const session = {
|
|
1018
|
+
id: (0, crypto_1.randomUUID)(),
|
|
1019
|
+
projectPath: canonicalProjectPath,
|
|
1020
|
+
projectKey: (0, config_dir_1.encodeProjectPath)(canonicalProjectPath),
|
|
1021
|
+
cwd: (0, path_1.resolve)(projectPath),
|
|
1022
|
+
model,
|
|
1023
|
+
startTime: now,
|
|
1024
|
+
createdAt: new Date(now).toISOString(),
|
|
1025
|
+
updatedAt: now,
|
|
1026
|
+
updatedAtIso: new Date(now).toISOString(),
|
|
1027
|
+
messageCount: 0,
|
|
1028
|
+
historySizeBytes: 0,
|
|
1029
|
+
gitBranch: options.gitBranch,
|
|
1030
|
+
tokenCount: 0,
|
|
1031
|
+
cost: 0,
|
|
1032
|
+
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
|
1033
|
+
};
|
|
1034
|
+
const sessionsDir = (0, paths_1.ensureResolvedProjectDir)(storageIdentity);
|
|
1035
|
+
const metaPath = (0, path_1.join)(sessionsDir, `${session.id}.json`);
|
|
1036
|
+
const catalogPath = getSessionCatalogPath();
|
|
1037
|
+
const lockTimeoutMessages = new Set([metaPath, catalogPath].map(path => `Timed out waiting for file lock ${path}.lock`));
|
|
1038
|
+
while (true) {
|
|
1039
|
+
options.beforeCommit?.();
|
|
1040
|
+
(0, paths_1.assertProjectStorageIdentity)(storageIdentity);
|
|
1041
|
+
try {
|
|
1042
|
+
return (0, file_lock_1.withFileLockSync)(metaPath, () => (0, file_lock_1.withFileLockSync)(catalogPath, () => {
|
|
1043
|
+
options.beforeCommit?.();
|
|
1044
|
+
(0, paths_1.assertProjectStorageIdentity)(storageIdentity);
|
|
1045
|
+
const catalog = readSessionCatalogFile(catalogPath);
|
|
1046
|
+
if (!catalog) {
|
|
1047
|
+
throw new Error('Session Catalog changed before creation. Retry.');
|
|
1048
|
+
}
|
|
1049
|
+
if ((0, fs_1.existsSync)(metaPath))
|
|
1050
|
+
throw new Error('Session identity already exists.');
|
|
1051
|
+
(0, atomic_write_1.atomicWriteFileSync)(metaPath, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
1052
|
+
try {
|
|
1053
|
+
catalog.sessions[session.id] = session;
|
|
1054
|
+
writeSessionCatalog(catalogPath, catalog);
|
|
1055
|
+
}
|
|
1056
|
+
catch (error) {
|
|
1057
|
+
// This identity was not exposed to a caller or Catalog yet.
|
|
1058
|
+
// Roll back its metadata while both locks are still ours.
|
|
1059
|
+
(0, fs_1.unlinkSync)(metaPath);
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
return session;
|
|
1063
|
+
}, { waitMs: 0 }), { waitMs: 0 });
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
if (!(error instanceof Error) ||
|
|
1067
|
+
!lockTimeoutMessages.has(error.message) ||
|
|
1068
|
+
Date.now() >= deadline) {
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 10));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
939
1075
|
/**
|
|
940
1076
|
* 保存会话元数据
|
|
941
1077
|
*/
|
|
@@ -3,6 +3,15 @@ export interface WorkspaceRegistryEntryV1 {
|
|
|
3
3
|
readonly canonicalPath: string;
|
|
4
4
|
readonly label: string;
|
|
5
5
|
readonly lastActivatedAt: string;
|
|
6
|
+
/**
|
|
7
|
+
* v0.3.25 #272 — when the workspace was first registered. Navigation order is
|
|
8
|
+
* derived from this stable timestamp; `lastActivatedAt` no longer reorders
|
|
9
|
+
* the tree when sessions are activated. Documents persisted before this field
|
|
10
|
+
* existed are backfilled from `lastActivatedAt` on parse (and persisted with
|
|
11
|
+
* the backfilled value on the next write), which keeps the migration
|
|
12
|
+
* deterministic.
|
|
13
|
+
*/
|
|
14
|
+
readonly registeredAt: string;
|
|
6
15
|
readonly pinnedOrder?: number;
|
|
7
16
|
}
|
|
8
17
|
export interface WorkspaceRegistryOptions {
|
|
@@ -47,6 +47,7 @@ class WorkspaceRegistryV1 {
|
|
|
47
47
|
canonicalPath,
|
|
48
48
|
label: workspaceLabel(canonicalPath),
|
|
49
49
|
lastActivatedAt: options.activated ? now : (existing?.lastActivatedAt ?? now),
|
|
50
|
+
registeredAt: existing?.registeredAt ?? now,
|
|
50
51
|
...(existing?.pinnedOrder !== undefined ? { pinnedOrder: existing.pinnedOrder } : {}),
|
|
51
52
|
});
|
|
52
53
|
const entries = existing
|
|
@@ -74,6 +75,7 @@ class WorkspaceRegistryV1 {
|
|
|
74
75
|
canonicalPath: path,
|
|
75
76
|
label: workspaceLabel(path),
|
|
76
77
|
lastActivatedAt: path === active ? now : (existing?.lastActivatedAt ?? now),
|
|
78
|
+
registeredAt: existing?.registeredAt ?? now,
|
|
77
79
|
...(existing?.pinnedOrder !== undefined ? { pinnedOrder: existing.pinnedOrder } : {}),
|
|
78
80
|
}));
|
|
79
81
|
}
|
|
@@ -102,6 +104,7 @@ class WorkspaceRegistryV1 {
|
|
|
102
104
|
canonicalPath: target.canonicalPath,
|
|
103
105
|
label: target.label,
|
|
104
106
|
lastActivatedAt: target.lastActivatedAt,
|
|
107
|
+
registeredAt: target.registeredAt,
|
|
105
108
|
...(pinned ? { pinnedOrder: target.pinnedOrder ?? currentMax + 1 } : {}),
|
|
106
109
|
});
|
|
107
110
|
this.writeDocument(document.entries.map(entry => (entry.id === id ? updated : entry)));
|
|
@@ -189,6 +192,11 @@ function parseEntry(value) {
|
|
|
189
192
|
!row.label.trim() ||
|
|
190
193
|
typeof row.lastActivatedAt !== 'string' ||
|
|
191
194
|
!Number.isFinite(Date.parse(row.lastActivatedAt)) ||
|
|
195
|
+
// v0.3.25 #272 — older documents have no registeredAt; backfill from
|
|
196
|
+
// lastActivatedAt so the migration is deterministic and the write-back
|
|
197
|
+
// persists the stable value.
|
|
198
|
+
(row.registeredAt !== undefined &&
|
|
199
|
+
(typeof row.registeredAt !== 'string' || !Number.isFinite(Date.parse(row.registeredAt)))) ||
|
|
192
200
|
(row.pinnedOrder !== undefined &&
|
|
193
201
|
(!Number.isSafeInteger(row.pinnedOrder) || Number(row.pinnedOrder) < 1))) {
|
|
194
202
|
return invalidDocument();
|
|
@@ -198,6 +206,7 @@ function parseEntry(value) {
|
|
|
198
206
|
canonicalPath: row.canonicalPath,
|
|
199
207
|
label: row.label,
|
|
200
208
|
lastActivatedAt: row.lastActivatedAt,
|
|
209
|
+
registeredAt: typeof row.registeredAt === 'string' ? row.registeredAt : row.lastActivatedAt,
|
|
201
210
|
...(row.pinnedOrder !== undefined ? { pinnedOrder: Number(row.pinnedOrder) } : {}),
|
|
202
211
|
});
|
|
203
212
|
}
|
|
@@ -209,8 +218,10 @@ function compareEntries(left, right) {
|
|
|
209
218
|
const rightPinned = right.pinnedOrder ?? Number.MAX_SAFE_INTEGER;
|
|
210
219
|
if (leftPinned !== rightPinned)
|
|
211
220
|
return leftPinned - rightPinned;
|
|
212
|
-
|
|
213
|
-
|
|
221
|
+
// v0.3.25 #272 — stable registration order: activating a session must not
|
|
222
|
+
// reorder the navigation tree, so lastActivatedAt no longer participates.
|
|
223
|
+
const registration = Date.parse(left.registeredAt) - Date.parse(right.registeredAt);
|
|
224
|
+
return registration || left.label.localeCompare(right.label) || left.id.localeCompare(right.id);
|
|
214
225
|
}
|
|
215
226
|
function freezeEntries(entries) {
|
|
216
227
|
return Object.freeze([...entries].sort(compareEntries).map(entry => Object.freeze({ ...entry })));
|
|
@@ -572,11 +572,14 @@ function createGitHistoryReader(options) {
|
|
|
572
572
|
// `--follow` only accepts a single path, which is exactly this case. It explains its own
|
|
573
573
|
// limits for copies and complex merges (plan G6) — we surface `followed` rather than
|
|
574
574
|
// pretending tracking is complete.
|
|
575
|
+
// v0.3.25 #270 — the validated revision must reach the log command: it was
|
|
576
|
+
// previously used only for the existence check, so every query walked HEAD.
|
|
575
577
|
const raw = await runGit([
|
|
576
578
|
'log',
|
|
577
579
|
`--max-count=${limit + 1}`,
|
|
578
580
|
`--format=${LIST_FORMAT}${RECORD_SEPARATOR}`,
|
|
579
581
|
'--follow',
|
|
582
|
+
target,
|
|
580
583
|
'--',
|
|
581
584
|
path,
|
|
582
585
|
]);
|
|
@@ -207,6 +207,15 @@ export declare class GitReadModelServiceV1 {
|
|
|
207
207
|
}): Promise<GitDiffDocumentV2>;
|
|
208
208
|
private versionReaderInstance?;
|
|
209
209
|
private versionReader;
|
|
210
|
+
/**
|
|
211
|
+
* v0.3.25 #269 — reads one working-tree file for the conflict view, refusing
|
|
212
|
+
* to leave the repository: the requested relative path (already guarded as
|
|
213
|
+
* relative and non-sensitive by the version reader) is resolved against the
|
|
214
|
+
* repository root and both the file's real path and its parent's real path
|
|
215
|
+
* must stay inside the root's real path. Returns null instead of throwing so
|
|
216
|
+
* a missing or unreadable file reads as an absent worktree version.
|
|
217
|
+
*/
|
|
218
|
+
private readContainedWorktreeFile;
|
|
210
219
|
/**
|
|
211
220
|
* v0.3.17 S5 — base / ours / theirs / worktree for a conflicted file.
|
|
212
221
|
*
|
|
@@ -6,6 +6,7 @@ exports.assertRelativeGitPath = assertRelativeGitPath;
|
|
|
6
6
|
const child_process_1 = require("child_process");
|
|
7
7
|
const crypto_1 = require("crypto");
|
|
8
8
|
const fs_1 = require("fs");
|
|
9
|
+
const promises_1 = require("fs/promises");
|
|
9
10
|
const path_1 = require("path");
|
|
10
11
|
const redaction_1 = require("../services/redaction");
|
|
11
12
|
const errors_1 = require("./errors");
|
|
@@ -349,9 +350,43 @@ class GitReadModelServiceV1 {
|
|
|
349
350
|
this.versionReaderInstance ?? (this.versionReaderInstance = (0, git_version_service_1.createGitVersionReader)({
|
|
350
351
|
runGit: async (args) => this.runGit(args, await this.requireRepositoryRoot()),
|
|
351
352
|
runGitBytes: async (args) => this.runGitBytes(args, await this.requireRepositoryRoot()),
|
|
353
|
+
readWorktreeFile: async (relativePath) => this.readContainedWorktreeFile(relativePath),
|
|
352
354
|
}));
|
|
353
355
|
return this.versionReaderInstance;
|
|
354
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* v0.3.25 #269 — reads one working-tree file for the conflict view, refusing
|
|
359
|
+
* to leave the repository: the requested relative path (already guarded as
|
|
360
|
+
* relative and non-sensitive by the version reader) is resolved against the
|
|
361
|
+
* repository root and both the file's real path and its parent's real path
|
|
362
|
+
* must stay inside the root's real path. Returns null instead of throwing so
|
|
363
|
+
* a missing or unreadable file reads as an absent worktree version.
|
|
364
|
+
*/
|
|
365
|
+
async readContainedWorktreeFile(relativePath) {
|
|
366
|
+
let root;
|
|
367
|
+
try {
|
|
368
|
+
root = (0, fs_1.realpathSync)(await this.requireRepositoryRoot());
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const absolute = (0, path_1.resolve)(root, relativePath);
|
|
375
|
+
const fileReal = (0, fs_1.realpathSync)(absolute);
|
|
376
|
+
const parentReal = (0, fs_1.realpathSync)((0, path_1.resolve)(fileReal, '..'));
|
|
377
|
+
if (fileReal !== root && !fileReal.startsWith(root + path_1.sep))
|
|
378
|
+
return null;
|
|
379
|
+
if (parentReal !== root && !parentReal.startsWith(root + path_1.sep))
|
|
380
|
+
return null;
|
|
381
|
+
const stat = (0, fs_1.statSync)(fileReal);
|
|
382
|
+
if (!stat.isFile())
|
|
383
|
+
return null;
|
|
384
|
+
return await (0, promises_1.readFile)(fileReal);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
355
390
|
/**
|
|
356
391
|
* v0.3.17 S5 — base / ours / theirs / worktree for a conflicted file.
|
|
357
392
|
*
|
|
@@ -66,6 +66,14 @@ export interface GitVersionReaderOptions {
|
|
|
66
66
|
readonly runGit: (args: readonly string[]) => Promise<string>;
|
|
67
67
|
/** Runs git and returns raw bytes, for content that may not be valid UTF-8. */
|
|
68
68
|
readonly runGitBytes: (args: readonly string[]) => Promise<Buffer>;
|
|
69
|
+
/**
|
|
70
|
+
* v0.3.25 #269 — reads one working-tree file relative to the repository root,
|
|
71
|
+
* returning null when it does not exist or cannot be read safely. The
|
|
72
|
+
* `worktree` conflict version must come from the filesystem: `hash-object`
|
|
73
|
+
* without `-w` computes an OID that is not in the object database, so any
|
|
74
|
+
* edited working file made `cat-file` fail the whole conflict response.
|
|
75
|
+
*/
|
|
76
|
+
readonly readWorktreeFile?: (relativePath: string) => Promise<Buffer | null>;
|
|
69
77
|
}
|
|
70
78
|
export declare function createGitVersionReader(options: GitVersionReaderOptions): GitVersionReader;
|
|
71
79
|
//# sourceMappingURL=git-version-service.d.ts.map
|
|
@@ -85,28 +85,25 @@ function parseLfsPointer(head) {
|
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
function createGitVersionReader(options) {
|
|
88
|
-
const { runGit, runGitBytes } = options;
|
|
88
|
+
const { runGit, runGitBytes, readWorktreeFile } = options;
|
|
89
|
+
/** Absent version — the label legitimately has no content for this shape. */
|
|
90
|
+
const absentVersion = (label) => Object.freeze({
|
|
91
|
+
label,
|
|
92
|
+
oid: null,
|
|
93
|
+
exists: false,
|
|
94
|
+
binary: false,
|
|
95
|
+
byteSize: 0,
|
|
96
|
+
truncated: false,
|
|
97
|
+
content: null,
|
|
98
|
+
lfs: null,
|
|
99
|
+
});
|
|
89
100
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* being pulled into memory just to be truncated.
|
|
101
|
+
* v0.3.25 #269 — decodes already-fetched bytes into a version, shared by the
|
|
102
|
+
* object-database reader and the working-tree reader so both apply the same
|
|
103
|
+
* size bound, binary probe and LFS-pointer detection.
|
|
94
104
|
*/
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
return Object.freeze({
|
|
98
|
-
label,
|
|
99
|
-
oid: null,
|
|
100
|
-
exists: false,
|
|
101
|
-
binary: false,
|
|
102
|
-
byteSize: 0,
|
|
103
|
-
truncated: false,
|
|
104
|
-
content: null,
|
|
105
|
-
lfs: null,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
const sizeRaw = (await runGit(['cat-file', '-s', oid])).trim();
|
|
109
|
-
const byteSize = Number(sizeRaw) || 0;
|
|
105
|
+
const versionFromBytes = (label, oid, bytes) => {
|
|
106
|
+
const byteSize = bytes.length;
|
|
110
107
|
if (byteSize > MAX_VERSION_BYTES) {
|
|
111
108
|
return Object.freeze({
|
|
112
109
|
label,
|
|
@@ -119,7 +116,6 @@ function createGitVersionReader(options) {
|
|
|
119
116
|
lfs: null,
|
|
120
117
|
});
|
|
121
118
|
}
|
|
122
|
-
const bytes = await runGitBytes(['cat-file', 'blob', oid]);
|
|
123
119
|
const probe = bytes.subarray(0, LFS_PROBE_BYTES).toString('utf8');
|
|
124
120
|
const lfs = parseLfsPointer(probe);
|
|
125
121
|
if (lfs) {
|
|
@@ -149,6 +145,31 @@ function createGitVersionReader(options) {
|
|
|
149
145
|
dataUrl,
|
|
150
146
|
});
|
|
151
147
|
};
|
|
148
|
+
/**
|
|
149
|
+
* Reads one blob by OID, bounded, with binary and LFS detection.
|
|
150
|
+
*
|
|
151
|
+
* The size is read first so an enormous historical file is reported by its size instead of
|
|
152
|
+
* being pulled into memory just to be truncated.
|
|
153
|
+
*/
|
|
154
|
+
const readVersion = async (label, oid) => {
|
|
155
|
+
if (oid === null)
|
|
156
|
+
return absentVersion(label);
|
|
157
|
+
const sizeRaw = (await runGit(['cat-file', '-s', oid])).trim();
|
|
158
|
+
const byteSize = Number(sizeRaw) || 0;
|
|
159
|
+
if (byteSize > MAX_VERSION_BYTES) {
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
label,
|
|
162
|
+
oid,
|
|
163
|
+
exists: true,
|
|
164
|
+
binary: false,
|
|
165
|
+
byteSize,
|
|
166
|
+
truncated: true,
|
|
167
|
+
content: null,
|
|
168
|
+
lfs: null,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return versionFromBytes(label, oid, await runGitBytes(['cat-file', 'blob', oid]));
|
|
172
|
+
};
|
|
152
173
|
return {
|
|
153
174
|
async conflict(pathInput) {
|
|
154
175
|
const path = assertRelativePath(pathInput);
|
|
@@ -168,15 +189,18 @@ function createGitVersionReader(options) {
|
|
|
168
189
|
continue;
|
|
169
190
|
stageOids.set(Number(stage), oid);
|
|
170
191
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
// v0.3.25 #269 — the working file is read from the filesystem. The previous
|
|
193
|
+
// `hash-object` (without -w) → `cat-file` chain only worked while the edited
|
|
194
|
+
// content happened to match an object git already stored; any real edit made
|
|
195
|
+
// `cat-file` fail the entire conflict response.
|
|
196
|
+
const worktreeBytes = await readWorktreeFile?.(path);
|
|
175
197
|
const [base, ours, theirs, worktree] = await Promise.all([
|
|
176
198
|
readVersion('base', stageOids.get(1) ?? null),
|
|
177
199
|
readVersion('ours', stageOids.get(2) ?? null),
|
|
178
200
|
readVersion('theirs', stageOids.get(3) ?? null),
|
|
179
|
-
|
|
201
|
+
worktreeBytes
|
|
202
|
+
? Promise.resolve(versionFromBytes('worktree', null, worktreeBytes))
|
|
203
|
+
: Promise.resolve(absentVersion('worktree')),
|
|
180
204
|
]);
|
|
181
205
|
const hasBase = stageOids.has(1);
|
|
182
206
|
const hasOurs = stageOids.has(2);
|
package/dist/web/protocol.d.ts
CHANGED
|
@@ -361,6 +361,11 @@ export interface WebWorkspaceSummaryV1 {
|
|
|
361
361
|
readonly available: boolean;
|
|
362
362
|
readonly sessionCount: number;
|
|
363
363
|
readonly lastActivatedAt: string;
|
|
364
|
+
/**
|
|
365
|
+
* v0.3.25 #272 — when the workspace was first registered. The navigation's
|
|
366
|
+
* stable default order is derived from this; optional for older Hosts.
|
|
367
|
+
*/
|
|
368
|
+
readonly registeredAt?: string;
|
|
364
369
|
readonly pinnedOrder?: number;
|
|
365
370
|
}
|
|
366
371
|
export interface WebWorkspaceProjectSummaryV1 {
|
|
@@ -471,6 +476,14 @@ export interface WebBootstrapV1 {
|
|
|
471
476
|
readonly workspace: string;
|
|
472
477
|
readonly configured: boolean;
|
|
473
478
|
readonly activeSessionId: string | null;
|
|
479
|
+
/**
|
|
480
|
+
* v0.3.24 — the Host's latest durable event cursor at bootstrap time. Clients
|
|
481
|
+
* use it as the replay anchor: durable envelopes at or below this cursor are
|
|
482
|
+
* already reflected by the baseline and must not be re-applied (a reloaded
|
|
483
|
+
* tab otherwise replays pre-baseline `workbench_state` broadcasts that would
|
|
484
|
+
* clear its restored foreground Session). Older clients ignore it.
|
|
485
|
+
*/
|
|
486
|
+
readonly eventCursor?: number;
|
|
474
487
|
readonly settings: WebSettingsSnapshotV1;
|
|
475
488
|
readonly capabilities: {
|
|
476
489
|
readonly goal: true;
|