ccdeck 3.26.5 → 3.27.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.
@@ -0,0 +1,188 @@
1
+ // The timeline of which account was active, and when.
2
+ //
3
+ // Nothing in claude-swap or the deck ever recorded this. sequence.json holds a
4
+ // single `activeAccountNumber` — who is active *now* — and overwrites it on
5
+ // every switch, so the moment a swap happens the previous truth is gone. The
6
+ // account-projects report needs the opposite: a durable, append-only record of
7
+ // "at time T the active account became X", so a transcript message written at
8
+ // some past instant can be attributed to whoever was active *then*.
9
+ //
10
+ // The deck is the one thing that performs a swap (it drives `cswap switch` and
11
+ // the auto tick), so it does not have to reconstruct or guess: it writes one
12
+ // line each time it moves the live account. The past before this file existed
13
+ // is unrecoverable — there is no timestamped history anywhere to mine — so the
14
+ // report is honest only from the first line here onward, and everything earlier
15
+ // is left unattributed.
16
+ //
17
+ // One line per swap, JSON:
18
+ // { "at": <ms>, "slot": <n>, "email": "...", "orgUuid": "...", "source": "manual"|"auto"|"start" }
19
+ // `source` "start" is the anchor the rollup writes once at boot for whoever is
20
+ // already active, so a machine that never swaps still attributes its work.
21
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { backupRoot } from "./claude-accounts.mjs";
25
+
26
+ /** The log the deck appends swaps to. Under ~/.agents-deck like cswap-auto's
27
+ * own state, not under the claude-swap store — this is the deck's record, not
28
+ * claude-swap's. */
29
+ export function swapLogPath(home = homedir()) {
30
+ return join(home, ".agents-deck", "log", "account-active.jsonl");
31
+ }
32
+
33
+ /** Read one JSON value from a file, or null if it is missing or unparseable. */
34
+ async function readJsonSafe(path) {
35
+ try {
36
+ return JSON.parse(await readFile(path, "utf8"));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** The `(email, organizationUuid)` a slot number points at right now, read from
43
+ * claude-swap's sequence.json. Null when the store or the slot is not there. */
44
+ export async function identityForSlot(slot, root = backupRoot()) {
45
+ const seq = await readJsonSafe(join(root, "sequence.json"));
46
+ const acc = seq?.accounts?.[String(slot)];
47
+ if (!acc) return null;
48
+ return {
49
+ email: typeof acc.email === "string" ? acc.email : "",
50
+ orgUuid: typeof acc.organizationUuid === "string" ? acc.organizationUuid : "",
51
+ };
52
+ }
53
+
54
+ /** The slot claude-swap says is active right now, or null. */
55
+ export async function currentActiveSlot(root = backupRoot()) {
56
+ const seq = await readJsonSafe(join(root, "sequence.json"));
57
+ const n = Number(seq?.activeAccountNumber);
58
+ return Number.isInteger(n) && n > 0 ? n : null;
59
+ }
60
+
61
+ /** Parse the log into swap entries sorted oldest-first. A truncated or garbage
62
+ * last line (a crash mid-append) is skipped, never fatal. */
63
+ export async function readSwapLog(path = swapLogPath()) {
64
+ const text = await readFile(path, "utf8").catch(() => "");
65
+ const out = [];
66
+ for (const line of text.split("\n")) {
67
+ if (!line) continue;
68
+ let obj = null;
69
+ try { obj = JSON.parse(line); } catch { continue; }
70
+ if (!obj || typeof obj !== "object") continue;
71
+ const at = Number(obj.at);
72
+ if (!Number.isFinite(at)) continue;
73
+ out.push({
74
+ at,
75
+ slot: Number(obj.slot) || 0,
76
+ email: typeof obj.email === "string" ? obj.email : "",
77
+ orgUuid: typeof obj.orgUuid === "string" ? obj.orgUuid : "",
78
+ source: typeof obj.source === "string" ? obj.source : "",
79
+ });
80
+ }
81
+ out.sort((a, b) => a.at - b.at);
82
+ return out;
83
+ }
84
+
85
+ /** Append one swap line. Creates the log directory on first use. Best-effort:
86
+ * a failure to record must never break a switch, so callers ignore rejection. */
87
+ export async function appendSwap(entry, path = swapLogPath()) {
88
+ await mkdir(join(path, ".."), { recursive: true });
89
+ await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
90
+ }
91
+
92
+ /**
93
+ * Record that `slot` became the active account, resolving its identity from the
94
+ * store. `source` says which path moved it. Returns the entry written, or null
95
+ * when the slot has no identity (nothing worth attributing to).
96
+ *
97
+ * Best-effort by contract: every caller sits on a hot path (a click, an auto
98
+ * tick) that must not fail because a log write did. Errors are swallowed here
99
+ * so callers can `void recordSwap(...)` without a catch.
100
+ */
101
+ export async function recordSwap(slot, source, { now = Date.now, root, path } = {}) {
102
+ try {
103
+ const store = root ?? backupRoot();
104
+ // Trust the store's own idea of who is active over a caller-supplied slot
105
+ // when that slot is missing or malformed — after a switch, sequence.json's
106
+ // activeAccountNumber is the authoritative new slot, so the auto tick can
107
+ // record without threading the number through cswap's JSON.
108
+ let n = Number(slot);
109
+ if (!Number.isInteger(n) || n <= 0) n = await currentActiveSlot(store);
110
+ if (!n) return null;
111
+ const id = await identityForSlot(n, store);
112
+ if (!id) return null;
113
+ const entry = { at: now(), slot: n, email: id.email, orgUuid: id.orgUuid, source };
114
+ await appendSwap(entry, path ?? swapLogPath());
115
+ return entry;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Seed an anchor for the currently-active account, once, if the log does not
123
+ * already end on that account. Called at boot so a deck that never swaps still
124
+ * attributes its work to the account it launched on, and so the very first
125
+ * upgrade writes a "tracked since" point.
126
+ *
127
+ * Deduped against the last entry's `(email, orgUuid)`: a restart on the same
128
+ * account adds nothing, but a manual switch made while the deck was down (so no
129
+ * line was written for it) is still captured here on the next boot.
130
+ */
131
+ export async function seedActive({ now = Date.now, root, path } = {}) {
132
+ try {
133
+ const logPath = path ?? swapLogPath();
134
+ const slot = await currentActiveSlot(root ?? backupRoot());
135
+ if (!slot) return null;
136
+ const id = await identityForSlot(slot, root ?? backupRoot());
137
+ if (!id) return null;
138
+ const entries = await readSwapLog(logPath);
139
+ const last = entries[entries.length - 1];
140
+ if (last && last.email === id.email && last.orgUuid === id.orgUuid) return null;
141
+ const entry = { at: now(), slot, email: id.email, orgUuid: id.orgUuid, source: "start" };
142
+ await appendSwap(entry, logPath);
143
+ return entry;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Which account was active at millisecond `ts`: the last entry at or before it.
151
+ * Null when `ts` precedes every entry — before tracking began — OR when the
152
+ * governing entry is a `stop` marker, which the rollup writes to fence off the
153
+ * time the deck was not running: nothing recorded who was active then, so work
154
+ * with a timestamp inside a stop→start gap is honestly unattributable rather
155
+ * than charged to whichever account happened to be active when the deck closed.
156
+ * `entries` must be sorted oldest-first, as `readSwapLog` returns them.
157
+ */
158
+ export function accountAtTime(entries, ts) {
159
+ if (!Array.isArray(entries) || !entries.length) return null;
160
+ if (ts < entries[0].at) return null;
161
+ // Binary search for the rightmost entry with at <= ts.
162
+ let lo = 0, hi = entries.length - 1, ans = 0;
163
+ while (lo <= hi) {
164
+ const mid = (lo + hi) >> 1;
165
+ if (entries[mid].at <= ts) { ans = mid; lo = mid + 1; }
166
+ else hi = mid - 1;
167
+ }
168
+ const e = entries[ans];
169
+ if (e.source === "stop") return null; // inside a gap the deck was not running
170
+ return { email: e.email, orgUuid: e.orgUuid, slot: e.slot };
171
+ }
172
+
173
+ /** Fence off a stretch of time as "the deck was not running" from `at`, so
174
+ * work timestamped after it is unattributed until the next real entry. */
175
+ export async function markGap(at, path = swapLogPath()) {
176
+ try {
177
+ await appendSwap({ at, slot: 0, email: "", orgUuid: "", source: "stop" }, path);
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** The earliest instant the log covers — what the report shows as "tracked
185
+ * since". Null on an empty log. */
186
+ export function trackedSince(entries) {
187
+ return Array.isArray(entries) && entries.length ? entries[0].at : null;
188
+ }