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.
Files changed (205) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/README.md +288 -2
  4. package/dist/cli.js +30210 -0
  5. package/dist/convert.d.ts +31 -0
  6. package/dist/convert.js +431 -0
  7. package/dist/deepagents_checkpoint.py +362 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +8402 -0
  10. package/dist/log-baseline.d.ts +26 -0
  11. package/dist/log-baseline.js +104 -0
  12. package/dist/log-content.d.ts +16 -0
  13. package/dist/log-content.js +80 -0
  14. package/dist/log-io.d.ts +10 -0
  15. package/dist/log-io.js +121 -0
  16. package/dist/log-read.d.ts +24 -0
  17. package/dist/log-read.js +117 -0
  18. package/dist/log-reconcile.d.ts +5 -0
  19. package/dist/log-reconcile.js +38 -0
  20. package/dist/log-records.d.ts +35 -0
  21. package/dist/log-records.js +85 -0
  22. package/dist/log-recovery.d.ts +18 -0
  23. package/dist/log-recovery.js +123 -0
  24. package/dist/log-stage.d.ts +14 -0
  25. package/dist/log-stage.js +138 -0
  26. package/dist/log-state.d.ts +89 -0
  27. package/dist/log-state.js +92 -0
  28. package/dist/log-switch.d.ts +3 -0
  29. package/dist/log-switch.js +98 -0
  30. package/dist/log-types.d.ts +129 -0
  31. package/dist/log-types.js +8 -0
  32. package/dist/log.d.ts +8 -0
  33. package/dist/log.js +62 -0
  34. package/dist/migrate.d.ts +29 -0
  35. package/dist/migrate.js +173 -0
  36. package/dist/normalization-runtime.d.ts +1 -0
  37. package/dist/normalization-runtime.js +3150 -0
  38. package/dist/portable.d.ts +9 -0
  39. package/dist/portable.js +26 -0
  40. package/dist/readers/canonical.d.ts +13 -0
  41. package/dist/readers/canonical.js +54 -0
  42. package/dist/readers/cline.d.ts +10 -0
  43. package/dist/readers/cline.js +107 -0
  44. package/dist/readers/cursor.d.ts +20 -0
  45. package/dist/readers/cursor.js +374 -0
  46. package/dist/readers/deepagents.d.ts +41 -0
  47. package/dist/readers/deepagents.js +163 -0
  48. package/dist/readers/fx.d.ts +11 -0
  49. package/dist/readers/fx.js +573 -0
  50. package/dist/readers/github-copilot.d.ts +8 -0
  51. package/dist/readers/github-copilot.js +216 -0
  52. package/dist/readers/grok-build.d.ts +12 -0
  53. package/dist/readers/grok-build.js +391 -0
  54. package/dist/readers/guards.d.ts +4 -0
  55. package/dist/readers/guards.js +121 -0
  56. package/dist/readers/pi-compaction.d.ts +7 -0
  57. package/dist/readers/pi-compaction.js +112 -0
  58. package/dist/readers/pi-context.d.ts +13 -0
  59. package/dist/readers/pi-context.js +64 -0
  60. package/dist/store.d.ts +25 -0
  61. package/dist/store.js +218 -0
  62. package/dist/types.d.ts +91 -0
  63. package/dist/types.js +1 -0
  64. package/dist/writers/claude-code.d.ts +5 -0
  65. package/dist/writers/claude-code.js +51 -0
  66. package/dist/writers/cline.d.ts +3 -0
  67. package/dist/writers/cline.js +77 -0
  68. package/dist/writers/codex.d.ts +3 -0
  69. package/dist/writers/codex.js +32 -0
  70. package/dist/writers/cursor.d.ts +3 -0
  71. package/dist/writers/cursor.js +197 -0
  72. package/dist/writers/deepagents.d.ts +3 -0
  73. package/dist/writers/deepagents.js +116 -0
  74. package/dist/writers/entropy.d.ts +5 -0
  75. package/dist/writers/entropy.js +33 -0
  76. package/dist/writers/fx.d.ts +3 -0
  77. package/dist/writers/fx.js +135 -0
  78. package/dist/writers/github-copilot.d.ts +3 -0
  79. package/dist/writers/github-copilot.js +71 -0
  80. package/dist/writers/grok-build.d.ts +3 -0
  81. package/dist/writers/grok-build.js +43 -0
  82. package/dist/writers/helpers.d.ts +17 -0
  83. package/dist/writers/helpers.js +75 -0
  84. package/dist/writers/omp.d.ts +3 -0
  85. package/dist/writers/omp.js +7 -0
  86. package/dist/writers/opencode.d.ts +3 -0
  87. package/dist/writers/opencode.js +65 -0
  88. package/dist/writers/pi.d.ts +3 -0
  89. package/dist/writers/pi.js +45 -0
  90. package/docs/cline-native-format.md +43 -0
  91. package/docs/copilot-native-format.md +78 -0
  92. package/docs/cursor-native-format.md +103 -0
  93. package/docs/deepagents-native-format.md +98 -0
  94. package/docs/evidence-api.md +170 -0
  95. package/docs/fx-native-format.md +87 -0
  96. package/docs/harness-parity-bootstrap.md +78 -0
  97. package/docs/harness-support-parity.md +107 -0
  98. package/docs/inspirations.md +103 -0
  99. package/docs/ledger-migration.md +141 -0
  100. package/docs/pi-compaction-context.md +75 -0
  101. package/docs/pi-provider-context.md +76 -0
  102. package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
  103. package/docs/research/ledger/README.md +96 -0
  104. package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
  105. package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
  106. package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
  107. package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
  108. package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
  109. package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
  110. package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
  111. package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
  112. package/docs/research/ledger/docs/inspirations.md +320 -0
  113. package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
  114. package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
  115. package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
  116. package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
  117. package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
  118. package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
  119. package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
  120. package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
  121. package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
  122. package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
  123. package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
  124. package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
  125. package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
  126. package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
  127. package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
  128. package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
  129. package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
  130. package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
  131. package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
  132. package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
  133. package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
  134. package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
  135. package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
  136. package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
  137. package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
  138. package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
  139. package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
  140. package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
  141. package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
  142. package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
  143. package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
  144. package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
  145. package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
  146. package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
  147. package/docs/research/ledger/manifest.json +540 -0
  148. package/legacy/NOTICE.md +50 -0
  149. package/legacy/bin/ledger-core.ts +7 -0
  150. package/legacy/bin/ledger.ts +7 -0
  151. package/legacy/bin/traj-bridge.ts +7 -0
  152. package/legacy/scripts/import-claude-history.ts +130 -0
  153. package/legacy/scripts/native-history-smoke.ts +52 -0
  154. package/legacy/scripts/observe-daemon.ts +570 -0
  155. package/legacy/scripts/retitle-sessions.md +19 -0
  156. package/legacy/scripts/verify-runtime-rollout.ts +151 -0
  157. package/legacy/src/canonical.ts +108 -0
  158. package/legacy/src/cli.ts +1293 -0
  159. package/legacy/src/db.ts +2254 -0
  160. package/legacy/src/diagnostics.ts +87 -0
  161. package/legacy/src/discovery.ts +56 -0
  162. package/legacy/src/embeddings.ts +382 -0
  163. package/legacy/src/filter.ts +602 -0
  164. package/legacy/src/gemini_embeddings.ts +103 -0
  165. package/legacy/src/identity.ts +9 -0
  166. package/legacy/src/index.ts +10 -0
  167. package/legacy/src/ingest.ts +791 -0
  168. package/legacy/src/managed.ts +178 -0
  169. package/legacy/src/native/common.ts +68 -0
  170. package/legacy/src/native/cursor.ts +647 -0
  171. package/legacy/src/native/grok.ts +540 -0
  172. package/legacy/src/native/kimi.ts +706 -0
  173. package/legacy/src/native/opencode.ts +470 -0
  174. package/legacy/src/native/sqlite-ro.ts +281 -0
  175. package/legacy/src/native.ts +97 -0
  176. package/legacy/src/normalizer.ts +457 -0
  177. package/legacy/src/parentage.ts +562 -0
  178. package/legacy/src/pressure.ts +162 -0
  179. package/legacy/src/private-storage.ts +74 -0
  180. package/legacy/src/reader.ts +1255 -0
  181. package/legacy/src/scanner.ts +591 -0
  182. package/legacy/src/schema-gate.ts +263 -0
  183. package/legacy/src/schema.ts +829 -0
  184. package/legacy/src/semantic-top-k.ts +71 -0
  185. package/legacy/src/session-hash.ts +49 -0
  186. package/legacy/src/store-status.ts +218 -0
  187. package/legacy/src/sync-preparation.ts +45 -0
  188. package/legacy/src/sync-truth.ts +843 -0
  189. package/legacy/src/sync.ts +1233 -0
  190. package/legacy/src/types.ts +436 -0
  191. package/legacy/src/vocab.txt +30522 -0
  192. package/legacy/src/watch.ts +673 -0
  193. package/legacy/src/writer-lease.ts +310 -0
  194. package/ops/README.md +150 -0
  195. package/ops/install-cli.sh +63 -0
  196. package/ops/install-native.sh +35 -0
  197. package/ops/install-release.sh +49 -0
  198. package/ops/linux/install-systemd-user.sh +103 -0
  199. package/ops/macos/install-launchd.sh +72 -0
  200. package/ops/sync-settings.sh +37 -0
  201. package/package.json +21 -5
  202. package/skills/trajectories/SKILL.md +138 -0
  203. package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
  204. package/skills/trajectories/scripts/trajectories_search.sh +26 -0
  205. 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
+ }