trajectories 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -0
- package/README.md +288 -2
- package/dist/cli.js +30210 -0
- package/dist/convert.d.ts +31 -0
- package/dist/convert.js +431 -0
- package/dist/deepagents_checkpoint.py +362 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8402 -0
- package/dist/log-baseline.d.ts +26 -0
- package/dist/log-baseline.js +104 -0
- package/dist/log-content.d.ts +16 -0
- package/dist/log-content.js +80 -0
- package/dist/log-io.d.ts +10 -0
- package/dist/log-io.js +121 -0
- package/dist/log-read.d.ts +24 -0
- package/dist/log-read.js +117 -0
- package/dist/log-reconcile.d.ts +5 -0
- package/dist/log-reconcile.js +38 -0
- package/dist/log-records.d.ts +35 -0
- package/dist/log-records.js +85 -0
- package/dist/log-recovery.d.ts +18 -0
- package/dist/log-recovery.js +123 -0
- package/dist/log-stage.d.ts +14 -0
- package/dist/log-stage.js +138 -0
- package/dist/log-state.d.ts +89 -0
- package/dist/log-state.js +92 -0
- package/dist/log-switch.d.ts +3 -0
- package/dist/log-switch.js +98 -0
- package/dist/log-types.d.ts +129 -0
- package/dist/log-types.js +8 -0
- package/dist/log.d.ts +8 -0
- package/dist/log.js +62 -0
- package/dist/migrate.d.ts +29 -0
- package/dist/migrate.js +173 -0
- package/dist/normalization-runtime.d.ts +1 -0
- package/dist/normalization-runtime.js +3150 -0
- package/dist/portable.d.ts +9 -0
- package/dist/portable.js +26 -0
- package/dist/readers/canonical.d.ts +13 -0
- package/dist/readers/canonical.js +54 -0
- package/dist/readers/cline.d.ts +10 -0
- package/dist/readers/cline.js +107 -0
- package/dist/readers/cursor.d.ts +20 -0
- package/dist/readers/cursor.js +374 -0
- package/dist/readers/deepagents.d.ts +41 -0
- package/dist/readers/deepagents.js +163 -0
- package/dist/readers/fx.d.ts +11 -0
- package/dist/readers/fx.js +573 -0
- package/dist/readers/github-copilot.d.ts +8 -0
- package/dist/readers/github-copilot.js +216 -0
- package/dist/readers/grok-build.d.ts +12 -0
- package/dist/readers/grok-build.js +391 -0
- package/dist/readers/guards.d.ts +4 -0
- package/dist/readers/guards.js +121 -0
- package/dist/readers/pi-compaction.d.ts +7 -0
- package/dist/readers/pi-compaction.js +112 -0
- package/dist/readers/pi-context.d.ts +13 -0
- package/dist/readers/pi-context.js +64 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.js +218 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/writers/claude-code.d.ts +5 -0
- package/dist/writers/claude-code.js +51 -0
- package/dist/writers/cline.d.ts +3 -0
- package/dist/writers/cline.js +77 -0
- package/dist/writers/codex.d.ts +3 -0
- package/dist/writers/codex.js +32 -0
- package/dist/writers/cursor.d.ts +3 -0
- package/dist/writers/cursor.js +197 -0
- package/dist/writers/deepagents.d.ts +3 -0
- package/dist/writers/deepagents.js +116 -0
- package/dist/writers/entropy.d.ts +5 -0
- package/dist/writers/entropy.js +33 -0
- package/dist/writers/fx.d.ts +3 -0
- package/dist/writers/fx.js +135 -0
- package/dist/writers/github-copilot.d.ts +3 -0
- package/dist/writers/github-copilot.js +71 -0
- package/dist/writers/grok-build.d.ts +3 -0
- package/dist/writers/grok-build.js +43 -0
- package/dist/writers/helpers.d.ts +17 -0
- package/dist/writers/helpers.js +75 -0
- package/dist/writers/omp.d.ts +3 -0
- package/dist/writers/omp.js +7 -0
- package/dist/writers/opencode.d.ts +3 -0
- package/dist/writers/opencode.js +65 -0
- package/dist/writers/pi.d.ts +3 -0
- package/dist/writers/pi.js +45 -0
- package/docs/cline-native-format.md +43 -0
- package/docs/copilot-native-format.md +78 -0
- package/docs/cursor-native-format.md +103 -0
- package/docs/deepagents-native-format.md +98 -0
- package/docs/evidence-api.md +170 -0
- package/docs/fx-native-format.md +87 -0
- package/docs/harness-parity-bootstrap.md +78 -0
- package/docs/harness-support-parity.md +107 -0
- package/docs/inspirations.md +103 -0
- package/docs/ledger-migration.md +141 -0
- package/docs/pi-compaction-context.md +75 -0
- package/docs/pi-provider-context.md +76 -0
- package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
- package/docs/research/ledger/README.md +96 -0
- package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
- package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
- package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
- package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
- package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
- package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
- package/docs/research/ledger/docs/inspirations.md +320 -0
- package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
- package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
- package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
- package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
- package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
- package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
- package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
- package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
- package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
- package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
- package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
- package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
- package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
- package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
- package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
- package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
- package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
- package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
- package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
- package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
- package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
- package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
- package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
- package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
- package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
- package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
- package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
- package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
- package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
- package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
- package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
- package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
- package/docs/research/ledger/manifest.json +540 -0
- package/legacy/NOTICE.md +50 -0
- package/legacy/bin/ledger-core.ts +7 -0
- package/legacy/bin/ledger.ts +7 -0
- package/legacy/bin/traj-bridge.ts +7 -0
- package/legacy/scripts/import-claude-history.ts +130 -0
- package/legacy/scripts/native-history-smoke.ts +52 -0
- package/legacy/scripts/observe-daemon.ts +570 -0
- package/legacy/scripts/retitle-sessions.md +19 -0
- package/legacy/scripts/verify-runtime-rollout.ts +151 -0
- package/legacy/src/canonical.ts +108 -0
- package/legacy/src/cli.ts +1293 -0
- package/legacy/src/db.ts +2254 -0
- package/legacy/src/diagnostics.ts +87 -0
- package/legacy/src/discovery.ts +56 -0
- package/legacy/src/embeddings.ts +382 -0
- package/legacy/src/filter.ts +602 -0
- package/legacy/src/gemini_embeddings.ts +103 -0
- package/legacy/src/identity.ts +9 -0
- package/legacy/src/index.ts +10 -0
- package/legacy/src/ingest.ts +791 -0
- package/legacy/src/managed.ts +178 -0
- package/legacy/src/native/common.ts +68 -0
- package/legacy/src/native/cursor.ts +647 -0
- package/legacy/src/native/grok.ts +540 -0
- package/legacy/src/native/kimi.ts +706 -0
- package/legacy/src/native/opencode.ts +470 -0
- package/legacy/src/native/sqlite-ro.ts +281 -0
- package/legacy/src/native.ts +97 -0
- package/legacy/src/normalizer.ts +457 -0
- package/legacy/src/parentage.ts +562 -0
- package/legacy/src/pressure.ts +162 -0
- package/legacy/src/private-storage.ts +74 -0
- package/legacy/src/reader.ts +1255 -0
- package/legacy/src/scanner.ts +591 -0
- package/legacy/src/schema-gate.ts +263 -0
- package/legacy/src/schema.ts +829 -0
- package/legacy/src/semantic-top-k.ts +71 -0
- package/legacy/src/session-hash.ts +49 -0
- package/legacy/src/store-status.ts +218 -0
- package/legacy/src/sync-preparation.ts +45 -0
- package/legacy/src/sync-truth.ts +843 -0
- package/legacy/src/sync.ts +1233 -0
- package/legacy/src/types.ts +436 -0
- package/legacy/src/vocab.txt +30522 -0
- package/legacy/src/watch.ts +673 -0
- package/legacy/src/writer-lease.ts +310 -0
- package/ops/README.md +150 -0
- package/ops/install-cli.sh +63 -0
- package/ops/install-native.sh +35 -0
- package/ops/install-release.sh +49 -0
- package/ops/linux/install-systemd-user.sh +103 -0
- package/ops/macos/install-launchd.sh +72 -0
- package/ops/sync-settings.sh +37 -0
- package/package.json +21 -5
- package/skills/trajectories/SKILL.md +138 -0
- package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
- package/skills/trajectories/scripts/trajectories_search.sh +26 -0
- package/index.js +0 -1
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remote truthfulness primitives: the failure semantics a sync surface must
|
|
5
|
+
* obey, as a typed vocabulary, a pure state machine, and a persistence
|
|
6
|
+
* contract — with no transport of any kind in this module.
|
|
7
|
+
*
|
|
8
|
+
* Ported from Wake (`iAmCorey/Wake@d9f702b6df0e85`, tag v0.6.5, MIT — mechanics
|
|
9
|
+
* with attribution, no code copied), `crates/wake-core/src/remote.rs`, whose
|
|
10
|
+
* remote mirror is the authority for what "the source did not answer" and "the
|
|
11
|
+
* source answered and holds nothing" are allowed to mean:
|
|
12
|
+
*
|
|
13
|
+
* - A **probe before the transfer** decides which paths exist remotely; the full
|
|
14
|
+
* whitelist is never handed to the transfer. Wake measured why on this host
|
|
15
|
+
* (`remote.rs#L13-L22`): openrsync aborts the remaining file list when one
|
|
16
|
+
* source's parent is missing — later sources silently untransferred while the
|
|
17
|
+
* exit code is only 23 — and any sender-side I/O error suppresses the deletion
|
|
18
|
+
* phase, so `--delete` would never fire. The probe's output is filtered to
|
|
19
|
+
* lines equal to a whitelist entry (`#L264-L281`), in whitelist order.
|
|
20
|
+
* - A **failed probe fails the whole attempt** (`#L333-L354`): no transfer runs
|
|
21
|
+
* for that round, and nothing is deleted or written because of it — "cannot
|
|
22
|
+
* treat cannot-connect as the remote is empty". An empty source is a fact only
|
|
23
|
+
* a reached probe may produce, which is why `empty` is its own terminal kind
|
|
24
|
+
* here rather than a quiet `ok`.
|
|
25
|
+
* - **Exit 24 (a source vanished mid-transfer) is not a failure** (`#L407-L431`);
|
|
26
|
+
* exit 23 is a generic "partial transfer" code that may be reclassified as
|
|
27
|
+
* vanished sources only when *every* error line names a missing source, across
|
|
28
|
+
* the GNU and openrsync dialects (`#L421-L431`). Wake tolerates one re-probe
|
|
29
|
+
* when the sources vanished between the probe and the transfer.
|
|
30
|
+
* - The mirror is **disposable**: removing a source purges its mirror, and the
|
|
31
|
+
* purge is re-asserted after every round because an in-flight transfer cannot
|
|
32
|
+
* be cancelled and would write the removed tree back (`#L202-L222`).
|
|
33
|
+
* - Host names are validated before they reach a session key or an argv
|
|
34
|
+
* (`#L194-L200`), and a failed attempt records its account while `last_sync_at`
|
|
35
|
+
* keeps naming the last sync that completed
|
|
36
|
+
* (`crates/wake-core/src/db.rs#L116-L122`, `#L864-L877`).
|
|
37
|
+
*
|
|
38
|
+
* What this module deliberately is not: a transport, a scheduler, a mirror. It
|
|
39
|
+
* spawns no process and touches no filesystem; the drivers that do take the
|
|
40
|
+
* observations here and hand back the reports. Everything in it is pure except
|
|
41
|
+
* `readSyncStamps`, which reads a table this build does not create — see
|
|
42
|
+
* {@link SYNC_STAMP_DDL} for that decision and its ladder obligation.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// ── Terminal vocabulary ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where one sync attempt stands, from the store's point of view. The set is
|
|
49
|
+
* closed and the kinds are claims, not moods:
|
|
50
|
+
*
|
|
51
|
+
* - `ok` — the source was reached and every path its probe confirmed present
|
|
52
|
+
* transferred. The mirror holds what the source holds as of this attempt.
|
|
53
|
+
* - `empty` — the source was reached and holds none of the whitelist. Reachable
|
|
54
|
+
* only through a successful probe: this is the kind a failure must never be
|
|
55
|
+
* mistaken for.
|
|
56
|
+
* - `partial` — the source was reached and transferred a subset; the missing
|
|
57
|
+
* part is exactly the sources that vanished while the transfer read them. The
|
|
58
|
+
* mirror is incomplete in a known way.
|
|
59
|
+
* - `unreachable` — no contact *in the round that settled*: no transfer ran, and
|
|
60
|
+
* nothing was written or deleted for it. The mirror holds exactly what the
|
|
61
|
+
* attempt's earlier rounds left, which is nothing at all when none reached.
|
|
62
|
+
* - `refused` — contact was established or attempted, but the attempt stopped
|
|
63
|
+
* short of a mirror state this store can vouch for: an invalid source name, a
|
|
64
|
+
* local tool that is not there, a mirror deletion that failed, or a transfer
|
|
65
|
+
* error that is not a vanished source. Never a claim of emptiness.
|
|
66
|
+
*/
|
|
67
|
+
export type SyncOutcomeKind = "ok" | "empty" | "partial" | "unreachable" | "refused";
|
|
68
|
+
|
|
69
|
+
/** Kinds that leave the mirror complete. Exhaustive over the union on purpose. */
|
|
70
|
+
const COMPLETE_KINDS: Record<SyncOutcomeKind, boolean> = {
|
|
71
|
+
ok: true,
|
|
72
|
+
empty: true,
|
|
73
|
+
partial: false,
|
|
74
|
+
unreachable: false,
|
|
75
|
+
refused: false,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The claim ceiling each terminal verdict licenses ({@link SyncMirrorStanding}). */
|
|
79
|
+
const MIRROR_STANDING: Record<SyncOutcomeKind, SyncMirrorStanding> = {
|
|
80
|
+
ok: "current",
|
|
81
|
+
empty: "current",
|
|
82
|
+
partial: "incomplete",
|
|
83
|
+
unreachable: "not-claimed",
|
|
84
|
+
refused: "not-claimed",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Why an attempt settled where it did: the machine's closed account of itself. */
|
|
88
|
+
export type SyncReason =
|
|
89
|
+
/** The source name cannot be addressed: see {@link hostNameRefusal}. */
|
|
90
|
+
| "host_name_invalid"
|
|
91
|
+
/** The probe's tool (Wake: `ssh`) is not on this machine. */
|
|
92
|
+
| "probe_tool_unavailable"
|
|
93
|
+
/** The probe tool exited non-zero: connect, auth, host key, or remote shell. */
|
|
94
|
+
| "probe_failed"
|
|
95
|
+
/** A reached source holds none of the whitelist. */
|
|
96
|
+
| "source_empty"
|
|
97
|
+
/** Every path a reached probe confirmed present transferred. */
|
|
98
|
+
| "transferred"
|
|
99
|
+
/** Sources vanished before the transfer could read them (Wake's exit 24 / 23). */
|
|
100
|
+
| "sources_vanished"
|
|
101
|
+
/** A mirror deletion the probe licensed did not complete. */
|
|
102
|
+
| "mirror_purge_failed"
|
|
103
|
+
/** The transfer's tool (Wake: `rsync`) is not on this machine. */
|
|
104
|
+
| "transfer_tool_unavailable"
|
|
105
|
+
/** The transfer failed for a reason that is not a vanished source. */
|
|
106
|
+
| "transfer_failed";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What the attempt did to the disposable mirror beside the store. Only a probe
|
|
110
|
+
* that reached the source licenses a mirror write, so the two kinds are also a
|
|
111
|
+
* proof: `keep-untouched` is exactly the no-reached-probe case, and a driver
|
|
112
|
+
* that reads it as "the mirror is fresh" is reading it wrong — it means this
|
|
113
|
+
* attempt produced no evidence about the mirror at all.
|
|
114
|
+
*/
|
|
115
|
+
export type SyncCacheAction =
|
|
116
|
+
/** No probe in this attempt reached the source: the mirror keeps exactly what the last settled attempt left. */
|
|
117
|
+
| { readonly kind: "keep-untouched" }
|
|
118
|
+
/**
|
|
119
|
+
* The source was reached and `paths` are the whitelist paths whose mirror
|
|
120
|
+
* entries this attempt actually deleted (Wake `remove_cached`,
|
|
121
|
+
* `remote.rs#L361-L387`). An empty `paths` is a verdict too: the mirror was
|
|
122
|
+
* compared against a reached source and nothing was removed — whether because
|
|
123
|
+
* nothing was absent, or because the deletion that was licensed failed. What
|
|
124
|
+
* the probe licensed is the outcome's own `absent`, deleted or not.
|
|
125
|
+
*/
|
|
126
|
+
| { readonly kind: "purge"; readonly paths: readonly string[] };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What the caller persists. `last_sync_at` is a claim about *completeness*, not
|
|
130
|
+
* about activity: an attempt that did not complete records its account and
|
|
131
|
+
* leaves the stamp naming the last sync that did. This is the one deliberate
|
|
132
|
+
* tightening of Wake's own record — it stamps a clean success for exit 24
|
|
133
|
+
* (`db.rs#L864-L877`), which would render a partial transfer as a clean sync.
|
|
134
|
+
*/
|
|
135
|
+
export type SyncStampAction =
|
|
136
|
+
| { readonly kind: "update-stamp" }
|
|
137
|
+
| { readonly kind: "record-error" };
|
|
138
|
+
|
|
139
|
+
/** One settled attempt, with everything a driver, a stamp and a disclosure need. */
|
|
140
|
+
export interface SyncOutcome {
|
|
141
|
+
/** The name the attempt addressed (Wake: a remote host). */
|
|
142
|
+
readonly source: string;
|
|
143
|
+
readonly kind: SyncOutcomeKind;
|
|
144
|
+
readonly reason: SyncReason;
|
|
145
|
+
/**
|
|
146
|
+
* The attempt's account of itself — Wake's `<tool> exited with N: <stderr tail>`
|
|
147
|
+
* shape (`remote.rs#L462-L471`), a mirror's filesystem error, or the name of a
|
|
148
|
+
* refusal. `null` exactly when the outcome is a fact (`ok`, `empty`).
|
|
149
|
+
*/
|
|
150
|
+
readonly detail: string | null;
|
|
151
|
+
/**
|
|
152
|
+
* The paths the settling round's probe confirmed present, in whitelist order.
|
|
153
|
+
* Empty when the round that settled never reached the source — a fact about
|
|
154
|
+
* that probe, not a claim that the source holds nothing.
|
|
155
|
+
*/
|
|
156
|
+
readonly present: readonly string[];
|
|
157
|
+
/**
|
|
158
|
+
* The whitelist paths the settling round's probe found absent — a *fact* only
|
|
159
|
+
* when that round reached the source, and the set the round's deletion was
|
|
160
|
+
* licensed for whether or not the deletion landed. Empty otherwise, where it
|
|
161
|
+
* claims nothing at all.
|
|
162
|
+
*/
|
|
163
|
+
readonly absent: readonly string[];
|
|
164
|
+
/** Probe+transfer rounds this attempt ran: 1, or 2 when a vanished round earned Wake's one re-probe. */
|
|
165
|
+
readonly rounds: 1 | 2;
|
|
166
|
+
readonly cache: SyncCacheAction;
|
|
167
|
+
readonly stamp: SyncStampAction;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Source names ───────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** Why a name cannot address a source. Closed, because a refusal is reported. */
|
|
173
|
+
export type HostNameRefusal =
|
|
174
|
+
| "empty"
|
|
175
|
+
/** A leading `-` (or any non-alphanumeric head) is an option a transport would execute. */
|
|
176
|
+
| "leading_non_alphanumeric"
|
|
177
|
+
/** `:` is the separator in this store's session keys (`source:native-id`, identity.ts). */
|
|
178
|
+
| "colon"
|
|
179
|
+
| "invalid_character";
|
|
180
|
+
|
|
181
|
+
/** Wake's charset (`remote.rs#L194-L200`): ASCII alphanumerics plus `. _ - @`. */
|
|
182
|
+
const HOST_NAME_CHAR = /^[A-Za-z0-9._@-]$/;
|
|
183
|
+
const HOST_NAME_HEAD = /^[A-Za-z0-9]$/;
|
|
184
|
+
|
|
185
|
+
/** Why a name was refused, in the words a refusal detail carries. */
|
|
186
|
+
const HOST_REFUSAL_TEXT: Record<HostNameRefusal, string> = {
|
|
187
|
+
empty: "it is empty",
|
|
188
|
+
leading_non_alphanumeric:
|
|
189
|
+
"it starts with a non-alphanumeric character, which a transport would read as an option",
|
|
190
|
+
colon: "':' is the separator in this store's session keys (source:native-id)",
|
|
191
|
+
invalid_character: "it contains characters outside [A-Za-z0-9._@-]",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Wake's `valid_host_name` (`remote.rs#L194-L200`), decomposed into the reasons
|
|
196
|
+
* it refuses. The name lands in this store's session keys and in a transport's
|
|
197
|
+
* argv, so an empty name, a colon, a leading option character, or a character
|
|
198
|
+
* outside the allowed set is refused before any tool runs — `user@host`,
|
|
199
|
+
* aliases and IPv4 stay addressable, and an IPv6 literal belongs in the user's
|
|
200
|
+
* ssh config behind an alias.
|
|
201
|
+
*/
|
|
202
|
+
export function hostNameRefusal(name: string): HostNameRefusal | null {
|
|
203
|
+
if (name.length === 0) return "empty";
|
|
204
|
+
if (name.includes(":")) return "colon";
|
|
205
|
+
if (!HOST_NAME_HEAD.test(name[0]!)) return "leading_non_alphanumeric";
|
|
206
|
+
for (const char of name) {
|
|
207
|
+
if (!HOST_NAME_CHAR.test(char)) return "invalid_character";
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Probe ──────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** What a probe reported, as the driver saw it. */
|
|
215
|
+
export type ProbeReport =
|
|
216
|
+
| { readonly kind: "spawn_failed"; readonly detail: string }
|
|
217
|
+
| {
|
|
218
|
+
readonly kind: "exited";
|
|
219
|
+
readonly code: number | null;
|
|
220
|
+
readonly stdout: string;
|
|
221
|
+
readonly stderr: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** What a probe means. `reached` is the only state that licenses a claim about content. */
|
|
225
|
+
export type ProbeResult =
|
|
226
|
+
| { readonly state: "tool_unavailable"; readonly detail: string }
|
|
227
|
+
| { readonly state: "failed"; readonly detail: string }
|
|
228
|
+
| {
|
|
229
|
+
readonly state: "reached";
|
|
230
|
+
readonly present: readonly string[];
|
|
231
|
+
readonly absent: readonly string[];
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The paths a probe's stdout confirms, split against the whitelist, in whitelist
|
|
236
|
+
* order. Only lines equal to a whitelist entry count (`remote.rs#L264-L281`): a
|
|
237
|
+
* remote rc file's echo, a MOTD, a parent directory, or a child path must never
|
|
238
|
+
* become a transfer source. Wake's probe prints a whitelist entry once per line,
|
|
239
|
+
* so the same line twice is still one path.
|
|
240
|
+
*/
|
|
241
|
+
function partitionPaths(stdout: string, paths: readonly string[]): {
|
|
242
|
+
present: readonly string[];
|
|
243
|
+
absent: readonly string[];
|
|
244
|
+
} {
|
|
245
|
+
const lines = new Set<string>();
|
|
246
|
+
for (const line of stdout.split(/\r?\n/)) lines.add(line.trim());
|
|
247
|
+
const present: string[] = [];
|
|
248
|
+
const absent: string[] = [];
|
|
249
|
+
for (const path of paths) (lines.has(path) ? present : absent).push(path);
|
|
250
|
+
return { present, absent };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Classify a probe report. Exit 0 is the only accepted outcome — Wake accepts
|
|
255
|
+
* nothing else for the probe (`remote.rs#L388-L394`) because a non-zero exit
|
|
256
|
+
* means ssh itself failed (auth, reachability, host key, no remote `sh`), and
|
|
257
|
+
* its stderr tail is the account a user needs.
|
|
258
|
+
*/
|
|
259
|
+
export function classifyProbe(
|
|
260
|
+
report: ProbeReport,
|
|
261
|
+
paths: readonly string[],
|
|
262
|
+
tool = "ssh",
|
|
263
|
+
): ProbeResult {
|
|
264
|
+
if (report.kind === "spawn_failed") return { state: "tool_unavailable", detail: report.detail };
|
|
265
|
+
if (report.code !== 0) {
|
|
266
|
+
return { state: "failed", detail: exitText(tool, report.code, report.stderr) };
|
|
267
|
+
}
|
|
268
|
+
const { present, absent } = partitionPaths(report.stdout, paths);
|
|
269
|
+
return { state: "reached", present, absent };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Transfer ───────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/** What a transfer reported. */
|
|
275
|
+
export type TransferReport =
|
|
276
|
+
| { readonly kind: "spawn_failed"; readonly detail: string }
|
|
277
|
+
| { readonly kind: "exited"; readonly code: number | null; readonly stderr: string };
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The dialects rsync reports errors in. Data, not branches: a shape this table
|
|
281
|
+
* does not know is not evidence either way, so a new dialect is one row here
|
|
282
|
+
* and nothing else changes.
|
|
283
|
+
*/
|
|
284
|
+
export interface RsyncDialect {
|
|
285
|
+
readonly id: string;
|
|
286
|
+
/** A line reporting a failed rsync operation. */
|
|
287
|
+
readonly errorLine: RegExp;
|
|
288
|
+
/** A line reporting a source the sender could not stat: the vanished shape. */
|
|
289
|
+
readonly vanishedSource: RegExp;
|
|
290
|
+
/** Lines that report the transfer's outcome rather than a failure. Never error lines. */
|
|
291
|
+
readonly summaries: readonly RegExp[];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* GNU and openrsync, pinned from Wake's own test fixtures
|
|
296
|
+
* (`remote.rs#L478-L496`) and its summary-line exclusion (`#L421-L431`). The
|
|
297
|
+
* GNU summary matters most: without the exclusion, exit 23's own summary line
|
|
298
|
+
* ("some files/attrs were not transferred (see previous errors)") would read as
|
|
299
|
+
* an unaccounted error and turn a vanished transfer into a refusal.
|
|
300
|
+
*/
|
|
301
|
+
export const RSYNC_DIALECTS: readonly RsyncDialect[] = [
|
|
302
|
+
{
|
|
303
|
+
id: "gnu",
|
|
304
|
+
// rsync: [sender] link_stat "/home/u/.codex/state_5.sqlite-wal" failed: No such file or directory (2)
|
|
305
|
+
// rsync: [sender] send_files failed to open "/y": Permission denied (13)
|
|
306
|
+
errorLine: /^rsync:\s+(?:\[[a-z]+\]\s+)?.*\b(?:failed|error)\b/,
|
|
307
|
+
vanishedSource: /^rsync:\s+(?:\[[a-z]+\]\s+)?.*\bfailed: No such file or directory(?: \(\d+\))?$/,
|
|
308
|
+
summaries: [
|
|
309
|
+
/some files\/attrs were not transferred/,
|
|
310
|
+
/some files vanished before they could be transferred/,
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "openrsync",
|
|
315
|
+
// rsync(63771): error: .codex/state_5.sqlite-wal: (l)stat: No such file or directory
|
|
316
|
+
errorLine: /^rsync\(\d+\): error:/,
|
|
317
|
+
vanishedSource: /^rsync\(\d+\): error: .*(?:\(l\)stat|lstat|link_stat|stat): No such file or directory$/,
|
|
318
|
+
summaries: [],
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* How rsync's stderr reads, dialect-resolved:
|
|
324
|
+
*
|
|
325
|
+
* - `no_error_lines` — no line is an error this table recognizes. Wake refuses
|
|
326
|
+
* to reclassify an evidence-free exit 23 on this shape (`remote.rs#L421-L431`).
|
|
327
|
+
* - `vanished_only` — at least one error line, every one of them a source that
|
|
328
|
+
* is not there.
|
|
329
|
+
* - `unaccounted` — an error line that is not a vanished source: permission,
|
|
330
|
+
* I/O, or anything else the exit code's meaning cannot absorb.
|
|
331
|
+
*/
|
|
332
|
+
export type RsyncStderrShape = "no_error_lines" | "vanished_only" | "unaccounted";
|
|
333
|
+
|
|
334
|
+
/** Classify every line of a transfer's stderr against {@link RSYNC_DIALECTS}. */
|
|
335
|
+
export function rsyncStderrShape(stderr: string): RsyncStderrShape {
|
|
336
|
+
let errors = 0;
|
|
337
|
+
let vanished = 0;
|
|
338
|
+
for (const line of stderr.split(/\r?\n/)) {
|
|
339
|
+
if (line.length === 0) continue;
|
|
340
|
+
if (RSYNC_DIALECTS.some((dialect) => dialect.summaries.some((s) => s.test(line)))) continue;
|
|
341
|
+
const dialect = RSYNC_DIALECTS.find((d) => d.errorLine.test(line));
|
|
342
|
+
if (!dialect) continue;
|
|
343
|
+
errors += 1;
|
|
344
|
+
if (dialect.vanishedSource.test(line)) vanished += 1;
|
|
345
|
+
}
|
|
346
|
+
if (errors === 0) return "no_error_lines";
|
|
347
|
+
return vanished === errors ? "vanished_only" : "unaccounted";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** What a transfer means. */
|
|
351
|
+
export type TransferResult =
|
|
352
|
+
| { readonly state: "tool_unavailable"; readonly detail: string }
|
|
353
|
+
| { readonly state: "complete" }
|
|
354
|
+
/**
|
|
355
|
+
* Sources vanished before the transfer read them. `exitCode` 24 is rsync's own
|
|
356
|
+
* claim (accepted unless an error line contradicts it); 23 is the generic code
|
|
357
|
+
* reclassified only on unanimous vanished-source evidence (`remote.rs#L407-L431`).
|
|
358
|
+
*/
|
|
359
|
+
| { readonly state: "vanished_sources"; readonly exitCode: 23 | 24; readonly detail: string }
|
|
360
|
+
| { readonly state: "failed"; readonly detail: string };
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Classify a transfer report. Exit 0 is complete whatever the stderr says
|
|
364
|
+
* (warnings are not failures); everything else is resolved against the error
|
|
365
|
+
* lines, because the exit code alone cannot tell "a source vanished while the
|
|
366
|
+
* agent wrote it" from "the transfer broke".
|
|
367
|
+
*/
|
|
368
|
+
export function classifyTransfer(report: TransferReport, tool = "rsync"): TransferResult {
|
|
369
|
+
if (report.kind === "spawn_failed") return { state: "tool_unavailable", detail: report.detail };
|
|
370
|
+
const { code, stderr } = report;
|
|
371
|
+
if (code === 0) return { state: "complete" };
|
|
372
|
+
const shape = rsyncStderrShape(stderr);
|
|
373
|
+
if (code === 24 && shape !== "unaccounted") {
|
|
374
|
+
return {
|
|
375
|
+
state: "vanished_sources",
|
|
376
|
+
exitCode: 24,
|
|
377
|
+
detail: `sources vanished before the transfer read them — ${exitText(tool, code, stderr)}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (code === 23 && shape === "vanished_only") {
|
|
381
|
+
return {
|
|
382
|
+
state: "vanished_sources",
|
|
383
|
+
exitCode: 23,
|
|
384
|
+
detail: `sources vanished before the transfer read them — ${exitText(tool, code, stderr)}`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { state: "failed", detail: exitText(tool, code, stderr) };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* `<tool> exited with N: <stderr tail>` — Wake's `exit_error`
|
|
392
|
+
* (`remote.rs#L462-L471`): the last three lines, joined, capped at 500
|
|
393
|
+
* characters because a single runaway line would otherwise become the account.
|
|
394
|
+
* A process killed by a signal has no exit code and says so.
|
|
395
|
+
*/
|
|
396
|
+
function exitText(tool: string, code: number | null, stderr: string): string {
|
|
397
|
+
const trimmed = stderr.trim();
|
|
398
|
+
const tail =
|
|
399
|
+
trimmed.length === 0
|
|
400
|
+
? ""
|
|
401
|
+
: trimmed.split(/\r?\n/).slice(-STDERR_TAIL_LINES).join("; ");
|
|
402
|
+
const capped =
|
|
403
|
+
tail.length <= STDERR_TAIL_CHARS ? tail : Array.from(tail).slice(0, STDERR_TAIL_CHARS).join("");
|
|
404
|
+
return code === null
|
|
405
|
+
? `${tool} terminated by signal: ${capped}`
|
|
406
|
+
: `${tool} exited with ${code}: ${capped}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const STDERR_TAIL_LINES = 3;
|
|
410
|
+
const STDERR_TAIL_CHARS = 500;
|
|
411
|
+
|
|
412
|
+
// ── The state machine ──────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Where an attempt is. `probe` and `transfer` are Wake's two steps; `purge` is
|
|
416
|
+
* the deletion of whitelist paths a reached probe found absent, which Wake runs
|
|
417
|
+
* between them (`remote.rs#L345-L349`); `settled` is terminal.
|
|
418
|
+
*/
|
|
419
|
+
export type SyncStage = "probe" | "purge" | "transfer" | "settled";
|
|
420
|
+
|
|
421
|
+
/** What the driver does next, in the order given. */
|
|
422
|
+
export type SyncDirective =
|
|
423
|
+
/** Run the probe for this attempt's whitelist. */
|
|
424
|
+
| { readonly kind: "probe" }
|
|
425
|
+
/** Delete these whitelist-relative paths from the mirror. */
|
|
426
|
+
| { readonly kind: "purge"; readonly paths: readonly string[] }
|
|
427
|
+
/** Transfer these source-relative paths (always a probe-confirmed subset of the whitelist). */
|
|
428
|
+
| { readonly kind: "transfer"; readonly paths: readonly string[] };
|
|
429
|
+
|
|
430
|
+
/** What the driver hands back. */
|
|
431
|
+
export type SyncObservation =
|
|
432
|
+
| { readonly kind: "probe"; readonly report: ProbeReport }
|
|
433
|
+
| { readonly kind: "purge"; readonly report: PurgeReport }
|
|
434
|
+
| { readonly kind: "transfer"; readonly report: TransferReport };
|
|
435
|
+
|
|
436
|
+
/** A mirror deletion's result: absence is success, a failure is a sync failure. */
|
|
437
|
+
export type PurgeReport =
|
|
438
|
+
| { readonly state: "removed" }
|
|
439
|
+
| { readonly state: "failed"; readonly detail: string };
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* One attempt's whole memory. Nothing else is carried between steps, so a
|
|
443
|
+
* driver that crashes mid-attempt leaves no state a later attempt can misread.
|
|
444
|
+
*/
|
|
445
|
+
export interface SyncAttempt {
|
|
446
|
+
readonly source: string;
|
|
447
|
+
readonly paths: readonly string[];
|
|
448
|
+
readonly stage: SyncStage;
|
|
449
|
+
/** 1, or 2 after a vanished round earned Wake's one re-probe (`remote.rs#L355-L359`). */
|
|
450
|
+
readonly round: 1 | 2;
|
|
451
|
+
/**
|
|
452
|
+
* True once any probe in this attempt reached the source, and never unset:
|
|
453
|
+
* reaching is the only thing that licenses a mirror write, so it stays a fact
|
|
454
|
+
* of the attempt even after a later round fails to reach again.
|
|
455
|
+
*/
|
|
456
|
+
readonly reached: boolean;
|
|
457
|
+
/** The last reached probe's confirmed paths, in whitelist order; empty until one lands. */
|
|
458
|
+
readonly present: readonly string[];
|
|
459
|
+
/** The last reached probe's absent paths; empty until one lands. */
|
|
460
|
+
readonly absent: readonly string[];
|
|
461
|
+
/**
|
|
462
|
+
* Every path this attempt's purges actually removed from the mirror: the union
|
|
463
|
+
* of the absent sets of purges that reported success. A record of deletions
|
|
464
|
+
* that landed, not of deletions that were licensed — a purge that failed joins
|
|
465
|
+
* nothing here, and the paths it was licensed to delete stay visible in the
|
|
466
|
+
* settled outcome's `absent`. It is also not a claim about what the mirror
|
|
467
|
+
* holds now: a path a later probe finds present again is transferred, not
|
|
468
|
+
* resurrected.
|
|
469
|
+
*/
|
|
470
|
+
readonly deleted: readonly string[];
|
|
471
|
+
/** Set exactly when `stage` is `settled`. */
|
|
472
|
+
readonly outcome: SyncOutcome | null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** What a step produced: the state to carry, and the work to run. */
|
|
476
|
+
export interface SyncAdvance {
|
|
477
|
+
readonly state: SyncAttempt;
|
|
478
|
+
readonly directives: readonly SyncDirective[];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Start an attempt. An invalid source name settles immediately — refused,
|
|
483
|
+
* mirror untouched — because the name would otherwise reach a session key or an
|
|
484
|
+
* argv; a valid one asks for the probe.
|
|
485
|
+
*/
|
|
486
|
+
export function beginSyncAttempt(source: string, paths: readonly string[]): SyncAdvance {
|
|
487
|
+
const refusal = hostNameRefusal(source);
|
|
488
|
+
const attempt: SyncAttempt = {
|
|
489
|
+
source,
|
|
490
|
+
paths: [...paths],
|
|
491
|
+
stage: "probe",
|
|
492
|
+
round: 1,
|
|
493
|
+
reached: false,
|
|
494
|
+
present: [],
|
|
495
|
+
absent: [],
|
|
496
|
+
deleted: [],
|
|
497
|
+
outcome: null,
|
|
498
|
+
};
|
|
499
|
+
if (refusal) {
|
|
500
|
+
return settle(
|
|
501
|
+
attempt,
|
|
502
|
+
"refused",
|
|
503
|
+
"host_name_invalid",
|
|
504
|
+
`invalid sync source name "${source}": ${HOST_REFUSAL_TEXT[refusal]}`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return { state: attempt, directives: [{ kind: "probe" }] };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* One transition. The observation must match the stage the attempt is waiting
|
|
512
|
+
* on; anything else is a driver bug and throws rather than being absorbed into
|
|
513
|
+
* an outcome the store would then believe.
|
|
514
|
+
*/
|
|
515
|
+
export function advanceSyncAttempt(attempt: SyncAttempt, observation: SyncObservation): SyncAdvance {
|
|
516
|
+
if (attempt.stage === "settled") {
|
|
517
|
+
throw new Error(`sync attempt for ${attempt.source} is settled; no observation can move it`);
|
|
518
|
+
}
|
|
519
|
+
if (observation.kind !== attempt.stage) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`sync attempt for ${attempt.source} waits on a ${attempt.stage} observation, got ${observation.kind}`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
switch (observation.kind) {
|
|
525
|
+
case "probe":
|
|
526
|
+
return advanceProbe(attempt, observation.report);
|
|
527
|
+
case "purge":
|
|
528
|
+
return advancePurge(attempt, observation.report);
|
|
529
|
+
case "transfer":
|
|
530
|
+
return advanceTransfer(attempt, observation.report);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function advanceProbe(attempt: SyncAttempt, report: ProbeReport): SyncAdvance {
|
|
535
|
+
const result = classifyProbe(report, attempt.paths);
|
|
536
|
+
if (result.state === "tool_unavailable") {
|
|
537
|
+
return settle(attempt, "unreachable", "probe_tool_unavailable", result.detail);
|
|
538
|
+
}
|
|
539
|
+
if (result.state === "failed") {
|
|
540
|
+
return settle(attempt, "unreachable", "probe_failed", result.detail);
|
|
541
|
+
}
|
|
542
|
+
const reached: SyncAttempt = {
|
|
543
|
+
...attempt,
|
|
544
|
+
reached: true,
|
|
545
|
+
present: result.present,
|
|
546
|
+
absent: result.absent,
|
|
547
|
+
};
|
|
548
|
+
if (result.absent.length > 0) {
|
|
549
|
+
return {
|
|
550
|
+
state: { ...reached, stage: "purge" },
|
|
551
|
+
directives: [{ kind: "purge", paths: result.absent }],
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (result.present.length === 0) {
|
|
555
|
+
return settle(reached, "empty", "source_empty", null);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
state: { ...reached, stage: "transfer" },
|
|
559
|
+
directives: [{ kind: "transfer", paths: result.present }],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function advancePurge(attempt: SyncAttempt, report: PurgeReport): SyncAdvance {
|
|
564
|
+
if (report.state === "failed") {
|
|
565
|
+
// The deletion was licensed and did not land. Nothing joins `deleted`; the
|
|
566
|
+
// paths the probe licensed stay visible in the settled outcome's `absent`,
|
|
567
|
+
// and the account names the filesystem failure.
|
|
568
|
+
return settle(attempt, "refused", "mirror_purge_failed", report.detail);
|
|
569
|
+
}
|
|
570
|
+
// Only a purge that reported success puts paths into `deleted`, so the record
|
|
571
|
+
// a driver reads names mirror entries that are really gone.
|
|
572
|
+
const deleted = [...attempt.deleted];
|
|
573
|
+
for (const path of attempt.absent) if (!deleted.includes(path)) deleted.push(path);
|
|
574
|
+
const purged: SyncAttempt = { ...attempt, deleted };
|
|
575
|
+
if (purged.present.length === 0) {
|
|
576
|
+
return settle(purged, "empty", "source_empty", null);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
state: { ...purged, stage: "transfer" },
|
|
580
|
+
directives: [{ kind: "transfer", paths: purged.present }],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function advanceTransfer(attempt: SyncAttempt, report: TransferReport): SyncAdvance {
|
|
585
|
+
const result = classifyTransfer(report);
|
|
586
|
+
switch (result.state) {
|
|
587
|
+
case "complete":
|
|
588
|
+
return settle(attempt, "ok", "transferred", null);
|
|
589
|
+
case "tool_unavailable":
|
|
590
|
+
return settle(attempt, "refused", "transfer_tool_unavailable", result.detail);
|
|
591
|
+
case "vanished_sources":
|
|
592
|
+
if (result.exitCode === 23 && attempt.round === 1) {
|
|
593
|
+
// The probe-to-transfer window is a race with a writer on the source; one
|
|
594
|
+
// re-probe is the whole of Wake's tolerance (`remote.rs#L355-L359`).
|
|
595
|
+
return {
|
|
596
|
+
// The round's facts belong to the round: a fresh probe is pending, so
|
|
597
|
+
// the state carries nothing from the round that vanished. What stays is
|
|
598
|
+
// what the attempt itself did (`reached`, `deleted`).
|
|
599
|
+
state: { ...attempt, stage: "probe", round: 2, present: [], absent: [] },
|
|
600
|
+
directives: [{ kind: "probe" }],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return settle(attempt, "partial", "sources_vanished", result.detail);
|
|
604
|
+
case "failed":
|
|
605
|
+
return settle(attempt, "refused", "transfer_failed", result.detail);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Settle with the attempt's own facts. The stamp action is derived here, once:
|
|
611
|
+
* only `ok` and `empty` advance `last_sync_at` (see {@link SyncStampAction}).
|
|
612
|
+
*/
|
|
613
|
+
function settle(
|
|
614
|
+
attempt: SyncAttempt,
|
|
615
|
+
kind: SyncOutcomeKind,
|
|
616
|
+
reason: SyncReason,
|
|
617
|
+
detail: string | null,
|
|
618
|
+
): SyncAdvance {
|
|
619
|
+
const outcome: SyncOutcome = {
|
|
620
|
+
source: attempt.source,
|
|
621
|
+
kind,
|
|
622
|
+
reason,
|
|
623
|
+
detail,
|
|
624
|
+
present: attempt.present,
|
|
625
|
+
absent: attempt.absent,
|
|
626
|
+
rounds: attempt.round,
|
|
627
|
+
cache: attempt.reached
|
|
628
|
+
? { kind: "purge", paths: attempt.deleted }
|
|
629
|
+
: { kind: "keep-untouched" },
|
|
630
|
+
stamp: COMPLETE_KINDS[kind] ? { kind: "update-stamp" } : { kind: "record-error" },
|
|
631
|
+
};
|
|
632
|
+
return { state: { ...attempt, stage: "settled", outcome }, directives: [] };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── The disposable mirror's lifecycle ──────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Which mirror directories belong to no configured source, and are therefore
|
|
639
|
+
* disposable. Wake purges these after every sync round rather than only on the
|
|
640
|
+
* removal itself (`remote.rs#L202-L222`): a transfer already in flight cannot be
|
|
641
|
+
* cancelled and would write the removed tree back, so the verdict is re-asserted
|
|
642
|
+
* until it holds. The rule is name equality against the configured set (Wake's
|
|
643
|
+
* `known.contains`), so a directory this build's validation would refuse is an
|
|
644
|
+
* orphan exactly when nothing configured claims it.
|
|
645
|
+
*
|
|
646
|
+
* Returns names in code-unit order so two callers — and two rounds — delete the
|
|
647
|
+
* same set in the same order. The caller owns the mirror root and the deletion;
|
|
648
|
+
* this function only decides.
|
|
649
|
+
*/
|
|
650
|
+
export function orphanedMirrors(
|
|
651
|
+
configured: readonly string[],
|
|
652
|
+
present: readonly string[],
|
|
653
|
+
): readonly string[] {
|
|
654
|
+
const known = new Set(configured);
|
|
655
|
+
const orphans = new Set<string>();
|
|
656
|
+
for (const name of present) if (!known.has(name)) orphans.add(name);
|
|
657
|
+
return [...orphans].sort();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ── The stamp: persistence contract ────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* One source's sync stamp, as the table would hold it. `last_sync_at` names the
|
|
664
|
+
* last sync that *completed*; an attempt that did not leaves it alone and puts
|
|
665
|
+
* its account in `last_sync_error` — which is why the two are validated against
|
|
666
|
+
* each other below.
|
|
667
|
+
*/
|
|
668
|
+
export interface SyncStampRow {
|
|
669
|
+
source: string;
|
|
670
|
+
/** The last complete sync, or null for a source that has never completed one. */
|
|
671
|
+
last_sync_at: string | null;
|
|
672
|
+
/** The last attempt's verdict. Never absent: a stamp is written by an attempt. */
|
|
673
|
+
last_outcome: SyncOutcomeKind;
|
|
674
|
+
/** The last attempt's account; null exactly when the last attempt completed. */
|
|
675
|
+
last_sync_error: string | null;
|
|
676
|
+
/** When the last attempt ran (complete or not) — the staleness floor's own stamp. */
|
|
677
|
+
updated_at: string;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The stamp table, schema-ready — and deliberately **not** executed by this
|
|
682
|
+
* cycle.
|
|
683
|
+
*
|
|
684
|
+
* The decision, since it is the one this module could plausibly get wrong: C8
|
|
685
|
+
* ships no transport, so no code path can write a row. Taking a schema version
|
|
686
|
+
* for a table nothing populates would migrate every store this build touches —
|
|
687
|
+
* and make every older installed build refuse them (`SCHEMA_MISMATCH`) — for
|
|
688
|
+
* zero data, would grow PR #52's ladder obligation from "accept 15" to "accept
|
|
689
|
+
* 15 and 16" on an unaccepted branch, and would freeze a row shape before the
|
|
690
|
+
* transport that owns it exists. The boring option wins: the contract lands now
|
|
691
|
+
* (this DDL, {@link SyncStampRow}, {@link readSyncStamps},
|
|
692
|
+
* {@link stampedSyncRow}, tests), and the cycle that ships the writer adds this
|
|
693
|
+
* constant to `CREATE_TABLES_SQL`, claims `SCHEMA_VERSION` 16, and enters the
|
|
694
|
+
* ladder from every live shape (13, 14, 15) the way C7's step did.
|
|
695
|
+
*
|
|
696
|
+
* The CHECKs are the contract, not decoration: a row cannot claim a complete
|
|
697
|
+
* outcome without a stamp or without clearing the error, and cannot record a
|
|
698
|
+
* failure without an account.
|
|
699
|
+
*/
|
|
700
|
+
export const SYNC_STAMP_DDL = `
|
|
701
|
+
CREATE TABLE IF NOT EXISTS sync_stamps (
|
|
702
|
+
source TEXT PRIMARY KEY,
|
|
703
|
+
last_sync_at TEXT,
|
|
704
|
+
last_outcome TEXT NOT NULL CHECK (last_outcome IN ('ok', 'empty', 'partial', 'unreachable', 'refused')),
|
|
705
|
+
last_sync_error TEXT,
|
|
706
|
+
updated_at TEXT NOT NULL,
|
|
707
|
+
CHECK (last_outcome IN ('ok', 'empty') OR last_sync_error IS NOT NULL),
|
|
708
|
+
CHECK (last_outcome NOT IN ('ok', 'empty') OR (last_sync_error IS NULL AND last_sync_at IS NOT NULL))
|
|
709
|
+
);
|
|
710
|
+
`;
|
|
711
|
+
|
|
712
|
+
const SYNC_STAMP_TABLE = "sync_stamps";
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The stamps a store holds, in source order, or `null` when the store has no
|
|
716
|
+
* stamp table at all.
|
|
717
|
+
*
|
|
718
|
+
* `null` is not `[]`. A store that predates the table has *unknown* sync state,
|
|
719
|
+
* and a surface that renders it as "never synced" — or worse, as clean — would
|
|
720
|
+
* state a fact the store cannot support. This is the same distinction
|
|
721
|
+
* `StoreStatus.coverage` draws for a store that predates the coverage table, and
|
|
722
|
+
* the same guard `TrajectoryDb.getSourceCoverageSummary` uses.
|
|
723
|
+
*/
|
|
724
|
+
export function readSyncStamps(db: Database): SyncStampRow[] | null {
|
|
725
|
+
const present = db
|
|
726
|
+
.query<{ name: string }, [string]>(
|
|
727
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1;",
|
|
728
|
+
)
|
|
729
|
+
.get(SYNC_STAMP_TABLE);
|
|
730
|
+
if (!present) return null;
|
|
731
|
+
return db
|
|
732
|
+
.query<SyncStampRow, []>(
|
|
733
|
+
`SELECT source, last_sync_at, last_outcome, last_sync_error, updated_at
|
|
734
|
+
FROM ${SYNC_STAMP_TABLE} ORDER BY source ASC`,
|
|
735
|
+
)
|
|
736
|
+
.all();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The row an attempt writes, given the row already there. Pure, and the place
|
|
741
|
+
* the stamp's two invariants are enforced once: a completing outcome stamps
|
|
742
|
+
* `last_sync_at` and clears the error; an incomplete one keeps the previous
|
|
743
|
+
* stamp (the last *complete* sync, even if that is another attempt's) and
|
|
744
|
+
* records the account. A previous row is only carried forward when it names the
|
|
745
|
+
* same source.
|
|
746
|
+
*
|
|
747
|
+
* An incomplete outcome with no detail is recorded under its reason code rather
|
|
748
|
+
* than as an empty account — the row never claims a failure it cannot explain.
|
|
749
|
+
*/
|
|
750
|
+
export function stampedSyncRow(
|
|
751
|
+
previous: SyncStampRow | null,
|
|
752
|
+
outcome: SyncOutcome,
|
|
753
|
+
attemptedAt: string,
|
|
754
|
+
): SyncStampRow {
|
|
755
|
+
const complete = COMPLETE_KINDS[outcome.kind];
|
|
756
|
+
const carried = previous?.source === outcome.source ? previous.last_sync_at : null;
|
|
757
|
+
return {
|
|
758
|
+
source: outcome.source,
|
|
759
|
+
last_sync_at: complete ? attemptedAt : carried,
|
|
760
|
+
last_outcome: outcome.kind,
|
|
761
|
+
last_sync_error: complete ? null : (outcome.detail ?? outcome.reason),
|
|
762
|
+
updated_at: attemptedAt,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── Coverage honesty ───────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* What the mirror that stands beside a stamp may be read as — the claim
|
|
770
|
+
* ceiling, per source:
|
|
771
|
+
*
|
|
772
|
+
* - `current` — the last attempt completed: as of `last_sync_at`, the mirror
|
|
773
|
+
* holds what the source held.
|
|
774
|
+
* - `incomplete` — the last attempt reached the source and transferred a
|
|
775
|
+
* subset: the mirror is missing what vanished, and nothing was purged to hide
|
|
776
|
+
* it.
|
|
777
|
+
* - `not-claimed` — no contact, or an attempt this store does not vouch for: the
|
|
778
|
+
* mirror may hold anything, and this store says nothing about the source.
|
|
779
|
+
*/
|
|
780
|
+
export type SyncMirrorStanding = "current" | "incomplete" | "not-claimed";
|
|
781
|
+
|
|
782
|
+
/** One source's disclosure, derived from its stamp and nothing else. */
|
|
783
|
+
export interface SyncSourceDisclosure {
|
|
784
|
+
readonly source: string;
|
|
785
|
+
/** The last attempt's verdict, verbatim. */
|
|
786
|
+
readonly outcome: SyncOutcomeKind;
|
|
787
|
+
/** The last complete sync; null when no attempt has ever completed for this source. */
|
|
788
|
+
readonly last_synced_at: string | null;
|
|
789
|
+
/** The last attempt's account; null when it completed. */
|
|
790
|
+
readonly error: string | null;
|
|
791
|
+
readonly last_attempt_at: string;
|
|
792
|
+
readonly mirror: SyncMirrorStanding;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* What a consuming surface discloses about sync state. Deliberately shaped like
|
|
797
|
+
* the coverage envelope (`coverage: {degraded, total, oldest_updated_at}`), with
|
|
798
|
+
* the same rule for absence: an unknown is not a zero.
|
|
799
|
+
*
|
|
800
|
+
* - `unknown` — the store holds no stamp table: sync state is not knowable here.
|
|
801
|
+
* Never render as "clean" and never as "never synced".
|
|
802
|
+
* - `never_synced` — the table exists and is empty: no attempt has run, which is
|
|
803
|
+
* a fact about attempts, not about what the mirror holds.
|
|
804
|
+
* - `recorded` — one entry per stamped source, the count whose last attempt did
|
|
805
|
+
* not complete, and the least-recently-*completed* sync (the staleness floor).
|
|
806
|
+
*/
|
|
807
|
+
export type SyncDisclosure =
|
|
808
|
+
| { readonly state: "unknown" }
|
|
809
|
+
| { readonly state: "never_synced" }
|
|
810
|
+
| {
|
|
811
|
+
readonly state: "recorded";
|
|
812
|
+
/** Stamped sources. A source with no stamp is absent, not clean. */
|
|
813
|
+
readonly total: number;
|
|
814
|
+
/** Sources whose last attempt did not complete — the `degraded` analog. */
|
|
815
|
+
readonly incomplete: number;
|
|
816
|
+
/** The oldest `last_sync_at`; null when nothing has ever completed. */
|
|
817
|
+
readonly oldest_synced_at: string | null;
|
|
818
|
+
readonly sources: readonly SyncSourceDisclosure[];
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
/** Derive the disclosure from stored stamps (`null` = the store predates the table). */
|
|
822
|
+
export function discloseSyncState(stamps: readonly SyncStampRow[] | null): SyncDisclosure {
|
|
823
|
+
if (stamps === null) return { state: "unknown" };
|
|
824
|
+
if (stamps.length === 0) return { state: "never_synced" };
|
|
825
|
+
const sources = [...stamps]
|
|
826
|
+
.sort((a, b) => (a.source < b.source ? -1 : a.source > b.source ? 1 : 0))
|
|
827
|
+
.map<SyncSourceDisclosure>((row) => ({
|
|
828
|
+
source: row.source,
|
|
829
|
+
outcome: row.last_outcome,
|
|
830
|
+
last_synced_at: row.last_sync_at,
|
|
831
|
+
error: row.last_sync_error,
|
|
832
|
+
last_attempt_at: row.updated_at,
|
|
833
|
+
mirror: MIRROR_STANDING[row.last_outcome],
|
|
834
|
+
}));
|
|
835
|
+
let oldest: string | null = null;
|
|
836
|
+
let incomplete = 0;
|
|
837
|
+
for (const source of sources) {
|
|
838
|
+
if (source.mirror !== "current") incomplete += 1;
|
|
839
|
+
if (source.last_synced_at === null) continue;
|
|
840
|
+
if (oldest === null || source.last_synced_at < oldest) oldest = source.last_synced_at;
|
|
841
|
+
}
|
|
842
|
+
return { state: "recorded", total: sources.length, incomplete, oldest_synced_at: oldest, sources };
|
|
843
|
+
}
|