@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/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
- }