@fastagent-sh/voicenote 0.22.2 → 1.0.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 +12 -394
- package/install.mjs +68 -0
- package/package.json +8 -41
- package/LICENSE +0 -21
- package/README.zh-CN.md +0 -396
- package/src/cli.ts +0 -2800
- package/src/jobs.ts +0 -460
- package/src/runLock.ts +0 -20
- package/src/tos.ts +0 -18
package/src/jobs.ts
DELETED
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
// The job-state model: what should run (classify), what should be forgotten
|
|
2
|
-
// (pruneUnseen), and how it all reads back (buildJobsView).
|
|
3
|
-
//
|
|
4
|
-
// `vn jobs` used to assemble one view from three unrelated sources — a regex
|
|
5
|
-
// over the launchd log (live), a second glob of the recorder (pending), and the
|
|
6
|
-
// state file (everything else) — so the three could never agree. Now `vn run` is
|
|
7
|
-
// the only writer and every view is a pure read. Keeping the *decisions* here
|
|
8
|
-
// too (not just the grouping) is deliberate: the queue you see and the queue
|
|
9
|
-
// that runs must come from one function, or they drift apart again.
|
|
10
|
-
//
|
|
11
|
-
// No fs, no process, no clock — all injected — so every rule is testable
|
|
12
|
-
// (see jobs.test.ts).
|
|
13
|
-
//
|
|
14
|
-
// The invariant worth naming: a record left in `running` by a process that is
|
|
15
|
-
// no longer alive is NOT running. Liveness is a pid check injected by the
|
|
16
|
-
// caller, never inferred from log text — that inference is what used to wedge
|
|
17
|
-
// a failed job at "Processing" forever.
|
|
18
|
-
|
|
19
|
-
// Lifecycle position only. WHY a job is where it is lives in `code`, so no two
|
|
20
|
-
// fields have to agree about the same fact — an earlier cut expressed "gave up"
|
|
21
|
-
// as `code` while leaving `state` alone, and the view and the classifier
|
|
22
|
-
// promptly disagreed about what such a record was.
|
|
23
|
-
type JobState = 'queued' | 'running' | 'done' | 'filtered' | 'error' | 'gave_up'
|
|
24
|
-
|
|
25
|
-
/** Which stage produced a failure; also carries the filter reason. */
|
|
26
|
-
type JobCode = 'transcribe_failed' | 'summary_failed' | 'interrupted' | 'too_small' | 'too_short' | 'too_old' | null
|
|
27
|
-
|
|
28
|
-
export type JobRecord = {
|
|
29
|
-
name: string
|
|
30
|
-
source_path: string
|
|
31
|
-
content_hash?: string
|
|
32
|
-
/** Local wall-clock `YYYY-MM-DDTHH:mm:ss` — sorts lexicographically, no TZ drift. */
|
|
33
|
-
recorded_at: string
|
|
34
|
-
size_bytes: number
|
|
35
|
-
duration_seconds: number | null
|
|
36
|
-
state: JobState
|
|
37
|
-
code: JobCode
|
|
38
|
-
detail: string | null
|
|
39
|
-
attempts: number
|
|
40
|
-
updated_at: string
|
|
41
|
-
title: string | null
|
|
42
|
-
paths: Record<string, string | null> | null
|
|
43
|
-
origin?: 'import'
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export type StateFile = {
|
|
47
|
-
version: 2
|
|
48
|
-
jobs: Record<string, JobRecord>
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Does this record own files on disk? Evidence, not a state enum: a
|
|
53
|
-
* `summary_failed` record has a transcript and a stub note, and enumerating
|
|
54
|
-
* states would have to remember that. Deleting such a record loses real work
|
|
55
|
-
* and re-pays for ASR when the recorder comes back.
|
|
56
|
-
*/
|
|
57
|
-
export const ownsOutput = (j: JobRecord): boolean => j.paths != null
|
|
58
|
-
|
|
59
|
-
// Give up auto-retrying a recording that keeps blowing up, so a permanently
|
|
60
|
-
// broken file can't burn an ASR (or LLM) call every scheduler tick. `vn forget`
|
|
61
|
-
// drops the record and puts it back in the queue.
|
|
62
|
-
export const MAX_ATTEMPTS = 3
|
|
63
|
-
|
|
64
|
-
/** The subset of JobCode a refusal may write to disk. */
|
|
65
|
-
type FilterCode = Extract<JobCode, 'too_small' | 'too_short' | 'too_old'>
|
|
66
|
-
|
|
67
|
-
// Split by whether the refusal is persisted, so the code that reaches disk is
|
|
68
|
-
// typed as such. A single `code: string` needed a cast at the write site, and
|
|
69
|
-
// the cast was the only thing keeping a display-only reason out of the record.
|
|
70
|
-
type Verdict =
|
|
71
|
-
| { run: true }
|
|
72
|
-
| { run: false; persist: true; code: FilterCode; detail: string | null }
|
|
73
|
-
| { run: false; persist: false; code: 'already_done' | 'gave_up'; detail: string | null }
|
|
74
|
-
|
|
75
|
-
type ScanFacts = { recordedAt: Date; sizeBytes: number; durationSeconds: number | null }
|
|
76
|
-
type Limits = { maxAgeHours: number; minBytes: number; minDurationSeconds: number }
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The single answer to "should this recording run now, and in what form".
|
|
80
|
-
* `vn jobs` reads back what this decided instead of re-deriving it with looser
|
|
81
|
-
* rules, which is why the shown queue and the real queue can no longer disagree.
|
|
82
|
-
*/
|
|
83
|
-
export function classify(
|
|
84
|
-
rec: ScanFacts,
|
|
85
|
-
entry: JobRecord | undefined,
|
|
86
|
-
limits: Limits,
|
|
87
|
-
opts: { force: boolean; notesMode: boolean; now: number },
|
|
88
|
-
): Verdict {
|
|
89
|
-
if (opts.force) return { run: true }
|
|
90
|
-
if (entry?.state === 'done') return { run: false, persist: false, code: 'already_done', detail: null }
|
|
91
|
-
// Only the summary is outstanding, and this run doesn't make summaries. The
|
|
92
|
-
// transcription stage is genuinely finished — re-running it would pay for ASR
|
|
93
|
-
// again and then mark the job `done` with no notes, permanently.
|
|
94
|
-
if (entry?.code === 'summary_failed' && !opts.notesMode) return { run: false, persist: false, code: 'already_done', detail: null }
|
|
95
|
-
// Retries are spent. reconcileInterrupted() is what puts a record here, so
|
|
96
|
-
// this branch needs no knowledge of *how* the attempts were used up.
|
|
97
|
-
if (entry?.state === 'gave_up') return { run: false, persist: false, code: 'gave_up', detail: entry.detail }
|
|
98
|
-
|
|
99
|
-
// Filters are deterministic properties of the file, checked before anything
|
|
100
|
-
// stateful so a too-short file can't ping-pong between error and queued.
|
|
101
|
-
const ageHours = (opts.now - rec.recordedAt.getTime()) / 3600_000
|
|
102
|
-
if (limits.maxAgeHours > 0 && ageHours > limits.maxAgeHours) return { run: false, persist: true, code: 'too_old', detail: `${ageHours.toFixed(0)}h > ${limits.maxAgeHours}h` }
|
|
103
|
-
if (rec.sizeBytes < limits.minBytes) return { run: false, persist: true, code: 'too_small', detail: `${rec.sizeBytes} < ${limits.minBytes} bytes` }
|
|
104
|
-
if (rec.durationSeconds !== null && rec.durationSeconds < limits.minDurationSeconds) return { run: false, persist: true, code: 'too_short', detail: `${rec.durationSeconds.toFixed(0)}s < ${limits.minDurationSeconds}s` }
|
|
105
|
-
|
|
106
|
-
// `error`, `queued` and `running` are retryable. A previously `filtered`
|
|
107
|
-
// record is runnable too once it passes the CURRENT filters, so widening the
|
|
108
|
-
// history range in Settings actually re-queues recordings marked `too_old`.
|
|
109
|
-
// Anything else came off disk hand-edited or from a newer build: refuse it.
|
|
110
|
-
if (entry && !RUNNABLE_STATES.has(entry.state)) {
|
|
111
|
-
return { run: false, persist: false, code: 'gave_up', detail: `Unrecognised state '${entry.state}'; \`vn forget ${entry.name}\` to start over` }
|
|
112
|
-
}
|
|
113
|
-
return { run: true }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const RUNNABLE_STATES = new Set<JobState>(['queued', 'running', 'error', 'filtered'])
|
|
117
|
-
|
|
118
|
-
/** Every way a started attempt can end. */
|
|
119
|
-
type Outcome =
|
|
120
|
-
| { kind: 'done'; title: string | null; paths: Record<string, string | null> | null }
|
|
121
|
-
| { kind: 'summary_failed'; title: string | null; paths: Record<string, string | null> | null; message: string }
|
|
122
|
-
| { kind: 'failed'; message: string }
|
|
123
|
-
| { kind: 'interrupted' }
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* The single place a started attempt is turned back into a record. It lives
|
|
127
|
-
* next to classify() and buildJobsView() on purpose: `running` used to be
|
|
128
|
-
* interpreted independently by all three, so they disagreed about what an
|
|
129
|
-
* interrupted-and-spent job was — the view called it "Queued" while the
|
|
130
|
-
* classifier refused to ever run it again.
|
|
131
|
-
*/
|
|
132
|
-
export function applyOutcome(entry: JobRecord, outcome: Outcome, now: string): void {
|
|
133
|
-
const spent = entry.attempts >= MAX_ATTEMPTS
|
|
134
|
-
const giveUp = (code: JobCode, why: string) => patchJob(entry, {
|
|
135
|
-
state: spent ? 'gave_up' : 'error',
|
|
136
|
-
code,
|
|
137
|
-
detail: spent
|
|
138
|
-
? `${why} — gave up after ${entry.attempts} attempts; \`vn forget ${entry.name}\` to retry`
|
|
139
|
-
: `${why} (attempt ${entry.attempts}/${MAX_ATTEMPTS})`,
|
|
140
|
-
}, now)
|
|
141
|
-
|
|
142
|
-
switch (outcome.kind) {
|
|
143
|
-
case 'done':
|
|
144
|
-
// Only a clean finish refunds the budget.
|
|
145
|
-
patchJob(entry, { state: 'done', code: null, detail: null, title: outcome.title, paths: outcome.paths, attempts: 0 }, now)
|
|
146
|
-
return
|
|
147
|
-
case 'summary_failed':
|
|
148
|
-
// The expensive transcript is on disk; keep its paths so a retry resumes
|
|
149
|
-
// there. Retrying notes re-runs the LLM, so it spends from the same budget.
|
|
150
|
-
patchJob(entry, { title: outcome.title, paths: outcome.paths }, now)
|
|
151
|
-
giveUp('summary_failed', outcome.message)
|
|
152
|
-
return
|
|
153
|
-
case 'failed':
|
|
154
|
-
giveUp('transcribe_failed', outcome.message)
|
|
155
|
-
return
|
|
156
|
-
case 'interrupted':
|
|
157
|
-
giveUp('interrupted', 'Run was interrupted before this job reported back')
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Open an attempt. Counted here, not at the end: a run killed mid-job (kill -9,
|
|
163
|
-
* OOM, SIGTERM) never reaches an end, and an uncounted attempt retries forever. */
|
|
164
|
-
export function startAttempt(entry: JobRecord, now: string): void {
|
|
165
|
-
patchJob(entry, { state: 'running', code: null, detail: null, attempts: entry.attempts + 1 }, now)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Manually requeue a failed job while retaining any saved transcript/audio. */
|
|
169
|
-
export function requeueFailed(entry: JobRecord, now: string): boolean {
|
|
170
|
-
if (entry.state !== 'error' && entry.state !== 'gave_up') return false
|
|
171
|
-
patchJob(entry, { state: 'queued', detail: null, attempts: 0 }, now)
|
|
172
|
-
return true
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Reclaim records left `running` by a dead run. Safe to do wholesale because the
|
|
177
|
-
* caller holds the run lock: no other run can own a `running` record right now.
|
|
178
|
-
*/
|
|
179
|
-
export function reconcileInterrupted(jobs: Record<string, JobRecord>, now: string): JobRecord[] {
|
|
180
|
-
const stale = Object.values(jobs).filter(j => j.state === 'running')
|
|
181
|
-
for (const j of stale) applyOutcome(j, { kind: 'interrupted' }, now)
|
|
182
|
-
return stale
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Convert the pre-0.18 layout (two reason-keyed buckets) to the one-map form.
|
|
187
|
-
* Pure so the one irreversible step in this codebase is testable: `error:*`
|
|
188
|
-
* entries are dropped, and they never come back.
|
|
189
|
-
*/
|
|
190
|
-
export function migrateLegacyState(raw: Record<string, any>, now: string): StateFile {
|
|
191
|
-
const jobs: Record<string, JobRecord> = {}
|
|
192
|
-
const nameOf = (path: string, id: string) => String(path || id).split(/[/\\]/).pop()!
|
|
193
|
-
const recordedAt = (name: string, fallback: string | undefined): string => {
|
|
194
|
-
const m = name.match(/(20\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/)
|
|
195
|
-
if (m) return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`
|
|
196
|
-
const d = fallback ? new Date(fallback) : null
|
|
197
|
-
return d && !Number.isNaN(+d) ? localIso(d) : '1970-01-01T00:00:00'
|
|
198
|
-
}
|
|
199
|
-
for (const [id, e] of Object.entries<any>(raw.processed_source_ids ?? {})) {
|
|
200
|
-
const name = nameOf(e.source_path, id)
|
|
201
|
-
jobs[id] = {
|
|
202
|
-
name, source_path: e.source_path ?? '', recorded_at: recordedAt(name, e.processed_at),
|
|
203
|
-
size_bytes: e.size_bytes ?? 0, duration_seconds: e.duration_seconds ?? null,
|
|
204
|
-
state: e.status === SUMMARY_FAILED_STATUS ? 'error' : 'done',
|
|
205
|
-
code: e.status === SUMMARY_FAILED_STATUS ? 'summary_failed' : null,
|
|
206
|
-
detail: e.status === SUMMARY_FAILED_STATUS ? 'Summary failed before 0.18; the saved transcript will be reused' : null,
|
|
207
|
-
attempts: 0, updated_at: e.processed_at ?? now,
|
|
208
|
-
title: e.title ?? null, paths: e.final_paths ?? e.local_paths ?? null,
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
for (const [id, e] of Object.entries<any>(raw.skipped_source_ids ?? {})) {
|
|
212
|
-
const reason = String(e.reason ?? '')
|
|
213
|
-
// `error:*` entries were scan artifacts, not jobs — overwhelmingly ENOENT
|
|
214
|
-
// from a recorder unplugged mid-run. Dropping them re-queues whatever is
|
|
215
|
-
// still on the device and forgets the rest.
|
|
216
|
-
if (reason.startsWith('error')) continue
|
|
217
|
-
const code = reason.split(':')[0] as JobCode
|
|
218
|
-
const name = nameOf(e.source_path, id)
|
|
219
|
-
jobs[id] = {
|
|
220
|
-
name, source_path: e.source_path ?? '', recorded_at: recordedAt(name, e.seen_at),
|
|
221
|
-
size_bytes: e.size_bytes ?? 0, duration_seconds: e.duration_seconds ?? null,
|
|
222
|
-
state: 'filtered', code, detail: reason.slice((code ?? '').length + 1) || null,
|
|
223
|
-
attempts: 0, updated_at: e.seen_at ?? now, title: null, paths: null,
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return { version: 2, jobs }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Drop records the scan no longer sees. A queued/filtered/error record whose
|
|
231
|
-
* file is gone was a scan artifact, not a job — keeping them is how 127 dead
|
|
232
|
-
* entries accumulated. Records that produced output are history and stay.
|
|
233
|
-
*
|
|
234
|
-
* `scanComplete` is the guard, not an optimisation: a partial listing (recorder
|
|
235
|
-
* yanked mid-glob) would otherwise wipe live queue entries. They'd come back on
|
|
236
|
-
* the next scan, but their retry counters wouldn't. It lives here rather than at
|
|
237
|
-
* the call site so the rule whose failure wipes a queue is covered by tests.
|
|
238
|
-
*/
|
|
239
|
-
export function pruneUnseen(jobs: Record<string, JobRecord>, seen: Set<string>, scanComplete: boolean): JobRecord[] {
|
|
240
|
-
if (!scanComplete) return []
|
|
241
|
-
const dropped: JobRecord[] = []
|
|
242
|
-
for (const [id, j] of Object.entries(jobs)) {
|
|
243
|
-
if (seen.has(id) || ownsOutput(j)) continue
|
|
244
|
-
delete jobs[id]
|
|
245
|
-
dropped.push(j) // the record, not the id: the caller must be able to name what it forgot
|
|
246
|
-
}
|
|
247
|
-
return dropped
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export type CurrentJob = { pid: number; source_id: string; step: string; started_at: string }
|
|
251
|
-
|
|
252
|
-
type JobView = {
|
|
253
|
-
id: string | null
|
|
254
|
-
status: 'running' | 'queued' | 'done' | 'notes_failed' | 'error' | 'gave_up' | 'filtered'
|
|
255
|
-
name: string
|
|
256
|
-
title: string | null
|
|
257
|
-
time: string | null
|
|
258
|
-
step: string | null
|
|
259
|
-
detail: string | null
|
|
260
|
-
notes: string | null
|
|
261
|
-
history_filtered: boolean
|
|
262
|
-
imported: boolean
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export const SUMMARY_FAILED_STATUS = 'summary_failed_transcript_saved'
|
|
266
|
-
|
|
267
|
-
/** Local wall clock, not UTC: recorder filenames are local time and the view sorts on this string. */
|
|
268
|
-
export function localIso(d: Date): string {
|
|
269
|
-
const p = (n: number) => String(n).padStart(2, '0')
|
|
270
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Apply a patch, reporting whether anything actually changed.
|
|
275
|
-
*
|
|
276
|
-
* The no-op guard is load-bearing, not tidiness: `classify` re-derives the same
|
|
277
|
-
* verdict for every filtered recording on every 60s scan, so an unconditional
|
|
278
|
-
* `updated_at` bump would make the state file differ on each tick and defeat the
|
|
279
|
-
* content-gated write that keeps synced workspaces quiet.
|
|
280
|
-
*/
|
|
281
|
-
export function patchJob(entry: JobRecord, patch: Partial<JobRecord>, now: string): boolean {
|
|
282
|
-
if (Object.entries(patch).every(([k, v]) => (entry as any)[k] === v)) return false
|
|
283
|
-
Object.assign(entry, patch, { updated_at: now })
|
|
284
|
-
return true
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export const emptyState = (): StateFile => ({ version: 2, jobs: {} })
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* One meaning of `limit` for both front doors (the CLI flag and the GUI's call):
|
|
291
|
-
* 0 = no limit, absent = `fallback`, anything else must be a non-negative
|
|
292
|
-
* integer. Coercing garbage to a default is how a truncated list gets mistaken
|
|
293
|
-
* for a complete one — the exact bug this module exists to remove.
|
|
294
|
-
*/
|
|
295
|
-
export function parseJobsLimit(raw: unknown, fallback: number): number {
|
|
296
|
-
if (raw === undefined || raw === null || raw === '') return fallback
|
|
297
|
-
const n = Number(raw)
|
|
298
|
-
if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid limit '${raw}': expected a non-negative integer (0 = no limit).`)
|
|
299
|
-
return n === 0 ? Infinity : n
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Parse a state file, throwing on anything that isn't one.
|
|
304
|
-
*
|
|
305
|
-
* Deliberately strict: a truncated or sync-mangled file that silently read as
|
|
306
|
-
* "nothing was ever processed" would re-transcribe the entire history, pay for
|
|
307
|
-
* ASR a second time, and then overwrite the evidence on the next save.
|
|
308
|
-
*/
|
|
309
|
-
export function parseStateFile(text: string, path: string): StateFile {
|
|
310
|
-
const parsed = parseStrictJson(text, path)
|
|
311
|
-
const version = (parsed as any)?.version
|
|
312
|
-
// A newer build's file must not be reinterpreted as v2: unknown states would
|
|
313
|
-
// be re-run by the classifier while the view calls them unrecognised.
|
|
314
|
-
if (Number.isFinite(version) && version > 2) {
|
|
315
|
-
throw new Error(`${path} was written by a newer voicenote (state version ${version}). Upgrade rather than risk re-processing everything.`)
|
|
316
|
-
}
|
|
317
|
-
const raw = (parsed as any)?.jobs
|
|
318
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
319
|
-
throw new Error(`${path} is not a job-state file (no \`jobs\` map). Refusing to continue rather than re-processing everything.`)
|
|
320
|
-
}
|
|
321
|
-
// Normalise at the boundary rather than trusting field by field downstream.
|
|
322
|
-
// `attempts` especially: a missing value makes `attempts >= MAX_ATTEMPTS`
|
|
323
|
-
// compare as NaN, which is false — the retry cap would silently never apply
|
|
324
|
-
// and a broken recording would burn ASR every scheduler tick.
|
|
325
|
-
const jobs: Record<string, JobRecord> = {}
|
|
326
|
-
for (const [id, j] of Object.entries<any>(raw)) {
|
|
327
|
-
if (!j || typeof j !== 'object') continue
|
|
328
|
-
jobs[id] = {
|
|
329
|
-
...j,
|
|
330
|
-
name: typeof j.name === 'string' ? j.name : id,
|
|
331
|
-
source_path: typeof j.source_path === 'string' ? j.source_path : '',
|
|
332
|
-
recorded_at: typeof j.recorded_at === 'string' ? j.recorded_at : '',
|
|
333
|
-
attempts: Number.isInteger(j.attempts) && j.attempts >= 0 ? j.attempts : 0,
|
|
334
|
-
paths: j.paths && typeof j.paths === 'object' ? j.paths : null,
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return { version: 2, jobs }
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** JSON.parse with the message a user can act on. */
|
|
341
|
-
export function parseStrictJson(text: string, path: string): unknown {
|
|
342
|
-
let parsed: unknown
|
|
343
|
-
try { parsed = JSON.parse(text) } catch (e: any) {
|
|
344
|
-
throw new Error(`${path} is unreadable (${e?.message || e}). Move it aside to start over — but note that re-processing every recording costs ASR again.`)
|
|
345
|
-
}
|
|
346
|
-
if (!parsed || typeof parsed !== 'object') throw new Error(`${path} is not a JSON object. Refusing to continue rather than re-processing everything.`)
|
|
347
|
-
return parsed
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const FILTER_LABELS: Record<string, string> = {
|
|
351
|
-
too_small: 'too small',
|
|
352
|
-
too_short: 'too short',
|
|
353
|
-
too_old: 'too old',
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** `2026-07-29T12:06:29` → `2026-07-29 12:06`. */
|
|
357
|
-
function displayTime(recordedAt: string | null): string | null {
|
|
358
|
-
if (!recordedAt) return null
|
|
359
|
-
return recordedAt.slice(0, 16).replace('T', ' ')
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function foldFiltered(records: JobRecord[]): JobView | null {
|
|
363
|
-
if (!records.length) return null
|
|
364
|
-
const counts = new Map<string, number>()
|
|
365
|
-
for (const r of records) {
|
|
366
|
-
const key = FILTER_LABELS[r.code ?? ''] ?? r.code ?? 'filtered'
|
|
367
|
-
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
368
|
-
}
|
|
369
|
-
const detail = [...counts].map(([label, n]) => `${label} ×${n}`).join(', ')
|
|
370
|
-
return {
|
|
371
|
-
id: null,
|
|
372
|
-
status: 'filtered',
|
|
373
|
-
name: `${records.length} recording${records.length > 1 ? 's' : ''} filtered out`,
|
|
374
|
-
title: null, time: null, step: null, detail, notes: null,
|
|
375
|
-
history_filtered: records.some(r => r.code === 'too_old'), imported: false,
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export function buildJobsView(
|
|
380
|
-
state: StateFile,
|
|
381
|
-
current: CurrentJob | null,
|
|
382
|
-
opts: { limit: number; alive: (pid: number) => boolean; recorderPresent: boolean },
|
|
383
|
-
): { items: JobView[]; total: number; queued_total: number; recorder_queued_total: number; recorder_present: boolean } {
|
|
384
|
-
// Two independent conditions must agree before a row is shown as running:
|
|
385
|
-
// the declaring process is alive, AND the record itself says `running`.
|
|
386
|
-
// current.json survives a kill -9, so the pid alone could be recycled by an
|
|
387
|
-
// unrelated long-lived process and wedge a finished job at "Processing" —
|
|
388
|
-
// the very failure this rewrite exists to remove.
|
|
389
|
-
const claimed = current && opts.alive(current.pid) ? current : null
|
|
390
|
-
const live = claimed && state.jobs?.[claimed.source_id]?.state === 'running' ? claimed : null
|
|
391
|
-
|
|
392
|
-
const running: JobView[] = []
|
|
393
|
-
const queued: JobView[] = []
|
|
394
|
-
const attention: JobView[] = []
|
|
395
|
-
const done: JobView[] = []
|
|
396
|
-
const filtered: JobRecord[] = []
|
|
397
|
-
|
|
398
|
-
for (const [id, j] of Object.entries(state.jobs ?? {})) {
|
|
399
|
-
const base = {
|
|
400
|
-
id,
|
|
401
|
-
name: j.name,
|
|
402
|
-
title: j.title ?? null,
|
|
403
|
-
time: displayTime(j.recorded_at),
|
|
404
|
-
step: null,
|
|
405
|
-
detail: null as string | null,
|
|
406
|
-
notes: j.paths?.notes ?? null,
|
|
407
|
-
history_filtered: false,
|
|
408
|
-
imported: j.origin === 'import',
|
|
409
|
-
_t: j.recorded_at ?? '',
|
|
410
|
-
}
|
|
411
|
-
if (live && live.source_id === id) { running.push({ ...base, status: 'running', step: live.step }); continue }
|
|
412
|
-
switch (j.state) {
|
|
413
|
-
case 'done': done.push({ ...base, status: 'done' }); break
|
|
414
|
-
// A `running` record with no live process is a crashed run; the next run
|
|
415
|
-
// reconciles it. Until then it belongs with the work still to do.
|
|
416
|
-
case 'running':
|
|
417
|
-
case 'queued': queued.push({ ...base, status: 'queued' }); break
|
|
418
|
-
// A saved transcript with a failed summary reads better as its own row:
|
|
419
|
-
// the stub note is openable and the retry is cheap (no ASR).
|
|
420
|
-
case 'error': attention.push({ ...base, status: j.code === 'summary_failed' ? 'notes_failed' : 'error', detail: j.detail }); break
|
|
421
|
-
case 'gave_up': attention.push({ ...base, status: 'gave_up', detail: j.detail }); break
|
|
422
|
-
case 'filtered': filtered.push(j); break
|
|
423
|
-
// `state` comes off disk and could be hand-edited or written by a newer
|
|
424
|
-
// build. Showing an unknown value as "done" would hide unprocessed work,
|
|
425
|
-
// so surface it instead.
|
|
426
|
-
default: attention.push({ ...base, status: 'error', detail: `Unrecognised state '${j.state}'` })
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Sort and display share one key (recorded_at). They used to differ — list
|
|
431
|
-
// sorted by processing time, rows labelled with recording time — which is why
|
|
432
|
-
// the list looked shuffled.
|
|
433
|
-
const asc = (a: any, b: any) => String(a._t).localeCompare(String(b._t))
|
|
434
|
-
queued.sort(asc)
|
|
435
|
-
attention.sort((a, b) => -asc(a, b))
|
|
436
|
-
done.sort((a, b) => -asc(a, b))
|
|
437
|
-
|
|
438
|
-
const filteredRow = foldFiltered(filtered)
|
|
439
|
-
// Priority is the array order: live work, then the queue, then anything needing
|
|
440
|
-
// attention, then history. Everything is subject to `limit` — exempting the
|
|
441
|
-
// head would make one broken credential (every recording failing MAX_ATTEMPTS
|
|
442
|
-
// times into `attention`) an unbounded list, with `total` claiming it was whole.
|
|
443
|
-
const ordered = [...running, ...queued, ...attention, ...done]
|
|
444
|
-
const items: JobView[] = ordered.slice(0, Math.max(0, opts.limit - (filteredRow ? 1 : 0)))
|
|
445
|
-
if (filteredRow) items.push(filteredRow)
|
|
446
|
-
const total = ordered.length + (filteredRow ? 1 : 0)
|
|
447
|
-
|
|
448
|
-
for (const it of items) delete (it as any)._t
|
|
449
|
-
// `queued_total` is pre-truncation on purpose: "N recordings waiting" counted
|
|
450
|
-
// from the visible page would contradict the "… X more" line right above it.
|
|
451
|
-
// Read `recorder_present` live from the caller, never stored — a persisted
|
|
452
|
-
// flag would keep claiming the recorder is connected after the agent stops.
|
|
453
|
-
return {
|
|
454
|
-
items,
|
|
455
|
-
total,
|
|
456
|
-
queued_total: running.length + queued.length,
|
|
457
|
-
recorder_queued_total: [...running, ...queued].filter(job => !job.imported).length,
|
|
458
|
-
recorder_present: opts.recorderPresent,
|
|
459
|
-
}
|
|
460
|
-
}
|
package/src/runLock.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// Pure logic behind the Windows run-lock ownership check (cli.ts's
|
|
2
|
-
// acquireRunLockWindows). Extracted (no fs, no process) so its one subtle
|
|
3
|
-
// invariant is tested: a transient READ failure must map to 'unknown', never
|
|
4
|
-
// to 'reclaimed'. The heartbeat and release paths branch on these three
|
|
5
|
-
// states, and collapsing 'unknown' into 'reclaimed' (or 'mine') is exactly
|
|
6
|
-
// the bug that would either hand a live lock away or delete a reclaimer's lock.
|
|
7
|
-
type LockOwnership = "mine" | "reclaimed" | "unknown";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @param raw lock-file contents, or null if the file could not be read
|
|
11
|
-
* (ENOENT, EBUSY under AV scan, …)
|
|
12
|
-
* @param ownPid this process's pid
|
|
13
|
-
*/
|
|
14
|
-
export function parseLockOwner(raw: string | null, ownPid: number): LockOwnership {
|
|
15
|
-
if (raw === null) return "unknown"; // read failed — do NOT assume reclaimed
|
|
16
|
-
let pid: number;
|
|
17
|
-
try { pid = Number(JSON.parse(raw)?.pid); } catch { return "unknown"; } // corrupt/partial write
|
|
18
|
-
if (!Number.isFinite(pid)) return "unknown";
|
|
19
|
-
return pid === ownPid ? "mine" : "reclaimed";
|
|
20
|
-
}
|
package/src/tos.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
export type TosConfig = {
|
|
2
|
-
endpoint: string
|
|
3
|
-
region: string
|
|
4
|
-
bucket: string
|
|
5
|
-
accessKey: string
|
|
6
|
-
secretKey: string
|
|
7
|
-
keep: boolean
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function tosObject(config: TosConfig, key: string): Bun.S3File {
|
|
11
|
-
return new Bun.S3Client({
|
|
12
|
-
accessKeyId: config.accessKey,
|
|
13
|
-
secretAccessKey: config.secretKey,
|
|
14
|
-
region: config.region,
|
|
15
|
-
endpoint: `https://${config.bucket}.${config.endpoint}`,
|
|
16
|
-
virtualHostedStyle: true,
|
|
17
|
-
}).file(key)
|
|
18
|
-
}
|