@animalabs/agent-framework 0.21.0 → 0.22.0
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/README.md +43 -0
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +102 -42
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +250 -39
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1602 -371
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +4 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +45 -8
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +81 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +257 -16
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1059 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +278 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +805 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +224 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +901 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +125 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +310 -35
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +4 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +14 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +73 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +977 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +19 -6
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +449 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +102 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2330 -18
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3079 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +120 -44
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +1661 -363
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +11 -0
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/server-connection.ts +41 -10
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +252 -16
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1058 -446
- package/src/modules/workspace/observe.ts +839 -0
- package/src/modules/workspace/reconcile.ts +1136 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/record-journal.ts +246 -0
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +335 -33
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +4 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +15 -0
- package/src/types/trace.ts +80 -0
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three-way rule over disk (D), the workspace store (S) and physical
|
|
3
|
+
* evidence (P), with branch-local intent (tombstones, store origin, sticky
|
|
4
|
+
* conflicts). Under it a scan or read never discards a pending store edit,
|
|
5
|
+
* and a branch switch is never mistaken for a disk edit:
|
|
6
|
+
*
|
|
7
|
+
* - D = S: agreement; P := D.
|
|
8
|
+
* - P known: D = P keeps the store side (a draft, a workspace deletion, or a
|
|
9
|
+
* file this branch doesn't have); S = P adopts disk (ingest, or remove S,
|
|
10
|
+
* or — for a binary/oversize disk version — drop S so the path reads
|
|
11
|
+
* disk-only); all three differing is a conflict.
|
|
12
|
+
* - P unknown: a tombstone, then store origin, then legacy entries decide;
|
|
13
|
+
* otherwise a new disk file is ingested or listed disk-only.
|
|
14
|
+
* - P pending (an intent whose effect may or may not have landed) resolves
|
|
15
|
+
* first; P interrupted is a sticky conflict until it's resolved explicitly
|
|
16
|
+
* or disk and the store agree. Another branch's pending adoption is left
|
|
17
|
+
* for its own branch to resolve, even where this branch agrees with disk.
|
|
18
|
+
*
|
|
19
|
+
* Only a push completes a push. A pending disk intent is the module's own
|
|
20
|
+
* write or unlink, and its completion releases the protection a draft has
|
|
21
|
+
* (P := what was written), so it is recorded only by a push whose barriers
|
|
22
|
+
* succeeded. An observation that finds the intended outcome on disk decides
|
|
23
|
+
* the path's state from it but records nothing for it — no completion, no
|
|
24
|
+
* agreement, no branch-intent change — and the path stays owed until a push
|
|
25
|
+
* redoes it. Otherwise a crash that loses an unbarriered write would leave
|
|
26
|
+
* P saying the draft reached disk, and the rule would adopt the older disk
|
|
27
|
+
* version over it. (An explicit path sync still chooses disk, replacing
|
|
28
|
+
* whatever the path's evidence was; that adopts disk, it doesn't certify the
|
|
29
|
+
* push.)
|
|
30
|
+
*
|
|
31
|
+
* Each pass observes disk first (the only async step), then reads S, P and
|
|
32
|
+
* intent and applies every decision in one synchronous step, in the agreed
|
|
33
|
+
* order: intents (durable) → tree commits and intent records → completions
|
|
34
|
+
* (after `store.sync()`). The caller serializes passes per mount. A drop (S
|
|
35
|
+
* removed for a disk copy the workspace can't hold) is the one tree adoption
|
|
36
|
+
* without an intent: it forgets P first, which is conservative, since a crash
|
|
37
|
+
* before the removal leaves P unknown with S present, a conflict, and never
|
|
38
|
+
* a wrong adoption.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { JsStore } from '@animalabs/chronicle';
|
|
42
|
+
import {
|
|
43
|
+
type Agreed,
|
|
44
|
+
type Candidate,
|
|
45
|
+
type DiskAgreement,
|
|
46
|
+
type Fingerprint,
|
|
47
|
+
type Interrupted,
|
|
48
|
+
type Pending,
|
|
49
|
+
type Physical,
|
|
50
|
+
type RootIdentity,
|
|
51
|
+
candidateOf,
|
|
52
|
+
sameCandidate,
|
|
53
|
+
sameRootIdentity,
|
|
54
|
+
} from './disk-agreement.js';
|
|
55
|
+
import type { BranchIntent, BranchIntents, ConflictKind, ConflictRecord, DiskCounterpart } from './branch-intent.js';
|
|
56
|
+
import {
|
|
57
|
+
type MountView,
|
|
58
|
+
ROOT_REPLACED,
|
|
59
|
+
type Walk,
|
|
60
|
+
fingerprintVouches,
|
|
61
|
+
locateRoot,
|
|
62
|
+
looksBinary,
|
|
63
|
+
observePath,
|
|
64
|
+
provenAbsent,
|
|
65
|
+
readHead,
|
|
66
|
+
readPath,
|
|
67
|
+
sniffImageMime,
|
|
68
|
+
walkScope,
|
|
69
|
+
} from './observe.js';
|
|
70
|
+
import { EffectFailed, type Expect, chainOf, syncDirectories, unlinkContained, writeContained } from './effects.js';
|
|
71
|
+
|
|
72
|
+
export type EntryState =
|
|
73
|
+
| 'synced'
|
|
74
|
+
| 'workspace-draft'
|
|
75
|
+
| 'workspace-deleted'
|
|
76
|
+
| 'not-in-branch'
|
|
77
|
+
| 'disk-only'
|
|
78
|
+
| 'conflict'
|
|
79
|
+
| 'disk-missing-provenance-unknown'
|
|
80
|
+
/** Tracked, but disk wasn't observed this pass (unreadable, ignored, unwalked). */
|
|
81
|
+
| 'unverified';
|
|
82
|
+
|
|
83
|
+
export interface ConflictReport {
|
|
84
|
+
kind: ConflictKind;
|
|
85
|
+
/** Where the disk side is kept: in the store, by reference to the disk file, or absent. */
|
|
86
|
+
diskCopy: 'stored' | 'referenced' | 'absent';
|
|
87
|
+
/** The disk file no longer matches what was recorded with the conflict. */
|
|
88
|
+
diskChangedSinceRecorded: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface PathReport {
|
|
92
|
+
path: string;
|
|
93
|
+
state: EntryState;
|
|
94
|
+
/** Store size for entries in the store; disk size otherwise. */
|
|
95
|
+
size?: number;
|
|
96
|
+
mimeType?: string;
|
|
97
|
+
conflict?: ConflictReport;
|
|
98
|
+
note?: string;
|
|
99
|
+
/**
|
|
100
|
+
* For a conflict, what this pass found on disk at the path: grep's account
|
|
101
|
+
* of a conflict's disk side needs it. Listings don't show it.
|
|
102
|
+
*/
|
|
103
|
+
diskNow?: 'file' | 'absent';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type TreeOp = 'created' | 'modified' | 'deleted';
|
|
107
|
+
|
|
108
|
+
export interface PassResult {
|
|
109
|
+
/** Tree changes adopted from disk. */
|
|
110
|
+
ops: Array<{ path: string; op: TreeOp }>;
|
|
111
|
+
/** What a path sync gave up of the workspace's own; `op` is what it did to the
|
|
112
|
+
* workspace copy, absent when it changed none (a deletion or conflict given
|
|
113
|
+
* up for a disk copy the workspace can't hold). */
|
|
114
|
+
discarded: Array<{ path: string; was: EntryState; op?: TreeOp }>;
|
|
115
|
+
/** Conflicts recorded by this pass, with the disk change behind each. */
|
|
116
|
+
newConflicts: Array<{ path: string; op: TreeOp }>;
|
|
117
|
+
/** Every path the pass decided, by path. */
|
|
118
|
+
reports: Map<string, PathReport>;
|
|
119
|
+
/** Regions the pass couldn't observe that matter to its scope. */
|
|
120
|
+
incomplete: Array<{ path: string; reason: string }>;
|
|
121
|
+
/** For a directory scope: the subdirectories disk shows directly beneath it. */
|
|
122
|
+
dirs: string[];
|
|
123
|
+
/**
|
|
124
|
+
* The branch the pass decided on, absent when it decided nothing. A caller
|
|
125
|
+
* that reads the tree or branch intent to go with the reports checks it is
|
|
126
|
+
* still the selected branch, synchronously, before it reads.
|
|
127
|
+
*/
|
|
128
|
+
branchId?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface MountRuntime {
|
|
132
|
+
name: string;
|
|
133
|
+
view: MountView;
|
|
134
|
+
/** Canonical mount root, for symlink containment. */
|
|
135
|
+
rootReal: string;
|
|
136
|
+
/**
|
|
137
|
+
* The device of the mount root's filesystem when that filesystem maintains
|
|
138
|
+
* ctime, else null. A fingerprint vouches, and is recorded, only for a file
|
|
139
|
+
* on that device: a file on another filesystem mounted beneath the root
|
|
140
|
+
* (which wasn't checked) is rehashed at every observation.
|
|
141
|
+
*/
|
|
142
|
+
ctimeTrustedDev: string | null;
|
|
143
|
+
treeStateId: string;
|
|
144
|
+
intents: BranchIntents;
|
|
145
|
+
readOnly: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type Scope =
|
|
149
|
+
| { kind: 'paths'; paths: string[] }
|
|
150
|
+
| { kind: 'dir'; dir: string; recursive: boolean };
|
|
151
|
+
|
|
152
|
+
export interface PassOptions {
|
|
153
|
+
/** Rehash every file instead of trusting fingerprints (an explicit sync). */
|
|
154
|
+
rehash?: boolean;
|
|
155
|
+
/** Paths whose disk state is adopted explicitly (a path sync). */
|
|
156
|
+
adopt?: (path: string) => boolean;
|
|
157
|
+
/** Walk cap for directory scopes. */
|
|
158
|
+
cap?: number;
|
|
159
|
+
/**
|
|
160
|
+
* The branch the caller asked on (a path sync). If another is selected when
|
|
161
|
+
* the pass gathers, it decides nothing: an adoption asked for on one branch
|
|
162
|
+
* would give up another branch's drafts.
|
|
163
|
+
*/
|
|
164
|
+
branchId?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ABSENT: Candidate = { kind: 'absent' };
|
|
168
|
+
export const WALK_CAP = 5000;
|
|
169
|
+
|
|
170
|
+
/** What a pass learned about D for one path. */
|
|
171
|
+
type DiskFact =
|
|
172
|
+
| { kind: 'absent' }
|
|
173
|
+
| { kind: 'unobserved'; reason: string }
|
|
174
|
+
| {
|
|
175
|
+
kind: 'file';
|
|
176
|
+
hash: string;
|
|
177
|
+
size: number;
|
|
178
|
+
mtimeMs: number;
|
|
179
|
+
fp: Fingerprint | null;
|
|
180
|
+
/** Text within limits, with its bytes: what disk→store sync may store. */
|
|
181
|
+
ingestible: Buffer | null;
|
|
182
|
+
mimeType?: string;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A file nothing tracks, too large to ingest: listed from its size and head
|
|
186
|
+
* alone. Its bytes were never hashed, so it can't be compared with anything
|
|
187
|
+
* — it is never evidence of agreement, only of a disk-only file.
|
|
188
|
+
*/
|
|
189
|
+
| { kind: 'unhashed'; size: number; mtimeMs: number; mimeType?: string };
|
|
190
|
+
|
|
191
|
+
function diskCandidate(d: DiskFact): Candidate | null {
|
|
192
|
+
if (d.kind === 'absent') return ABSENT;
|
|
193
|
+
if (d.kind === 'file') return { kind: 'content', hash: d.hash };
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function agreedFromDisk(d: DiskFact): Agreed {
|
|
198
|
+
if (d.kind !== 'file') return { kind: 'absent' };
|
|
199
|
+
return { kind: 'content', hash: d.hash, size: d.size, ...(d.fp ? { fp: d.fp } : {}) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inScope(path: string, scope: Scope): boolean {
|
|
203
|
+
if (scope.kind === 'paths') return scope.paths.includes(path);
|
|
204
|
+
const dir = scope.dir;
|
|
205
|
+
if (dir === '') return scope.recursive || !path.includes('/');
|
|
206
|
+
if (!path.startsWith(dir + '/')) return false;
|
|
207
|
+
return scope.recursive || !path.slice(dir.length + 1).includes('/');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Learn D for a path. Content is read only when a decision needs it; a
|
|
212
|
+
* recorded fingerprint can vouch for unchanged bytes where the filesystem
|
|
213
|
+
* maintains ctime.
|
|
214
|
+
*/
|
|
215
|
+
async function learnDisk(
|
|
216
|
+
mount: MountRuntime,
|
|
217
|
+
path: string,
|
|
218
|
+
tracked: { p: Physical | undefined; hasStore: boolean; hasIntent: boolean },
|
|
219
|
+
opts: PassOptions,
|
|
220
|
+
): Promise<DiskFact> {
|
|
221
|
+
const seen = await observePath(mount.view, mount.rootReal, path);
|
|
222
|
+
if (seen.kind !== 'file') return seen;
|
|
223
|
+
const p = tracked.p;
|
|
224
|
+
const trusted = (fp: { dev: string }) => mount.ctimeTrustedDev !== null && fp.dev === mount.ctimeTrustedDev;
|
|
225
|
+
if (!opts.rehash && p?.kind === 'content' && fingerprintVouches(p.fp, seen.fp, trusted(seen.fp))) {
|
|
226
|
+
return { kind: 'file', hash: p.hash, size: seen.size, mtimeMs: seen.mtimeMs, fp: p.fp ?? null, ingestible: null };
|
|
227
|
+
}
|
|
228
|
+
const nothingTracked = p === undefined && !tracked.hasStore && !tracked.hasIntent;
|
|
229
|
+
if (nothingTracked && seen.size > mount.view.maxFileSize) {
|
|
230
|
+
// A new oversize file only needs listing: its size, and its head for the
|
|
231
|
+
// MIME type. Hashing it would read all of it, on every listing.
|
|
232
|
+
const head = await readHead(mount.view, mount.rootReal, path);
|
|
233
|
+
if (head.kind !== 'head') return head;
|
|
234
|
+
return { kind: 'unhashed', size: head.size, mtimeMs: head.mtimeMs, mimeType: sniffImageMime(head.head) };
|
|
235
|
+
}
|
|
236
|
+
const read = await readPath(mount.view, mount.rootReal, path, mount.view.maxFileSize);
|
|
237
|
+
if (read.kind !== 'read') return read;
|
|
238
|
+
const ingestible = read.bytes !== null && !looksBinary(read.head) ? read.bytes : null;
|
|
239
|
+
return {
|
|
240
|
+
kind: 'file',
|
|
241
|
+
hash: read.hash,
|
|
242
|
+
size: read.size,
|
|
243
|
+
mtimeMs: read.mtimeMs,
|
|
244
|
+
// A fingerprint is recorded only where it can later vouch for the bytes.
|
|
245
|
+
fp: trusted(read.fp) ? read.fp : null,
|
|
246
|
+
ingestible,
|
|
247
|
+
...(ingestible ? {} : { mimeType: sniffImageMime(read.head) }),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The rule's verdict for one path, before effects. */
|
|
252
|
+
interface Verdict {
|
|
253
|
+
state: EntryState;
|
|
254
|
+
/** Adopt disk into the tree: set S to D's bytes, or remove S. */
|
|
255
|
+
adopt?: 'ingest' | 'remove' | 'drop';
|
|
256
|
+
/** New P (undefined: unchanged; 'forget': unknown). */
|
|
257
|
+
p?: Agreed | Interrupted | 'forget';
|
|
258
|
+
/** New branch intent (undefined: unchanged). */
|
|
259
|
+
intent?: BranchIntent;
|
|
260
|
+
/** A conflict to record now. */
|
|
261
|
+
conflict?: ConflictKind;
|
|
262
|
+
op?: TreeOp;
|
|
263
|
+
note?: string;
|
|
264
|
+
/**
|
|
265
|
+
* Disk shows what a pending push of ours intended, unconfirmed: the state
|
|
266
|
+
* is decided from it, and nothing at all is recorded (see module doc).
|
|
267
|
+
*/
|
|
268
|
+
unconfirmed?: true;
|
|
269
|
+
/** A path sync's adoption gives up this workspace state (see decide). */
|
|
270
|
+
discards?: EntryState;
|
|
271
|
+
/**
|
|
272
|
+
* The last confirmed evidence decide resolved for the path, which an
|
|
273
|
+
* adoption's intent carries as its prior: a settled same-branch adoption's
|
|
274
|
+
* outcome, where the recorded P is still that adoption's pending record.
|
|
275
|
+
*/
|
|
276
|
+
prior?: Agreed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** States in which the rule keeps a change of the workspace's own over disk. */
|
|
280
|
+
const WORKSPACE_KEPT: ReadonlySet<EntryState> = new Set<EntryState>([
|
|
281
|
+
'workspace-draft',
|
|
282
|
+
'workspace-deleted',
|
|
283
|
+
'conflict',
|
|
284
|
+
'disk-missing-provenance-unknown',
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const UNCONFIRMED_NOTE = 'disk holds what a materialize that has not completed put here; materialize again to complete it';
|
|
288
|
+
|
|
289
|
+
function keepStoreSide(hasStore: boolean, bi: BranchIntent | null): EntryState {
|
|
290
|
+
if (hasStore) return 'workspace-draft';
|
|
291
|
+
return bi?.tombstone ? 'workspace-deleted' : 'not-in-branch';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Settle an adoption intent on the branch it was recorded on, by the tree
|
|
296
|
+
* alone: S at the adopted content means the commit landed and only its
|
|
297
|
+
* completion was lost; anything else means it didn't, and the rule runs again
|
|
298
|
+
* from the prior evidence (redoing the adoption if disk still holds it).
|
|
299
|
+
*
|
|
300
|
+
* Content is proof only while nothing else has written the path since, so
|
|
301
|
+
* every workspace mutation settles a pending intent first (the module's
|
|
302
|
+
* mutation boundary): a later write of the same content can't pass for the
|
|
303
|
+
* old adoption.
|
|
304
|
+
*/
|
|
305
|
+
function settleAdoption(p: Pending, s: { hash: string; size: number } | null): Agreed | 'forget' {
|
|
306
|
+
const sc: Candidate = s ? { kind: 'content', hash: s.hash } : ABSENT;
|
|
307
|
+
if (sameCandidate(sc, p.expect)) {
|
|
308
|
+
return p.expect.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: p.expect.hash, size: s!.size };
|
|
309
|
+
}
|
|
310
|
+
return p.prior ?? 'forget';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The synchronous precondition of every workspace mutation: evidence owed a
|
|
315
|
+
* barrier is made durable first, and an adoption left pending on the
|
|
316
|
+
* selected branch is settled by the tree before the mutation changes it, and
|
|
317
|
+
* durably, so the settlement can't be lost while the new write survives.
|
|
318
|
+
* Afterwards the write can never pass for the adoption's outcome. Other
|
|
319
|
+
* pending intents never consult the tree, so a write can't confuse them.
|
|
320
|
+
* Holds whether or not an earlier observation could settle it.
|
|
321
|
+
*/
|
|
322
|
+
export function settleAdoptionBeforeMutation(
|
|
323
|
+
store: JsStore,
|
|
324
|
+
agreement: DiskAgreement,
|
|
325
|
+
mount: { name: string; treeStateId: string },
|
|
326
|
+
path: string,
|
|
327
|
+
): void {
|
|
328
|
+
// Evidence still owed a barrier (from a failed one, or read back at open)
|
|
329
|
+
// is made durable before any new tree mutation is permitted at all.
|
|
330
|
+
if (agreement.needsBarrier) agreement.barrier();
|
|
331
|
+
const p = agreement.get(mount.name, path);
|
|
332
|
+
if (p?.kind !== 'pending' || p.effect !== 'adopt' || p.branchId !== store.currentBranch().id) return;
|
|
333
|
+
const entry = store.treeGet(mount.treeStateId, path);
|
|
334
|
+
const settled = settleAdoption(p, entry ? { hash: entry.blobHash, size: entry.size } : null);
|
|
335
|
+
if (settled === 'forget') agreement.forget(mount.name, path);
|
|
336
|
+
else agreement.set(mount.name, path, settled, { afterCommittedState: true });
|
|
337
|
+
agreement.barrier();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolve a pending intent against what was observed. Returns the evidence
|
|
342
|
+
* the rule should use, and whether that resolution should be recorded.
|
|
343
|
+
*/
|
|
344
|
+
function resolvePending(
|
|
345
|
+
p: Pending,
|
|
346
|
+
dc: Candidate,
|
|
347
|
+
s: { hash: string; size: number } | null,
|
|
348
|
+
d: DiskFact,
|
|
349
|
+
branchId: string,
|
|
350
|
+
): { p: Physical | undefined; record?: Agreed | Interrupted | 'forget'; uncaptured?: true; unconfirmed?: true } {
|
|
351
|
+
const prior = p.prior;
|
|
352
|
+
const priorCandidate = prior ? candidateOf(prior) : null;
|
|
353
|
+
if (p.effect === 'disk') {
|
|
354
|
+
if (sameCandidate(dc, p.expect)) {
|
|
355
|
+
// Our own effect shows on disk, but nothing proves it durable: decided
|
|
356
|
+
// on, never recorded. Only a push whose barriers succeed completes it.
|
|
357
|
+
const shown = p.expect.kind === 'absent' ? ({ kind: 'absent' } as Agreed) : agreedFromDisk(d);
|
|
358
|
+
return { p: shown, unconfirmed: true };
|
|
359
|
+
}
|
|
360
|
+
if (priorCandidate && sameCandidate(dc, priorCandidate)) return { p: prior!, record: prior! };
|
|
361
|
+
// A write whose prior was unknown left no file: it never created one (a
|
|
362
|
+
// partial write leaves a file behind), so P is as unknown as before.
|
|
363
|
+
if (!prior && dc.kind === 'absent' && p.expect.kind === 'content') return { p: undefined, record: 'forget' };
|
|
364
|
+
const interrupted: Interrupted = { kind: 'interrupted', candidates: [...(priorCandidate ? [priorCandidate] : []), p.expect] };
|
|
365
|
+
return { p: interrupted, record: interrupted };
|
|
366
|
+
}
|
|
367
|
+
// An adoption: its meaning depends on the branch it was recorded on.
|
|
368
|
+
if (p.branchId === branchId) {
|
|
369
|
+
const landed = sameCandidate(s ? { kind: 'content', hash: s.hash } : ABSENT, p.expect);
|
|
370
|
+
// Landed, with disk at the adopted content too: its fingerprint joins the evidence.
|
|
371
|
+
if (landed && p.expect.kind === 'content' && sameCandidate(dc, p.expect)) {
|
|
372
|
+
const agreed = agreedFromDisk(d);
|
|
373
|
+
return { p: agreed, record: agreed };
|
|
374
|
+
}
|
|
375
|
+
const settled = settleAdoption(p, s);
|
|
376
|
+
return settled === 'forget' ? { p: undefined, record: 'forget' } : { p: settled, record: settled };
|
|
377
|
+
}
|
|
378
|
+
// Another branch, whose S says nothing of whether the adoption happened
|
|
379
|
+
// over there. Disk still holding the prior is D = P: the store has that
|
|
380
|
+
// content. Disk holding the adopted content, or anything newer, may hold
|
|
381
|
+
// the only copy of it, so this branch lists it as a conflict and nothing
|
|
382
|
+
// pushes over it; P stays pending for the intent's own branch to resolve.
|
|
383
|
+
if (priorCandidate && sameCandidate(dc, priorCandidate)) return { p };
|
|
384
|
+
return { p, uncaptured: true };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* An explicit path sync: the store takes disk's state, whatever the evidence
|
|
389
|
+
* — a pending push's included. That chooses disk; it doesn't certify the push.
|
|
390
|
+
*/
|
|
391
|
+
function adoptDisk(d: Extract<DiskFact, { kind: 'absent' | 'file' }>, s: { hash: string; size: number } | null): Verdict {
|
|
392
|
+
if (d.kind === 'absent') {
|
|
393
|
+
return { state: 'synced', ...(s ? { adopt: 'remove' as const, op: 'deleted' as const } : {}), p: { kind: 'absent' }, intent: {} };
|
|
394
|
+
}
|
|
395
|
+
// Disk agreeing with the store is disk's state already, whatever the file
|
|
396
|
+
// is: a binary or oversize file the workspace holds stays held.
|
|
397
|
+
if (s && s.hash === d.hash) return { state: 'synced', p: agreedFromDisk(d), intent: {} };
|
|
398
|
+
if (d.ingestible) return { state: 'synced', adopt: 'ingest', op: s ? 'modified' : 'created', p: agreedFromDisk(d), intent: {} };
|
|
399
|
+
return { state: 'disk-only', ...(s ? { adopt: 'drop' as const, op: 'deleted' as const } : {}), p: 'forget', intent: {} };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The rule (see module doc). Pure. */
|
|
403
|
+
function decide(
|
|
404
|
+
d: DiskFact,
|
|
405
|
+
s: { hash: string; size: number } | null,
|
|
406
|
+
pIn: Physical | undefined,
|
|
407
|
+
bi: BranchIntent | null,
|
|
408
|
+
branchId: string,
|
|
409
|
+
adopt: boolean,
|
|
410
|
+
): Verdict {
|
|
411
|
+
// A branch intent that can't be read may have held a tombstone or a
|
|
412
|
+
// conflict: nothing is decided over it, until a path sync replaces it.
|
|
413
|
+
if (bi?.unreadable !== undefined && !adopt) return { state: 'unverified', note: `its branch intent can't be read: ${bi.unreadable}` };
|
|
414
|
+
if (d.kind === 'unobserved') {
|
|
415
|
+
// Disk proves nothing, but an adoption on this branch settles by the tree.
|
|
416
|
+
if (pIn?.kind === 'pending' && pIn.effect === 'adopt' && pIn.branchId === branchId && !adopt) {
|
|
417
|
+
return { state: 'unverified', note: d.reason, p: settleAdoption(pIn, s) };
|
|
418
|
+
}
|
|
419
|
+
return { state: 'unverified', note: d.reason };
|
|
420
|
+
}
|
|
421
|
+
if (d.kind === 'unhashed') {
|
|
422
|
+
// Observed this way only when nothing tracks the path, which is then a
|
|
423
|
+
// disk-only file whatever else holds. Were anything to track it, its
|
|
424
|
+
// unhashed bytes could prove nothing about it.
|
|
425
|
+
if (pIn === undefined && s === null && bi === null) return { state: 'disk-only' };
|
|
426
|
+
return { state: 'unverified', note: 'not hashed' };
|
|
427
|
+
}
|
|
428
|
+
const dc = diskCandidate(d)!;
|
|
429
|
+
const sc: Candidate = s ? { kind: 'content', hash: s.hash } : ABSENT;
|
|
430
|
+
|
|
431
|
+
if (adopt) {
|
|
432
|
+
// What a path sync gives up is exactly what a sync without a path would
|
|
433
|
+
// have kept of the workspace's own (a draft, a deletion, a conflict's
|
|
434
|
+
// workspace side), and the verdict says which.
|
|
435
|
+
const taken = adoptDisk(d, s);
|
|
436
|
+
// A give-up is an adoption, or the intent it clears: a deletion or a
|
|
437
|
+
// conflict given up for a disk copy the workspace can't hold changes no
|
|
438
|
+
// tree entry, and is listed all the same.
|
|
439
|
+
if (taken.adopt || (bi && taken.intent !== undefined)) {
|
|
440
|
+
const kept = decide(d, s, pIn, bi, branchId, false);
|
|
441
|
+
if (WORKSPACE_KEPT.has(kept.state)) taken.discards = kept.state;
|
|
442
|
+
if (taken.adopt && kept.prior) taken.prior = kept.prior;
|
|
443
|
+
}
|
|
444
|
+
return taken;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// A pending intent resolves first; its resolution is recorded unless the
|
|
448
|
+
// verdict below records newer evidence.
|
|
449
|
+
let p = pIn;
|
|
450
|
+
let resolution: Agreed | Interrupted | 'forget' | undefined;
|
|
451
|
+
let uncaptured = false;
|
|
452
|
+
if (p?.kind === 'pending') {
|
|
453
|
+
const resolved = resolvePending(p, dc, s, d, branchId);
|
|
454
|
+
if (resolved.unconfirmed) {
|
|
455
|
+
// Disk shows our pending push's outcome (P as it intended): the state
|
|
456
|
+
// follows from that, and nothing is recorded — no completion, no
|
|
457
|
+
// agreement, no branch-intent change — until a push confirms it.
|
|
458
|
+
const state: EntryState = sameCandidate(dc, sc) ? 'synced' : bi?.conflict ? 'conflict' : keepStoreSide(s !== null, bi);
|
|
459
|
+
return { state, note: UNCONFIRMED_NOTE, unconfirmed: true };
|
|
460
|
+
}
|
|
461
|
+
p = resolved.p;
|
|
462
|
+
resolution = resolved.record;
|
|
463
|
+
uncaptured = resolved.uncaptured === true;
|
|
464
|
+
}
|
|
465
|
+
const verdict = (v: Verdict): Verdict => (resolution && v.p === undefined ? { ...v, p: resolution } : v);
|
|
466
|
+
|
|
467
|
+
// Agreement settles everything, a recorded conflict included.
|
|
468
|
+
if (sameCandidate(dc, sc)) {
|
|
469
|
+
const settles = bi?.tombstone || bi?.conflict || bi?.origin ? { intent: {} } : {};
|
|
470
|
+
// Another branch's pending adoption is still pending here (resolvePending
|
|
471
|
+
// leaves it): that branch resolves it, by its own tree. Recording this
|
|
472
|
+
// branch's agreement over it would lose the adoption there.
|
|
473
|
+
if (p?.kind === 'pending') return verdict({ state: 'synced', ...settles });
|
|
474
|
+
if (dc.kind === 'absent') {
|
|
475
|
+
return verdict({ state: 'synced', ...(p && p.kind !== 'absent' ? { p: { kind: 'absent' } as Agreed } : {}), ...settles });
|
|
476
|
+
}
|
|
477
|
+
const agreed = agreedFromDisk(d) as Extract<Agreed, { kind: 'content' }>;
|
|
478
|
+
const recorded = p?.kind === 'content' ? p : null;
|
|
479
|
+
// Re-record only when P says something else, or a fresh fingerprint can
|
|
480
|
+
// replace one that no longer vouches. A fresh one exists only where it's
|
|
481
|
+
// trusted, so both are judged as trusted here.
|
|
482
|
+
const stale = !recorded || recorded.hash !== agreed.hash || recorded.size !== agreed.size ||
|
|
483
|
+
(agreed.fp !== undefined && fingerprintVouches(agreed.fp, agreed.fp, true) &&
|
|
484
|
+
!fingerprintVouches(recorded.fp, agreed.fp, true));
|
|
485
|
+
return verdict({ state: 'synced', ...(stale ? { p: agreed } : {}), ...settles });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// A recorded conflict stays until it is resolved explicitly or disk and store converge.
|
|
489
|
+
if (bi?.conflict) return verdict({ state: 'conflict' });
|
|
490
|
+
|
|
491
|
+
if (p?.kind === 'interrupted' || uncaptured) return verdict({ state: 'conflict', conflict: 'interrupted' });
|
|
492
|
+
|
|
493
|
+
if (p?.kind === 'pending') {
|
|
494
|
+
// Unresolved from another branch, with disk still holding its prior.
|
|
495
|
+
return verdict({ state: keepStoreSide(s !== null, bi) });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (p !== undefined) {
|
|
499
|
+
const pc = candidateOf(p);
|
|
500
|
+
if (sameCandidate(dc, pc)) return verdict({ state: keepStoreSide(s !== null, bi) });
|
|
501
|
+
if (sameCandidate(sc, pc)) {
|
|
502
|
+
// Only disk changed since it last agreed: adopt it. P is global, so
|
|
503
|
+
// S = P means the workspace holds what disk last agreed with, whichever
|
|
504
|
+
// branch that agreement came from, and a branch's intent doesn't
|
|
505
|
+
// outrank it. A known P of absent means disk did lose the file, which
|
|
506
|
+
// settles a tombstone here: a file disk shows now is a new one, and
|
|
507
|
+
// adopting it gives up nothing of the workspace's own. A tombstone
|
|
508
|
+
// guards a deletion only while P is unknown (below).
|
|
509
|
+
const settles = bi ? { intent: {} } : {};
|
|
510
|
+
// The adoption's intent carries this resolved evidence as its prior.
|
|
511
|
+
const prior = p.kind === 'absent' || p.kind === 'content' ? { prior: p } : {};
|
|
512
|
+
if (d.kind === 'absent') return { state: 'synced', adopt: 'remove', op: 'deleted', p: { kind: 'absent' }, ...prior, ...settles };
|
|
513
|
+
if (d.ingestible) {
|
|
514
|
+
return { state: 'synced', adopt: 'ingest', op: s ? 'modified' : 'created', p: agreedFromDisk(d), ...prior, ...settles };
|
|
515
|
+
}
|
|
516
|
+
return { state: 'disk-only', ...(s ? { adopt: 'drop' as const, op: 'deleted' as const } : {}), p: 'forget', ...settles };
|
|
517
|
+
}
|
|
518
|
+
const kind: ConflictKind = d.kind === 'absent'
|
|
519
|
+
? 'deleted-on-disk'
|
|
520
|
+
: !s && bi?.tombstone ? 'changed-after-workspace-delete' : 'both-changed';
|
|
521
|
+
return verdict({ state: 'conflict', conflict: kind });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// No evidence: intent and provenance decide.
|
|
525
|
+
if (!s && bi?.tombstone) {
|
|
526
|
+
if (d.kind === 'file' && d.hash === bi.tombstone.hash) return { state: 'workspace-deleted' };
|
|
527
|
+
return { state: 'conflict', conflict: 'changed-after-workspace-delete' };
|
|
528
|
+
}
|
|
529
|
+
if (s) {
|
|
530
|
+
if (d.kind === 'absent') return { state: bi?.origin === 'store' ? 'workspace-draft' : 'disk-missing-provenance-unknown' };
|
|
531
|
+
return { state: 'conflict', conflict: bi?.origin === 'store' ? 'store-origin-collision' : 'unknown-provenance' };
|
|
532
|
+
}
|
|
533
|
+
// A new disk file.
|
|
534
|
+
if (d.kind === 'file' && d.ingestible) return { state: 'synced', adopt: 'ingest', op: 'created', p: agreedFromDisk(d) };
|
|
535
|
+
return { state: 'disk-only' };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function counterpartOf(store: JsStore, d: DiskFact): DiskCounterpart | null {
|
|
539
|
+
if (d.kind !== 'file') return null;
|
|
540
|
+
const stored = d.ingestible ? store.storeBlob(d.ingestible, 'text/plain') : undefined;
|
|
541
|
+
return { hash: d.hash, size: d.size, mtimeMs: d.mtimeMs, ...(stored ? { stored } : {}) };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function conflictReport(record: ConflictRecord, d: DiskFact): ConflictReport {
|
|
545
|
+
const current = diskCandidate(d);
|
|
546
|
+
const recorded: Candidate = record.disk ? { kind: 'content', hash: record.disk.hash } : ABSENT;
|
|
547
|
+
return {
|
|
548
|
+
kind: record.kind,
|
|
549
|
+
diskCopy: record.disk === null ? 'absent' : record.disk.stored ? 'stored' : 'referenced',
|
|
550
|
+
diskChangedSinceRecorded: current !== null && !sameCandidate(current, recorded),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** A pass's view of its scope: candidates enumerated on one branch, and what disk showed for each. */
|
|
555
|
+
interface Gathered {
|
|
556
|
+
branchId: string;
|
|
557
|
+
/** The root the facts were gathered under, when it was available. */
|
|
558
|
+
rootIdentity: RootIdentity | null;
|
|
559
|
+
walk: Walk | null;
|
|
560
|
+
facts: Map<string, DiskFact>;
|
|
561
|
+
dirs: string[];
|
|
562
|
+
incomplete: Array<{ path: string; reason: string }>;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** How often a pass gathers again when the branch changes under it. */
|
|
566
|
+
const GATHER_ATTEMPTS = 3;
|
|
567
|
+
|
|
568
|
+
async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRuntime, scope: Scope, opts: PassOptions): Promise<Gathered> {
|
|
569
|
+
// A missing mount root is an unavailable mount (an unmounted drive, a root
|
|
570
|
+
// being replaced), not proof that every file in it was deleted, and so is a
|
|
571
|
+
// root that isn't the directory disk last agreed under: the empty
|
|
572
|
+
// directory a watcher makes where a drive's mountpoint was. The root is
|
|
573
|
+
// the operator's choice and may itself be a symlink to a directory, so it
|
|
574
|
+
// is followed, as `rootReal` is: `followSymlinks` governs links within it.
|
|
575
|
+
const root = await locateRoot(mount.view, mount.rootReal);
|
|
576
|
+
const rootUnavailable = 'reason' in root ? root.reason : null;
|
|
577
|
+
|
|
578
|
+
// The branch is labelled where the tracked candidates are read, with no
|
|
579
|
+
// await between: the decision is checked against this label.
|
|
580
|
+
const branchId = store.currentBranch().id;
|
|
581
|
+
const gathered: Gathered = {
|
|
582
|
+
branchId,
|
|
583
|
+
rootIdentity: 'identity' in root ? root.identity : null,
|
|
584
|
+
walk: null,
|
|
585
|
+
facts: new Map(),
|
|
586
|
+
dirs: [],
|
|
587
|
+
incomplete: [],
|
|
588
|
+
};
|
|
589
|
+
if (rootUnavailable !== null) {
|
|
590
|
+
// Said whether or not anything is tracked: an unavailable mount must not
|
|
591
|
+
// read as a verified empty directory.
|
|
592
|
+
gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: rootUnavailable });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Which paths to decide: what disk shows, and what the store, P and intent
|
|
596
|
+
// track. The tracked ones are read synchronously, on this branch.
|
|
597
|
+
const candidates = new Set<string>();
|
|
598
|
+
let walk: Walk | null = null;
|
|
599
|
+
if (scope.kind === 'paths') {
|
|
600
|
+
for (const p of scope.paths) candidates.add(p);
|
|
601
|
+
} else {
|
|
602
|
+
const prefix = scope.dir ? scope.dir + '/' : undefined;
|
|
603
|
+
for (const e of store.treeList(mount.treeStateId, prefix)) if (inScope(e.path, scope)) candidates.add(e.path);
|
|
604
|
+
for (const [p] of agreement.paths(mount.name, scope.dir)) if (inScope(p, scope)) candidates.add(p);
|
|
605
|
+
for (const [p] of mount.intents.list(scope.dir)) if (inScope(p, scope)) candidates.add(p);
|
|
606
|
+
if (rootUnavailable === null) {
|
|
607
|
+
walk = await walkScope(mount.view, mount.rootReal, scope.dir, scope.recursive, opts.cap ?? WALK_CAP);
|
|
608
|
+
for (const f of walk.files) if (inScope(f, scope)) candidates.add(f);
|
|
609
|
+
// Ignored names are listed only when something tracks them (above).
|
|
610
|
+
for (const [p, why] of walk.others) if (why !== 'ignored by the mount' && inScope(p, scope)) candidates.add(p);
|
|
611
|
+
for (const d of walk.dirs) if (inScope(d, { kind: 'dir', dir: scope.dir, recursive: false })) gathered.dirs.push(d);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
gathered.walk = walk;
|
|
615
|
+
|
|
616
|
+
// Observe. Tracked facts are read again, synchronously, when deciding.
|
|
617
|
+
for (const path of candidates) {
|
|
618
|
+
if (rootUnavailable !== null) {
|
|
619
|
+
gathered.facts.set(path, { kind: 'unobserved', reason: rootUnavailable });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (walk && provenAbsent(walk, path)) {
|
|
623
|
+
gathered.facts.set(path, { kind: 'absent' });
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (walk && !walk.files.has(path) && !walk.others.has(path)) {
|
|
627
|
+
// Beneath a region the walk didn't reach: unproven either way.
|
|
628
|
+
const region = walk.incomplete.find((r) => r.path === '' || path === r.path || path.startsWith(r.path + '/'));
|
|
629
|
+
gathered.facts.set(path, { kind: 'unobserved', reason: region ? region.reason : 'not reached by the walk' });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (walk?.others.get(path) === 'ignored by the mount') {
|
|
633
|
+
gathered.facts.set(path, { kind: 'unobserved', reason: 'ignored by the mount' });
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const p = agreement.get(mount.name, path);
|
|
637
|
+
const hasStore = store.treeGet(mount.treeStateId, path) !== null;
|
|
638
|
+
const hasIntent = mount.intents.get(path) !== null;
|
|
639
|
+
gathered.facts.set(path, await learnDisk(mount, path, { p, hasStore, hasIntent }, opts));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Everything above was observed by path beneath the root's path. A root
|
|
643
|
+
// replaced meanwhile lends its replacement's files, and their absence, to
|
|
644
|
+
// the original's paths, so if the root isn't the one the pass began under
|
|
645
|
+
// now, nothing observed counts and nothing is decided from it.
|
|
646
|
+
if (gathered.rootIdentity !== null) {
|
|
647
|
+
const after = await locateRoot(mount.view, mount.rootReal);
|
|
648
|
+
if (!('identity' in after) || !sameRootIdentity(after.identity, gathered.rootIdentity)) {
|
|
649
|
+
const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
|
|
650
|
+
for (const path of gathered.facts.keys()) gathered.facts.set(path, { kind: 'unobserved', reason });
|
|
651
|
+
gathered.rootIdentity = null;
|
|
652
|
+
gathered.walk = null;
|
|
653
|
+
gathered.dirs = [];
|
|
654
|
+
gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return gathered;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* One disk→store pass over a scope: the watcher's paths, a lazy read, a
|
|
662
|
+
* listing's directory, a full scan, or a path sync (opts.adopt).
|
|
663
|
+
*/
|
|
664
|
+
export async function reconcilePass(
|
|
665
|
+
store: JsStore,
|
|
666
|
+
agreement: DiskAgreement,
|
|
667
|
+
mount: MountRuntime,
|
|
668
|
+
scope: Scope,
|
|
669
|
+
opts: PassOptions = {},
|
|
670
|
+
): Promise<PassResult> {
|
|
671
|
+
agreement.ensureReconciled();
|
|
672
|
+
// Evidence a failed barrier left unsynced is settled before anything is
|
|
673
|
+
// decided from it; if the barrier fails again, so does the pass.
|
|
674
|
+
if (agreement.needsBarrier) agreement.barrier();
|
|
675
|
+
const result: PassResult = { ops: [], discarded: [], newConflicts: [], reports: new Map(), incomplete: [], dirs: [] };
|
|
676
|
+
|
|
677
|
+
// Enumerate on one branch and observe disk (async). The decision below must
|
|
678
|
+
// describe the same branch the candidates came from: a branch switch during
|
|
679
|
+
// observation would otherwise yield a "complete" listing that omits the new
|
|
680
|
+
// branch's drafts. So a changed branch gathers again; a branch that keeps
|
|
681
|
+
// changing leaves the pass explicitly incomplete, deciding nothing.
|
|
682
|
+
let gathered: Gathered | null = null;
|
|
683
|
+
for (let attempt = 0; attempt < GATHER_ATTEMPTS; attempt++) {
|
|
684
|
+
const next = await gather(store, agreement, mount, scope, opts);
|
|
685
|
+
if (store.currentBranch().id === next.branchId) {
|
|
686
|
+
gathered = next;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (gathered === null) {
|
|
691
|
+
result.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: 'the selected branch kept changing while the scan ran' });
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
if (opts.branchId !== undefined && gathered.branchId !== opts.branchId) {
|
|
695
|
+
result.incomplete.push({
|
|
696
|
+
path: scope.kind === 'dir' ? scope.dir : scope.paths.join(', '),
|
|
697
|
+
reason: 'the selected branch changed before this sync ran; nothing was taken from disk — sync again on this branch to take it',
|
|
698
|
+
});
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
const { walk, facts } = gathered;
|
|
702
|
+
result.dirs.push(...gathered.dirs);
|
|
703
|
+
result.incomplete.push(...gathered.incomplete);
|
|
704
|
+
|
|
705
|
+
// Decide and apply — synchronously, so nothing interleaves.
|
|
706
|
+
const branchId = store.currentBranch().id;
|
|
707
|
+
result.branchId = branchId;
|
|
708
|
+
type Planned = { path: string; verdict: Verdict; d: DiskFact; s: { hash: string; size: number } | null; p: Physical | undefined; bi: BranchIntent | null };
|
|
709
|
+
const planned: Planned[] = [];
|
|
710
|
+
for (const [path, d] of facts) {
|
|
711
|
+
const entry = store.treeGet(mount.treeStateId, path);
|
|
712
|
+
const s = entry ? { hash: entry.blobHash, size: entry.size } : null;
|
|
713
|
+
const p = agreement.get(mount.name, path);
|
|
714
|
+
const bi = mount.intents.get(path);
|
|
715
|
+
const verdict = decide(d, s, p, bi, branchId, opts.adopt?.(path) === true);
|
|
716
|
+
planned.push({ path, verdict, d, s, p, bi });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// The evidence about to be recorded describes the root it was gathered
|
|
720
|
+
// under, which is recorded first (once per mount), so no crash can leave
|
|
721
|
+
// evidence without its root.
|
|
722
|
+
if (gathered.rootIdentity && planned.some((x) => x.verdict.adopt !== undefined || x.verdict.p !== undefined)) {
|
|
723
|
+
agreement.recordRootIdentity(mount.name, gathered.rootIdentity);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// 1. Intents for adoptions, durable before any tree commit.
|
|
727
|
+
const adoptions = planned.filter((x) => x.verdict.adopt === 'ingest' || x.verdict.adopt === 'remove');
|
|
728
|
+
adoptions.forEach((x, i) => {
|
|
729
|
+
const prior = x.verdict.prior ?? (x.p && (x.p.kind === 'absent' || x.p.kind === 'content') ? x.p : null);
|
|
730
|
+
agreement.intend(mount.name, x.path, {
|
|
731
|
+
effect: 'adopt',
|
|
732
|
+
prior,
|
|
733
|
+
expect: x.verdict.adopt === 'remove' ? ABSENT : { kind: 'content', hash: (x.d as { hash: string }).hash },
|
|
734
|
+
branchId,
|
|
735
|
+
}, { durable: i === adoptions.length - 1 });
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// 2. Forgetting P for a dropped entry is conservative, so it goes first.
|
|
739
|
+
for (const x of planned) if (x.verdict.adopt === 'drop') agreement.forget(mount.name, x.path);
|
|
740
|
+
|
|
741
|
+
// 3. Tree commits and branch intent.
|
|
742
|
+
let committed = false;
|
|
743
|
+
for (const x of planned) {
|
|
744
|
+
const v = x.verdict;
|
|
745
|
+
if (v.adopt === 'ingest' && x.d.kind === 'file' && x.d.ingestible) {
|
|
746
|
+
const blobHash = store.storeBlob(x.d.ingestible, 'text/plain');
|
|
747
|
+
store.treeSet(mount.treeStateId, x.path, { blobHash, size: x.d.ingestible.byteLength, mode: 0o644 });
|
|
748
|
+
committed = true;
|
|
749
|
+
} else if ((v.adopt === 'remove' || v.adopt === 'drop') && x.s) {
|
|
750
|
+
store.treeRemove(mount.treeStateId, x.path);
|
|
751
|
+
committed = true;
|
|
752
|
+
}
|
|
753
|
+
if (v.conflict) {
|
|
754
|
+
const record: ConflictRecord = { kind: v.conflict, at: Date.now(), disk: counterpartOf(store, x.d) };
|
|
755
|
+
mount.intents.update(x.path, (cur) => ({ ...cur, conflict: record }));
|
|
756
|
+
result.newConflicts.push({ path: x.path, op: x.d.kind === 'absent' ? 'deleted' : 'modified' });
|
|
757
|
+
committed = true;
|
|
758
|
+
} else if (v.intent !== undefined) {
|
|
759
|
+
mount.intents.put(x.path, v.intent);
|
|
760
|
+
committed = true;
|
|
761
|
+
}
|
|
762
|
+
if (v.op && v.adopt) result.ops.push({ path: x.path, op: v.op });
|
|
763
|
+
if (v.discards) result.discarded.push({ path: x.path, was: v.discards, ...(v.op && v.adopt ? { op: v.op } : {}) });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// 4. Completions and agreements, after the commits they assert are synced —
|
|
767
|
+
// this pass's, and any earlier tool write's an agreement rests on.
|
|
768
|
+
let first = true;
|
|
769
|
+
let recorded = false;
|
|
770
|
+
for (const x of planned) {
|
|
771
|
+
const next = x.verdict.p;
|
|
772
|
+
if (x.verdict.adopt === 'drop') recorded = true; // its forget, in step 2
|
|
773
|
+
if (next === undefined) continue;
|
|
774
|
+
recorded = true;
|
|
775
|
+
if (next === 'forget') {
|
|
776
|
+
agreement.forget(mount.name, x.path); // no-op when step 2 already did
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
agreement.set(mount.name, x.path, next, { afterCommittedState: first });
|
|
780
|
+
first = false;
|
|
781
|
+
}
|
|
782
|
+
// 5. Durable before returning. An agreement advance (D = S) has no pending
|
|
783
|
+
// intent to recover from, so losing its append would leave an older P that
|
|
784
|
+
// another branch would misread as a disk edit.
|
|
785
|
+
if (recorded || committed || agreement.needsBarrier) agreement.barrier();
|
|
786
|
+
agreement.maybeCheckpoint();
|
|
787
|
+
|
|
788
|
+
// Reports.
|
|
789
|
+
for (const x of planned) {
|
|
790
|
+
const after = mount.intents.get(x.path);
|
|
791
|
+
const entry = store.treeGet(mount.treeStateId, x.path);
|
|
792
|
+
const report: PathReport = { path: x.path, state: x.verdict.state };
|
|
793
|
+
if (entry) report.size = entry.size;
|
|
794
|
+
else if (x.d.kind === 'file' || x.d.kind === 'unhashed') report.size = x.d.size;
|
|
795
|
+
if (x.verdict.state === 'disk-only' && (x.d.kind === 'file' || x.d.kind === 'unhashed') && x.d.mimeType) report.mimeType = x.d.mimeType;
|
|
796
|
+
if (x.verdict.state === 'conflict' && after?.conflict) {
|
|
797
|
+
report.conflict = conflictReport(after.conflict, x.d);
|
|
798
|
+
if (x.d.kind === 'file' || x.d.kind === 'absent') report.diskNow = x.d.kind;
|
|
799
|
+
}
|
|
800
|
+
if (x.verdict.note) report.note = x.verdict.note;
|
|
801
|
+
if (!(x.verdict.state === 'synced' && !entry)) result.reports.set(x.path, report);
|
|
802
|
+
}
|
|
803
|
+
if (walk) {
|
|
804
|
+
const tracked = [...result.reports.values()].filter((r) => r.state === 'unverified').map((r) => r.path);
|
|
805
|
+
for (const region of walk.incomplete) {
|
|
806
|
+
const hides = tracked.some((p) => p === region.path || p.startsWith(region.path + '/'));
|
|
807
|
+
if (region.kind === 'error' || region.kind === 'cap' || hides) result.incomplete.push({ path: region.path, reason: region.reason });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return result;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ===========================================================================
|
|
814
|
+
// Store → disk
|
|
815
|
+
// ===========================================================================
|
|
816
|
+
|
|
817
|
+
export interface PushOptions {
|
|
818
|
+
/**
|
|
819
|
+
* Overwrite (or unlink) a disk copy in conflict, or one that can't be
|
|
820
|
+
* verified. A disk copy nothing has evidence about is overwritten only when
|
|
821
|
+
* `named` too: a bare push can't tell it from a newer copy.
|
|
822
|
+
*/
|
|
823
|
+
force?: boolean;
|
|
824
|
+
/**
|
|
825
|
+
* The caller named these paths (a path given to materialize), rather than
|
|
826
|
+
* the evidence selecting them. Only such a push writes a path nothing has
|
|
827
|
+
* evidence about: over a differing disk copy with `force`, or where disk
|
|
828
|
+
* has no copy at all.
|
|
829
|
+
*/
|
|
830
|
+
named?: boolean;
|
|
831
|
+
/** Unlink workspace-deleted files whose disk copy is one the store holds. */
|
|
832
|
+
applyDeletions?: boolean;
|
|
833
|
+
/** Called before each disk write/unlink, so the caller can suppress the watcher echo. */
|
|
834
|
+
beforeEffect?: (path: string) => void;
|
|
835
|
+
/**
|
|
836
|
+
* The branch the caller chose `paths` on. If another is selected by the
|
|
837
|
+
* time the push decides, it writes nothing: a selection made on one branch
|
|
838
|
+
* pushed with another branch's content would be neither branch's materialize.
|
|
839
|
+
*/
|
|
840
|
+
branchId?: string;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export interface PushResult {
|
|
844
|
+
written: string[];
|
|
845
|
+
unchanged: string[];
|
|
846
|
+
deleted: string[];
|
|
847
|
+
/** Refused paths, each with its reason. */
|
|
848
|
+
skipped: Array<{ path: string; reason: string }>;
|
|
849
|
+
/** Workspace deletions left on disk because applyDeletions wasn't given. */
|
|
850
|
+
pendingDeletions: string[];
|
|
851
|
+
/** The branch and sequence the push planned on: what disk now reflects.
|
|
852
|
+
* Absent when it planned nothing (a read-only mount, or the selected
|
|
853
|
+
* branch changed after its paths were chosen): disk then still reflects
|
|
854
|
+
* whatever it did before. */
|
|
855
|
+
branchId?: string;
|
|
856
|
+
sequence?: number;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Make disk hold the store's version of each path, under the same rule:
|
|
861
|
+
* drafts are written, agreement is left alone, conflicts and unverifiable
|
|
862
|
+
* disk copies are refused unless `force`, and a workspace deletion reaches
|
|
863
|
+
* disk only with `applyDeletions` (with `force` too when disk changed since).
|
|
864
|
+
* A path nothing has evidence about is written only when `named`.
|
|
865
|
+
* A path whose earlier push shows on disk unconfirmed is pushed again, so its
|
|
866
|
+
* completion rests on barriers that succeeded.
|
|
867
|
+
*
|
|
868
|
+
* Every write or unlink is announced by a durable intent first, and checked
|
|
869
|
+
* at the point of effect (effects.ts): the boundary always, and — unless
|
|
870
|
+
* `force` — that disk still holds what was decided on. Then every directory
|
|
871
|
+
* whose entries the push created or removed is synced, from each written
|
|
872
|
+
* file's parent up to the mount root and each unlinked file's parent, once
|
|
873
|
+
* per push and after all of its effects, and only then are completions
|
|
874
|
+
* recorded. An effect that failed without touching its path puts the path's
|
|
875
|
+
* evidence back exactly as it was before the intent; one that touched it, or
|
|
876
|
+
* whose barrier failed, leaves the intent pending.
|
|
877
|
+
*/
|
|
878
|
+
export async function pushPaths(
|
|
879
|
+
store: JsStore,
|
|
880
|
+
agreement: DiskAgreement,
|
|
881
|
+
mount: MountRuntime,
|
|
882
|
+
paths: string[],
|
|
883
|
+
opts: PushOptions = {},
|
|
884
|
+
): Promise<PushResult> {
|
|
885
|
+
agreement.ensureReconciled();
|
|
886
|
+
if (agreement.needsBarrier) agreement.barrier(); // as a pass does: nothing decided from unsynced evidence
|
|
887
|
+
const result: PushResult = { written: [], unchanged: [], deleted: [], skipped: [], pendingDeletions: [] };
|
|
888
|
+
if (mount.readOnly) return result;
|
|
889
|
+
// A root that isn't available takes no writes, forced or not: restoring the
|
|
890
|
+
// store's files into an empty mountpoint would fill the disk the drive was
|
|
891
|
+
// unmounted from. The push plans nothing, so it reports no branch.
|
|
892
|
+
const root = await locateRoot(mount.view, mount.rootReal);
|
|
893
|
+
if ('reason' in root) {
|
|
894
|
+
for (const path of paths) result.skipped.push({ path, reason: root.reason });
|
|
895
|
+
return result;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
type Plan = {
|
|
899
|
+
path: string;
|
|
900
|
+
/**
|
|
901
|
+
* The path's evidence just before this push's intent, after what planning
|
|
902
|
+
* itself recorded (an agreement, a resolved intent), put back if the
|
|
903
|
+
* effect never touches the path.
|
|
904
|
+
*/
|
|
905
|
+
before: Physical | undefined;
|
|
906
|
+
prior: Agreed | null;
|
|
907
|
+
/** What the effect may find at the path. */
|
|
908
|
+
expect: Expect;
|
|
909
|
+
} & ({ kind: 'write'; blob: Buffer; hash: string } | { kind: 'unlink' });
|
|
910
|
+
const plans: Plan[] = [];
|
|
911
|
+
|
|
912
|
+
// Observe disk.
|
|
913
|
+
const facts = new Map<string, DiskFact>();
|
|
914
|
+
for (const path of paths) {
|
|
915
|
+
const p = agreement.get(mount.name, path);
|
|
916
|
+
facts.set(path, await learnDisk(mount, path, { p, hasStore: true, hasIntent: true }, {}));
|
|
917
|
+
}
|
|
918
|
+
// Each path was observed by path beneath the root's path. Under a root
|
|
919
|
+
// replaced meanwhile they describe the replacement, so, as a pass does,
|
|
920
|
+
// nothing is decided or recorded from them.
|
|
921
|
+
const after = await locateRoot(mount.view, mount.rootReal);
|
|
922
|
+
if (!('identity' in after) || !sameRootIdentity(after.identity, root.identity)) {
|
|
923
|
+
const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
|
|
924
|
+
for (const path of paths) result.skipped.push({ path, reason });
|
|
925
|
+
return result;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Decide synchronously what to push.
|
|
929
|
+
const branchId = store.currentBranch().id;
|
|
930
|
+
if (opts.branchId !== undefined && opts.branchId !== branchId) {
|
|
931
|
+
for (const path of paths) {
|
|
932
|
+
result.skipped.push({ path, reason: 'the selected branch changed after these paths were chosen; nothing was written — materialize again' });
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
result.branchId = branchId;
|
|
937
|
+
result.sequence = store.currentSequence();
|
|
938
|
+
let recorded = false;
|
|
939
|
+
for (const [path, d] of facts) {
|
|
940
|
+
const entry = store.treeGet(mount.treeStateId, path);
|
|
941
|
+
const s = entry ? { hash: entry.blobHash, size: entry.size } : null;
|
|
942
|
+
const p = agreement.get(mount.name, path);
|
|
943
|
+
const bi = mount.intents.get(path);
|
|
944
|
+
const v = decide(d, s, p, bi, branchId, false);
|
|
945
|
+
// What the effect may find: what this observation saw, unless forced.
|
|
946
|
+
const expect: Expect = opts.force || d.kind === 'unobserved' || d.kind === 'unhashed'
|
|
947
|
+
? { kind: 'any' }
|
|
948
|
+
: d.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: d.hash };
|
|
949
|
+
if (v.adopt) {
|
|
950
|
+
// Disk changed since it last agreed and the workspace copy didn't. Pushing
|
|
951
|
+
// would silently revert the disk change; its evidence stays as it is, so
|
|
952
|
+
// a later sync adopts it.
|
|
953
|
+
if (!s) continue; // nothing in the workspace to push
|
|
954
|
+
if (!opts.force) {
|
|
955
|
+
result.skipped.push({
|
|
956
|
+
path,
|
|
957
|
+
reason: 'stale copy: disk changed since it last agreed with the workspace, which did not — ' +
|
|
958
|
+
'sync this path to adopt the disk version, or pass force to overwrite it',
|
|
959
|
+
});
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const priorP = p && (p.kind === 'absent' || p.kind === 'content') ? p : null;
|
|
963
|
+
const blob = store.getBlob(s.hash);
|
|
964
|
+
if (blob) plans.push({ path, kind: 'write', blob, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect });
|
|
965
|
+
else result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
// Whatever the verdict learned about disk (an agreement, a resolved
|
|
969
|
+
// intent) is recorded before anything is pushed over it, and the root it
|
|
970
|
+
// was learned under before that (once per mount), as a pass does.
|
|
971
|
+
if (v.p !== undefined) agreement.recordRootIdentity(mount.name, root.identity);
|
|
972
|
+
if (v.p === 'forget') agreement.forget(mount.name, path);
|
|
973
|
+
else if (v.p !== undefined) agreement.set(mount.name, path, v.p, { afterCommittedState: true });
|
|
974
|
+
if (v.p !== undefined) recorded = true;
|
|
975
|
+
// The prior a new intent carries is the last confirmed evidence: for a
|
|
976
|
+
// path whose earlier push is unconfirmed, that push's own prior, never
|
|
977
|
+
// the bytes it wrote.
|
|
978
|
+
const known = v.unconfirmed ? (p as Pending).prior ?? undefined : v.p === 'forget' ? undefined : v.p !== undefined ? v.p : p;
|
|
979
|
+
const prior = known && (known.kind === 'absent' || known.kind === 'content') ? known : null;
|
|
980
|
+
if (v.state === 'synced' && !v.unconfirmed) {
|
|
981
|
+
if (v.intent !== undefined) {
|
|
982
|
+
mount.intents.put(path, v.intent);
|
|
983
|
+
recorded = true;
|
|
984
|
+
}
|
|
985
|
+
if (s) result.unchanged.push(path);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (v.state === 'unverified') {
|
|
989
|
+
if (!opts.force) {
|
|
990
|
+
result.skipped.push({ path, reason: `cannot verify disk copy (${v.note ?? 'not observed'}) — fix it and materialize again, or pass force to overwrite it` });
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// A file merely absent from this branch is left alone, an unconfirmed
|
|
995
|
+
// push of another branch's bytes included: nothing replays or unlinks it.
|
|
996
|
+
if (v.state === 'not-in-branch' || v.state === 'disk-only') continue;
|
|
997
|
+
const kind = bi?.conflict?.kind ?? v.conflict ?? 'both-changed';
|
|
998
|
+
// Without evidence there was no agreement for disk to change since, and
|
|
999
|
+
// nothing tells a newer disk copy from an older one: only a push of the
|
|
1000
|
+
// named path overwrites it, forced.
|
|
1001
|
+
const noEvidence = kind === 'unknown-provenance' || kind === 'store-origin-collision';
|
|
1002
|
+
if (v.state === 'conflict' && (!opts.force || (noEvidence && !opts.named))) {
|
|
1003
|
+
if (v.conflict) {
|
|
1004
|
+
mount.intents.update(path, (cur) => ({ ...cur, conflict: { kind: v.conflict!, at: Date.now(), disk: counterpartOf(store, d) } }));
|
|
1005
|
+
recorded = true;
|
|
1006
|
+
}
|
|
1007
|
+
result.skipped.push({
|
|
1008
|
+
path,
|
|
1009
|
+
reason: noEvidence
|
|
1010
|
+
? `disk holds a different copy, and nothing records which is newer (conflict: ${kind}) — ` +
|
|
1011
|
+
'sync this path to adopt the disk version, or materialize this path with force to overwrite it'
|
|
1012
|
+
: kind === 'interrupted'
|
|
1013
|
+
? 'disk holds bytes that match neither what was being written there nor what disk last agreed with ' +
|
|
1014
|
+
'(conflict: interrupted), as a write that failed or was cut off partway leaves — check the file, then ' +
|
|
1015
|
+
'pass force to write the workspace copy over it, or sync this path to adopt the disk copy as it is'
|
|
1016
|
+
: `stale copy: disk changed since it last agreed with the workspace (conflict: ${kind}) — ` +
|
|
1017
|
+
'sync this path to adopt the disk version, or pass force to overwrite it',
|
|
1018
|
+
});
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
// An entry from before the evidence that disk lacks: nothing records
|
|
1022
|
+
// whether disk deleted it or never had it, and most such entries came
|
|
1023
|
+
// from disk. Writing it back could undo a deliberate deletion, so only a
|
|
1024
|
+
// push of the named path writes it.
|
|
1025
|
+
if (v.state === 'disk-missing-provenance-unknown' && !opts.named) {
|
|
1026
|
+
result.skipped.push({
|
|
1027
|
+
path,
|
|
1028
|
+
reason: 'disk has no copy, and nothing records whether it was deleted there or never written — ' +
|
|
1029
|
+
'sync this path to drop it from the workspace, or materialize this path to write it',
|
|
1030
|
+
});
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (!s) {
|
|
1034
|
+
// Only a deliberate workspace deletion ever unlinks; a file that is
|
|
1035
|
+
// merely absent from this branch is never deleted from disk.
|
|
1036
|
+
if (!bi?.tombstone) continue;
|
|
1037
|
+
if (v.unconfirmed && d.kind === 'absent') {
|
|
1038
|
+
// Our own unlink already reached disk, unconfirmed: confirm it — its
|
|
1039
|
+
// directory synced, nothing removed — applyDeletions or not. Only
|
|
1040
|
+
// ever a confirmation, force or not: a file that appears meanwhile
|
|
1041
|
+
// refuses it.
|
|
1042
|
+
plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect: { kind: 'absent' } });
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (!opts.applyDeletions) {
|
|
1046
|
+
result.pendingDeletions.push(path);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (d.kind === 'absent') continue;
|
|
1050
|
+
plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect });
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
const blob = store.getBlob(s.hash);
|
|
1054
|
+
if (!blob) {
|
|
1055
|
+
result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
plans.push({ path, kind: 'write', blob, hash: s.hash, prior, before: agreement.get(mount.name, path), expect });
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// 1. Durable intents before any disk effect, the root they write under first.
|
|
1062
|
+
if (plans.length > 0) agreement.recordRootIdentity(mount.name, root.identity);
|
|
1063
|
+
plans.forEach((step, i) => {
|
|
1064
|
+
agreement.intend(mount.name, step.path, {
|
|
1065
|
+
effect: 'disk',
|
|
1066
|
+
prior: step.prior,
|
|
1067
|
+
expect: step.kind === 'write' ? { kind: 'content', hash: step.hash } : ABSENT,
|
|
1068
|
+
branchId,
|
|
1069
|
+
}, { durable: i === plans.length - 1 });
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// 2. Effects, each bound at its point of use.
|
|
1073
|
+
const effected: Array<{ step: Plan; dirs: string[] }> = [];
|
|
1074
|
+
for (const step of plans) {
|
|
1075
|
+
opts.beforeEffect?.(step.path);
|
|
1076
|
+
try {
|
|
1077
|
+
if (step.kind === 'write') {
|
|
1078
|
+
await writeContained(mount.view, mount.rootReal, step.path, step.blob, step.expect);
|
|
1079
|
+
effected.push({ step, dirs: chainOf(step.path) });
|
|
1080
|
+
} else {
|
|
1081
|
+
// Its directory is synced whether this unlink removed the entry or
|
|
1082
|
+
// found it gone: either way the completion claims a durable absence.
|
|
1083
|
+
effected.push({ step, dirs: [await unlinkContained(mount.view, mount.rootReal, step.path, step.expect)] });
|
|
1084
|
+
}
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
const failure = err instanceof EffectFailed ? err : new EffectFailed(err instanceof Error ? err.message : String(err), true);
|
|
1087
|
+
if (!failure.touched) {
|
|
1088
|
+
// Disk at this path is as planning saw it: its evidence goes back to
|
|
1089
|
+
// exactly what it was, whatever kind that was.
|
|
1090
|
+
agreement.restore(mount.name, step.path, step.before);
|
|
1091
|
+
recorded = true;
|
|
1092
|
+
result.skipped.push({ path: step.path, reason: `not ${step.kind === 'write' ? 'written' : 'unlinked'}: ${failure.message}` });
|
|
1093
|
+
} else {
|
|
1094
|
+
// No completion: the intent stays pending, and the next observation
|
|
1095
|
+
// resolves it from whatever disk then holds.
|
|
1096
|
+
result.skipped.push({ path: step.path, reason: `${failure.message}; the outcome is checked at the next observation` });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// 3. Barriers: every directory whose entries the push changed, synced
|
|
1102
|
+
// once, after all of its effects. A directory created by an earlier,
|
|
1103
|
+
// failed attempt is synced too: the whole chain, whoever made it.
|
|
1104
|
+
const unsynced = await syncDirectories(mount.view, mount.rootReal, effected.flatMap((x) => x.dirs));
|
|
1105
|
+
|
|
1106
|
+
// 4. Completions. P is physical and global. Branch intent belongs to the
|
|
1107
|
+
// branch this push planned on: it is settled only while that branch is
|
|
1108
|
+
// still selected, and otherwise by convergence when the branch returns.
|
|
1109
|
+
for (const { step, dirs } of effected) {
|
|
1110
|
+
const failed = dirs.find((dir) => unsynced.has(dir));
|
|
1111
|
+
if (failed !== undefined) {
|
|
1112
|
+
result.skipped.push({
|
|
1113
|
+
path: step.path,
|
|
1114
|
+
reason: `could not make the ${step.kind === 'write' ? 'write' : 'unlink'} durable (directory fsync of ${failed === '' ? 'the mount root' : failed}): ` +
|
|
1115
|
+
`${unsynced.get(failed)}; the outcome is checked at the next observation`,
|
|
1116
|
+
});
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
const value: Agreed = step.kind === 'write' ? { kind: 'content', hash: step.hash, size: step.blob.byteLength } : { kind: 'absent' };
|
|
1120
|
+
agreement.set(mount.name, step.path, value);
|
|
1121
|
+
recorded = true;
|
|
1122
|
+
const planned = store.currentBranch().id === branchId;
|
|
1123
|
+
if (step.kind === 'write') {
|
|
1124
|
+
if (planned) mount.intents.update(step.path, (cur) => { const next = { ...cur }; delete next.conflict; delete next.origin; return next; });
|
|
1125
|
+
result.written.push(step.path);
|
|
1126
|
+
} else {
|
|
1127
|
+
if (planned) mount.intents.put(step.path, null);
|
|
1128
|
+
result.deleted.push(step.path);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// Durable before returning, like a pass: agreements recorded here have no
|
|
1132
|
+
// pending intent behind them, and completions shouldn't wait for one.
|
|
1133
|
+
if (recorded || agreement.needsBarrier) agreement.barrier();
|
|
1134
|
+
agreement.maybeCheckpoint();
|
|
1135
|
+
return result;
|
|
1136
|
+
}
|