@smthrs/engine-store 1.0.0-rc.6 → 1.0.0-rc.8

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.
Files changed (50) hide show
  1. package/README.md +13 -11
  2. package/dist/cjs/ArtifactSync.d.ts.map +1 -1
  3. package/dist/cjs/ArtifactSync.js +5 -0
  4. package/dist/cjs/ArtifactSync.js.map +2 -2
  5. package/dist/cjs/DisasterRecovery.d.ts +9 -26
  6. package/dist/cjs/DisasterRecovery.d.ts.map +1 -1
  7. package/dist/cjs/DisasterRecovery.js +27 -18
  8. package/dist/cjs/DisasterRecovery.js.map +2 -2
  9. package/dist/cjs/StepBoundary.d.ts +8 -6
  10. package/dist/cjs/StepBoundary.d.ts.map +1 -1
  11. package/dist/cjs/StepBoundary.js +10 -7
  12. package/dist/cjs/StepBoundary.js.map +2 -2
  13. package/dist/cjs/index.d.ts +12 -0
  14. package/dist/cjs/index.d.ts.map +1 -1
  15. package/dist/cjs/index.js.map +2 -2
  16. package/dist/cjs/internal/ActionPersistence.d.ts.map +1 -1
  17. package/dist/cjs/internal/ActionPersistence.js +27 -2
  18. package/dist/cjs/internal/ActionPersistence.js.map +3 -3
  19. package/dist/esm/ArtifactSync.d.ts.map +1 -1
  20. package/dist/esm/ArtifactSync.js +6 -1
  21. package/dist/esm/ArtifactSync.js.map +1 -1
  22. package/dist/esm/DisasterRecovery.d.ts +9 -26
  23. package/dist/esm/DisasterRecovery.d.ts.map +1 -1
  24. package/dist/esm/DisasterRecovery.js +46 -27
  25. package/dist/esm/DisasterRecovery.js.map +1 -1
  26. package/dist/esm/StepBoundary.d.ts +8 -6
  27. package/dist/esm/StepBoundary.d.ts.map +1 -1
  28. package/dist/esm/StepBoundary.js +17 -8
  29. package/dist/esm/StepBoundary.js.map +1 -1
  30. package/dist/esm/index.d.ts +12 -0
  31. package/dist/esm/index.d.ts.map +1 -1
  32. package/dist/esm/index.js +12 -0
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/internal/ActionPersistence.d.ts.map +1 -1
  35. package/dist/esm/internal/ActionPersistence.js +59 -3
  36. package/dist/esm/internal/ActionPersistence.js.map +1 -1
  37. package/docs/README.md +3 -1
  38. package/docs/api.md +23 -17
  39. package/docs/concepts/attempts-and-replay.md +15 -2
  40. package/docs/guides/back-up-and-restore.md +9 -3
  41. package/docs/guides/reclaim-runs-from-a-dead-host.md +16 -0
  42. package/docs/installation.md +5 -1
  43. package/docs/quickstart.md +9 -1
  44. package/docs/troubleshooting.md +3 -0
  45. package/package.json +18 -18
  46. package/src/ArtifactSync.ts +7 -0
  47. package/src/DisasterRecovery.ts +90 -58
  48. package/src/StepBoundary.ts +29 -14
  49. package/src/index.ts +14 -0
  50. package/src/internal/ActionPersistence.ts +64 -3
package/docs/README.md CHANGED
@@ -11,7 +11,9 @@ over.
11
11
  It is an [Effect](https://effect.website) library: every surface below is a
12
12
  `Layer` you compose or an `Effect` you run.
13
13
 
14
- Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
14
+ ```bash
15
+ npm install @smthrs/engine-store@next effect
16
+ ```
15
17
 
16
18
  ## What it solves
17
19
 
package/docs/api.md CHANGED
@@ -369,16 +369,16 @@ interface Service {
369
369
  }
370
370
  ```
371
371
 
372
- | Export | Signature | Meaning |
373
- | ------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
374
- | `StepBoundary` | `Context.Service<Service>` | Service tag. |
375
- | `make` | `(service: Service) => Service` | Brands an implementation, so a wrong shape is reported where it is written. |
376
- | `makeFileSystem` | `(fs: FileSystem, path: Path, artifacts: ArtifactStore.Service, options?: FileSystemOptions) => Service` | The production boundary. |
377
- | `layer` | `Layer<Service, never, FileSystem \| Path \| ArtifactStore>` | Provides it, confining replay to the kernel `Workspace` root when one is in context. Resolves that root with the host `Path`. |
378
- | `layerTest` | `(options?: TestOptions) => Layer<Service>` | Deterministic in-memory boundary. |
379
- | `exactReads` | `(descriptor: FileBoundary) => ReadonlyArray<FileInput>` | Exact read inputs, ignoring declarations that still need expansion. |
380
- | `readSetMatches` | `(prepared: PreparedBoundary) => boolean` | Whether the measured snapshot still matches the declaration. |
381
- | `referencedDigests` | `(evidence: BoundaryEvidence) => ReadonlyArray<ArtifactStore.Digest>` | The digests the evidence references rather than inlines. Evidence from a foreign implementation yields the empty list. |
372
+ | Export | Signature | Meaning |
373
+ | ------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
374
+ | `StepBoundary` | `Context.Service<Service>` | Service tag. |
375
+ | `make` | `(service: Service) => Service` | Brands an implementation, so a wrong shape is reported where it is written. |
376
+ | `makeFileSystem` | `(fs: FileSystem, path: Path, artifacts: ArtifactStore.Service, options?: FileSystemOptions) => Service` | The production boundary. |
377
+ | `layer` | `Layer<Service, never, FileSystem \| Path \| ArtifactStore>` | Provides it, rooting measurement and replay at the kernel `Workspace` root when one is in context. Resolves that root with the host `Path`. |
378
+ | `layerTest` | `(options?: TestOptions) => Layer<Service>` | Deterministic in-memory boundary. |
379
+ | `exactReads` | `(descriptor: FileBoundary) => ReadonlyArray<FileInput>` | Exact read inputs, ignoring declarations that still need expansion. |
380
+ | `readSetMatches` | `(prepared: PreparedBoundary) => boolean` | Whether the measured snapshot still matches the declaration. |
381
+ | `referencedDigests` | `(evidence: BoundaryEvidence) => ReadonlyArray<ArtifactStore.Digest>` | The digests the evidence references rather than inlines. Evidence from a foreign implementation yields the empty list. |
382
382
 
383
383
  ### Schemas
384
384
 
@@ -393,11 +393,11 @@ interface Service {
393
393
 
394
394
  ### FileSystemOptions
395
395
 
396
- | Field | Default | Meaning |
397
- | --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
398
- | `maxInlineBytes` | 1 MiB | The largest single output inlined into the evidence. Anything larger is stored by digest reference. |
399
- | `maxTotalInlineBytes` | 8 MiB | The largest aggregate inline payload one settle may fold in. Past it, an output is spilled even though it individually fits. |
400
- | `root` | cwd | The root replay confines writes and removals to. A filesystem already confined to a root keeps its own. |
396
+ | Field | Default | Meaning |
397
+ | --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
398
+ | `maxInlineBytes` | 1 MiB | The largest single output inlined into the evidence. Anything larger is stored by digest reference. |
399
+ | `maxTotalInlineBytes` | 8 MiB | The largest aggregate inline payload one settle may fold in. Past it, an output is spilled even though it individually fits. |
400
+ | `root` | cwd | The root declared paths resolve against: `prepare` and `settle` measure under it, and replay confines writes and removals to it. A filesystem already confined to a root keeps its own. |
401
401
 
402
402
  `replayOutputs` writes and deletes what the evidence names, so it confines its
403
403
  own mutations instead of trusting the composition. Every write, removal, probe,
@@ -406,7 +406,10 @@ one descriptor-relative, no-follow request per call against the pinned root. A
406
406
  symlink on a replayed path (a parent, the final component, or a member of a
407
407
  replayed tree) refuses with `UnsupportedBoundary`, and so does a host with no
408
408
  descriptor-relative executor or isolation attestation, before any host call.
409
- `prepare` and `settle` only read and keep the caller's filesystem.
409
+ `prepare` and `settle` only read and keep the caller's filesystem, but hand a
410
+ path-based host each declared path resolved against `root`, the root a step's
411
+ own FileSystem resolves against. A same-named file in the process working
412
+ directory therefore never answers the dirty check (issue #3911).
410
413
 
411
414
  ### TestOptions
412
415
 
@@ -892,7 +895,10 @@ durable fails and the shared entry is withheld.
892
895
 
893
896
  `hydrate(digests)` establishes that this host can resolve every referenced
894
897
  artifact and reports whether the replay is now worth retrying. It never fails a
895
- run: a shared tier that is down must not stop work that can simply be done.
898
+ run: a shared tier that is down must not stop work that can simply be done. A
899
+ downloaded blob counts only when this host's store files it under the requested
900
+ digest, so a shared tier that serves the wrong bytes costs the replay, never
901
+ its correctness.
896
902
 
897
903
  The two tiers cannot both be layers, because they inhabit the same tag and
898
904
  composing them would shadow one with the other.
@@ -30,15 +30,21 @@ question, answered in [Cache admission](./cache-admission.md).
30
30
  | ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
31
31
  | `succeeded` | Returns the recorded result, and calls `StepBoundary.replayOutputs` first so the workspace holds the outputs the step produced. |
32
32
  | `failed` | Rethrows the persisted domain failure. It never re-admits the attempt. |
33
- | `running` | Recovers under the run fence and shared admission permit, honoring any recorded effect crossing. |
33
+ | `running` | Asks the step cache first when the step is cache-eligible, then recovers under the run fence and admission permit. |
34
34
  | `suspended` | Continues the same attempt rather than burning a new one against the retry budget. |
35
35
 
36
- An admitted attempt remains authoritative when a matching shared cache row
36
+ A settled attempt remains authoritative when a matching shared cache row
37
37
  exists; another run's cached success cannot replace its result or failure.
38
38
  Failure values were schema-encoded before persistence, so a `_tag` survives
39
39
  the JSON round trip and a `RetryPolicy`'s non-retryable matching still applies
40
40
  on replay.
41
41
 
42
+ A `running` attempt has no result of its own. When its step is cache-eligible
43
+ (sealed, with a hard boundary), a verified row another run recorded under the
44
+ same key answers it, and the row is closed as `succeeded` with that result. A
45
+ recorded successful effect crossing still wins over the cache, because it
46
+ carries this run's own result.
47
+
42
48
  ## Which columns are redacted
43
49
 
44
50
  Attempt and run rows share a database with the journal, so failure text meets
@@ -62,6 +68,13 @@ A permit covers one run and step key through admission, execution, and
62
68
  settlement. Concurrent same-key dispatches wait, then replay the settled
63
69
  attempt instead of executing another body. Interruption releases the permit.
64
70
 
71
+ A cache-eligible dispatch also holds a permit for its cache address, which runs
72
+ share. A second run that asks one engine for a step that engine is already
73
+ computing waits, then takes the first run's cache row instead of running the
74
+ body beside it. Engines in other processes are not excluded: two processes can
75
+ still execute one key at once, and the second recording resolves as described
76
+ in [Cache admission](./cache-admission.md).
77
+
65
78
  Holding the permit and the run fence permits recovery of a stranded `running`
66
79
  attempt. A recorded successful effect crossing supplies the result directly;
67
80
  an unresolved irreversible crossing requires an idempotency key before retry.
@@ -110,10 +110,16 @@ file (`store.sqlite3`), an `objects` directory, and a `restored.json` marker, an
110
110
  is a fresh store directory to compose `NodeDatabase.layer` and
111
111
  `ArtifactStore.layerFileSystem` over.
112
112
 
113
+ A failed restore removes what it wrote, so nothing store-shaped is left in the
114
+ target and you can retry into the same directory. A target that was not empty
115
+ is refused with `not_empty` and left untouched.
116
+
113
117
  `restore` alone leaves the pre-backup owner fences in place. Run
114
118
  `DisasterRecovery.fence(manifest)` against the restored file before any engine
115
119
  adopts it, or use `restoreAndFence`, which does both and returns a
116
- `FencedRestoredStore`.
120
+ `FencedRestoredStore`. When fencing refuses, `restoreAndFence` also removes the
121
+ restored files, so no engine can adopt a store that still carries pre-backup
122
+ owners.
117
123
 
118
124
  ## What fencing does
119
125
 
@@ -149,8 +155,8 @@ its own fresh owner.
149
155
  | `not_empty` | A target or backup directory exists and is not empty. |
150
156
  | `invalid_manifest` | The manifest is missing or does not decode. |
151
157
  | `missing_file` | A file the manifest lists is absent. |
152
- | `digest_mismatch` | The database snapshot does not hash to its recorded digest. |
153
- | `artifact_corruption` | A blob does not hash to its address. |
158
+ | `digest_mismatch` | A backup file does not hash to its manifest digest. |
159
+ | `artifact_corruption` | A live blob does not hash to its address during a backup. |
154
160
  | `snapshot_incomplete` | The snapshot references artifacts the capture could not take. |
155
161
  | `schema_mismatch` | A recorded migration is missing or has a different name. |
156
162
  | `io` | A host read or write refused, including an oversized file. |
@@ -93,6 +93,22 @@ at the staleness cutoff. A deferral is forgotten as soon as the row leaves the
93
93
  stale window, so a fresh stall under a new lease is probed on the first tick
94
94
  that sees it.
95
95
 
96
+ ## What a reclaimed run does with its unfinished step
97
+
98
+ The step that was running when the owner died left an attempt row in state
99
+ `running`. The re-drive reaches that attempt again, and what it does depends on
100
+ the step:
101
+
102
+ - A cache-eligible step (sealed, with a hard boundary) asks the step cache
103
+ first. When another run already recorded the same key, for example the run
104
+ you started to replace the killed one, the reclaimed attempt takes that
105
+ result and the body does not run again. When that run is still computing it
106
+ in the same process, the reclaimed attempt waits for it.
107
+ - Any other step re-executes under its original attempt number. An
108
+ irreversible step whose effect was dispatched but never recorded as settled
109
+ needs an idempotency key to re-execute; without one the run fails with
110
+ `IrreversibleRetryRequiresIdempotencyKey`.
111
+
96
112
  ## Check a run's own view
97
113
 
98
114
  `DurableEngineState` answers directly, which is useful in a diagnostic tool:
@@ -7,7 +7,11 @@ sidebar:
7
7
 
8
8
  ## Install the package
9
9
 
10
- Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
10
+ ```bash
11
+ npm install @smthrs/engine-store@next effect
12
+ ```
13
+
14
+ The `next` tag carries the 1.0 release candidates.
11
15
 
12
16
  The package requires Node.js 26.4.0 or later and ships as both ESM and
13
17
  CommonJS with TypeScript declarations. Its runtime dependencies, including
@@ -13,7 +13,11 @@ the whole point of the package.
13
13
  ## Prerequisites
14
14
 
15
15
  - Node.js 26.4.0 or later.
16
- - A package with the dependencies installed. Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
16
+ - A project with the packages installed:
17
+
18
+ ```bash
19
+ npm install @smthrs/engine-store@next @smthrs/artifacts@next @smthrs/database@next @smthrs/flow@next @smthrs/journal@next @smthrs/kernel@next @smthrs/platform-node@next @smthrs/run-store@next @smthrs/step-cache@next @effect/platform-node effect
20
+ ```
17
21
 
18
22
  ## Declare a sealed action and a flow
19
23
 
@@ -193,6 +197,10 @@ console.log(await Effect.runPromise(main("./.quickstart/.flows/engine.db", "./.q
193
197
  { first: '42', second: '42', executions: 1 }
194
198
  ```
195
199
 
200
+ The assembled program is
201
+ [`examples/quickstart.ts`](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/engine-store/examples/quickstart.ts);
202
+ the package's test suite runs it on every change.
203
+
196
204
  ## What just happened
197
205
 
198
206
  The first run claimed `analyse-1`, opened an attempt row for the sealed step,
@@ -304,6 +304,9 @@ migrations omit or rename a historical entry recorded in the manifest. Forward
304
304
  additions within installed lower blocks are compatible; check historical IDs
305
305
  and names before fencing again.
306
306
 
307
+ A failed backup, `restore`, or `restoreAndFence` removes the files it wrote, so
308
+ fix the cause and retry into the same directory.
309
+
307
310
  ## The composition does not compile
308
311
 
309
312
  **What happened.** A required service is missing. A composition failure is an
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smthrs/engine-store",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.6",
4
+ "version": "1.0.0-rc.8",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "engine"
@@ -386,29 +386,29 @@
386
386
  "tag": "next"
387
387
  },
388
388
  "dependencies": {
389
- "@smthrs/artifacts": "1.0.0-rc.6",
390
- "@smthrs/canonical": "1.0.0-rc.6",
391
- "@smthrs/crypto": "1.0.0-rc.6",
392
- "@smthrs/database": "1.0.0-rc.6",
393
- "@smthrs/engine": "1.0.0-rc.6",
394
- "@smthrs/flow": "1.0.0-rc.6",
395
- "@smthrs/journal": "1.0.0-rc.6",
396
- "@smthrs/kernel": "1.0.0-rc.6",
397
- "@smthrs/keys": "1.0.0-rc.6",
398
- "@smthrs/plan": "1.0.0-rc.6",
399
- "@smthrs/plan-store": "1.0.0-rc.6",
400
- "@smthrs/run-store": "1.0.0-rc.6",
401
- "@smthrs/step-cache": "1.0.0-rc.6",
402
- "@smthrs/capability": "1.0.0-rc.6"
389
+ "@smthrs/artifacts": "1.0.0-rc.8",
390
+ "@smthrs/canonical": "1.0.0-rc.8",
391
+ "@smthrs/crypto": "1.0.0-rc.8",
392
+ "@smthrs/database": "1.0.0-rc.8",
393
+ "@smthrs/engine": "1.0.0-rc.8",
394
+ "@smthrs/flow": "1.0.0-rc.8",
395
+ "@smthrs/journal": "1.0.0-rc.8",
396
+ "@smthrs/kernel": "1.0.0-rc.8",
397
+ "@smthrs/keys": "1.0.0-rc.8",
398
+ "@smthrs/plan": "1.0.0-rc.8",
399
+ "@smthrs/plan-store": "1.0.0-rc.8",
400
+ "@smthrs/run-store": "1.0.0-rc.8",
401
+ "@smthrs/step-cache": "1.0.0-rc.8",
402
+ "@smthrs/capability": "1.0.0-rc.8"
403
403
  },
404
404
  "devDependencies": {
405
405
  "@effect/language-service": "0.87.2",
406
406
  "@effect/platform-node": "4.0.4",
407
407
  "@effect/vitest": "4.0.4",
408
408
  "@eslint/js": "9.39.5",
409
- "@smthrs/core": "1.0.0-rc.6",
410
- "@smthrs/patterns": "1.0.0-rc.6",
411
- "@smthrs/platform-node": "1.0.0-rc.6",
409
+ "@smthrs/core": "1.0.0-rc.8",
410
+ "@smthrs/patterns": "1.0.0-rc.8",
411
+ "@smthrs/platform-node": "1.0.0-rc.8",
412
412
  "@types/node": "26.4.1",
413
413
  "@vitest/coverage-v8": "5.0.0",
414
414
  "dprint": "0.57.1",
@@ -241,6 +241,13 @@ export const make = (options: {
241
241
  // them written into this workspace.
242
242
  const written = yield* remote.get(digest).pipe(
243
243
  Effect.flatMap((bytes) => local.put(bytes)),
244
+ // A custom tier may skip that verification; the address `put`
245
+ // returned is the proof the requested blob is now local.
246
+ Effect.flatMap((address) =>
247
+ address === digest ?
248
+ Effect.void :
249
+ Effect.fail({ message: `the shared tier served the bytes of ${address}` })
250
+ ),
244
251
  Effect.tapError(fallBack("the artifact could not be fetched", [digest])),
245
252
  Effect.option
246
253
  )
@@ -407,6 +407,23 @@ const ensureEmptyDirectory = (
407
407
  }
408
408
  })
409
409
 
410
+ /**
411
+ * Removes every entry of a directory this module filled after admitting it
412
+ * empty, leaving the directory reusable for a retry. Cleanup is best effort;
413
+ * the caller reports the original failure.
414
+ */
415
+ const discardEntries = (fs: FileSystem.FileSystem, directory: string): Effect.Effect<void> =>
416
+ fs.readDirectory(directory).pipe(
417
+ Effect.flatMap((entries) =>
418
+ Effect.forEach(
419
+ entries,
420
+ (entry) => fs.remove(`${directory}/${entry}`, { recursive: true, force: true }),
421
+ { discard: true }
422
+ )
423
+ ),
424
+ Effect.ignore
425
+ )
426
+
410
427
  /**
411
428
  * A relative blob path in the store's fanout layout: a two-hex directory
412
429
  * holding a 64-hex digest that begins with it. Anything else under the
@@ -660,18 +677,8 @@ export const backup = <R = never, E = never>(
660
677
  // The directory was empty when the capture began, so everything in it is
661
678
  // this capture's: the snapshot, any SQLite sidecar the inspection
662
679
  // connection left, and the copied blobs. A failed capture removes them and
663
- // leaves the directory reusable for a retry. Cleanup is best effort; the
664
- // original failure is the one reported.
665
- const discardPartial = fs.readDirectory(options.directory).pipe(
666
- Effect.flatMap((entries) =>
667
- Effect.forEach(
668
- entries,
669
- (entry) => fs.remove(`${options.directory}/${entry}`, { recursive: true, force: true }),
670
- { discard: true }
671
- )
672
- ),
673
- Effect.ignore
674
- )
680
+ // leaves the directory reusable for a retry.
681
+ const discardPartial = discardEntries(fs, options.directory)
675
682
 
676
683
  return yield* (options.objectsDirectory === undefined
677
684
  ? capture
@@ -719,6 +726,57 @@ export const verify = Effect.fn("DisasterRecovery.verify")(function*(
719
726
  return manifest
720
727
  })
721
728
 
729
+ /** Copies a verified backup into an admitted empty target directory. */
730
+ const writeRestoredStore = (
731
+ fs: FileSystem.FileSystem,
732
+ options: RestoreOptions,
733
+ manifest: BackupManifest,
734
+ maxFileSizeBytes: number
735
+ ): Effect.Effect<RestoredStore, DisasterRecoveryError, Crypto.Crypto> =>
736
+ Effect.gen(function*() {
737
+ const databaseBytes = yield* checkedFile(
738
+ fs,
739
+ "restore",
740
+ `${options.backupDirectory}/${manifest.database.file}`,
741
+ manifest.database.sha256,
742
+ maxFileSizeBytes
743
+ )
744
+ const databaseFile = `${options.targetDirectory}/${databaseFileName}`
745
+ yield* fs.writeFile(databaseFile, databaseBytes).pipe(
746
+ Effect.mapError(ioFailure("restore", `writing ${databaseFile}`))
747
+ )
748
+
749
+ const objectsDirectory = `${options.targetDirectory}/${objectsDirectoryName}`
750
+ for (const entry of manifest.artifacts) {
751
+ const bytes = yield* checkedFile(
752
+ fs,
753
+ "restore",
754
+ blobPath(`${options.backupDirectory}/${objectsDirectoryName}`, entry.digest),
755
+ entry.digest,
756
+ maxFileSizeBytes
757
+ )
758
+ yield* fs.makeDirectory(`${objectsDirectory}/${entry.digest.slice(0, 2)}`, { recursive: true }).pipe(
759
+ Effect.mapError(ioFailure("restore", `creating the objects directory for ${entry.digest}`))
760
+ )
761
+ yield* fs.writeFile(blobPath(objectsDirectory, entry.digest), bytes).pipe(
762
+ Effect.mapError(ioFailure("restore", `writing the blob for ${entry.digest}`))
763
+ )
764
+ }
765
+
766
+ const restoredAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
767
+ const marker = {
768
+ formatVersion: 1,
769
+ restoredAtMs,
770
+ backupCreatedAtMs: manifest.createdAtMs,
771
+ databaseSha256: manifest.database.sha256
772
+ }
773
+ yield* fs.writeFileString(
774
+ `${options.targetDirectory}/${restoredMarkerFileName}`,
775
+ `${JSON.stringify(marker, null, 2)}\n`
776
+ ).pipe(Effect.mapError(ioFailure("restore", `writing ${restoredMarkerFileName}`)))
777
+ return { databaseFile, objectsDirectory, manifest } satisfies RestoredStore
778
+ })
779
+
722
780
  /**
723
781
  * Restores a verified backup into an empty target directory.
724
782
  *
@@ -728,7 +786,8 @@ export const verify = Effect.fn("DisasterRecovery.verify")(function*(
728
786
  * is a fresh store directory to compose `NodeDatabase.layer` and
729
787
  * `ArtifactStore.layerFileSystem` over. The database is restored with the
730
788
  * pre-backup owner fences still in place — run {@link fence} against it
731
- * before any engine adopts it.
789
+ * before any engine adopts it. A failed copy removes everything it wrote, so
790
+ * the target is left empty and reusable for a retry.
732
791
  *
733
792
  * @since 0.1.0
734
793
  * @category operations
@@ -741,47 +800,13 @@ export const restore = Effect.fn("DisasterRecovery.restore")(function*(options:
741
800
  const manifest = yield* readManifest(fs, "restore", options.backupDirectory, maxFileSizeBytes)
742
801
  yield* ensureEmptyDirectory(fs, "restore", options.targetDirectory)
743
802
 
744
- const databaseBytes = yield* checkedFile(
745
- fs,
746
- "restore",
747
- `${options.backupDirectory}/${manifest.database.file}`,
748
- manifest.database.sha256,
749
- maxFileSizeBytes
750
- )
751
- const databaseFile = `${options.targetDirectory}/${databaseFileName}`
752
- yield* fs.writeFile(databaseFile, databaseBytes).pipe(
753
- Effect.mapError(ioFailure("restore", `writing ${databaseFile}`))
803
+ // The target was empty when the copy began, so everything in it is this
804
+ // restore's. A refused blob or a failed write removes the partial store, so
805
+ // nothing store-shaped is left to compose over and a retry can reuse it.
806
+ return yield* Effect.onError(
807
+ writeRestoredStore(fs, options, manifest, maxFileSizeBytes),
808
+ () => discardEntries(fs, options.targetDirectory)
754
809
  )
755
-
756
- const objectsDirectory = `${options.targetDirectory}/${objectsDirectoryName}`
757
- for (const entry of manifest.artifacts) {
758
- const bytes = yield* checkedFile(
759
- fs,
760
- "restore",
761
- blobPath(`${options.backupDirectory}/${objectsDirectoryName}`, entry.digest),
762
- entry.digest,
763
- maxFileSizeBytes
764
- )
765
- yield* fs.makeDirectory(`${objectsDirectory}/${entry.digest.slice(0, 2)}`, { recursive: true }).pipe(
766
- Effect.mapError(ioFailure("restore", `creating the objects directory for ${entry.digest}`))
767
- )
768
- yield* fs.writeFile(blobPath(objectsDirectory, entry.digest), bytes).pipe(
769
- Effect.mapError(ioFailure("restore", `writing the blob for ${entry.digest}`))
770
- )
771
- }
772
-
773
- const restoredAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
774
- const marker = {
775
- formatVersion: 1,
776
- restoredAtMs,
777
- backupCreatedAtMs: manifest.createdAtMs,
778
- databaseSha256: manifest.database.sha256
779
- }
780
- yield* fs.writeFileString(
781
- `${options.targetDirectory}/${restoredMarkerFileName}`,
782
- `${JSON.stringify(marker, null, 2)}\n`
783
- ).pipe(Effect.mapError(ioFailure("restore", `writing ${restoredMarkerFileName}`)))
784
- return { databaseFile, objectsDirectory, manifest } satisfies RestoredStore
785
810
  })
786
811
 
787
812
  /**
@@ -868,10 +893,12 @@ export const fence = Effect.fn("DisasterRecovery.fence")(function*(manifest: Bac
868
893
  /**
869
894
  * Restores a backup and immediately fences the restored database.
870
895
  *
871
- * This is the one-shot operator API for disaster recovery. The database
872
- * driver itself still arrives through a caller-supplied Layer so this module
873
- * remains host-neutral and browser-bundleable; the bundled Node script passes
874
- * `NodeDatabase.layer` for the restored file.
896
+ * This is the one-shot operator API for disaster recovery. When fencing
897
+ * refuses, the restored files are removed, so no engine can adopt a store that
898
+ * still carries pre-backup owners. The database driver itself still arrives
899
+ * through a caller-supplied Layer so this module remains host-neutral and
900
+ * browser-bundleable; the bundled Node script passes `NodeDatabase.layer` for
901
+ * the restored file.
875
902
  *
876
903
  * @since 0.1.0
877
904
  * @category operations
@@ -880,9 +907,14 @@ export const restoreAndFence = <R = never, E = never>(
880
907
  options: RestoreAndFenceOptions<R, E>
881
908
  ): Effect.Effect<FencedRestoredStore, DisasterRecoveryError | E, FileSystem.FileSystem | Crypto.Crypto | R> =>
882
909
  Effect.gen(function*() {
910
+ const fs = yield* FileSystem.FileSystem
883
911
  const restored = yield* restore(options)
912
+ // A refused fence leaves a database that still carries pre-backup owner
913
+ // fences beside a `restored.json` marker. Remove the whole restore so no
914
+ // engine can adopt it and the operator can retry into the same target.
884
915
  const summary = yield* fence(restored.manifest).pipe(
885
- Effect.provide(options.databaseLayer(restored.databaseFile))
916
+ Effect.provide(options.databaseLayer(restored.databaseFile)),
917
+ Effect.onError(() => discardEntries(fs, options.targetDirectory))
886
918
  )
887
919
  return { ...restored, fence: summary } satisfies FencedRestoredStore
888
920
  })
@@ -496,12 +496,14 @@ export interface FileSystemOptions {
496
496
  */
497
497
  readonly maxTotalInlineBytes?: number | undefined
498
498
  /**
499
- * The workspace root cache replay confines its writes, removals, and prune
500
- * walk to. A filesystem already confined to a root (the kernel-guarded
501
- * layer's service, or a `confined` view) keeps its own root and ignores
502
- * this. Otherwise it defaults to the process working directory, the root a
503
- * path-based host resolves `prepare` and `settle` against. {@link layer}
504
- * passes the kernel `Workspace` root when one is in context.
499
+ * The workspace root every declared path resolves against: `prepare` and
500
+ * `settle` measure under it, and cache replay confines its writes, removals,
501
+ * and prune walk to it. A filesystem already confined to a root (the
502
+ * kernel-guarded layer's service, or a `confined` view) keeps its own root
503
+ * and ignores this. Otherwise it defaults to the process working directory,
504
+ * the root a path-based host resolves a relative path against. {@link layer}
505
+ * passes the kernel `Workspace` root when one is in context, which is the
506
+ * root a step's own FileSystem resolves against (issue #3911).
505
507
  */
506
508
  readonly root?: string | undefined
507
509
  }
@@ -573,7 +575,16 @@ export const makeFileSystem = (
573
575
  const digest = yield* Schema.decodeUnknownEffect(Sha256)(bytes).pipe(Effect.orDie)
574
576
  return { digest, bytes } satisfies MeasuredDigest
575
577
  })
576
- const readDigest = (path: string) => readDigestFrom(fs, path)
578
+ // Declared paths are workspace-relative. A confined filesystem resolves them
579
+ // against its own root, but a path-based host resolves them against the
580
+ // process working directory, where a same-named file would answer the dirty
581
+ // check and let a stale declaration serve a cache hit (issue #3911). Such a
582
+ // host is handed each path resolved against the configured root; results
583
+ // stay keyed by the declared path.
584
+ const readRoot = KernelFileSystem.confinedRoot(fs) === undefined ? options.root : undefined
585
+ const hostPath = (path: string): string => readRoot === undefined ? path : platformPath.resolve(readRoot, path)
586
+ const enumeration: FileEnumeration.EnumerationOptions = readRoot === undefined ? {} : { resolve: hostPath }
587
+ const readDigest = (path: string) => readDigestFrom(fs, hostPath(path))
577
588
  const batch = KernelFileSystem.batch(fs)
578
589
  const measurements = Effect.fn("StepBoundary.measurements")(
579
590
  function*(paths: ReadonlyArray<string>, content: boolean) {
@@ -594,7 +605,7 @@ export const makeFileSystem = (
594
605
  const response = yield* batch.execute(
595
606
  paths.slice(offset, offset + batch.maxSize).map((path) => ({
596
607
  operation: "digest",
597
- path,
608
+ path: hostPath(path),
598
609
  content
599
610
  }))
600
611
  ).pipe(Effect.mapError(hostFailure))
@@ -636,10 +647,14 @@ export const makeFileSystem = (
636
647
  // workspace-relative expansion and let a tree replay delete dotfiles the
637
648
  // producer wrote.
638
649
  const expandGlob = Effect.fn("StepBoundary.expandGlob")(function*(glob: FileSet.Glob) {
639
- return yield* FileEnumeration.expandGlob(fs, glob).pipe(Effect.mapError(hostFailure))
650
+ return yield* FileEnumeration.expandGlob(fs, glob, enumeration).pipe(Effect.mapError(hostFailure))
640
651
  })
641
- const treeEntries = Effect.fn("StepBoundary.treeEntries")(function*(host: FileSystem.FileSystem, path: string) {
642
- return yield* FileEnumeration.entriesUnder(host, path).pipe(Effect.mapError(hostFailure))
652
+ const treeEntries = Effect.fn("StepBoundary.treeEntries")(function*(
653
+ host: FileSystem.FileSystem,
654
+ path: string,
655
+ resolution: FileEnumeration.EnumerationOptions
656
+ ) {
657
+ return yield* FileEnumeration.entriesUnder(host, path, resolution).pipe(Effect.mapError(hostFailure))
643
658
  })
644
659
  // Replay writes and deletes what evidence names, so the lexical check alone
645
660
  // is not a boundary: a symlink planted on a lexically valid path would carry
@@ -647,7 +662,7 @@ export const makeFileSystem = (
647
662
  // its prune walk go through a view pinned to the root, where each call is
648
663
  // one descriptor-relative, no-follow request and a symlink anywhere on the
649
664
  // path is refused rather than followed. `prepare` and `settle` only read,
650
- // and keep the caller's filesystem.
665
+ // and keep the caller's filesystem, rooted through `hostPath`.
651
666
  const replayRoot = KernelFileSystem.confinedRoot(fs) ?? options.root ?? "."
652
667
  const replayHost: Effect.Effect<FileSystem.FileSystem, UnsupportedBoundary> = KernelFileSystem.isConfinable(fs)
653
668
  ? KernelFileSystem.confined(fs, replayRoot).pipe(
@@ -849,7 +864,7 @@ export const makeFileSystem = (
849
864
  if (typeof entry === "string") outputPaths.push(entry)
850
865
  else if (entry._tag === "Glob") outputPaths.push(...yield* expandGlob(entry))
851
866
  else {
852
- const files = (yield* treeEntries(fs, entry.path)).files
867
+ const files = (yield* treeEntries(fs, entry.path, enumeration)).files
853
868
  outputPaths.push(...files)
854
869
  treeMembers.push({ path: entry.path, files })
855
870
  }
@@ -1007,7 +1022,7 @@ export const makeFileSystem = (
1007
1022
  .map((output) => output.path)
1008
1023
  .filter((path) => path.startsWith(prefix))
1009
1024
  )
1010
- const entries = yield* treeEntries(host, tree.path)
1025
+ const entries = yield* treeEntries(host, tree.path, {})
1011
1026
  for (const path of entries.files) {
1012
1027
  if (!recorded.has(path)) yield* host.remove(path).pipe(Effect.mapError(hostFailure))
1013
1028
  }
package/src/index.ts CHANGED
@@ -67,7 +67,21 @@ export * as RunChangeFeed from "./RunChangeFeed.ts"
67
67
  * @category services
68
68
  */
69
69
  export * as PlanInputStore from "./PlanInputStore.ts"
70
+
71
+ /**
72
+ * Owner-fenced plan merge decisions, recorded before a merge is appended.
73
+ *
74
+ * @since 1.0.0
75
+ * @category services
76
+ */
70
77
  export * as PlanMergeStore from "./PlanMergeStore.ts"
78
+
79
+ /**
80
+ * Drives a persisted plan: the node scheduler.
81
+ *
82
+ * @since 0.1.0
83
+ * @category services
84
+ */
71
85
  export * as PlanScheduler from "./PlanScheduler.ts"
72
86
 
73
87
  /**