@frontera-sdk/cli 1.53.20 → 1.53.22

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,294 @@
1
+ import { agentAttributes, isAgentService, type AgentService } from './agent-events'
2
+
3
+ /**
4
+ * The telemetry event catalog, version 1 (design: docs/superpowers/specs/2026-11-02-frontera-telemetry-design.md).
5
+ *
6
+ * One table lists every event and the attributes it may carry. The event types,
7
+ * the attribute allowlist the collector is checked against, and the parse of an
8
+ * outbox line all read it, so adding an attribute is one change here and one to
9
+ * the collector's allowlist.
10
+ */
11
+
12
+ type Kind = 'string' | 'int' | 'bool' | readonly string[]
13
+
14
+ type ValueOf<K> = K extends 'string' ? string : K extends 'int' ? number : K extends 'bool' ? boolean : K extends readonly (infer V)[] ? V : never
15
+
16
+ type Fields<S> = { -readonly [K in keyof S]: ValueOf<S[K]> }
17
+
18
+ interface EventSpec {
19
+ readonly required: Readonly<Record<string, Kind>>
20
+ readonly optional: Readonly<Record<string, Kind>>
21
+ /** Whether a failure of this event carries `error.code`, `error.template` and `error.fingerprint`. */
22
+ readonly error: boolean
23
+ }
24
+
25
+ export const HARNESSES = ['claude', 'codex', 'grok', 'opencode'] as const
26
+ export type Harness = (typeof HARNESSES)[number]
27
+
28
+ export const VIA = ['forge', 'agent', 'terminal', 'ci'] as const
29
+ export type Via = (typeof VIA)[number]
30
+
31
+ export const APPLY_OUTCOMES = ['applied', 'failed', 'runtime_down', 'stopped'] as const
32
+ export type ApplyOutcome = (typeof APPLY_OUTCOMES)[number]
33
+
34
+ /** What started a round of applies in `local dev`. */
35
+ export const APPLY_TRIGGERS = ['files_changed', 'runtime_started', 'dev_started', 'once', 'runtime_answering', 'restore'] as const
36
+ export type ApplyTrigger = (typeof APPLY_TRIGGERS)[number]
37
+
38
+ /** What ended, for `frontera.cli.process_exit`: the watcher, an App's dev server, the runtime, or one of its components. */
39
+ export const EXIT_WHAT = ['local_dev', 'app_dev', 'runtime', 'postgres', 'storage', 'service', 'jobs', 'runner', 'studio'] as const
40
+ export type ExitWhat = (typeof EXIT_WHAT)[number]
41
+
42
+ export const SEVERITIES = { INFO: 9, WARN: 13, ERROR: 17 } as const
43
+ /** `INFO` for success, `WARN` for refused or stopped, `ERROR` for failure. */
44
+ export type Severity = keyof typeof SEVERITIES
45
+
46
+ /** Attributes any event may carry, set from the process that records it. */
47
+ export const RECORD_ATTRIBUTES = {
48
+ 'frontera.user.id': 'string',
49
+ 'frontera.session.id': 'string',
50
+ 'frontera.harness': HARNESSES,
51
+ 'frontera.invocation.id': 'string',
52
+ 'frontera.project.hash': 'string',
53
+ } as const satisfies Record<string, Kind>
54
+
55
+ export const ERROR_ATTRIBUTES = ['error.code', 'error.template', 'error.fingerprint'] as const
56
+
57
+ export const CATALOG = {
58
+ 'frontera.cli.command': {
59
+ required: { 'frontera.noun': 'string', 'frontera.verb': 'string', 'frontera.flags': 'string', 'frontera.exit_code': 'int', 'frontera.via': VIA, duration_ms: 'int' },
60
+ optional: {},
61
+ error: true,
62
+ },
63
+ 'frontera.cli.mcp_tool': {
64
+ required: { 'frontera.tool': 'string', 'frontera.ok': 'bool', duration_ms: 'int' },
65
+ optional: {},
66
+ error: true,
67
+ },
68
+ 'frontera.cli.apply': {
69
+ required: { 'frontera.item_kind': 'string', 'frontera.item_hash': 'string', 'frontera.outcome': APPLY_OUTCOMES, 'frontera.attempt': 'int', 'frontera.trigger': APPLY_TRIGGERS, duration_ms: 'int' },
70
+ optional: { 'frontera.recovered_after_attempts': 'int', 'frontera.recovered_after_ms': 'int' },
71
+ error: true,
72
+ },
73
+ 'frontera.cli.apply_round': {
74
+ required: { 'frontera.trigger': APPLY_TRIGGERS, 'frontera.queued': 'int', 'frontera.applied': 'int', 'frontera.failed': 'int', duration_ms: 'int' },
75
+ optional: {},
76
+ error: false,
77
+ },
78
+ 'frontera.cli.process_exit': {
79
+ required: { 'frontera.what': EXIT_WHAT, 'frontera.expected': 'bool', duration_ms: 'int' },
80
+ optional: { 'frontera.exit_code': 'int', 'frontera.signal': 'string' },
81
+ error: false,
82
+ },
83
+ 'frontera.forge.start': {
84
+ required: { 'frontera.projects': 'int', 'frontera.sessions': 'int', 'frontera.harnesses_found': 'string' },
85
+ optional: {},
86
+ error: false,
87
+ },
88
+ 'frontera.forge.session_open': {
89
+ required: { 'frontera.kind': ['agent', 'terminal'], 'frontera.mode': 'string' },
90
+ // Absent for a plain shell.
91
+ optional: { 'frontera.harness': HARNESSES },
92
+ error: false,
93
+ },
94
+ 'frontera.forge.agent_turn': {
95
+ required: {
96
+ 'frontera.harness': HARNESSES,
97
+ 'frontera.status': 'string',
98
+ 'frontera.tokens_in': 'int',
99
+ 'frontera.tokens_out': 'int',
100
+ 'frontera.commands': 'int',
101
+ 'frontera.failed_commands': 'int',
102
+ 'frontera.frontera_failed': 'int',
103
+ 'frontera.repeat_failures': 'int',
104
+ duration_ms: 'int',
105
+ },
106
+ optional: {},
107
+ error: true,
108
+ },
109
+ 'frontera.forge.request': {
110
+ required: { 'frontera.route': 'string', 'frontera.method': 'string', 'frontera.status': 'int', duration_ms: 'int' },
111
+ optional: {},
112
+ error: false,
113
+ },
114
+ 'frontera.forge.crash': {
115
+ required: { 'frontera.process': ['window', 'server', 'main'] },
116
+ optional: { 'frontera.page': 'string' },
117
+ error: true,
118
+ },
119
+ } as const satisfies Record<`frontera.${string}`, EventSpec>
120
+
121
+ type Catalog = typeof CATALOG
122
+ export type EventName = keyof Catalog
123
+
124
+ export const EVENT_NAMES = Object.keys(CATALOG) as EventName[]
125
+
126
+ export type RecordAttributes = Partial<Fields<typeof RECORD_ATTRIBUTES>>
127
+
128
+ export type AttributesOf<N extends EventName> = Fields<Catalog[N]['required']> & Partial<Fields<Catalog[N]['optional']>> & RecordAttributes
129
+
130
+ /** A failure, as `redact.ts` makes it: never the raw message. */
131
+ export interface ErrorFields {
132
+ code: string
133
+ template: string
134
+ fingerprint: string
135
+ }
136
+
137
+ /** One event as code records it. An event name outside the catalog, or an attribute its entry does not list, does not compile. */
138
+ export type TelemetryEvent = {
139
+ [N in EventName]: { name: N; severity: Severity; attributes: AttributesOf<N> } & (Catalog[N]['error'] extends true ? { error?: ErrorFields } : { error?: never })
140
+ }[EventName]
141
+
142
+ export type AttributeValue = string | number | boolean
143
+
144
+ /** Set once per batch. A type, not an interface, so it reads as a plain attribute map. */
145
+ export type Resource = {
146
+ 'service.name': 'frontera-cli' | 'frontera-forge'
147
+ 'service.version': string
148
+ 'deployment.environment': 'release' | 'dev'
149
+ 'os.type': string
150
+ 'host.arch': string
151
+ 'frontera.device.id': string
152
+ 'frontera.wsl': boolean
153
+ }
154
+
155
+ /** A coding agent's resource as Forge's relay writes it: the agent's name and version, the Forge session, and this machine's fields as Forge's own. */
156
+ export type AgentResource = Omit<Resource, 'service.name' | 'deployment.environment'> & {
157
+ 'service.name': AgentService
158
+ 'frontera.session.id': string
159
+ }
160
+
161
+ /** One line of an outbox file: the event with its attributes flattened, and the resource of the process that wrote it. */
162
+ export interface FronteraRecord {
163
+ resource: Resource
164
+ name: EventName
165
+ severity: Severity
166
+ /** Unix milliseconds. */
167
+ time: number
168
+ attributes: Record<string, AttributeValue>
169
+ }
170
+
171
+ /** A coding agent's own event, kept by its relay (agent-events.ts). */
172
+ export interface AgentRecord {
173
+ resource: AgentResource
174
+ name: string
175
+ severity: Severity
176
+ time: number
177
+ attributes: Record<string, AttributeValue>
178
+ }
179
+
180
+ export type OutboxRecord = FronteraRecord | AgentRecord
181
+
182
+ /** Every log record attribute key the catalog allows, for checking the collector's allowlist against. */
183
+ export const ATTRIBUTE_KEYS: readonly string[] = [
184
+ ...new Set([
185
+ 'event.name',
186
+ ...ERROR_ATTRIBUTES,
187
+ ...Object.keys(RECORD_ATTRIBUTES),
188
+ ...Object.values(CATALOG as Record<string, EventSpec>).flatMap((spec) => [...Object.keys(spec.required), ...Object.keys(spec.optional)]),
189
+ ]),
190
+ ].sort()
191
+
192
+ function kindOf(name: EventName, key: string): Kind | undefined {
193
+ const spec = CATALOG[name] as EventSpec
194
+ if (spec.error && (ERROR_ATTRIBUTES as readonly string[]).includes(key)) return 'string'
195
+ const table = [spec.required, spec.optional, RECORD_ATTRIBUTES as Record<string, Kind>].find((t) => Object.hasOwn(t, key))
196
+ return table?.[key]
197
+ }
198
+
199
+ function fits(kind: Kind, value: unknown): value is AttributeValue {
200
+ if (kind === 'string') return typeof value === 'string'
201
+ if (kind === 'int') return typeof value === 'number' && Number.isInteger(value)
202
+ if (kind === 'bool') return typeof value === 'boolean'
203
+ return typeof value === 'string' && kind.includes(value)
204
+ }
205
+
206
+ /** The flat attributes of an event: its own, the process's context under them, and its error fields. */
207
+ export function flatAttributes(event: TelemetryEvent, context: RecordAttributes = {}): Record<string, AttributeValue> {
208
+ const flat: Record<string, AttributeValue> = {}
209
+ for (const [key, value] of Object.entries({ ...context, ...event.attributes })) if (value !== undefined) flat[key] = value as AttributeValue
210
+ if (event.error) {
211
+ flat['error.code'] = event.error.code
212
+ flat['error.template'] = event.error.template
213
+ flat['error.fingerprint'] = event.error.fingerprint
214
+ }
215
+ return flat
216
+ }
217
+
218
+ /**
219
+ * Values that leave are short: an error template keeps its own 300
220
+ * characters, any other string 128. A key or an email address that slipped
221
+ * into one is masked, as the collector masks it (its `redaction` processor).
222
+ */
223
+ const MAX_STRING: Readonly<Record<string, number>> = { 'error.template': 300 }
224
+ export const BLOCKED_VALUES: readonly RegExp[] = [/sk-[A-Za-z0-9_-]{16,}/g, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g]
225
+
226
+ function clean<V extends AttributeValue>(key: string, value: V): V {
227
+ if (typeof value !== 'string') return value
228
+ const max = MAX_STRING[key] ?? 128
229
+ // Cut before masking: the email pattern backtracks quadratically on a long run of word characters.
230
+ const masked = BLOCKED_VALUES.reduce((text, rule) => text.replace(rule, '****'), value.slice(0, max + 64))
231
+ return masked.slice(0, max) as V
232
+ }
233
+
234
+ const HOST_KEYS = ['service.version', 'os.type', 'host.arch', 'frontera.device.id'] as const
235
+
236
+ function parseResource(raw: unknown): Resource | AgentResource | null {
237
+ if (!raw || typeof raw !== 'object') return null
238
+ const r = raw as Record<string, unknown>
239
+ if (!HOST_KEYS.every((k) => typeof r[k] === 'string') || typeof r['frontera.wsl'] !== 'boolean') return null
240
+ const [version, os, arch, device] = HOST_KEYS.map((k) => clean(k, r[k] as string))
241
+ const host = { 'service.version': version!, 'os.type': os!, 'host.arch': arch!, 'frontera.device.id': device!, 'frontera.wsl': r['frontera.wsl'] }
242
+ const service = r['service.name']
243
+ if (isAgentService(service)) {
244
+ const session = r['frontera.session.id']
245
+ return typeof session === 'string' && session ? { 'service.name': service, ...host, 'frontera.session.id': clean('frontera.session.id', session) } : null
246
+ }
247
+ const environment = r['deployment.environment']
248
+ if ((service !== 'frontera-cli' && service !== 'frontera-forge') || (environment !== 'release' && environment !== 'dev')) return null
249
+ return { 'service.name': service, 'service.version': version!, 'deployment.environment': environment, 'os.type': os!, 'host.arch': arch!, 'frontera.device.id': device!, 'frontera.wsl': host['frontera.wsl'] }
250
+ }
251
+
252
+ /** A Frontera event's attributes: those its catalog entry lists, of the right type. Null for a name outside the catalog. */
253
+ function catalogAttributes(name: string, attributes: Readonly<Record<string, unknown>>): Record<string, AttributeValue> | null {
254
+ if (!Object.hasOwn(CATALOG, name)) return null
255
+ const kept: Record<string, AttributeValue> = {}
256
+ for (const [key, value] of Object.entries(attributes)) {
257
+ const kind = kindOf(name as EventName, key)
258
+ if (kind && fits(kind, value)) kept[key] = value
259
+ }
260
+ return kept
261
+ }
262
+
263
+ /**
264
+ * An outbox line, parsed. Files on disk may be cut short by a crash or written
265
+ * by another release, or by hand: a line that is not a catalog event or a
266
+ * kept agent event is dropped, an attribute its event does not list, or of
267
+ * the wrong type, is left out, and every string is cut short and masked.
268
+ */
269
+ export function parseRecord(line: string): OutboxRecord | null {
270
+ let raw: unknown
271
+ try {
272
+ raw = JSON.parse(line)
273
+ } catch {
274
+ return null
275
+ }
276
+ return recordOf(raw)
277
+ }
278
+
279
+ /** A record as it may leave the laptop, or null; what parseRecord reads and the agents' relay writes. */
280
+ export function recordOf(raw: unknown): OutboxRecord | null {
281
+ if (!raw || typeof raw !== 'object') return null
282
+ const { resource: rawResource, name, severity, time, attributes } = raw as Record<string, unknown>
283
+ if (typeof name !== 'string') return null
284
+ if (typeof severity !== 'string' || !Object.hasOwn(SEVERITIES, severity)) return null
285
+ if (typeof time !== 'number' || !Number.isFinite(time)) return null
286
+ const resource = parseResource(rawResource)
287
+ if (!resource) return null
288
+ const given = attributes && typeof attributes === 'object' ? (attributes as Record<string, unknown>) : {}
289
+ const service = resource['service.name']
290
+ const kept = isAgentService(service) ? agentAttributes(service, name, given) : catalogAttributes(name, given)
291
+ if (!kept) return null
292
+ for (const [key, value] of Object.entries(kept)) kept[key] = clean(key, value)
293
+ return { resource, name, severity: severity as Severity, time, attributes: kept } as OutboxRecord
294
+ }
@@ -0,0 +1,44 @@
1
+ import type { Recorder } from './recorder'
2
+
3
+ const OFF: Recorder = { mode: { mode: 'off', reason: 'switched_off' }, deviceId: '', record() {}, write() {}, roll() {}, close() {}, hash: () => '' }
4
+
5
+ /**
6
+ * The recorder an app records through, made where the app makes it (Forge's
7
+ * server, the `frontera` process): if making it or any call on it throws,
8
+ * the event is lost and the work goes on. A recorder that cannot be made
9
+ * records nothing.
10
+ */
11
+ export function failOpen(make: () => Recorder): Recorder {
12
+ let made: Recorder
13
+ let deviceId: string
14
+ try {
15
+ made = make()
16
+ deviceId = made.deviceId
17
+ } catch {
18
+ return OFF
19
+ }
20
+ const quiet =
21
+ <A extends unknown[]>(call: (...args: A) => void) =>
22
+ (...args: A) => {
23
+ try {
24
+ call(...args)
25
+ } catch {
26
+ // Telemetry never fails the work it records.
27
+ }
28
+ }
29
+ return {
30
+ mode: made.mode,
31
+ deviceId,
32
+ record: quiet((event) => made.record(event)),
33
+ write: quiet((record) => made.write(record)),
34
+ roll: quiet(() => made.roll()),
35
+ close: quiet(() => made.close()),
36
+ hash: (...parts) => {
37
+ try {
38
+ return made.hash(...parts)
39
+ } catch {
40
+ return ''
41
+ }
42
+ },
43
+ }
44
+ }
@@ -0,0 +1,112 @@
1
+ import { SEVERITIES, type AttributeValue, type OutboxRecord, type Severity } from './events'
2
+
3
+ /** OTLP/JSON `AnyValue`. int64 is a decimal string, as the protobuf JSON mapping writes it. */
4
+ type AnyValue = { stringValue: string } | { intValue: string } | { doubleValue: number } | { boolValue: boolean }
5
+ interface KeyValue {
6
+ key: string
7
+ value: AnyValue
8
+ }
9
+
10
+ export interface OtlpLogsBody {
11
+ resourceLogs: Array<{
12
+ resource: { attributes: KeyValue[] }
13
+ scopeLogs: Array<{
14
+ scope: { name: string; version: string }
15
+ logRecords: Array<{
16
+ timeUnixNano: string
17
+ observedTimeUnixNano: string
18
+ severityNumber: number
19
+ severityText: string
20
+ body: { stringValue: string }
21
+ attributes: KeyValue[]
22
+ }>
23
+ }>
24
+ }>
25
+ }
26
+
27
+ export const SCOPE = { name: 'frontera-telemetry', version: '1' } as const
28
+
29
+ function anyValue(value: AttributeValue): AnyValue {
30
+ if (typeof value === 'boolean') return { boolValue: value }
31
+ if (typeof value === 'number') return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }
32
+ return { stringValue: value }
33
+ }
34
+
35
+ const keyValues = (attributes: Readonly<Record<string, AttributeValue>>): KeyValue[] => Object.entries(attributes).map(([key, value]) => ({ key, value: anyValue(value) }))
36
+
37
+ const nanos = (ms: number) => `${BigInt(Math.round(ms)) * 1_000_000n}`
38
+
39
+ /**
40
+ * Records as an OTLP/HTTP JSON logs request: one `resourceLogs` entry per
41
+ * distinct resource, in the order first seen; one log record per event, its
42
+ * body and `event.name` attribute the event name.
43
+ */
44
+ export function toOtlpLogs(records: readonly OutboxRecord[], observedAt: number = Date.now()): OtlpLogsBody {
45
+ const byResource = new Map<string, OtlpLogsBody['resourceLogs'][number]>()
46
+ for (const record of records) {
47
+ const key = JSON.stringify(Object.entries(record.resource).sort(([a], [b]) => a.localeCompare(b)))
48
+ let entry = byResource.get(key)
49
+ if (!entry) {
50
+ entry = { resource: { attributes: keyValues(record.resource) }, scopeLogs: [{ scope: { ...SCOPE }, logRecords: [] }] }
51
+ byResource.set(key, entry)
52
+ }
53
+ entry.scopeLogs[0]!.logRecords.push({
54
+ timeUnixNano: nanos(record.time),
55
+ observedTimeUnixNano: nanos(observedAt),
56
+ severityNumber: SEVERITIES[record.severity],
57
+ severityText: record.severity,
58
+ body: { stringValue: record.name },
59
+ attributes: keyValues({ 'event.name': record.name, ...record.attributes }),
60
+ })
61
+ }
62
+ return { resourceLogs: [...byResource.values()] }
63
+ }
64
+
65
+ /** One log record of an OTLP/HTTP JSON request, its attributes as plain values (nested and byte values left out). */
66
+ export interface OtlpLogRecord {
67
+ resource: Record<string, AttributeValue>
68
+ /** Unix ms: when it happened, else when it was seen, else now. */
69
+ time: number
70
+ severity: Severity
71
+ attributes: Record<string, AttributeValue>
72
+ }
73
+
74
+ type Json = Record<string, unknown>
75
+ const list = (value: unknown): Json[] => (Array.isArray(value) ? value.filter((v): v is Json => !!v && typeof v === 'object') : [])
76
+
77
+ function plain(value: unknown): AttributeValue | undefined {
78
+ if (!value || typeof value !== 'object') return undefined
79
+ const v = value as Json
80
+ if (typeof v.stringValue === 'string') return v.stringValue
81
+ if (typeof v.boolValue === 'boolean') return v.boolValue
82
+ if (typeof v.doubleValue === 'number') return v.doubleValue
83
+ const int = typeof v.intValue === 'string' || typeof v.intValue === 'number' ? Number(v.intValue) : Number.NaN
84
+ return Number.isSafeInteger(int) ? int : undefined
85
+ }
86
+
87
+ function attributesOf(value: unknown): Record<string, AttributeValue> {
88
+ const out: Record<string, AttributeValue> = {}
89
+ for (const kv of list(value)) {
90
+ const v = plain(kv.value)
91
+ if (typeof kv.key === 'string' && v !== undefined) out[kv.key] = v
92
+ }
93
+ return out
94
+ }
95
+
96
+ const millis = (nanos: unknown): number | null => {
97
+ const n = typeof nanos === 'string' || typeof nanos === 'number' ? Number(nanos) : 0
98
+ return Number.isFinite(n) && n > 0 ? Math.round(n / 1_000_000) : null
99
+ }
100
+
101
+ /** An OTLP severity number as the catalog's three: 17 and above ERROR, 13 and above WARN, else INFO. */
102
+ const severityOf = (n: unknown): Severity => (typeof n !== 'number' ? 'INFO' : n >= SEVERITIES.ERROR ? 'ERROR' : n >= SEVERITIES.WARN ? 'WARN' : 'INFO')
103
+
104
+ /** The log records of an OTLP/HTTP JSON logs request (what toOtlpLogs writes, and what other programs' exporters send). */
105
+ export function fromOtlpLogs(body: unknown, now: number = Date.now()): OtlpLogRecord[] {
106
+ return list((body as Json | null)?.resourceLogs).flatMap((logs) => {
107
+ const resource = attributesOf((logs.resource as Json | undefined)?.attributes)
108
+ return list(logs.scopeLogs).flatMap((scope) =>
109
+ list(scope.logRecords).map((r) => ({ resource, time: millis(r.timeUnixNano) ?? millis(r.observedTimeUnixNano) ?? now, severity: severityOf(r.severityNumber), attributes: attributesOf(r.attributes) })),
110
+ )
111
+ })
112
+ }
@@ -0,0 +1,175 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { appendFileSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * The outbox: a folder of JSON-lines files, one event per line.
7
+ *
8
+ * Each writer owns its own file, so concurrent CLI runs never share one and
9
+ * need no lock. A file's state is its suffix: written to as `<id>.jsonl.open`,
10
+ * closed as `<id>.jsonl` (the only state the sender reads), and claimed by the
11
+ * sender as `<id>.jsonl.sending` until the collector takes it. Every step is a
12
+ * rename, so a crash at any point leaves each file in one whole state.
13
+ *
14
+ * The folder is Forge's: it makes it while telemetry is on and removes it when
15
+ * the switch goes off. No writer ever makes it, so removing it stops every
16
+ * writer at once, including processes started while it was on.
17
+ */
18
+
19
+ const SUFFIX = { open: '.jsonl.open', closed: '.jsonl', sending: '.jsonl.sending' } as const
20
+ export type FileState = keyof typeof SUFFIX
21
+
22
+ export interface OutboxFile {
23
+ /** `<unix ms>-<uuid>`: unique per file, oldest first when sorted by `time`. */
24
+ id: string
25
+ state: FileState
26
+ bytes: number
27
+ /** Unix ms the file was made (from its id). */
28
+ time: number
29
+ mtimeMs: number
30
+ }
31
+
32
+ export const OUTBOX_LIMITS = {
33
+ /** Past this, the oldest closed files are dropped. */
34
+ maxBytes: 50 * 1024 * 1024,
35
+ /** Closed files older than this are dropped. */
36
+ maxAgeMs: 14 * 24 * 60 * 60_000,
37
+ /** A `.sending` file this old belongs to a sender that crashed between the post and the delete: put back. */
38
+ staleSendingMs: 10 * 60_000,
39
+ /** A `.open` file untouched this long belongs to a writer that died without closing it: closed for it. */
40
+ staleOpenMs: 10 * 60_000,
41
+ /** A writer closes its file at this size and starts another, so one file always fits one request. */
42
+ rollBytes: 256 * 1024,
43
+ /** A long-lived writer closes its file this often, so the sender sees its events while it runs. */
44
+ rollMs: 60_000,
45
+ } as const
46
+
47
+ const pathOf = (dir: string, id: string, state: FileState) => join(dir, `${id}${SUFFIX[state]}`)
48
+
49
+ function stateOf(name: string): { id: string; state: FileState } | null {
50
+ for (const state of ['sending', 'open', 'closed'] as const) {
51
+ if (name.endsWith(SUFFIX[state])) return { id: name.slice(0, -SUFFIX[state].length), state }
52
+ }
53
+ return null
54
+ }
55
+
56
+ /** Every outbox file, oldest first. A missing folder is an empty outbox. */
57
+ export function listOutbox(dir: string): OutboxFile[] {
58
+ let names: string[]
59
+ try {
60
+ names = readdirSync(dir)
61
+ } catch {
62
+ return []
63
+ }
64
+ const files: OutboxFile[] = []
65
+ for (const name of names) {
66
+ const parsed = stateOf(name)
67
+ if (!parsed) continue
68
+ try {
69
+ const stat = statSync(join(dir, name))
70
+ const time = Number.parseInt(parsed.id, 10)
71
+ files.push({ ...parsed, bytes: stat.size, mtimeMs: stat.mtimeMs, time: Number.isFinite(time) ? time : stat.mtimeMs })
72
+ } catch {
73
+ // Claimed or deleted since the listing.
74
+ }
75
+ }
76
+ return files.sort((a, b) => a.time - b.time || a.id.localeCompare(b.id))
77
+ }
78
+
79
+ function move(dir: string, id: string, from: FileState, to: FileState, toId = id): boolean {
80
+ try {
81
+ renameSync(pathOf(dir, id, from), pathOf(dir, toId, to))
82
+ return true
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Claim a closed file for sending. False when it is gone (another sender took
90
+ * it). Touched, so the 10 minutes after which a claim counts as left by a
91
+ * crashed sender run from the claim, not from the file's last write.
92
+ */
93
+ export function claim(dir: string, id: string, now: number = Date.now()): boolean {
94
+ if (!move(dir, id, 'closed', 'sending')) return false
95
+ try {
96
+ utimesSync(pathOf(dir, id, 'sending'), now / 1000, now / 1000)
97
+ } catch {
98
+ // Taken back since; the claim still holds or is gone, either way nothing to touch.
99
+ }
100
+ return true
101
+ }
102
+
103
+ /** Give a claimed file back after a failed send. */
104
+ export const putBack = (dir: string, id: string) => move(dir, id, 'sending', 'closed')
105
+
106
+ /** Delete a file the collector took, or one that can never be sent. */
107
+ export function remove(dir: string, id: string, state: FileState = 'sending'): void {
108
+ try {
109
+ unlinkSync(pathOf(dir, id, state))
110
+ } catch {
111
+ // Already gone.
112
+ }
113
+ }
114
+
115
+ export function readPath(dir: string, file: Pick<OutboxFile, 'id' | 'state'>): string {
116
+ return pathOf(dir, file.id, file.state)
117
+ }
118
+
119
+ /**
120
+ * Converge after crashes: a `.sending` file older than 10 minutes goes back
121
+ * (its sender died between the post and the delete, so it is sent again), and
122
+ * a `.open` file untouched for 10 minutes is closed for the writer that died.
123
+ * It is closed under a new id: a writer that was only slow, and appends again,
124
+ * then starts a new file of the old name instead of overwriting this one.
125
+ */
126
+ export function recoverStale(dir: string, now: number): void {
127
+ for (const file of listOutbox(dir)) {
128
+ if (now - file.mtimeMs < (file.state === 'open' ? OUTBOX_LIMITS.staleOpenMs : OUTBOX_LIMITS.staleSendingMs)) continue
129
+ if (file.state === 'sending') putBack(dir, file.id)
130
+ if (file.state === 'open') move(dir, file.id, 'open', 'closed', `${file.id}-r${randomUUID().slice(0, 8)}`)
131
+ }
132
+ }
133
+
134
+ /** Drop closed files older than 14 days, then the oldest until the closed files hold 50 MiB or less. Returns how many were dropped. */
135
+ export function prune(dir: string, now: number, limits: { maxBytes: number; maxAgeMs: number } = OUTBOX_LIMITS): number {
136
+ const closed = listOutbox(dir).filter((f) => f.state === 'closed')
137
+ let total = closed.reduce((sum, f) => sum + f.bytes, 0)
138
+ let dropped = 0
139
+ for (const file of closed) {
140
+ if (now - file.mtimeMs <= limits.maxAgeMs && total <= limits.maxBytes) continue
141
+ remove(dir, file.id, 'closed')
142
+ total -= file.bytes
143
+ dropped += 1
144
+ }
145
+ return dropped
146
+ }
147
+
148
+ export interface OutboxWriter {
149
+ /** Append one line to this writer's open file, starting one when there is none. Throws when the folder is gone. */
150
+ append(line: string): void
151
+ /** Close the open file, if any, so the sender can take it. The next append starts a new one. */
152
+ roll(): void
153
+ }
154
+
155
+ export function outboxWriter(dir: string, opts: { now?: () => number; rollBytes?: number } = {}): OutboxWriter {
156
+ const now = opts.now ?? Date.now
157
+ const rollBytes = opts.rollBytes ?? OUTBOX_LIMITS.rollBytes
158
+ let current: { id: string; bytes: number } | null = null
159
+ const roll = () => {
160
+ if (!current) return
161
+ const { id } = current
162
+ current = null
163
+ move(dir, id, 'open', 'closed')
164
+ }
165
+ return {
166
+ append(line) {
167
+ current ??= { id: `${now()}-${randomUUID()}`, bytes: 0 }
168
+ const text = `${line}\n`
169
+ appendFileSync(pathOf(dir, current.id, 'open'), text)
170
+ current.bytes += Buffer.byteLength(text)
171
+ if (current.bytes >= rollBytes) roll()
172
+ },
173
+ roll,
174
+ }
175
+ }