@anionex/dsh-turn-rewind 0.2.3 → 0.3.1

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/lib/engine.js CHANGED
@@ -4,11 +4,12 @@ import { join, resolve } from 'node:path';
4
4
  import { lstat, realpath } from 'node:fs/promises';
5
5
  import { createDeadline } from './deadline.js';
6
6
  import { ChangeLedgerError, errorMessage } from './errors.js';
7
- import { discoverRepository, discoverRepositoryRoot, ensureGitWorktreeIdentity, resolveGitWorktreeRoot, sameRepositoryFence, } from './git.js';
7
+ import { ensureGitWorktreeIdentity, resolveGitWorktreeRoot } from './git.js';
8
8
  import { captureGitTurnCheckpoint, deleteGitCheckpoint, deleteGitCheckpointRef, publishGitCheckpoint, readGitBlob, verifyGitCheckpoint, } from './git-checkpoint.js';
9
9
  import { assertSafeParents, ensureSafeParents, expandHome, isNodeError, isWithin, pruneEmptyParents, removeRestoreTarget, replaceRegularFile, replaceSymbolicLink, resolveWorkspacePath, validateRelativePath, } from './path-utils.js';
10
10
  import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
11
11
  import { LedgerStore } from './store.js';
12
+ import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js';
12
13
  import { LEDGER_FORMAT_VERSION, } from './types.js';
13
14
  const DEFAULTS = {
14
15
  maxRestorePoints: 50,
@@ -59,12 +60,21 @@ export class ChangeLedgerEngine {
59
60
  const restoredOperations = await this.store.initialize();
60
61
  return restoredOperations + await this.reconcileGitCheckpointJournals();
61
62
  }
63
+ /**
64
+ * Acquire the exclusive lock for one workspace.
65
+ *
66
+ * A Git worktree also binds the shared per-worktree lock derived from its Git
67
+ * identity; an ordinary directory owns only the durable directory lock.
68
+ */
62
69
  async acquireWorkspace(workspace, signal) {
63
- const identity = await ensureGitWorktreeIdentity(workspace, signal);
64
- if (identity.root !== workspace) {
65
- throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`);
70
+ if (workspace.type === 'directory') {
71
+ return this.store.acquire(workspace.root, undefined, undefined, signal);
72
+ }
73
+ const identity = await ensureGitWorktreeIdentity(workspace.root, signal);
74
+ if (identity.root !== workspace.root) {
75
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace.root)}`);
66
76
  }
67
- return this.store.acquire(workspace, identity.lockPath, {
77
+ return this.store.acquire(workspace.root, identity.lockPath, {
68
78
  commonDir: identity.commonDir,
69
79
  gitDir: identity.gitDir,
70
80
  worktreeId: identity.worktreeId,
@@ -75,7 +85,7 @@ export class ChangeLedgerEngine {
75
85
  for (const journal of await this.store.listGitCheckpointJournals()) {
76
86
  const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace);
77
87
  const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace };
78
- const release = await this.acquireWorkspace(workspace);
88
+ const release = await this.acquireWorkspace({ type: 'git', root: workspace });
79
89
  try {
80
90
  const manifest = await this.tryReadManifest(workspace, journal.restorePointId);
81
91
  if (journal.action === 'publish') {
@@ -167,9 +177,9 @@ export class ChangeLedgerEngine {
167
177
  /** Create a durable restore point for the current Git worktree. */
168
178
  async create(options) {
169
179
  await this.ready;
170
- const source = await discoverRepository(options.cwd, options.signal);
180
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
171
181
  await this.assertStorageSeparated(source.state.root);
172
- const release = await this.acquireWorkspace(source.state.root, options.signal);
182
+ const release = await this.acquireWorkspace(source.state, options.signal);
173
183
  try {
174
184
  const label = normalizeLabel(options.label);
175
185
  const manifest = await this.createLocked({
@@ -206,9 +216,9 @@ export class ChangeLedgerEngine {
206
216
  throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
207
217
  }
208
218
  try {
209
- const source = await discoverRepository(options.cwd, signal);
219
+ const source = await discoverWorkspace(options.cwd, this.config, signal);
210
220
  await this.assertStorageSeparated(source.state.root);
211
- const release = await this.acquireWorkspace(source.state.root, signal);
221
+ const release = await this.acquireWorkspace(source.state, signal);
212
222
  try {
213
223
  const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal);
214
224
  const duplicate = existing.find(manifest => manifest.kind === 'turn'
@@ -223,7 +233,9 @@ export class ChangeLedgerEngine {
223
233
  }
224
234
  throwIfAborted(signal);
225
235
  let manifest;
226
- if (this.config.turnCheckpointMode === 'legacy') {
236
+ // Git-native checkpoints live in the repository object database, so an
237
+ // ordinary directory always uses the ledger's own content-addressed store.
238
+ if (this.config.turnCheckpointMode === 'legacy' || source.state.type === 'directory') {
227
239
  manifest = await this.createLocked({
228
240
  cwd: source.state.root,
229
241
  kind: 'turn',
@@ -278,20 +290,20 @@ export class ChangeLedgerEngine {
278
290
  /** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
279
291
  async recordTurnCheckpointSkip(options) {
280
292
  await waitWithSignal(this.ready, options.signal);
281
- const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
282
- await this.assertStorageSeparated(workspace);
293
+ const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal);
294
+ await this.assertStorageSeparated(workspace.root);
283
295
  const release = await this.acquireWorkspace(workspace, options.signal);
284
296
  try {
285
297
  throwIfAborted(options.signal);
286
- const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest => manifest.kind === 'turn'
298
+ const existing = (await this.store.listManifests(workspace.root, options.signal)).some(manifest => manifest.kind === 'turn'
287
299
  && manifest.sessionId === options.sessionId
288
300
  && manifest.turn === options.turn
289
301
  && manifest.turnStartSeq === options.turnStartSeq);
290
302
  if (existing) {
291
- await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
303
+ await this.store.deleteTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq);
292
304
  return;
293
305
  }
294
- await this.store.writeTurnCheckpointSkip(workspace, {
306
+ await this.store.writeTurnCheckpointSkip(workspace.root, {
295
307
  version: 1,
296
308
  sessionId: options.sessionId,
297
309
  turn: options.turn,
@@ -307,11 +319,11 @@ export class ChangeLedgerEngine {
307
319
  /** Read a durable skip marker for one exact prompt boundary. */
308
320
  async findTurnCheckpointSkip(options) {
309
321
  await this.ready;
310
- const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
311
- await this.assertStorageSeparated(workspace);
322
+ const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal);
323
+ await this.assertStorageSeparated(workspace.root);
312
324
  const release = await this.acquireWorkspace(workspace, options.signal);
313
325
  try {
314
- const skip = await this.store.readTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
326
+ const skip = await this.store.readTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq);
315
327
  return skip === undefined ? undefined : { reason: skip.reason };
316
328
  }
317
329
  finally {
@@ -321,9 +333,9 @@ export class ChangeLedgerEngine {
321
333
  /** Find the prompt-anchored checkpoint captured before one session turn. */
322
334
  async findTurnCheckpoint(options) {
323
335
  await this.ready;
324
- const source = await discoverRepository(options.cwd, options.signal);
336
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
325
337
  await this.assertStorageSeparated(source.state.root);
326
- const release = await this.acquireWorkspace(source.state.root, options.signal);
338
+ const release = await this.acquireWorkspace(source.state, options.signal);
327
339
  try {
328
340
  const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
329
341
  && point.sessionId === options.sessionId
@@ -340,9 +352,9 @@ export class ChangeLedgerEngine {
340
352
  /** List restore points for the current worktree. */
341
353
  async list(options) {
342
354
  await this.ready;
343
- const source = await discoverRepository(options.cwd, options.signal);
355
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
344
356
  await this.assertStorageSeparated(source.state.root);
345
- const release = await this.acquireWorkspace(source.state.root, options.signal);
357
+ const release = await this.acquireWorkspace(source.state, options.signal);
346
358
  try {
347
359
  const manifests = await this.store.listManifests(source.state.root);
348
360
  return manifests
@@ -358,9 +370,9 @@ export class ChangeLedgerEngine {
358
370
  /** Compare one restore point with the current worktree. */
359
371
  async inspect(options) {
360
372
  await this.ready;
361
- const source = await discoverRepository(options.cwd, options.signal);
373
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
362
374
  await this.assertStorageSeparated(source.state.root);
363
- const release = await this.acquireWorkspace(source.state.root, options.signal);
375
+ const release = await this.acquireWorkspace(source.state, options.signal);
364
376
  try {
365
377
  const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
366
378
  if (manifest.version === 2)
@@ -374,15 +386,17 @@ export class ChangeLedgerEngine {
374
386
  const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
375
387
  if (currentEntries === undefined)
376
388
  throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
389
+ const currentState = current.source.state;
390
+ assertWorkspaceIdentityCompatible(manifest.repository, currentState);
377
391
  return {
378
392
  restorePoint: summarize(manifest),
379
393
  currentTreeHash: current.treeHash,
380
- currentRepository: current.source.state,
381
- ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
382
- ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
383
- ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
384
- headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
385
- operationChanged: manifest.repository.operation !== current.source.state.operation,
394
+ currentRepository: currentState,
395
+ ...(currentState.type === 'git' && currentState.head !== undefined ? { currentHead: currentState.head } : {}),
396
+ ...(currentState.type === 'git' && currentState.branch !== undefined ? { currentBranch: currentState.branch } : {}),
397
+ ...(currentState.type === 'git' && currentState.operation !== undefined ? { currentOperation: currentState.operation } : {}),
398
+ headChanged: repositoryHeadChanged(manifest.repository, currentState),
399
+ operationChanged: workspaceOperationChanged(manifest.repository, currentState),
386
400
  changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
387
401
  };
388
402
  }
@@ -394,9 +408,9 @@ export class ChangeLedgerEngine {
394
408
  async planRestore(options) {
395
409
  await this.ready;
396
410
  this.expirePlans();
397
- const source = await discoverRepository(options.cwd, options.signal);
411
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
398
412
  await this.assertStorageSeparated(source.state.root);
399
- const release = await this.acquireWorkspace(source.state.root, options.signal);
413
+ const release = await this.acquireWorkspace(source.state, options.signal);
400
414
  try {
401
415
  const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
402
416
  if (manifest.version === 2)
@@ -413,8 +427,8 @@ export class ChangeLedgerEngine {
413
427
  if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
414
428
  throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
415
429
  }
416
- if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
417
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again');
430
+ if (options.expectedRepository !== undefined && !sameWorkspaceFence(options.expectedRepository, current.source.state)) {
431
+ throw workspaceStaleError(options.expectedRepository, current.source.state, 'inspection');
418
432
  }
419
433
  assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
420
434
  const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries);
@@ -467,7 +481,7 @@ export class ChangeLedgerEngine {
467
481
  this.activePlans.add(plan.id);
468
482
  try {
469
483
  await this.assertStorageSeparated(plan.workspace);
470
- const release = await this.acquireWorkspace(plan.workspace, options.signal);
484
+ const release = await this.acquireWorkspace(plan.repository, options.signal);
471
485
  try {
472
486
  const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId);
473
487
  if (manifest.version === 2)
@@ -590,9 +604,9 @@ export class ChangeLedgerEngine {
590
604
  if (options.confirmation !== `DELETE ${options.restorePointId}`) {
591
605
  throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`);
592
606
  }
593
- const source = await discoverRepository(options.cwd, options.signal);
607
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
594
608
  await this.assertStorageSeparated(source.state.root);
595
- const release = await this.acquireWorkspace(source.state.root, options.signal);
609
+ const release = await this.acquireWorkspace(source.state, options.signal);
596
610
  try {
597
611
  const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
598
612
  if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
@@ -609,9 +623,9 @@ export class ChangeLedgerEngine {
609
623
  /** List restore operations that were interrupted or require manual recovery. */
610
624
  async listRecovery(options) {
611
625
  await this.ready;
612
- const source = await discoverRepository(options.cwd, options.signal);
626
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
613
627
  await this.assertStorageSeparated(source.state.root);
614
- const release = await this.acquireWorkspace(source.state.root, options.signal);
628
+ const release = await this.acquireWorkspace(source.state, options.signal);
615
629
  try {
616
630
  return (await this.store.listOperations(source.state.root))
617
631
  .filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
@@ -698,10 +712,10 @@ export class ChangeLedgerEngine {
698
712
  let liveRepository = false;
699
713
  let release;
700
714
  try {
701
- const root = await discoverRepositoryRoot(workspace, signal);
702
- if (root === workspace) {
703
- await this.assertStorageSeparated(root);
704
- release = await this.acquireWorkspace(root, signal);
715
+ const identity = await discoverWorkspaceIdentity(workspace, signal);
716
+ if (identity.root === workspace) {
717
+ await this.assertStorageSeparated(identity.root);
718
+ release = await this.acquireWorkspace(identity, signal);
705
719
  liveRepository = true;
706
720
  }
707
721
  else {
@@ -938,11 +952,13 @@ export function resolveConfig(config) {
938
952
  };
939
953
  }
940
954
  function summarize(manifest) {
955
+ const workspace = manifest.repository;
941
956
  return {
942
957
  format: manifest.version,
943
958
  id: manifest.id,
944
959
  kind: manifest.kind,
945
960
  workspace: manifest.workspace,
961
+ workspaceType: workspace.type,
946
962
  ...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
947
963
  ...(manifest.label === undefined ? {} : { label: manifest.label }),
948
964
  ...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
@@ -955,17 +971,29 @@ function summarize(manifest) {
955
971
  totalBytes: manifest.totalBytes,
956
972
  restoreCount: manifest.restoreCount,
957
973
  ...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
958
- ...(manifest.repository.head === undefined ? {} : { head: manifest.repository.head }),
959
- ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
960
- ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
961
- stagedPathCount: manifest.repository.stagedPaths.length,
974
+ ...(workspace.type === 'git' && workspace.head !== undefined ? { head: workspace.head } : {}),
975
+ ...(workspace.type === 'git' && workspace.branch !== undefined ? { branch: workspace.branch } : {}),
976
+ ...(workspace.type === 'git' && workspace.operation !== undefined ? { operation: workspace.operation } : {}),
977
+ stagedPathCount: workspace.type === 'git' ? workspace.stagedPaths.length : 0,
962
978
  ...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
963
979
  };
964
980
  }
965
- function assertRepositoryCompatible(manifest, current, allowHeadChange) {
966
- if (manifest.repository.root !== current.root || manifest.repository.commonDir !== current.commonDir) {
981
+ /** Reject a fence that now describes a different workspace or workspace mode. */
982
+ function assertWorkspaceIdentityCompatible(before, after) {
983
+ if (before.type !== after.type) {
984
+ throw new ChangeLedgerError('WORKSPACE_MODE_CHANGED', `workspace mode changed from ${before.type} to ${after.type}`);
985
+ }
986
+ if (before.root !== after.root) {
987
+ throw new ChangeLedgerError('WORKSPACE_CHANGED', 'restore point no longer belongs to this workspace root');
988
+ }
989
+ if (before.type === 'git' && after.type === 'git' && before.commonDir !== after.commonDir) {
967
990
  throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree');
968
991
  }
992
+ }
993
+ function assertRepositoryCompatible(manifest, current, allowHeadChange) {
994
+ assertWorkspaceIdentityCompatible(manifest.repository, current);
995
+ if (manifest.repository.type !== 'git' || current.type !== 'git')
996
+ return;
969
997
  if (manifest.repository.operation !== current.operation) {
970
998
  throw new ChangeLedgerError('GIT_OPERATION_CHANGED', `Git operation changed from ${manifest.repository.operation ?? 'none'} to ${current.operation ?? 'none'}`);
971
999
  }
@@ -974,16 +1002,26 @@ function assertRepositoryCompatible(manifest, current, allowHeadChange) {
974
1002
  }
975
1003
  }
976
1004
  function repositoryHeadChanged(before, after) {
1005
+ if (before.type !== 'git' || after.type !== 'git')
1006
+ return false;
977
1007
  return before.head !== after.head || before.branch !== after.branch;
978
1008
  }
1009
+ function workspaceOperationChanged(before, after) {
1010
+ if (before.type !== 'git' || after.type !== 'git')
1011
+ return false;
1012
+ return before.operation !== after.operation;
1013
+ }
979
1014
  function assertPlanRepositoryFresh(planned, current) {
980
- if (planned.root !== current.root
981
- || planned.commonDir !== current.commonDir
982
- || planned.head !== current.head
983
- || planned.branch !== current.branch
984
- || planned.operation !== current.operation) {
985
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again');
1015
+ if (!sameWorkspaceFence(planned, current)) {
1016
+ throw workspaceStaleError(planned, current, 'restore planning');
1017
+ }
1018
+ }
1019
+ /** Stable stale-plan error for the workspace mode that actually drifted. */
1020
+ function workspaceStaleError(before, after, phase) {
1021
+ if (before.type === 'git' && after.type === 'git') {
1022
+ return new ChangeLedgerError('PLAN_STALE_REPOSITORY', `Git repository state changed after ${phase}; inspect and plan again`);
986
1023
  }
1024
+ return new ChangeLedgerError('PLAN_STALE_WORKSPACE', `workspace state changed after ${phase}; inspect and plan again`);
987
1025
  }
988
1026
  function assertJournalMatchesManifest(journal, manifest) {
989
1027
  if (manifest.version !== 2
package/lib/git.js CHANGED
@@ -37,6 +37,7 @@ export async function discoverRepository(cwd, signal) {
37
37
  const operation = await gitOperation(gitDir);
38
38
  return {
39
39
  state: {
40
+ type: 'git',
40
41
  root,
41
42
  commonDir,
42
43
  ...(head === undefined ? {} : { head }),
@@ -47,6 +48,47 @@ export async function discoverRepository(cwd, signal) {
47
48
  paths,
48
49
  };
49
50
  }
51
+ /**
52
+ * Discover the owning Git worktree, or return undefined when `cwd` is not
53
+ * inside any repository. Every other discovery failure still throws, so a
54
+ * broken repository never silently degrades into an ordinary directory.
55
+ * @param cwd - candidate working directory.
56
+ * @param signal - optional caller cancellation.
57
+ * @returns the snapshot source, or undefined outside every repository.
58
+ */
59
+ export async function discoverRepositoryOptional(cwd, signal) {
60
+ try {
61
+ return await discoverRepository(cwd, signal);
62
+ }
63
+ catch (error) {
64
+ if (isNotRepositoryError(error))
65
+ return undefined;
66
+ throw error;
67
+ }
68
+ }
69
+ /** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
70
+ export async function discoverRepositoryRootOptional(cwd, signal) {
71
+ try {
72
+ return await discoverRepositoryRoot(cwd, signal);
73
+ }
74
+ catch (error) {
75
+ if (isNotRepositoryError(error))
76
+ return undefined;
77
+ throw error;
78
+ }
79
+ }
80
+ /**
81
+ * Whether one discovery failure is Git's plain "not a git repository" verdict.
82
+ *
83
+ * `gitEnvironment` pins the C locale, so the message text is stable and this
84
+ * three-way check (code, exit status, message) cannot swallow a real failure.
85
+ */
86
+ function isNotRepositoryError(error) {
87
+ return error instanceof ChangeLedgerError
88
+ && error.code === 'GIT_COMMAND_FAILED'
89
+ && gitExitCode(error.cause) === 128
90
+ && error.message.includes('not a git repository');
91
+ }
50
92
  /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
51
93
  export async function discoverRepositoryRoot(cwd, signal) {
52
94
  const canonicalCwd = await canonicalDirectory(cwd);
@@ -237,6 +279,10 @@ function gitEnvironment(extra) {
237
279
  ...extra,
238
280
  GIT_OPTIONAL_LOCKS: '0',
239
281
  GIT_TERMINAL_PROMPT: '0',
282
+ // Git's diagnostics are matched for the "not a git repository" verdict that
283
+ // selects ordinary-directory mode; pin the C locale so that stays stable.
284
+ LC_ALL: 'C',
285
+ LANG: 'C',
240
286
  };
241
287
  }
242
288
  function gitExitCode(error) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { createDeadline } from './deadline.js';
3
3
  import { ChangeLedgerError, errorMessage } from './errors.js';
4
- import { discoverRepositoryRoot } from './git.js';
4
+ import { discoverWorkspaceRoot } from './workspace.js';
5
5
  function getSessionEvents(session) {
6
6
  if (!session)
7
7
  return [];
@@ -82,7 +82,7 @@ export class TurnCheckpointCoordinator {
82
82
  this.skips.delete(key);
83
83
  const capture = (async () => {
84
84
  try {
85
- const workspace = await discoverRepositoryRoot(cwd, captureSignal);
85
+ const workspace = await discoverWorkspaceRoot(cwd, captureSignal);
86
86
  await this.serializeWorkspace(workspace, captureSignal, async () => {
87
87
  try {
88
88
  await this.engine.createTurnCheckpoint({
@@ -360,7 +360,29 @@ async function readSession(ctx, sessionId) {
360
360
  if (live !== undefined)
361
361
  return live;
362
362
  const stored = await ctx.sessionQuery.readSession(sessionId);
363
- return { id: sessionId, header: stored.session, events: stored.events };
363
+ return {
364
+ id: sessionId,
365
+ header: stored.session,
366
+ events: stored.events,
367
+ ...(stored.inheritedEventCount === undefined ? {} : { inheritedEventCount: stored.inheritedEventCount }),
368
+ };
369
+ }
370
+ /**
371
+ * Fork-inherited prefix length reported by the host, or `undefined` when the host
372
+ * exposes no such datum.
373
+ *
374
+ * DSH releases before 0.1.5 carried it as `header.seedLength`; 0.1.5 deleted that
375
+ * field (a stored header still carrying it is rejected) and publishes the same
376
+ * quantity as `inheritedEventCount` on the live session and on its stored snapshot.
377
+ * @param session - live or stored session to read the lineage boundary from.
378
+ * @returns the inherited prefix length in events.
379
+ */
380
+ function lineageBoundary(session) {
381
+ const legacy = session.header.seedLength;
382
+ if (typeof legacy === 'number')
383
+ return legacy;
384
+ const current = session.inheritedEventCount;
385
+ return typeof current === 'number' ? current : undefined;
364
386
  }
365
387
  async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
366
388
  let current = await readSession(ctx, sessionId);
@@ -375,12 +397,16 @@ async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
375
397
  const seen = new Set([sessionId]);
376
398
  while (true) {
377
399
  const parentId = current.header.parentSession;
378
- const seedLength = current.header.seedLength;
379
- if ((parentId === undefined) !== (seedLength === undefined)) {
400
+ // A session is lineage-bound exactly when it declares a parent, and only then is
401
+ // the inherited-prefix length meaningful. 0.1.5 reports `0` — not `undefined` —
402
+ // for a session with no inherited prefix, so the datum must never be consulted
403
+ // for a root session or this guard inverts and rejects every unforked session.
404
+ const boundary = parentId === undefined ? undefined : lineageBoundary(current);
405
+ if ((parentId === undefined) !== (boundary === undefined)) {
380
406
  throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage has incomplete parent metadata');
381
407
  }
382
- if (parentId === undefined || seedLength === undefined
383
- || target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
408
+ if (parentId === undefined || boundary === undefined
409
+ || target.messageSeq >= boundary || target.turnStartSeq >= boundary) {
384
410
  return { target };
385
411
  }
386
412
  if (seen.has(parentId)) {
@@ -521,7 +547,7 @@ async function sharedWorkspaceSessions(agents, cwd) {
521
547
  const listed = agents?.list() ?? [];
522
548
  if (listed.length === 0)
523
549
  return [];
524
- const root = await discoverRepositoryRoot(cwd);
550
+ const root = await discoverWorkspaceRoot(cwd);
525
551
  const shared = [];
526
552
  for (const agent of listed) {
527
553
  if (agent.status !== 'running')
@@ -530,11 +556,11 @@ async function sharedWorkspaceSessions(agents, cwd) {
530
556
  if (session.header.cwd === undefined)
531
557
  continue;
532
558
  try {
533
- if (await discoverRepositoryRoot(session.header.cwd) === root)
559
+ if (await discoverWorkspaceRoot(session.header.cwd) === root)
534
560
  shared.push(session.id);
535
561
  }
536
562
  catch {
537
- // A live Session whose cwd is not a valid Git worktree cannot share this worktree.
563
+ // A live Session whose cwd cannot be resolved cannot share this workspace.
538
564
  }
539
565
  }
540
566
  return shared.sort();
package/lib/settings.js CHANGED
@@ -1,8 +1,15 @@
1
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
2
1
  import z from '@deepseek-ai/schemastery';
3
2
  import { resolveConfig } from './engine.js';
4
- /** Namespace join key shared by the host section and the browser settings card. */
5
- export const TURN_REWIND_SETTINGS_NAMESPACE = settingsNamespace('turn-rewind');
3
+ /**
4
+ * Namespace join key shared by the host section and the browser settings card.
5
+ *
6
+ * DSH 0.1.5 removed the `settingsNamespace` helper and made `SettingsNamespace`
7
+ * a compile-time-only brand, so a plain lowercase literal is the entire runtime
8
+ * value — the upstream convention (`const CHAT_SETTINGS_NAMESPACE = 'ui-chat'`).
9
+ * The literal satisfies 0.1.5's registration grammar `/^[a-z][a-z0-9-]*$/` and is
10
+ * the exact key the browser card binds through `ctx.settingsScope.bind`.
11
+ */
12
+ export const TURN_REWIND_SETTINGS_NAMESPACE = 'turn-rewind';
6
13
  /** Schemastery schema for the `turn-rewind` settings namespace. */
7
14
  export const TurnRewindSettingsSchema = (() => {
8
15
  const defaults = tunableSettings(resolveConfig({}));
@@ -41,10 +48,15 @@ function tunableSettings(resolved) {
41
48
  * running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
42
49
  * the user layer persists through the DSH settings provider. `storageDir` is never
43
50
  * carried by the namespace: the storage root must not move while the engine runs.
51
+ *
52
+ * `settings` is an optional service, so the whole wiring stays behind
53
+ * `ctx.inject(['settings'], …)`: a host without a provider keeps running on the
54
+ * composition entry alone.
44
55
  */
45
56
  export function installTurnRewindSettings(ctx, config, engine) {
46
- let source = () => tunableSettings(resolveConfig(config));
47
- installSettingsSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, source(), {
57
+ const entry = tunableSettings(resolveConfig(config));
58
+ let source = () => entry;
59
+ const hooks = {
48
60
  setSource: (current) => { source = current; },
49
61
  onChange: () => {
50
62
  try {
@@ -54,5 +66,26 @@ export function installTurnRewindSettings(ctx, config, engine) {
54
66
  ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`);
55
67
  }
56
68
  },
69
+ };
70
+ ctx.inject(['settings'], (scope) => {
71
+ const provider = scope.settings;
72
+ // 0.1.5 owns the canonical wiring on the provider itself; it also suppresses the
73
+ // detach fallback while the consumer is unloading, which the deleted free
74
+ // function did not do.
75
+ if (typeof provider.installSection === 'function') {
76
+ provider.installSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, entry, hooks);
77
+ return;
78
+ }
79
+ // Older hosts shipped that wiring only as the free `installSettingsSection`,
80
+ // which 0.1.5 removed, so reproduce its body against `register` + `scope.get()`
81
+ // + `scope.watch()` — present in every supported version.
82
+ const settingsScope = provider.register(TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, { base: entry });
83
+ hooks.setSource(() => settingsScope.get());
84
+ scope.effect(() => () => {
85
+ hooks.setSource(() => entry);
86
+ hooks.onChange();
87
+ });
88
+ hooks.onChange();
89
+ settingsScope.watch(() => { hooks.onChange(); });
57
90
  });
58
91
  }
package/lib/snapshot.js CHANGED
@@ -2,18 +2,20 @@ import { createHash } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, open, readlink } from 'node:fs/promises';
4
4
  import { ChangeLedgerError } from './errors.js';
5
- import { discoverRepository, sameRepositoryFence } from './git.js';
6
5
  import { isNodeError, resolveWorkspacePath } from './path-utils.js';
6
+ import { discoverWorkspace, sameWorkspaceFence } from './workspace.js';
7
7
  const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
8
8
  /** Capture the current tracked and non-ignored Git working tree. */
9
9
  export async function captureTree(options) {
10
10
  throwIfAborted(options.signal);
11
- const source = await discoverRepository(options.cwd, options.signal);
11
+ const source = await discoverWorkspace(options.cwd, options.config, options.signal);
12
12
  if (source.paths.length > options.config.maxFiles) {
13
13
  throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
14
14
  }
15
15
  const entries = Object.create(null);
16
- const gitObjectFormat = options.gitObjectFormat;
16
+ // Git-object capture only applies to a Git worktree; an ordinary directory
17
+ // always stores its own content-addressed blobs.
18
+ const gitObjectFormat = source.state.type === 'git' ? options.gitObjectFormat : undefined;
17
19
  const gitCapture = gitObjectFormat === undefined
18
20
  ? undefined
19
21
  : {
@@ -99,14 +101,20 @@ export async function captureStableTree(options) {
99
101
  });
100
102
  const second = await captureTree(options);
101
103
  if (first.treeHash === second.treeHash
102
- && sameRepositoryFence(first.source.state, second.source.state)
103
- && arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
104
+ && sameWorkspaceFence(first.source.state, second.source.state)
105
+ && sameStagedPaths(first.source, second.source)
104
106
  && arraysEqual(first.source.paths, second.source.paths)) {
105
107
  return second;
106
108
  }
107
109
  }
108
110
  throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures');
109
111
  }
112
+ /** Whether two captures observed the same index state; ordinary directories have none. */
113
+ function sameStagedPaths(left, right) {
114
+ if (left.state.type !== 'git' || right.state.type !== 'git')
115
+ return true;
116
+ return arraysEqual(left.state.stagedPaths, right.state.stagedPaths);
117
+ }
110
118
  /** Compute stable path-level differences between two captured trees. */
111
119
  export function diffTrees(before, after, comparisonBefore = before, comparisonAfter = after) {
112
120
  const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths);
@@ -140,7 +140,14 @@ export interface ManageOverview {
140
140
  }
141
141
  /** Return the rewind anchor and editable text owned by one direct user message. */
142
142
  export declare function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null;
143
- /** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
143
+ /**
144
+ * Browser plugin entry: bridge every direct user-message action row to the rewind UI.
145
+ *
146
+ * Every service read on `ctx` must be declared here: Cordis throws while reading an
147
+ * undeclared service off the context proxy, before optional chaining can apply.
148
+ * `settingsScope` is provided by `@deepseek-ai/dsh-client-ui-settings` and may be
149
+ * absent, which is what `ctx.settingsScope?.bind(…)` below relies on.
150
+ */
144
151
  export declare const inject: string[];
145
152
  export declare function apply(ctx: ClientContextLike): void;
146
153
  /**
@@ -0,0 +1,10 @@
1
+ import type { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js';
2
+ /** Ordinary-directory discovery result plus its eligible path inventory. */
3
+ export interface DirectorySnapshotSource {
4
+ readonly state: DirectoryWorkspaceState;
5
+ readonly paths: readonly string[];
6
+ }
7
+ /** Resolve the canonical ordinary directory used as a non-Git workspace root. */
8
+ export declare function discoverDirectoryRoot(cwd: string): Promise<string>;
9
+ /** Enumerate regular files and symlinks in an ordinary directory using rewind ignore rules. */
10
+ export declare function discoverDirectory(cwd: string, config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>, signal?: AbortSignal): Promise<DirectorySnapshotSource>;
@@ -19,6 +19,12 @@ export declare class ChangeLedgerEngine {
19
19
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
20
20
  initialize(): Promise<number>;
21
21
  private initializeStore;
22
+ /**
23
+ * Acquire the exclusive lock for one workspace.
24
+ *
25
+ * A Git worktree also binds the shared per-worktree lock derived from its Git
26
+ * identity; an ordinary directory owns only the durable directory lock.
27
+ */
22
28
  private acquireWorkspace;
23
29
  private reconcileGitCheckpointJournals;
24
30
  private publishGitManifest;