@gotcos/glasses-server 6.46.1 → 6.48.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/CHANGELOG.md +25 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four durable stores behind every merge action, and the one mutex that owns them
|
|
3
|
+
* (6.47.0, WS4).
|
|
4
|
+
*
|
|
5
|
+
* `.actions.json` what the engine did, and how to undo it
|
|
6
|
+
* `.suggestions.json` what it wants a person to decide
|
|
7
|
+
* `.tombstones.json` input pairs a person has refused; automation never revisits them
|
|
8
|
+
* `.engine-status.json` modes, runs, and the one-per-install first-run report
|
|
9
|
+
*
|
|
10
|
+
* ONE WRITER, ONE MUTEX. Five triggers feed the runner (an import page, two G2 finalization
|
|
11
|
+
* paths, orphan recovery, a 30 s tick) and three routes mutate the same files from HTTP.
|
|
12
|
+
* Read-modify-write on JSON from two of those at once loses one of them silently, which for
|
|
13
|
+
* `.tombstones.json` means a merge a person explicitly refused quietly comes back. Every
|
|
14
|
+
* mutation goes through `update()`, which serializes on one promise chain; nothing here
|
|
15
|
+
* writes a store outside it.
|
|
16
|
+
*
|
|
17
|
+
* IDS ARE DERIVED, NEVER MINTED. An action's id is a hash of its kind and its sorted
|
|
18
|
+
* canonical input ids, so the same merge proposed twice is the same id, and re-running the
|
|
19
|
+
* engine over an unchanged backlog cannot fill the store with duplicates of one decision.
|
|
20
|
+
*
|
|
21
|
+
* CANONICAL IDS. A G2 recording is its `sessionId`; a Fireflies meeting is its vendor id.
|
|
22
|
+
* The same two ids in both modes: an action recorded in imports mode and the same one in
|
|
23
|
+
* apply mode have to be the same action, or a person who switches modes sees their history
|
|
24
|
+
* duplicate itself.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash } from 'node:crypto'
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
31
|
+
import { importsRoot } from './imported-meeting-library.js'
|
|
32
|
+
import type { MeetingEngineMode } from './meeting-engine-mode.js'
|
|
33
|
+
import { securePrivateDirectory } from './secure-user-config.js'
|
|
34
|
+
|
|
35
|
+
export const ACTIONS_FILENAME = '.actions.json'
|
|
36
|
+
export const SUGGESTIONS_FILENAME = '.suggestions.json'
|
|
37
|
+
export const TOMBSTONES_FILENAME = '.tombstones.json'
|
|
38
|
+
export const ENGINE_STATUS_FILENAME = '.engine-status.json'
|
|
39
|
+
|
|
40
|
+
/** Where a derived record is copied when Revert finds it edited since it was written. */
|
|
41
|
+
export const REVERTED_DIR_NAME = '.reverted'
|
|
42
|
+
|
|
43
|
+
export const STORE_SCHEMA = 1
|
|
44
|
+
|
|
45
|
+
/** Ceiling on remembered rows per store, oldest first. */
|
|
46
|
+
export const MAX_ACTIONS = 5_000
|
|
47
|
+
export const MAX_SUGGESTIONS = 5_000
|
|
48
|
+
export const MAX_TOMBSTONES = 5_000
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ceiling on remembered file hashes.
|
|
52
|
+
*
|
|
53
|
+
* The cache is rewritten to exactly what the last collected pass saw, so it prunes itself
|
|
54
|
+
* and this cap is a backstop against a pathological tree rather than the normal bound. It
|
|
55
|
+
* matters because this file is parsed on every status poll.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_HASH_CACHE_ENTRIES = 20_000
|
|
58
|
+
|
|
59
|
+
export type ActionKind = 'merge' | 'split'
|
|
60
|
+
export type ActionTier = 'auto' | 'accepted_suggestion' | 'legacy_applied'
|
|
61
|
+
export type ActionState = 'pending' | 'applied' | 'failed' | 'revert_pending' | 'reverted'
|
|
62
|
+
/** Where the action's effect landed. `imports` writes records; `apply` drives the pipeline. */
|
|
63
|
+
export type ActionMode = 'imports' | 'apply'
|
|
64
|
+
|
|
65
|
+
export interface CanonicalInputs {
|
|
66
|
+
/** G2 `sessionId`s. */
|
|
67
|
+
sessionIds: string[]
|
|
68
|
+
/** Fireflies vendor ids. */
|
|
69
|
+
firefliesIds: string[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ActionOutput {
|
|
73
|
+
/** Absolute path in imports mode; operations-relative in apply mode. */
|
|
74
|
+
path: string
|
|
75
|
+
/** `blended:<h16>` or `imported:fireflies:<h16>` in imports mode; absent in apply mode. */
|
|
76
|
+
recordId?: string
|
|
77
|
+
sidecarPath?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which way this action is being driven.
|
|
82
|
+
*
|
|
83
|
+
* WHY IT IS ON THE ROW AND NOT DERIVED FROM `state`. A failed revert is reset to `pending`
|
|
84
|
+
* so it can be retried, and `pending` is indistinguishable from "an apply that has not run
|
|
85
|
+
* yet". Deriving the direction from the state therefore turned every retried Undo into a
|
|
86
|
+
* Redo: the next drive spawned `--apply-merge-decision` on an action whose whole purpose was
|
|
87
|
+
* to take that apply back. The direction is decided once, when the action is created or when
|
|
88
|
+
* a Revert claims it, and survives every failure in between.
|
|
89
|
+
*/
|
|
90
|
+
export type ActionDirection = 'apply' | 'revert'
|
|
91
|
+
|
|
92
|
+
/** What a pipeline child did on its way to failing. Diagnostics only; never a decision. */
|
|
93
|
+
export interface PipelineFailureDiagnostics {
|
|
94
|
+
/** Null when the child never ran, or was killed by a signal. */
|
|
95
|
+
code: number | null
|
|
96
|
+
signal: string | null
|
|
97
|
+
timedOut: boolean
|
|
98
|
+
elapsedMs: number
|
|
99
|
+
/** Trimmed tail of the child's stderr. Bounded; never meeting content. */
|
|
100
|
+
stderr?: string
|
|
101
|
+
/** The pipeline's own `COS_MERGE_DECISION_INVALID=` line, on exit 4. */
|
|
102
|
+
decisionInvalid?: string
|
|
103
|
+
/** Set when the child could not be spawned at all. */
|
|
104
|
+
spawnError?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface MergeActionRecord {
|
|
108
|
+
id: string
|
|
109
|
+
kind: ActionKind
|
|
110
|
+
tier: ActionTier
|
|
111
|
+
inputs: CanonicalInputs
|
|
112
|
+
/** The fingerprint key of the inputs when the action was decided. */
|
|
113
|
+
fingerprints: string
|
|
114
|
+
outputs: ActionOutput[]
|
|
115
|
+
/** sha256 of each output's markdown, in `outputs` order. */
|
|
116
|
+
outputSha256: string[]
|
|
117
|
+
state: ActionState
|
|
118
|
+
mode: ActionMode
|
|
119
|
+
/** Apply or revert. Durable, so a failed revert retries as a revert. */
|
|
120
|
+
direction?: ActionDirection
|
|
121
|
+
error?: string
|
|
122
|
+
/** What the last failing pipeline child did. Absent until one fails. */
|
|
123
|
+
diagnostics?: PipelineFailureDiagnostics
|
|
124
|
+
/** Epoch ms this action may next be driven. Set on a deferred pipeline apply. */
|
|
125
|
+
nextAt?: number
|
|
126
|
+
at: string
|
|
127
|
+
/** Bounded, so a pipeline that keeps failing does not retry forever. */
|
|
128
|
+
attempts?: number
|
|
129
|
+
/** For a split, which piece this output is. */
|
|
130
|
+
pieceIndex?: number
|
|
131
|
+
/** The parent the pipeline already merged this into, on a `legacy_applied` row. */
|
|
132
|
+
legacyParentPath?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The state a deferred or retried action of this direction goes back to.
|
|
137
|
+
*
|
|
138
|
+
* `pending` and `revert_pending` are the two waiting states, and which one an action waits
|
|
139
|
+
* in is the ONLY durable record of what the next drive should spawn.
|
|
140
|
+
*/
|
|
141
|
+
export function pendingStateFor(direction: ActionDirection | undefined): ActionState {
|
|
142
|
+
return direction === 'revert' ? 'revert_pending' : 'pending'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The direction an action is being driven in.
|
|
147
|
+
*
|
|
148
|
+
* Rows written before 6.47.0's QA pass carry no `direction`, so the waiting state is the
|
|
149
|
+
* fallback: `revert_pending` could only have been reached through a Revert.
|
|
150
|
+
*/
|
|
151
|
+
export function directionOf(action: Pick<MergeActionRecord, 'direction' | 'state'>): ActionDirection {
|
|
152
|
+
if (action.direction) return action.direction
|
|
153
|
+
return action.state === 'revert_pending' ? 'revert' : 'apply'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Both states an action can sit in while waiting to be driven. */
|
|
157
|
+
export function isWaitingState(state: ActionState): boolean {
|
|
158
|
+
return state === 'pending' || state === 'revert_pending'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type SuggestionKind = 'merge' | 'would_merge' | 'split'
|
|
162
|
+
export type SuggestionState = 'open' | 'confirmed' | 'accepted' | 'dismissed'
|
|
163
|
+
|
|
164
|
+
export interface MergeSuggestionRecord {
|
|
165
|
+
id: string
|
|
166
|
+
kind: SuggestionKind
|
|
167
|
+
inputs: CanonicalInputs
|
|
168
|
+
fingerprints: string
|
|
169
|
+
evidence: { K1: number; K2: number; spans?: Array<{ startS: number; endS: number }> }
|
|
170
|
+
state: SuggestionState
|
|
171
|
+
decidedAt?: string
|
|
172
|
+
at: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** An unordered pair of canonical ids a person said do not belong together. */
|
|
176
|
+
export interface Tombstone {
|
|
177
|
+
pair: [string, string]
|
|
178
|
+
at: string
|
|
179
|
+
/** The action or suggestion the refusal came from. */
|
|
180
|
+
source?: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface FirstRunReport {
|
|
184
|
+
startedAt: string
|
|
185
|
+
completedAt?: string
|
|
186
|
+
mode: MeetingEngineMode
|
|
187
|
+
scanned: number
|
|
188
|
+
auto: number
|
|
189
|
+
wouldMerge: number
|
|
190
|
+
suggested: number
|
|
191
|
+
none: number
|
|
192
|
+
alreadyMerged: number
|
|
193
|
+
deferredByCap: number
|
|
194
|
+
errors: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What the clock band rejected this run, and how far off the anchors it dropped were.
|
|
199
|
+
*
|
|
200
|
+
* `PAIRING_CLOCK_BAND_S` is the one rule standing between a real merge and a neighbouring
|
|
201
|
+
* meeting's boilerplate, and it was chosen from 198 scored recordings. Nothing recorded how
|
|
202
|
+
* often it FIRES on this Mac's own data, so a band that is wrong here is invisible. These
|
|
203
|
+
* counts make it observable without keeping any meeting content.
|
|
204
|
+
*/
|
|
205
|
+
export interface ClockBandStats {
|
|
206
|
+
/** Candidates that lost at least one anchor to the band. */
|
|
207
|
+
candidates: number
|
|
208
|
+
/** Anchors dropped, summed across candidates. */
|
|
209
|
+
anchorsDropped: number
|
|
210
|
+
/** Candidates the band reduced to zero shared anchors. */
|
|
211
|
+
candidatesZeroed: number
|
|
212
|
+
/** Largest absolute clock skew, in seconds, among the winners this run. */
|
|
213
|
+
maxWinnerSkewS: number
|
|
214
|
+
/** Median absolute clock skew, in seconds, among the winners this run. */
|
|
215
|
+
medianWinnerSkewS: number
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface EngineRunSummary {
|
|
219
|
+
at: string
|
|
220
|
+
mode: MeetingEngineMode
|
|
221
|
+
trigger: string
|
|
222
|
+
scanned: number
|
|
223
|
+
auto: number
|
|
224
|
+
suggested: number
|
|
225
|
+
wouldMerge: number
|
|
226
|
+
none: number
|
|
227
|
+
alreadyMerged: number
|
|
228
|
+
deferredByCap: number
|
|
229
|
+
errors: number
|
|
230
|
+
/** Why the run did nothing, when it did nothing. */
|
|
231
|
+
skippedReason?: string
|
|
232
|
+
/** Inputs the collector refused, by reason code. */
|
|
233
|
+
inputsSkipped?: Record<string, number>
|
|
234
|
+
/** The clock band's effect this run. Absent when nothing was scored. */
|
|
235
|
+
clockBand?: ClockBandStats
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One file's identity, so an unchanged file is never read a second time. */
|
|
239
|
+
export interface HashCacheEntry {
|
|
240
|
+
sha256: string
|
|
241
|
+
mtimeMs: number
|
|
242
|
+
size: number
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What the last collected pass saw, so the next one can prove nothing changed.
|
|
247
|
+
*
|
|
248
|
+
* Principle 7 says the engine never blocks live capture. The pass that hashed every sidecar
|
|
249
|
+
* on the main thread broke that on its own: 817 MB of `.g2-chunks.json` read per pass, every
|
|
250
|
+
* six hours, in the same event loop as chunk writes. Both halves of the fix are here — the
|
|
251
|
+
* scan stamp lets a pass BAIL before collecting, and the hash cache means a pass that does
|
|
252
|
+
* collect only reads the files whose mtime or size moved.
|
|
253
|
+
*/
|
|
254
|
+
export interface InputScanStamp {
|
|
255
|
+
newestMtimeMs: number
|
|
256
|
+
count: number
|
|
257
|
+
mode: MeetingEngineMode
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface EngineStatusFile {
|
|
261
|
+
schema: number
|
|
262
|
+
/** Epoch ms this install first ran the engine. The D14 advisory boundary. */
|
|
263
|
+
engineInstalledAt?: number
|
|
264
|
+
/** Epoch ms the mode last moved to `apply`. The D14 boundary in apply mode. */
|
|
265
|
+
applyModeSince?: number
|
|
266
|
+
firstRun?: FirstRunReport
|
|
267
|
+
lastRun?: EngineRunSummary
|
|
268
|
+
/** Last answer from `sync_meetings.py --merge-engine-status`, and when. */
|
|
269
|
+
pipelineSees?: { mode: string; active: boolean; appliedActions: number } | null
|
|
270
|
+
pipelineSeenAt?: number
|
|
271
|
+
/** sha256 by absolute path, keyed on mtime and size. Rewritten to what the pass saw. */
|
|
272
|
+
hashCache?: Record<string, HashCacheEntry>
|
|
273
|
+
lastInputScan?: InputScanStamp
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ActionsStoreFile {
|
|
277
|
+
schema: number
|
|
278
|
+
actions: MergeActionRecord[]
|
|
279
|
+
suggestions: MergeSuggestionRecord[]
|
|
280
|
+
tombstones: Tombstone[]
|
|
281
|
+
status: EngineStatusFile
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Ids ───────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function hash16(input: string): string {
|
|
287
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The canonical id list an action or suggestion hashes.
|
|
292
|
+
*
|
|
293
|
+
* Sorted, so the order the engine happened to visit inputs in cannot mint a second id for
|
|
294
|
+
* one decision. Joined with a comma because a G2 session id may itself contain a colon
|
|
295
|
+
* (`normalizeSessionId` allows it), and a separator that can appear inside a part is a
|
|
296
|
+
* separator that can collide.
|
|
297
|
+
*/
|
|
298
|
+
export function canonicalIdList(inputs: CanonicalInputs): string[] {
|
|
299
|
+
return [
|
|
300
|
+
...inputs.sessionIds.map(id => `g2:${id}`),
|
|
301
|
+
...inputs.firefliesIds.map(id => `ff:${id}`),
|
|
302
|
+
].sort()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function actionIdFor(kind: string, inputs: CanonicalInputs): string {
|
|
306
|
+
return `a_${hash16(`${kind}:${canonicalIdList(inputs).join(',')}`)}`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function suggestionIdFor(kind: string, inputs: CanonicalInputs): string {
|
|
310
|
+
return `s_${hash16(`${kind}:${canonicalIdList(inputs).join(',')}`)}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Tombstones ────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Every unordered pair in an input set.
|
|
317
|
+
*
|
|
318
|
+
* A tombstone is a PAIR and not a whole set, so that refusing "this capture is not that
|
|
319
|
+
* meeting" also blocks the three-way merge that would have swept the same wrong pair in with
|
|
320
|
+
* a second capture. Blocking only the exact set is how a refused merge comes back one
|
|
321
|
+
* recording later.
|
|
322
|
+
*/
|
|
323
|
+
export function inputPairs(inputs: CanonicalInputs): Array<[string, string]> {
|
|
324
|
+
const ids = canonicalIdList(inputs)
|
|
325
|
+
// A set of ONE (a split of a single recording, a legacy adoption of a single capture) has
|
|
326
|
+
// no pair, and an empty pair list is a set nothing can ever block. It is remembered as its
|
|
327
|
+
// own self-pair instead, which blocks exactly the same single-input proposal and nothing
|
|
328
|
+
// wider — refusing "split this recording" must not also refuse merging it.
|
|
329
|
+
if (ids.length === 1) return [[ids[0], ids[0]]]
|
|
330
|
+
const pairs: Array<[string, string]> = []
|
|
331
|
+
for (let i = 0; i < ids.length; i++) {
|
|
332
|
+
for (let j = i + 1; j < ids.length; j++) pairs.push([ids[i], ids[j]])
|
|
333
|
+
}
|
|
334
|
+
return pairs
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function pairKey(pair: readonly [string, string]): string {
|
|
338
|
+
return [...pair].sort().join('|')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Does any pair in this input set carry a tombstone? */
|
|
342
|
+
export function isTombstoned(tombstones: readonly Tombstone[], inputs: CanonicalInputs): boolean {
|
|
343
|
+
if (tombstones.length === 0) return false
|
|
344
|
+
const blocked = new Set(tombstones.map(row => pairKey(row.pair)))
|
|
345
|
+
return inputPairs(inputs).some(pair => blocked.has(pairKey(pair)))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── The store ─────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
export function emptyStore(): ActionsStoreFile {
|
|
351
|
+
return { schema: STORE_SCHEMA, actions: [], suggestions: [], tombstones: [], status: { schema: STORE_SCHEMA } }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function readJsonArray<T>(path: string, key: string): T[] {
|
|
355
|
+
if (!existsSync(path)) return []
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
358
|
+
const rows = parsed?.[key]
|
|
359
|
+
return Array.isArray(rows) ? (rows as T[]) : []
|
|
360
|
+
} catch {
|
|
361
|
+
// An unreadable store is an empty one for reading purposes. It is NOT emptied on
|
|
362
|
+
// disk: the next write rewrites it, and until then the corrupt bytes stay for a
|
|
363
|
+
// person to look at rather than being silently destroyed by the reader.
|
|
364
|
+
return []
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* An async mutex. One chain, so a second caller waits for the first to finish rather than
|
|
370
|
+
* interleaving with it.
|
|
371
|
+
*
|
|
372
|
+
* `run` never rejects the chain itself: a caller's failure is delivered to that caller and
|
|
373
|
+
* the chain continues, because one failed update must not wedge every later one.
|
|
374
|
+
*/
|
|
375
|
+
export class AsyncMutex {
|
|
376
|
+
private tail: Promise<unknown> = Promise.resolve()
|
|
377
|
+
|
|
378
|
+
run<T>(work: () => T | Promise<T>): Promise<T> {
|
|
379
|
+
const result = this.tail.then(work, work)
|
|
380
|
+
this.tail = result.then(() => undefined, () => undefined)
|
|
381
|
+
return result
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Resolves when everything currently queued has finished. */
|
|
385
|
+
idle(): Promise<unknown> {
|
|
386
|
+
return this.tail
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export class MeetingActionsStore {
|
|
391
|
+
readonly root: string
|
|
392
|
+
private readonly mutex = new AsyncMutex()
|
|
393
|
+
|
|
394
|
+
constructor(options: { root?: string } = {}) {
|
|
395
|
+
this.root = options.root ?? importsRoot()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private path(filename: string): string {
|
|
399
|
+
return join(this.root, filename)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
actionsPath(): string { return this.path(ACTIONS_FILENAME) }
|
|
403
|
+
suggestionsPath(): string { return this.path(SUGGESTIONS_FILENAME) }
|
|
404
|
+
tombstonesPath(): string { return this.path(TOMBSTONES_FILENAME) }
|
|
405
|
+
statusPath(): string { return this.path(ENGINE_STATUS_FILENAME) }
|
|
406
|
+
revertedDir(): string { return this.path(REVERTED_DIR_NAME) }
|
|
407
|
+
|
|
408
|
+
/** A consistent snapshot of all four files. Safe to call outside the mutex. */
|
|
409
|
+
read(): ActionsStoreFile {
|
|
410
|
+
const status = existsSync(this.statusPath())
|
|
411
|
+
? (() => {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(readFileSync(this.statusPath(), 'utf8')) as EngineStatusFile
|
|
414
|
+
return parsed && typeof parsed === 'object' ? { ...parsed, schema: STORE_SCHEMA } : { schema: STORE_SCHEMA }
|
|
415
|
+
} catch {
|
|
416
|
+
return { schema: STORE_SCHEMA }
|
|
417
|
+
}
|
|
418
|
+
})()
|
|
419
|
+
: { schema: STORE_SCHEMA }
|
|
420
|
+
return {
|
|
421
|
+
schema: STORE_SCHEMA,
|
|
422
|
+
actions: readJsonArray<MergeActionRecord>(this.actionsPath(), 'actions'),
|
|
423
|
+
suggestions: readJsonArray<MergeSuggestionRecord>(this.suggestionsPath(), 'suggestions'),
|
|
424
|
+
tombstones: readJsonArray<Tombstone>(this.tombstonesPath(), 'tombstones'),
|
|
425
|
+
status,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Read, mutate, write, all inside the mutex.
|
|
431
|
+
*
|
|
432
|
+
* The mutation runs on a snapshot the caller may edit freely; only the files whose rows
|
|
433
|
+
* actually changed are rewritten, so a status poll does not rewrite the action log.
|
|
434
|
+
*/
|
|
435
|
+
update<T>(mutate: (store: ActionsStoreFile) => T | Promise<T>): Promise<T> {
|
|
436
|
+
return this.mutex.run(async () => {
|
|
437
|
+
const before = this.read()
|
|
438
|
+
// A DEEP copy, not a spread. Almost every mutation here edits a row in place — an
|
|
439
|
+
// action moving to `applied`, a suggestion to `dismissed` — and a shallow copy shares
|
|
440
|
+
// those row objects with `before`, so the change check compares a value with itself,
|
|
441
|
+
// finds no difference, and writes nothing. Under a spread the store could only ever
|
|
442
|
+
// record rows being ADDED, and every state change was silently dropped.
|
|
443
|
+
const working: ActionsStoreFile = structuredClone(before)
|
|
444
|
+
const result = await mutate(working)
|
|
445
|
+
securePrivateDirectory(this.root)
|
|
446
|
+
this.writeIfChanged(this.actionsPath(), 'actions', before.actions, working.actions.slice(-MAX_ACTIONS))
|
|
447
|
+
this.writeIfChanged(this.suggestionsPath(), 'suggestions', before.suggestions, working.suggestions.slice(-MAX_SUGGESTIONS))
|
|
448
|
+
this.writeIfChanged(this.tombstonesPath(), 'tombstones', before.tombstones, working.tombstones.slice(-MAX_TOMBSTONES))
|
|
449
|
+
const nextStatus = { ...working.status, schema: STORE_SCHEMA }
|
|
450
|
+
if (JSON.stringify(before.status) !== JSON.stringify(nextStatus)) {
|
|
451
|
+
durableAtomicWriteFileSync(this.statusPath(), `${JSON.stringify(nextStatus, null, 2)}\n`, { mode: 0o600 })
|
|
452
|
+
}
|
|
453
|
+
return result
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private writeIfChanged(path: string, key: string, before: unknown[], after: unknown[]): void {
|
|
458
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return
|
|
459
|
+
durableAtomicWriteFileSync(path, `${JSON.stringify({ schema: STORE_SCHEMA, [key]: after }, null, 2)}\n`, { mode: 0o600 })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Resolves when every queued mutation has finished. Tests and shutdown use it. */
|
|
463
|
+
idle(): Promise<unknown> {
|
|
464
|
+
return this.mutex.idle()
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let store: MeetingActionsStore | null = null
|
|
469
|
+
|
|
470
|
+
export function getMeetingActionsStore(): MeetingActionsStore {
|
|
471
|
+
store ??= new MeetingActionsStore()
|
|
472
|
+
return store
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Test seam: drop the process-wide store so a later getter rebuilds it. */
|
|
476
|
+
export function resetMeetingActionsStore(): void {
|
|
477
|
+
store = null
|
|
478
|
+
}
|