@vibe-commit/capture 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -1
- package/dist/agent_hooks.js +2 -2
- package/dist/cli.d.ts +35 -0
- package/dist/cli.js +412 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/backfill.js +19 -2
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/finalize.d.ts +37 -1
- package/dist/commands/finalize.js +131 -29
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/status.js +7 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status_machine.js +5 -0
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/commands/upload_worker.d.ts +63 -0
- package/dist/commands/upload_worker.js +336 -0
- package/dist/commands/upload_worker.js.map +1 -0
- package/dist/commands/why.d.ts +19 -1
- package/dist/commands/why.js +35 -3
- package/dist/commands/why.js.map +1 -1
- package/dist/commit_files.d.ts +64 -0
- package/dist/commit_files.js +83 -0
- package/dist/commit_files.js.map +1 -0
- package/dist/copy/strings.d.ts +34 -14
- package/dist/copy/strings.js +51 -14
- package/dist/copy/strings.js.map +1 -1
- package/dist/drain.d.ts +68 -0
- package/dist/drain.js +234 -0
- package/dist/drain.js.map +1 -0
- package/dist/firing.d.ts +169 -0
- package/dist/firing.js +334 -0
- package/dist/firing.js.map +1 -0
- package/dist/hooks/entry.d.ts +171 -26
- package/dist/hooks/entry.js +335 -507
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +36 -87
- package/dist/hooks/fire_lock.js +40 -99
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/hooks/post_commit.d.ts +11 -0
- package/dist/hooks/post_commit.js +16 -8
- package/dist/hooks/post_commit.js.map +1 -1
- package/dist/identity.d.ts +32 -0
- package/dist/identity.js +38 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +3 -17
- package/dist/index.js +59 -450
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +31 -0
- package/dist/paths.js.map +1 -1
- package/dist/pending_prune.d.ts +2 -2
- package/dist/pending_prune.js +2 -2
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +7 -0
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +63 -4
- package/dist/post.js +76 -24
- package/dist/post.js.map +1 -1
- package/dist/replay.d.ts +167 -0
- package/dist/replay.js +301 -0
- package/dist/replay.js.map +1 -0
- package/dist/session_targets.d.ts +12 -0
- package/dist/session_targets.js +6 -1
- package/dist/session_targets.js.map +1 -1
- package/dist/spool.d.ts +55 -21
- package/dist/spool.js +182 -38
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +18 -2
- package/dist/spool_union.js +23 -2
- package/dist/spool_union.js.map +1 -1
- package/dist/state.js +21 -14
- package/dist/state.js.map +1 -1
- package/dist/stdin.d.ts +2 -0
- package/dist/stdin.js +13 -0
- package/dist/stdin.js.map +1 -0
- package/dist/uploader_status.d.ts +34 -0
- package/dist/uploader_status.js +25 -0
- package/dist/uploader_status.js.map +1 -0
- package/dist/uploader_view.d.ts +39 -0
- package/dist/uploader_view.js +106 -0
- package/dist/uploader_view.js.map +1 -0
- package/dist/verbs.d.ts +11 -0
- package/dist/verbs.js +13 -0
- package/dist/verbs.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +2 -2
package/dist/spool.d.ts
CHANGED
|
@@ -31,14 +31,15 @@
|
|
|
31
31
|
* only when that question has an answer, and the answer carries the rung that
|
|
32
32
|
* produced it so the server can tell an observation from a guess.
|
|
33
33
|
*
|
|
34
|
-
* ## The line is deliberately small
|
|
34
|
+
* ## The line is deliberately small; the file list rides in the BODY, never a header
|
|
35
35
|
*
|
|
36
|
-
* `{sha, branch, at, files}`. Only the SHA reaches
|
|
36
|
+
* `{sha, branch, at, files}`. Only the SHA reaches a HEADER (`CR-170`
|
|
37
37
|
* §4): commit messages were MEASURED at 9/20, 8/20 and 5/20 commits over 4096
|
|
38
38
|
* bytes across the three repos, largest 12,112, so a quarter of real commits
|
|
39
39
|
* would land permanently truncated in a header. Metadata is backfillable from
|
|
40
|
-
* the sha; the observation is not. `files` is
|
|
41
|
-
* in
|
|
40
|
+
* the sha; the observation is not. `files` is git's changed-file list; since 0.10.0 it
|
|
41
|
+
* goes out in the length-framed body trailer of `commit_files.ts` (granted
|
|
42
|
+
* repositories only) and is never put in a header.
|
|
42
43
|
*/
|
|
43
44
|
import { type PendingMode, type TurnStart } from "./pending_bound.js";
|
|
44
45
|
import { type SessionKey } from "./paths.js";
|
|
@@ -132,7 +133,7 @@ export interface SpoolEntry {
|
|
|
132
133
|
readonly branch: string | null;
|
|
133
134
|
/** ISO-8601, from git's own `%cI`. */
|
|
134
135
|
readonly at: string;
|
|
135
|
-
/** Paths touched.
|
|
136
|
+
/** Paths touched, git's own list. Sent in the body trailer since 0.10.0 (`commit_files.ts`). ⛔ Never a header. */
|
|
136
137
|
readonly files: readonly string[];
|
|
137
138
|
/**
|
|
138
139
|
* ⛔ **HOW THE SESSION ON THIS LINE WAS DECIDED.** Recorded at OBSERVATION
|
|
@@ -141,9 +142,13 @@ export interface SpoolEntry {
|
|
|
141
142
|
*/
|
|
142
143
|
readonly attribution: AttributionRung;
|
|
143
144
|
/**
|
|
144
|
-
* `TODOS[176]` — the WALL CLOCK (ms since the epoch) of the `post-commit` process when it spooled this line.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
145
|
+
* `TODOS[176]` — the WALL CLOCK (ms since the epoch) of the `post-commit` process when it spooled this line. `pending_bound.ts`
|
|
146
|
+
* compares it with the start of the turn being delivered (the first unsent prompt-like record). Never the committer date (settable).
|
|
147
|
+
*
|
|
148
|
+
* U2: written on EVERY line now, spool and pending alike, and kept by promotion. The upload worker replays a firing "as of" the moment
|
|
149
|
+
* its hook ran, and the commit lines that firing may carry are exactly those with `observedAt <= firing.at`; a later commit must ride a
|
|
150
|
+
* later delivery. A line from an older client has none and counts as OLD: it is carried by the first delivery that reads it, so a
|
|
151
|
+
* spool of legacy lines still drains (`undefined <= firing.at` is false in JS, which is why callers must test for absence first).
|
|
147
152
|
*/
|
|
148
153
|
readonly observedAt?: number;
|
|
149
154
|
}
|
|
@@ -216,6 +221,8 @@ export declare function appendSpool(home: string, key: SessionKey, entry: SpoolE
|
|
|
216
221
|
* and losing one observation beats losing the whole spool.
|
|
217
222
|
*/
|
|
218
223
|
export declare function readSpool(home: string, key: SessionKey): SpoolEntry[];
|
|
224
|
+
/** How many lines of `raw` are well-formed commit entries (the unit `count` is in). `cap` bounds the answer (a pending file is capped). */
|
|
225
|
+
export declare function wellFormedCommitLines(raw: string, cap?: number): number;
|
|
219
226
|
/**
|
|
220
227
|
* Drop the first `count` entries — called ONLY after the server's 2xx.
|
|
221
228
|
*
|
|
@@ -223,15 +230,17 @@ export declare function readSpool(home: string, key: SessionKey): SpoolEntry[];
|
|
|
223
230
|
* `post-commit` may have appended while the send was in flight. Slicing the
|
|
224
231
|
* array we read BEFORE the send would rewrite the file without those lines and
|
|
225
232
|
* lose them silently. Appends only ever go to the END, so the first `count`
|
|
226
|
-
* lines are still the same ones we delivered, and dropping exactly
|
|
227
|
-
* correct against the current file rather than against a stale snapshot.
|
|
233
|
+
* well-formed lines are still the same ones we delivered, and dropping exactly
|
|
234
|
+
* that many is correct against the current file rather than against a stale snapshot.
|
|
228
235
|
*
|
|
229
|
-
* ⚠ **A residual race remains and is accepted:** a commit landing between this
|
|
230
|
-
* re-read and the write is lost. The window is one `readFileSync` plus one
|
|
236
|
+
* ⚠ **A residual race remains and is accepted (D14):** a commit landing between this
|
|
237
|
+
* re-read and the end of the write is lost. The window is one `readFileSync` plus one
|
|
231
238
|
* `writeFileSync` on a small file, the cost is one un-delivered commit row, and
|
|
232
239
|
* closing it properly means a lock file on a path that must never block
|
|
233
240
|
* (`post-commit` runs inside the user's `git commit`). Recorded rather than
|
|
234
|
-
* hidden.
|
|
241
|
+
* hidden. The worker runs this mid-turn rather than at an idle Stop, so the window is
|
|
242
|
+
* hit more often than it was; the upgrade path if a lost commit is ever observed is
|
|
243
|
+
* one file per commit.
|
|
235
244
|
*/
|
|
236
245
|
export declare function dropSpooled(home: string, key: SessionKey, count: number): boolean;
|
|
237
246
|
/**
|
|
@@ -246,10 +255,10 @@ export declare function dropSpooled(home: string, key: SessionKey, count: number
|
|
|
246
255
|
* BOUND one — and a bound sha resent into a sealed turn is a second, permanent
|
|
247
256
|
* edge (`cr071:157`). Only the per-line decision is right.
|
|
248
257
|
*
|
|
249
|
-
* ⚠ THE SAME
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
258
|
+
* ⚠ THE SAME WALK AS `dropSpooled` (U2): `count` is a number of WELL-FORMED lines, as `capSpool` computes it, so the consumed region
|
|
259
|
+
* ends after the `count`-th well-formed line and a malformed line inside it is dropped with it. (This docblock used to say `count` was
|
|
260
|
+
* computed from raw lines. It was not.) A well-formed line that carries a rung that never goes on the wire is dropped exactly as
|
|
261
|
+
* `dropSpooled` would drop it — the server cannot have named it.
|
|
253
262
|
*
|
|
254
263
|
* ⚠ The residual race `dropSpooled` documents applies here unchanged.
|
|
255
264
|
*/
|
|
@@ -357,6 +366,11 @@ export interface Observed {
|
|
|
357
366
|
readonly shas: readonly string[];
|
|
358
367
|
/** ⛔ Same length as `shas`, same order. Wire rungs only. */
|
|
359
368
|
readonly attributions: readonly AttributionRung[];
|
|
369
|
+
/**
|
|
370
|
+
* ⛔ Same length as `shas`, same order: git's changed-file list for each sha, UNCHANGED (E8). It never reaches a header;
|
|
371
|
+
* `commit_files.ts` turns it into the body trailer, and only for a granted repository.
|
|
372
|
+
*/
|
|
373
|
+
readonly files: readonly (readonly string[])[];
|
|
360
374
|
/** Spool lines consumed — ⛔ **held ones INCLUDED, so they drain.** */
|
|
361
375
|
readonly count: number;
|
|
362
376
|
}
|
|
@@ -434,7 +448,11 @@ export declare function rewriteSpoolPath(home: string, key: SessionKey): string
|
|
|
434
448
|
export declare function appendRewrites(home: string, key: SessionKey, pairs: readonly RewritePair[]): number;
|
|
435
449
|
/** Every well-formed pair in the rewrite spool, oldest first. */
|
|
436
450
|
export declare function readRewrites(home: string, key: SessionKey): RewritePair[];
|
|
437
|
-
/**
|
|
451
|
+
/**
|
|
452
|
+
* Drop the first `count` pairs — called ONLY after the server's 2xx. `count` is a number of WELL-FORMED pairs (`capSuccessors(readRewrites)`),
|
|
453
|
+
* so it walks like `dropSpooled` does (U2, trap 8): a malformed line among the consumed ones is passed over, never counted. A re-sent pair is
|
|
454
|
+
* harmless on the server (`commit_sha_successors` treats a duplicate as success) but the arithmetic was the same defect.
|
|
455
|
+
*/
|
|
438
456
|
export declare function dropRewrites(home: string, key: SessionKey, count: number): boolean;
|
|
439
457
|
/**
|
|
440
458
|
* The pairs this hook will carry, and how many to drop after a 2xx.
|
|
@@ -594,6 +612,10 @@ export declare function isFullSha(value: unknown): value is string;
|
|
|
594
612
|
export declare const MAX_PENDING_SHAS = 16;
|
|
595
613
|
/** `<repo>/<session>.pending.jsonl`, beside the commit spool. */
|
|
596
614
|
export declare function pendingSpoolPath(home: string, key: SessionKey): string | null;
|
|
615
|
+
/** Test seam (precedent: `PruneSeams` in `pending_prune.ts`): runs between `appendPending`'s dedupe read and its append, exactly where a promotion would land. */
|
|
616
|
+
export interface PendingSeams {
|
|
617
|
+
readonly afterDedupeRead?: () => void;
|
|
618
|
+
}
|
|
597
619
|
/**
|
|
598
620
|
* Record an uncorroborated observation. Returns whether it was written.
|
|
599
621
|
*
|
|
@@ -602,9 +624,15 @@ export declare function pendingSpoolPath(home: string, key: SessionKey): string
|
|
|
602
624
|
* would promote into two spool entries and two permanent rows —
|
|
603
625
|
* `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so two capture
|
|
604
626
|
* ids are two legal rows for one commit.
|
|
627
|
+
*
|
|
628
|
+
* ⛔ **A REAL APPEND, NOT A READ-MODIFY-WRITE (U2, trap 6).** This used to read the file, add the entry and WRITE THE WHOLE FILE BACK.
|
|
629
|
+
* Trace: pending holds [B]; `post-commit` for D reads [B]; the worker promotes B and removes the file; `post-commit` writes [B, D]. The
|
|
630
|
+
* next firing promotes B AGAIN, and once the first B is delivered that is a second permanent row. Now it reads only to dedupe, then
|
|
631
|
+
* `appendFileSync`s one line: a promotion that emptied the file in between leaves D alone in a fresh file, and B is never resurrected.
|
|
632
|
+
* The cap (`MAX_PENDING_SHAS`) moved to `readPending` and to promotion's own rewrite, so an append never has to rewrite anything.
|
|
605
633
|
*/
|
|
606
|
-
export declare function appendPending(home: string, key: SessionKey, entry: SpoolEntry): boolean;
|
|
607
|
-
/** Every uncorroborated entry for this session, oldest first. */
|
|
634
|
+
export declare function appendPending(home: string, key: SessionKey, entry: SpoolEntry, seams?: PendingSeams): boolean;
|
|
635
|
+
/** Every uncorroborated entry for this session, oldest first, at most the newest `MAX_PENDING_SHAS` (the cap lives HERE since `appendPending` no longer rewrites). */
|
|
608
636
|
export declare function readPending(home: string, key: SessionKey): SpoolEntry[];
|
|
609
637
|
/** Remove the pending file entirely. Used once its lines have been promoted. */
|
|
610
638
|
export declare function clearPending(home: string, key: SessionKey): boolean;
|
|
@@ -652,4 +680,10 @@ corroboration: "this-session-is-running",
|
|
|
652
680
|
* session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
|
|
653
681
|
* when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
|
|
654
682
|
*/
|
|
655
|
-
bound: TurnStart, mode: PendingMode
|
|
683
|
+
bound: TurnStart, mode: PendingMode,
|
|
684
|
+
/**
|
|
685
|
+
* U2 — **THE MOMENT THE HOOK FIRED**, epoch ms. A worker replays a firing late; a commit observed AFTER the firing belongs to a later
|
|
686
|
+
* delivery, so a pending line with `observedAt > asOfMs` is neither promoted nor dropped: it stays in the file. Unset means "now" and
|
|
687
|
+
* every line is due, which is what the inline hook always meant. A line with NO `observedAt` (an older client) is always due.
|
|
688
|
+
*/
|
|
689
|
+
asOfMs?: number): number;
|
package/dist/spool.js
CHANGED
|
@@ -31,20 +31,22 @@
|
|
|
31
31
|
* only when that question has an answer, and the answer carries the rung that
|
|
32
32
|
* produced it so the server can tell an observation from a guess.
|
|
33
33
|
*
|
|
34
|
-
* ## The line is deliberately small
|
|
34
|
+
* ## The line is deliberately small; the file list rides in the BODY, never a header
|
|
35
35
|
*
|
|
36
|
-
* `{sha, branch, at, files}`. Only the SHA reaches
|
|
36
|
+
* `{sha, branch, at, files}`. Only the SHA reaches a HEADER (`CR-170`
|
|
37
37
|
* §4): commit messages were MEASURED at 9/20, 8/20 and 5/20 commits over 4096
|
|
38
38
|
* bytes across the three repos, largest 12,112, so a quarter of real commits
|
|
39
39
|
* would land permanently truncated in a header. Metadata is backfillable from
|
|
40
|
-
* the sha; the observation is not. `files` is
|
|
41
|
-
* in
|
|
40
|
+
* the sha; the observation is not. `files` is git's changed-file list; since 0.10.0 it
|
|
41
|
+
* goes out in the length-framed body trailer of `commit_files.ts` (granted
|
|
42
|
+
* repositories only) and is never put in a header.
|
|
42
43
|
*/
|
|
43
44
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
44
45
|
import { dirname, join } from "node:path";
|
|
45
46
|
import { keepsPendingLine } from "./pending_bound.js";
|
|
46
47
|
import { repoKeyNotePath, repoSessionsDir, sessionStatePath } from "./paths.js";
|
|
47
48
|
import { writeFileAtomic } from "./atomic_write.js";
|
|
49
|
+
import { liveAtOf } from "./firing.js";
|
|
48
50
|
/**
|
|
49
51
|
* How many SHAs one hook may put on the wire.
|
|
50
52
|
*
|
|
@@ -164,6 +166,37 @@ export function readSpool(home, key) {
|
|
|
164
166
|
}
|
|
165
167
|
return out;
|
|
166
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* ⛔ **THE ONE WALK BEHIND EVERY SPOOL DROP (U2, trap 8).** `count` is a number of WELL-FORMED lines: `readSpool` skips a line that does
|
|
171
|
+
* not parse and `capSpool` counts the rest. The drops used to apply that count to RAW non-empty lines, so one malformed line among
|
|
172
|
+
* the consumed ones shifted the cut by one and left a DELIVERED commit at the head, which went out again on a new capture:
|
|
173
|
+
* `capture_commits`' key is `(org_id, capture_id, commit_sha)` and the write is a plain insert, so that is a SECOND PERMANENT ROW.
|
|
174
|
+
* (Raw [A, torn, B, C] sent A, B, C with count 3; `slice(3)` left C. Measured on 0.10.0.)
|
|
175
|
+
*
|
|
176
|
+
* The walk goes through raw non-empty lines, consuming a malformed one for free (it was never counted and can never be sent) and
|
|
177
|
+
* stopping after the `count`-th well-formed one. `consumed` is everything up to and including it; `rest` is the remaining raw text,
|
|
178
|
+
* untouched. A malformed line AFTER the cut stays in `rest`: it belongs to the next drop's walk.
|
|
179
|
+
*/
|
|
180
|
+
function walkConsumed(raw, count, wellFormed) {
|
|
181
|
+
const lines = raw.split("\n").filter((l) => l.trim() !== "");
|
|
182
|
+
let seen = 0;
|
|
183
|
+
let cut = 0;
|
|
184
|
+
while (cut < lines.length && seen < count) {
|
|
185
|
+
if (wellFormed(lines[cut]))
|
|
186
|
+
seen += 1;
|
|
187
|
+
cut += 1;
|
|
188
|
+
}
|
|
189
|
+
return { consumed: lines.slice(0, cut), rest: lines.slice(cut) };
|
|
190
|
+
}
|
|
191
|
+
const isEntryLine = (line) => parseEntry(line) !== null;
|
|
192
|
+
/** How many lines of `raw` are well-formed commit entries (the unit `count` is in). `cap` bounds the answer (a pending file is capped). */
|
|
193
|
+
export function wellFormedCommitLines(raw, cap = Infinity) {
|
|
194
|
+
let n = 0;
|
|
195
|
+
for (const line of raw.split("\n"))
|
|
196
|
+
if (line.trim() !== "" && isEntryLine(line))
|
|
197
|
+
n += 1;
|
|
198
|
+
return Math.min(n, cap);
|
|
199
|
+
}
|
|
167
200
|
/**
|
|
168
201
|
* Drop the first `count` entries — called ONLY after the server's 2xx.
|
|
169
202
|
*
|
|
@@ -171,23 +204,25 @@ export function readSpool(home, key) {
|
|
|
171
204
|
* `post-commit` may have appended while the send was in flight. Slicing the
|
|
172
205
|
* array we read BEFORE the send would rewrite the file without those lines and
|
|
173
206
|
* lose them silently. Appends only ever go to the END, so the first `count`
|
|
174
|
-
* lines are still the same ones we delivered, and dropping exactly
|
|
175
|
-
* correct against the current file rather than against a stale snapshot.
|
|
207
|
+
* well-formed lines are still the same ones we delivered, and dropping exactly
|
|
208
|
+
* that many is correct against the current file rather than against a stale snapshot.
|
|
176
209
|
*
|
|
177
|
-
* ⚠ **A residual race remains and is accepted:** a commit landing between this
|
|
178
|
-
* re-read and the write is lost. The window is one `readFileSync` plus one
|
|
210
|
+
* ⚠ **A residual race remains and is accepted (D14):** a commit landing between this
|
|
211
|
+
* re-read and the end of the write is lost. The window is one `readFileSync` plus one
|
|
179
212
|
* `writeFileSync` on a small file, the cost is one un-delivered commit row, and
|
|
180
213
|
* closing it properly means a lock file on a path that must never block
|
|
181
214
|
* (`post-commit` runs inside the user's `git commit`). Recorded rather than
|
|
182
|
-
* hidden.
|
|
215
|
+
* hidden. The worker runs this mid-turn rather than at an idle Stop, so the window is
|
|
216
|
+
* hit more often than it was; the upgrade path if a lost commit is ever observed is
|
|
217
|
+
* one file per commit.
|
|
183
218
|
*/
|
|
184
219
|
export function dropSpooled(home, key, count) {
|
|
185
220
|
const path = spoolPath(home, key);
|
|
186
221
|
if (path === null || count <= 0)
|
|
187
222
|
return false;
|
|
188
223
|
try {
|
|
189
|
-
const
|
|
190
|
-
writeFileSync(path,
|
|
224
|
+
const { rest } = walkConsumed(readFileSync(path, "utf8"), count, isEntryLine);
|
|
225
|
+
writeFileSync(path, rest.map((l) => `${l}\n`).join(""), { mode: 0o600 });
|
|
191
226
|
chmodSync(path, 0o600);
|
|
192
227
|
return true;
|
|
193
228
|
}
|
|
@@ -207,10 +242,10 @@ export function dropSpooled(home, key, count) {
|
|
|
207
242
|
* BOUND one — and a bound sha resent into a sealed turn is a second, permanent
|
|
208
243
|
* edge (`cr071:157`). Only the per-line decision is right.
|
|
209
244
|
*
|
|
210
|
-
* ⚠ THE SAME
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
245
|
+
* ⚠ THE SAME WALK AS `dropSpooled` (U2): `count` is a number of WELL-FORMED lines, as `capSpool` computes it, so the consumed region
|
|
246
|
+
* ends after the `count`-th well-formed line and a malformed line inside it is dropped with it. (This docblock used to say `count` was
|
|
247
|
+
* computed from raw lines. It was not.) A well-formed line that carries a rung that never goes on the wire is dropped exactly as
|
|
248
|
+
* `dropSpooled` would drop it — the server cannot have named it.
|
|
214
249
|
*
|
|
215
250
|
* ⚠ The residual race `dropSpooled` documents applies here unchanged.
|
|
216
251
|
*/
|
|
@@ -219,12 +254,12 @@ export function settleSpooled(home, key, count, keep) {
|
|
|
219
254
|
if (path === null || count <= 0)
|
|
220
255
|
return false;
|
|
221
256
|
try {
|
|
222
|
-
const
|
|
223
|
-
const kept =
|
|
257
|
+
const { consumed, rest } = walkConsumed(readFileSync(path, "utf8"), count, isEntryLine);
|
|
258
|
+
const kept = consumed.filter((line) => {
|
|
224
259
|
const entry = parseEntry(line);
|
|
225
260
|
return entry !== null && WIRE_RUNGS.includes(entry.attribution) && keep.has(entry.sha);
|
|
226
261
|
});
|
|
227
|
-
writeFileSync(path, [...kept, ...
|
|
262
|
+
writeFileSync(path, [...kept, ...rest].map((l) => `${l}\n`).join(""), { mode: 0o600 });
|
|
228
263
|
chmodSync(path, 0o600);
|
|
229
264
|
return true;
|
|
230
265
|
}
|
|
@@ -388,6 +423,11 @@ export function activeSessionFor(home, repoKey, envSessionId, nowMs = Date.now()
|
|
|
388
423
|
* field is one number. `state.ts` owns the schema and its docblock; this is the single reader of it outside that module.
|
|
389
424
|
*/
|
|
390
425
|
function readLiveAt(path) {
|
|
426
|
+
// U3: through the ONE liveness reader (`firing.ts`): the later of the state file's `liveAt` and the hook's own `.live` stamp, which sits
|
|
427
|
+
// beside it. A session whose hook has stamped but whose worker has not yet delivered anything is live all the same.
|
|
428
|
+
return liveAtOf(readStateLiveAt(path), path.replace(/\.json$/, ".live"));
|
|
429
|
+
}
|
|
430
|
+
function readStateLiveAt(path) {
|
|
391
431
|
try {
|
|
392
432
|
const v = JSON.parse(readFileSync(path, "utf8")).liveAt;
|
|
393
433
|
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
|
|
@@ -423,12 +463,20 @@ function liveSessions(home, repoKey, nowMs, windowMs) {
|
|
|
423
463
|
return [];
|
|
424
464
|
}
|
|
425
465
|
const live = [];
|
|
466
|
+
// U3: a session is a state file (`<sid>.json`) OR a hook stamp (`<sid>.live`), listed once. A stamp with no state file is a session whose
|
|
467
|
+
// hook has run and whose worker has not yet delivered: on Codex and Cursor (no env session id) its commit must still find it live.
|
|
468
|
+
const stems = new Set();
|
|
426
469
|
for (const entry of entries) {
|
|
427
|
-
// ⚠ State files only. `.spool.jsonl` and `.rewrites.jsonl` live here too and
|
|
428
|
-
// are not sessions — and neither ends with `.json`, which is the same
|
|
470
|
+
// ⚠ State files and `.live` stamps only. `.spool.jsonl` and `.rewrites.jsonl` live here too and
|
|
471
|
+
// are not sessions — and neither ends with `.json` or `.live`, which is the same
|
|
429
472
|
// load-bearing spelling `spoolPath` documents.
|
|
430
|
-
if (
|
|
431
|
-
|
|
473
|
+
if (entry.endsWith(".json"))
|
|
474
|
+
stems.add(entry.slice(0, -".json".length));
|
|
475
|
+
else if (entry.endsWith(".live"))
|
|
476
|
+
stems.add(entry.slice(0, -".live".length));
|
|
477
|
+
}
|
|
478
|
+
for (const stem of stems) {
|
|
479
|
+
const entry = `${stem}.json`;
|
|
432
480
|
// ⛔ THE CONTENT CLOCK, NEVER THE FILE'S MTIME (VG review 2, D2). `SessionState.liveAt` is written only by a hook;
|
|
433
481
|
// a `vibecommit finalize` of a long-dead session rewrites the file and moved the mtime, which made that session
|
|
434
482
|
// "live" and let a plain-terminal commit be filed under it at an edge-minting rung. A file with no `liveAt`
|
|
@@ -436,7 +484,7 @@ function liveSessions(home, repoKey, nowMs, windowMs) {
|
|
|
436
484
|
const touchedAt = readLiveAt(join(dir, entry));
|
|
437
485
|
if (touchedAt === null || nowMs - touchedAt > windowMs)
|
|
438
486
|
continue;
|
|
439
|
-
live.push({ sessionId:
|
|
487
|
+
live.push({ sessionId: stem, at: touchedAt });
|
|
440
488
|
}
|
|
441
489
|
// ⚠ Ties broken by name so the guess is at least DETERMINISTIC. Two state files
|
|
442
490
|
// written in the same millisecond is ordinary on a coarse-grained filesystem,
|
|
@@ -491,13 +539,15 @@ export function capSpool(entries) {
|
|
|
491
539
|
const taken = entries.slice(0, MAX_SPOOLED_SHAS);
|
|
492
540
|
const shas = [];
|
|
493
541
|
const attributions = [];
|
|
542
|
+
const files = [];
|
|
494
543
|
for (const entry of taken) {
|
|
495
544
|
if (!WIRE_RUNGS.includes(entry.attribution))
|
|
496
545
|
continue;
|
|
497
546
|
shas.push(entry.sha);
|
|
498
547
|
attributions.push(entry.attribution);
|
|
548
|
+
files.push(entry.files);
|
|
499
549
|
}
|
|
500
|
-
return { shas, attributions, count: taken.length };
|
|
550
|
+
return { shas, attributions, files, count: taken.length };
|
|
501
551
|
}
|
|
502
552
|
/**
|
|
503
553
|
* How many rewrite pairs one hook may put on the wire.
|
|
@@ -588,14 +638,18 @@ export function readRewrites(home, key) {
|
|
|
588
638
|
}
|
|
589
639
|
return out;
|
|
590
640
|
}
|
|
591
|
-
/**
|
|
641
|
+
/**
|
|
642
|
+
* Drop the first `count` pairs — called ONLY after the server's 2xx. `count` is a number of WELL-FORMED pairs (`capSuccessors(readRewrites)`),
|
|
643
|
+
* so it walks like `dropSpooled` does (U2, trap 8): a malformed line among the consumed ones is passed over, never counted. A re-sent pair is
|
|
644
|
+
* harmless on the server (`commit_sha_successors` treats a duplicate as success) but the arithmetic was the same defect.
|
|
645
|
+
*/
|
|
592
646
|
export function dropRewrites(home, key, count) {
|
|
593
647
|
const path = rewriteSpoolPath(home, key);
|
|
594
648
|
if (path === null || count <= 0)
|
|
595
649
|
return false;
|
|
596
650
|
try {
|
|
597
|
-
const
|
|
598
|
-
writeFileSync(path,
|
|
651
|
+
const { rest } = walkConsumed(readFileSync(path, "utf8"), count, (l) => parsePair(l) !== null);
|
|
652
|
+
writeFileSync(path, rest.map((l) => `${l}\n`).join(""), { mode: 0o600 });
|
|
599
653
|
chmodSync(path, 0o600);
|
|
600
654
|
return true;
|
|
601
655
|
}
|
|
@@ -1353,8 +1407,14 @@ export function pendingSpoolPath(home, key) {
|
|
|
1353
1407
|
* would promote into two spool entries and two permanent rows —
|
|
1354
1408
|
* `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so two capture
|
|
1355
1409
|
* ids are two legal rows for one commit.
|
|
1410
|
+
*
|
|
1411
|
+
* ⛔ **A REAL APPEND, NOT A READ-MODIFY-WRITE (U2, trap 6).** This used to read the file, add the entry and WRITE THE WHOLE FILE BACK.
|
|
1412
|
+
* Trace: pending holds [B]; `post-commit` for D reads [B]; the worker promotes B and removes the file; `post-commit` writes [B, D]. The
|
|
1413
|
+
* next firing promotes B AGAIN, and once the first B is delivered that is a second permanent row. Now it reads only to dedupe, then
|
|
1414
|
+
* `appendFileSync`s one line: a promotion that emptied the file in between leaves D alone in a fresh file, and B is never resurrected.
|
|
1415
|
+
* The cap (`MAX_PENDING_SHAS`) moved to `readPending` and to promotion's own rewrite, so an append never has to rewrite anything.
|
|
1356
1416
|
*/
|
|
1357
|
-
export function appendPending(home, key, entry) {
|
|
1417
|
+
export function appendPending(home, key, entry, seams = {}) {
|
|
1358
1418
|
const path = pendingSpoolPath(home, key);
|
|
1359
1419
|
if (path === null)
|
|
1360
1420
|
return false;
|
|
@@ -1362,10 +1422,10 @@ export function appendPending(home, key, entry) {
|
|
|
1362
1422
|
const existing = readPending(home, key);
|
|
1363
1423
|
if (existing.some((e) => e.sha === entry.sha))
|
|
1364
1424
|
return false;
|
|
1365
|
-
|
|
1425
|
+
seams.afterDedupeRead?.();
|
|
1366
1426
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
1367
1427
|
noteRepoKey(home, key.repoKey);
|
|
1368
|
-
|
|
1428
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
1369
1429
|
chmodSync(path, 0o600);
|
|
1370
1430
|
return true;
|
|
1371
1431
|
}
|
|
@@ -1373,7 +1433,7 @@ export function appendPending(home, key, entry) {
|
|
|
1373
1433
|
return false;
|
|
1374
1434
|
}
|
|
1375
1435
|
}
|
|
1376
|
-
/** Every uncorroborated entry for this session, oldest first. */
|
|
1436
|
+
/** Every uncorroborated entry for this session, oldest first, at most the newest `MAX_PENDING_SHAS` (the cap lives HERE since `appendPending` no longer rewrites). */
|
|
1377
1437
|
export function readPending(home, key) {
|
|
1378
1438
|
const path = pendingSpoolPath(home, key);
|
|
1379
1439
|
if (path === null)
|
|
@@ -1391,7 +1451,7 @@ export function readPending(home, key) {
|
|
|
1391
1451
|
if (entry !== null)
|
|
1392
1452
|
out.push(entry);
|
|
1393
1453
|
}
|
|
1394
|
-
return out;
|
|
1454
|
+
return out.slice(-MAX_PENDING_SHAS);
|
|
1395
1455
|
}
|
|
1396
1456
|
/** Remove the pending file entirely. Used once its lines have been promoted. */
|
|
1397
1457
|
export function clearPending(home, key) {
|
|
@@ -1450,22 +1510,106 @@ corroboration,
|
|
|
1450
1510
|
* session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
|
|
1451
1511
|
* when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
|
|
1452
1512
|
*/
|
|
1453
|
-
bound, mode
|
|
1513
|
+
bound, mode,
|
|
1514
|
+
/**
|
|
1515
|
+
* U2 — **THE MOMENT THE HOOK FIRED**, epoch ms. A worker replays a firing late; a commit observed AFTER the firing belongs to a later
|
|
1516
|
+
* delivery, so a pending line with `observedAt > asOfMs` is neither promoted nor dropped: it stays in the file. Unset means "now" and
|
|
1517
|
+
* every line is due, which is what the inline hook always meant. A line with NO `observedAt` (an older client) is always due.
|
|
1518
|
+
*/
|
|
1519
|
+
asOfMs = Number.POSITIVE_INFINITY) {
|
|
1454
1520
|
void corroboration;
|
|
1455
1521
|
const pending = readPending(home, key);
|
|
1456
1522
|
if (pending.length === 0)
|
|
1457
1523
|
return 0;
|
|
1458
1524
|
let promoted = 0;
|
|
1459
1525
|
for (const entry of pending) {
|
|
1526
|
+
if (entry.observedAt !== undefined && entry.observedAt > asOfMs)
|
|
1527
|
+
continue;
|
|
1460
1528
|
if (!keepsPendingLine(entry, bound, mode))
|
|
1461
1529
|
continue;
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
if (appendSpool(home, key, { ...rest, attribution: "env_session_id" }))
|
|
1530
|
+
// U2: `observedAt` STAYS on the line. It is the clock the worker pins a delivery by (`observedAt <= firing.at`), and a promoted
|
|
1531
|
+
// line is no exception: a commit made in turn 2 must not ride a delivery capped at turn 1 because it passed through pending.
|
|
1532
|
+
if (insertSpooled(home, key, { ...entry, attribution: "env_session_id" }))
|
|
1466
1533
|
promoted += 1;
|
|
1467
1534
|
}
|
|
1468
|
-
|
|
1535
|
+
settlePending(home, key, asOfMs);
|
|
1469
1536
|
return promoted;
|
|
1470
1537
|
}
|
|
1538
|
+
/**
|
|
1539
|
+
* U2 — put one promoted line into the spool BY `observedAt`, not at the end.
|
|
1540
|
+
*
|
|
1541
|
+
* The worker promotes late, so turn 2's commit C can already be in the spool when turn 1's commit B is promoted. Appending gives [C, B],
|
|
1542
|
+
* the pin's prefix (`observedAt <= firing.at`) is empty, and B rides turn 2's delivery unheld. Inserted before the first line that is
|
|
1543
|
+
* observed LATER, B is carried by turn 1's delivery. A line with no `observedAt` is neither newer nor older: it stays where it is. A
|
|
1544
|
+
* malformed line is passed over and kept as raw text, and never counts as newer.
|
|
1545
|
+
*
|
|
1546
|
+
* Appends when nothing newer is there (the common case: one `appendFileSync`); rewrites the file, keeping every other line's raw text,
|
|
1547
|
+
* only when something is. The rewrite shares `dropSpooled`'s accepted window (D14).
|
|
1548
|
+
*/
|
|
1549
|
+
function insertSpooled(home, key, entry) {
|
|
1550
|
+
const path = spoolPath(home, key);
|
|
1551
|
+
if (path === null)
|
|
1552
|
+
return false;
|
|
1553
|
+
try {
|
|
1554
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
1555
|
+
noteRepoKey(home, key.repoKey);
|
|
1556
|
+
const line = JSON.stringify(entry);
|
|
1557
|
+
const at = entry.observedAt;
|
|
1558
|
+
let lines = [];
|
|
1559
|
+
if (at !== undefined) {
|
|
1560
|
+
try {
|
|
1561
|
+
lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim() !== "");
|
|
1562
|
+
}
|
|
1563
|
+
catch {
|
|
1564
|
+
lines = [];
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const newer = at === undefined ? -1 : lines.findIndex((l) => {
|
|
1568
|
+
const e = parseEntry(l);
|
|
1569
|
+
return e !== null && e.observedAt !== undefined && e.observedAt > at;
|
|
1570
|
+
});
|
|
1571
|
+
if (newer === -1) {
|
|
1572
|
+
appendFileSync(path, `${line}\n`, { mode: 0o600 });
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
lines.splice(newer, 0, line);
|
|
1576
|
+
writeFileSync(path, lines.map((l) => `${l}\n`).join(""), { mode: 0o600 });
|
|
1577
|
+
}
|
|
1578
|
+
chmodSync(path, 0o600);
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* After a promotion pass: forget the pending lines that were settled (promoted or dropped), KEEP those observed after `asOfMs`.
|
|
1587
|
+
* Re-reads the file so a line `post-commit` appended since the pass began is judged by its own clock, not lost; with nothing newer
|
|
1588
|
+
* the file is removed, as `clearPending` always did. The kept lines are capped at `MAX_PENDING_SHAS`.
|
|
1589
|
+
*/
|
|
1590
|
+
function settlePending(home, key, asOfMs) {
|
|
1591
|
+
const path = pendingSpoolPath(home, key);
|
|
1592
|
+
if (path === null)
|
|
1593
|
+
return;
|
|
1594
|
+
if (asOfMs === Number.POSITIVE_INFINITY) {
|
|
1595
|
+
clearPending(home, key);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
try {
|
|
1599
|
+
const keep = readFileSync(path, "utf8")
|
|
1600
|
+
.split("\n")
|
|
1601
|
+
.filter((l) => {
|
|
1602
|
+
const e = parseEntry(l);
|
|
1603
|
+
return e !== null && e.observedAt !== undefined && e.observedAt > asOfMs;
|
|
1604
|
+
})
|
|
1605
|
+
.slice(-MAX_PENDING_SHAS);
|
|
1606
|
+
if (keep.length === 0)
|
|
1607
|
+
clearPending(home, key);
|
|
1608
|
+
else
|
|
1609
|
+
writeFileSync(path, keep.map((l) => `${l}\n`).join(""), { mode: 0o600 });
|
|
1610
|
+
}
|
|
1611
|
+
catch {
|
|
1612
|
+
clearPending(home, key);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1471
1615
|
//# sourceMappingURL=spool.js.map
|