@snack-ai/opencode 1.0.4 → 1.0.5

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 CHANGED
@@ -12,7 +12,10 @@ afterwards.
12
12
 
13
13
  That difference matters more than it sounds. Some things simply do not survive to disk — a prompt
14
14
  the provider refuses outright can leave no durable trace in OpenCode's own database, and a refusal
15
- SNACK cannot see is a refusal it cannot learn from. The plugin catches those as they happen.
15
+ SNACK cannot see is a refusal it cannot learn from. The plugin catches those as they happen — when
16
+ OpenCode reports one with a status code. OpenCode `1.18.15` retries a 429 itself and reports the
17
+ retry only as the provider's free text, so for such a turn the plugin states no ending at all and
18
+ leaves it to SNACK's reading of the database.
16
19
 
17
20
  It is deliberately tiny. It appends one line of JSON per event to a private file and gets out of the
18
21
  way. It never opens a database, never imports the SNACK CLI, never phones anywhere, and never —
@@ -42,11 +45,12 @@ metadata:
42
45
 
43
46
  - which prompt and session it belongs to, by identifier;
44
47
  - the provider and model, and when it happened;
45
- - how it ended: completed, cancelled, an operational error, or an observed restriction and its
46
- class;
47
- - token counts and cost as the provider reported them.
48
+ - how it ended: completed, cancelled, an operational error, or an observed restriction and its class
49
+ — once per prompt, and not at all for a turn OpenCode retried.
48
50
 
49
- There is no field for prompt text or response text, and the schema refuses unknown fields outright.
51
+ Token counts and cost are not among them: the schema has room for usage, but the plugin leaves it
52
+ empty and SNACK takes usage from OpenCode's database. There is no field for prompt text or response
53
+ text, and the schema refuses unknown fields outright.
50
54
 
51
55
  With `--enable-prospective-analysis`, each prompt additionally carries a few non-semantic shape
52
56
  features: an estimated token count, a bucketed line count, a bucketed count of fenced code blocks,
@@ -75,12 +79,22 @@ would carry an invented meaning downstream for as long as it lived.
75
79
 
76
80
  Events are appended as NDJSON to segment files with `0600` permissions in a `0700` directory. Append
77
81
  is the only write operation; nothing is ever rewritten in place, which is what makes a crash
78
- mid-write recoverable rather than corrupting.
82
+ mid-write recoverable rather than corrupting. A write that fails is cut back to where it began, and
83
+ an append to a segment that ends mid-line starts on a fresh line, so a broken line never takes the
84
+ next event with it.
79
85
 
80
86
  A line cut short by a crash is exactly what truncation recovery expects: the reader validates each
81
87
  line, discards the incomplete one with a sanitized diagnostic, and keeps everything before it. The
82
88
  count of refused records surfaces in `snack sync` as `rejected_invalid` rather than disappearing.
83
89
 
90
+ Each append takes a writer lock for the milliseconds it lasts. A lock older than two minutes was
91
+ abandoned, so the plugin and `snack sync` take it over whatever process id it names, and
92
+ `snack doctor` warns with `spool_lock:<alias>` while one is there. The takeover is atomic: two
93
+ writers that judge the same lock abandoned at once never both hold it, and a failed append is
94
+ truncated back only by a writer that still holds the lock. Age is read from the wall clock, so a
95
+ clock jump of more than two minutes, or a laptop resumed mid-append, can take over a lock still
96
+ held; that costs at most the event being written.
97
+
84
98
  Segments are removed only after **every configured source has committed past them**. A cursor that
85
99
  advanced without its transaction committing would silently drop history, so cursors move only inside
86
100
  the committing transaction.
@@ -104,7 +118,8 @@ and driven through the capture path in tests; a canary reaching any written byte
104
118
 
105
119
  The provider's own error **code** is stored on purpose — it is what distinguishes a rate limit from
106
120
  a timeout, and classifying that difference correctly is the entire reason SNACK does not treat your
107
- flaky Wi-Fi as a quota event. The error _message_ is not stored.
121
+ flaky Wi-Fi as a quota event. The error _message_ is not stored. A retry status is read for its type
122
+ alone; its message is never kept or classified.
108
123
 
109
124
  ## Compatibility
110
125
 
@@ -113,5 +128,10 @@ Requires Node.js 24 and a `@snack-ai/cli` that accepts `spool-event-v1`. Event `
113
128
  version of this plugin. `snack doctor` reports a registration pinned at an older version as outdated
114
129
  rather than incompatible, and re-running `snack setup opencode --install-plugin` updates the pin.
115
130
 
131
+ On OpenCode `1.18.15`, use `1.0.5` or later. Earlier versions file the first prompt of every session
132
+ under the provider of OpenCode's `small_model`, which names the session, follow a cancelled prompt
133
+ with `success` events, and leave the model name empty. A current `snack sync` keeps the exclusion
134
+ OpenCode's database records for such a cancellation.
135
+
116
136
  Apache-2.0. Security reports go through the private channel in
117
137
  [SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
package/README.pt-BR.md CHANGED
@@ -12,7 +12,9 @@ O OpenCode já anota o que você fez. Este plugin assiste isso acontecer, em vez
12
12
  Essa diferença importa mais do que parece. Algumas coisas simplesmente não sobrevivem até o disco —
13
13
  um prompt que o provedor recusa de cara pode não deixar rastro durável no banco do próprio OpenCode,
14
14
  e uma recusa que o SNACK não enxerga é uma recusa com a qual ele não aprende. O plugin pega essas no
15
- ato.
15
+ ato — quando o OpenCode reporta uma com código de status. O OpenCode `1.18.15` repete sozinho um 429
16
+ e reporta a nova tentativa só como texto livre do provedor, então nesse turno o plugin não declara
17
+ desfecho algum e deixa isso para a leitura que o SNACK faz do banco.
16
18
 
17
19
  Ele é propositalmente minúsculo. Acrescenta uma linha de JSON por evento num arquivo privado e sai
18
20
  da frente. Nunca abre banco, nunca importa a CLI do SNACK, nunca liga para lugar nenhum, e nunca —
@@ -42,12 +44,12 @@ byte-idêntica. Todo campo é metadado:
42
44
 
43
45
  - a qual prompt e sessão pertence, por identificador;
44
46
  - o provedor e o modelo, e quando aconteceu;
45
- - como terminou: completado, cancelado, um erro operacional, ou uma restrição observada e sua
46
- classe;
47
- - contagens de tokens e custo, como o provedor reportou.
47
+ - como terminou: completado, cancelado, um erro operacional, ou uma restrição observada e sua classe
48
+ — uma vez por prompt, e nenhuma para um turno que o OpenCode repetiu.
48
49
 
49
- Não existe campo para texto de prompt nem de resposta, e o schema recusa campos desconhecidos de
50
- forma categórica.
50
+ Contagens de tokens e custo não estão entre eles: o schema tem lugar para uso, mas o plugin o deixa
51
+ vazio e o SNACK tira o uso do banco do OpenCode. Não existe campo para texto de prompt nem de
52
+ resposta, e o schema recusa campos desconhecidos de forma categórica.
51
53
 
52
54
  Com `--enable-prospective-analysis`, cada prompt carrega também algumas features não semânticas de
53
55
  formato: contagem estimada de tokens, contagem de linhas em faixas, contagem de blocos de código em
@@ -77,13 +79,24 @@ não reconhece carregaria um significado inventado rio abaixo por todo o tempo e
77
79
 
78
80
  Eventos são acrescentados como NDJSON em arquivos de segmento com permissão `0600` num diretório
79
81
  `0700`. Acrescentar é a única operação de escrita; nada é reescrito no lugar, e é isso que torna uma
80
- queda no meio da escrita recuperável em vez de corruptora.
82
+ queda no meio da escrita recuperável em vez de corruptora. Uma escrita que falha é cortada de volta
83
+ até onde começou, e um acréscimo a um segmento que termina no meio de uma linha começa numa linha
84
+ nova, então uma linha quebrada nunca leva o evento seguinte junto.
81
85
 
82
86
  Uma linha cortada por uma queda é exatamente o que a recuperação de truncamento espera: o leitor
83
87
  valida cada linha, descarta a incompleta com um diagnóstico sanitizado, e mantém tudo que veio
84
88
  antes. A contagem de registros recusados aparece no `snack sync` como `rejected_invalid` em vez de
85
89
  sumir.
86
90
 
91
+ Cada acréscimo segura um lock de escrita pelos milissegundos que dura. Um lock com mais de dois
92
+ minutos foi abandonado, então o plugin e o `snack sync` o assumem seja qual for o id de processo que
93
+ ele nomeia, e o `snack doctor` avisa com `spool_lock:<alias>` enquanto houver um. A tomada é
94
+ atômica: dois escritores que julgam o mesmo lock abandonado ao mesmo tempo nunca o seguram juntos, e
95
+ um acréscimo que falhou só é truncado de volta por um escritor que ainda segura o lock. A idade é
96
+ lida no relógio de parede, então um salto de relógio de mais de dois minutos, ou um notebook que
97
+ volta da suspensão no meio de um acréscimo, pode tomar um lock ainda em uso; isso custa no máximo o
98
+ evento sendo escrito.
99
+
87
100
  Segmentos só são removidos depois que **toda fonte configurada commitou além deles**. Um cursor que
88
101
  avançasse sem sua transação commitar descartaria histórico em silêncio, então cursores só se movem
89
102
  dentro da transação que commita.
@@ -108,7 +121,8 @@ qualquer byte escrito quebra o build.
108
121
 
109
122
  O **código** de erro do provedor é armazenado de propósito — é o que distingue um rate limit de um
110
123
  timeout, e classificar essa diferença corretamente é a razão inteira de o SNACK não tratar o seu
111
- Wi-Fi instável como evento de quota. A _mensagem_ de erro não é armazenada.
124
+ Wi-Fi instável como evento de quota. A _mensagem_ de erro não é armazenada. Um status de nova
125
+ tentativa é lido só pelo seu tipo; a mensagem dele nunca é guardada nem classificada.
112
126
 
113
127
  ## Compatibilidade
114
128
 
@@ -117,5 +131,10 @@ Requer Node.js 24 e uma `@snack-ai/cli` que aceite `spool-event-v1`. O `schema_v
117
131
  publicada dele. O `snack doctor` reporta um registro fixado numa versão antiga como desatualizado, e
118
132
  não como incompatível; rodar `snack setup opencode --install-plugin` de novo atualiza o pin.
119
133
 
134
+ No OpenCode `1.18.15`, use a `1.0.5` ou posterior. As versões anteriores registram o primeiro prompt
135
+ de toda sessão sob o provedor do `small_model` do OpenCode, que dá nome à sessão, gravam eventos de
136
+ `success` depois de um prompt cancelado, e deixam o nome do modelo vazio. Um `snack sync` atual
137
+ mantém a exclusão que o banco do OpenCode registra para esse cancelamento.
138
+
120
139
  Apache-2.0. Relatos de segurança vão pelo canal privado descrito em
121
140
  [SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snack-ai/opencode",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Fail-open OpenCode metadata capture for SNACK",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/plugin.js CHANGED
@@ -1,25 +1,42 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
1
  import { join } from "node:path";
4
- import process from "node:process";
5
- import { setTimeout as delay } from "node:timers/promises";
6
2
 
7
- const spoolFilename = "current.open";
3
+ import { appendEvent } from "./spool-writer.js";
4
+
8
5
  const maxPendingWrites = 100;
9
- const maxSegmentBytes = 1024 * 1024;
6
+
7
+ /**
8
+ * Agents OpenCode runs on the side of a prompt, on models of their own: `title` names the session
9
+ * with `small_model` on its first prompt, `compaction` and `summary` rewrite history. Their
10
+ * `chat.params` describe a call the prompt did not ask for, so they never route one.
11
+ */
12
+ const auxiliaryAgents = new Set(["title", "compaction", "summary"]);
13
+
14
+ /**
15
+ * @typedef {object} PromptState
16
+ * @property {string} promptId
17
+ * @property {string | null} agent
18
+ * @property {string | null} provider
19
+ * @property {string | null} model
20
+ * @property {string} spoolDirectory
21
+ * @property {boolean} retried
22
+ * @property {Record<string, unknown>[]} buffered
23
+ */
10
24
 
11
25
  /**
12
26
  * Fail-open OpenCode plugin entrypoint.
13
27
  *
14
28
  * @param {unknown} _context
15
- * @param {{installation_id?: unknown, spool_directory?: unknown, prospective_analysis?: unknown, source_bindings?: unknown}} [options]
29
+ * @param {{installation_id?: unknown, spool_directory?: unknown, prospective_analysis?: unknown, source_bindings?: unknown} | null} [options]
16
30
  */
17
31
  export async function SnackOpenCodePlugin(_context, options = {}) {
18
- const installationId = stringOrNull(options.installation_id);
19
- const spoolDirectory = stringOrNull(options.spool_directory);
20
- const captureFeatures = options.prospective_analysis === true;
21
- const sourceBindings = bindingMap(options.source_bindings);
22
- /** @type {Map<string, {promptId: string, provider: string | null, model: string | null, spoolDirectory: string, buffered: Record<string, unknown>[]}>} */
32
+ // OpenCode passes whatever the configuration tuple holds, `null` and non-objects included; an
33
+ // initializer that throws there takes the host down with it.
34
+ const settings = recordOrNull(options) ?? {};
35
+ const installationId = stringOrNull(settings.installation_id);
36
+ const spoolDirectory = stringOrNull(settings.spool_directory);
37
+ const captureFeatures = settings.prospective_analysis === true;
38
+ const sourceBindings = bindingMap(settings.source_bindings);
39
+ /** @type {Map<string, PromptState>} */
23
40
  const prompts = new Map();
24
41
  let writes = Promise.resolve();
25
42
  let pendingWrites = 0;
@@ -59,13 +76,14 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
59
76
  /**
60
77
  * Route a session's spool directory once its provider is known.
61
78
  *
62
- * OpenCode declares `model` optional on `chat.message` and does not send it on `1.18.10`, so the
63
- * routing decision cannot be taken there. An event written to `_pending` is never attributed and
64
- * never revisited, so a prompt whose provider is still unknown is held rather than misfiled, and
65
- * released as soon as `chat.params` names one. A prompt whose provider never arrives is released
66
- * to `_pending` at the terminal event, which is where it would have gone anyway.
79
+ * OpenCode declares `model` optional on `chat.message` and does not send it on `1.18.10` or
80
+ * `1.18.15`; the user message on the hook's output names it there, and older hosts may not. An
81
+ * event written to `_pending` is never attributed and never revisited, so a prompt whose provider
82
+ * is still unknown is held rather than misfiled, and released as soon as the prompt's own
83
+ * `chat.params` names one. A prompt whose provider never arrives is released to `_pending` at
84
+ * its terminal event, or when the next prompt of its session replaces it.
67
85
  *
68
- * @param {{provider: string | null, model: string | null, spoolDirectory: string, buffered: Record<string, unknown>[]}} prompt
86
+ * @param {PromptState} prompt
69
87
  */
70
88
  const release = (prompt) => {
71
89
  for (const event of prompt.buffered.splice(0)) {
@@ -75,19 +93,33 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
75
93
 
76
94
  return {
77
95
  async dispose() {
78
- for (const prompt of prompts.values()) release(prompt);
79
- await writes;
96
+ try {
97
+ for (const prompt of prompts.values()) release(prompt);
98
+ prompts.clear();
99
+ await writes;
100
+ } catch {
101
+ // Capture must never change OpenCode shutdown behavior.
102
+ }
80
103
  },
81
104
  async "chat.params"(/** @type {Record<string, unknown>} */ input) {
82
105
  try {
83
106
  const sessionId = stringOrNull(input.sessionID);
84
107
  const prompt = sessionId ? prompts.get(sessionId) : undefined;
85
108
  if (!prompt || prompt.provider || !spoolDirectory) return;
109
+ // Only the call that answers this prompt routes it. On 1.18.15 the session title is
110
+ // generated with `small_model` -- possibly another provider -- and its `chat.params`
111
+ // arrives first, on the first prompt of every session.
112
+ const agent = stringOrNull(input.agent);
113
+ if (agent !== null && auxiliaryAgents.has(agent)) return;
114
+ if (prompt.agent !== null && agent !== null && agent !== prompt.agent) return;
115
+ const answered = stringOrNull(recordOrNull(input.message)?.id);
116
+ if (answered !== null && answered !== prompt.promptId) return;
86
117
  const model = recordOrNull(input.model);
87
118
  const provider = stringOrNull(model?.providerID);
88
119
  if (!provider) return;
89
120
  prompt.provider = provider;
90
- prompt.model = stringOrNull(model?.modelID);
121
+ // `chat.params` carries the provider's model record, which names the model `id`.
122
+ prompt.model = stringOrNull(model?.id) ?? stringOrNull(model?.modelID);
91
123
  prompt.spoolDirectory = sourceBindings.get(provider) ?? join(spoolDirectory, "_pending");
92
124
  release(prompt);
93
125
  } catch {
@@ -103,20 +135,27 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
103
135
  const outputMessage = recordOrNull(recordOrNull(output)?.message);
104
136
  const promptId = stringOrNull(input.messageID) ?? stringOrNull(outputMessage?.id);
105
137
  if (!sessionId || !promptId || !spoolDirectory) return;
106
- const model = recordOrNull(input.model);
138
+ const model = recordOrNull(input.model) ?? recordOrNull(outputMessage?.model);
107
139
  const provider = stringOrNull(model?.providerID);
108
140
  const modelId = stringOrNull(model?.modelID);
109
141
  const targetDirectory = provider
110
142
  ? (sourceBindings.get(provider) ?? join(spoolDirectory, "_pending"))
111
143
  : join(spoolDirectory, "_pending");
112
144
  if (!targetDirectory) return;
145
+ /** @type {PromptState} */
113
146
  const prompt = {
114
147
  promptId,
148
+ agent: stringOrNull(input.agent) ?? stringOrNull(outputMessage?.agent),
115
149
  provider,
116
150
  model: modelId,
117
151
  spoolDirectory: targetDirectory,
118
- /** @type {Record<string, unknown>[]} */ buffered: [],
152
+ retried: false,
153
+ buffered: [],
119
154
  };
155
+ // A prompt queued behind one still held for its provider replaces it here; what the
156
+ // earlier one buffered is released, to `_pending`, rather than dropped.
157
+ const previous = prompts.get(sessionId);
158
+ if (previous) release(previous);
120
159
  prompts.set(sessionId, prompt);
121
160
  const occurredAt = new Date().toISOString();
122
161
  const started = {
@@ -148,15 +187,32 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
148
187
  try {
149
188
  const event = recordOrNull(input.event);
150
189
  const type = stringOrNull(event?.type);
151
- if (type !== "session.idle" && type !== "session.error") return;
190
+ if (type !== "session.idle" && type !== "session.error" && type !== "session.status")
191
+ return;
152
192
  const properties = recordOrNull(event?.properties) ?? {};
153
193
  const sessionId = stringOrNull(properties.sessionID);
154
194
  const prompt = sessionId ? prompts.get(sessionId) : undefined;
155
195
  if (!sessionId || !prompt) return;
196
+ if (type === "session.status") {
197
+ // Only the status's type is read. Its `message` is the provider's free text and is
198
+ // never kept, compared, or classified.
199
+ if (stringOrNull(recordOrNull(properties.status)?.type) === "retry")
200
+ prompt.retried = true;
201
+ return;
202
+ }
203
+ // One terminal per prompt. 1.18.15 emits `session.idle` after `session.error`, twice after
204
+ // an abort, and again after every later `/shell` or `/summarize`: a prompt still in the map
205
+ // would be re-emitted, each time later than the last.
206
+ prompts.delete(sessionId);
207
+ release(prompt);
208
+ // OpenCode retries a 429 itself and reports it only as `session.status` `retry`, which
209
+ // carries no structured status code; the turn then ends in `session.idle` whether it
210
+ // succeeded or was cancelled. `spool-event-v1` lets `session_idle` say only `success`, so a
211
+ // retried turn states no terminal at all and backfill, which reads how it ended, decides.
212
+ if (type === "session.idle" && prompt.retried) return;
156
213
  const occurredAt = timestampOrNow(properties.time);
157
214
  const error = recordOrNull(properties.error);
158
215
  const restricted = type === "session.error" && isExplicitRateLimit(error);
159
- release(prompt);
160
216
  append(prompt.spoolDirectory, {
161
217
  schema_version: 1,
162
218
  event_id: `${type}:${sessionId}:${prompt.promptId}:${occurredAt}`,
@@ -191,103 +247,6 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
191
247
  };
192
248
  }
193
249
 
194
- /** @param {string} spoolDirectory @param {Record<string, unknown>} event */
195
- async function appendEvent(spoolDirectory, event) {
196
- await mkdir(spoolDirectory, { recursive: true, mode: 0o700 });
197
- await chmod(spoolDirectory, 0o700);
198
- const release = await acquireSpoolLock(spoolDirectory);
199
- try {
200
- const file = join(spoolDirectory, spoolFilename);
201
- try {
202
- if ((await stat(file)).size >= maxSegmentBytes) {
203
- await rename(file, join(spoolDirectory, `segment-${Date.now()}-${randomUUID()}.ndjson`));
204
- }
205
- } catch (error) {
206
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
207
- }
208
- const handle = await open(file, "a", 0o600);
209
- try {
210
- await handle.writeFile(`${JSON.stringify(event)}\n`, "utf8");
211
- await handle.sync();
212
- } finally {
213
- await handle.close();
214
- }
215
- await chmod(file, 0o600);
216
- } finally {
217
- await release();
218
- }
219
- }
220
-
221
- /** @param {string} spoolDirectory */
222
- async function acquireSpoolLock(spoolDirectory) {
223
- const lock = join(spoolDirectory, ".writer.lock");
224
- const token = randomUUID();
225
- for (let attempt = 0; attempt < 4; attempt += 1) {
226
- try {
227
- const handle = await open(lock, "wx", 0o600);
228
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}\n`, "utf8");
229
- await handle.sync();
230
- if ((await readSpoolLock(lock))?.token !== token) {
231
- await handle.close();
232
- if (attempt < 3) continue;
233
- throw new Error("Spool writer lost lock ownership.");
234
- }
235
- return async () => {
236
- await handle.close();
237
- if ((await readSpoolLock(lock))?.token === token) await rm(lock, { force: true });
238
- };
239
- } catch (error) {
240
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
241
- const owner = await readSpoolLock(lock);
242
- if (owner !== null && !processIsAlive(owner.pid)) {
243
- await rm(lock, { force: true });
244
- continue;
245
- }
246
- if (owner === null && (await lockIsStale(lock))) {
247
- await rm(lock, { force: true });
248
- continue;
249
- }
250
- if (attempt < 3) await delay(2);
251
- }
252
- }
253
- throw new Error("Spool writer is busy.");
254
- }
255
-
256
- /** @param {string} lock */
257
- async function lockIsStale(lock) {
258
- try {
259
- return Date.now() - (await stat(lock)).mtimeMs > 120_000;
260
- } catch {
261
- return false;
262
- }
263
- }
264
-
265
- /** @param {string} lock */
266
- async function readSpoolLock(lock) {
267
- try {
268
- const value = JSON.parse(await readFile(lock, "utf8"));
269
- return recordOrNull(value) &&
270
- typeof value.pid === "number" &&
271
- Number.isSafeInteger(value.pid) &&
272
- value.pid > 0 &&
273
- typeof value.token === "string"
274
- ? { pid: value.pid, token: value.token }
275
- : null;
276
- } catch {
277
- return null;
278
- }
279
- }
280
-
281
- /** @param {number} pid */
282
- function processIsAlive(pid) {
283
- try {
284
- process.kill(pid, 0);
285
- return true;
286
- } catch (error) {
287
- return error instanceof Error && "code" in error && error.code === "EPERM";
288
- }
289
- }
290
-
291
250
  /** @param {unknown} output */
292
251
  function analyzePrompt(output) {
293
252
  const parts = recordOrNull(output)?.parts;
@@ -346,8 +305,13 @@ function isExplicitRateLimit(error) {
346
305
 
347
306
  /** @param {unknown} value */
348
307
  function timestampOrNow(value) {
349
- if (typeof value === "string" && !Number.isNaN(Date.parse(value)))
350
- return new Date(value).toISOString();
308
+ if (typeof value === "string") {
309
+ const parsed = new Date(value);
310
+ const year = parsed.getUTCFullYear();
311
+ // `toISOString` spells a year outside 0000-9999 with a sign and six digits, which the schema's
312
+ // `date-time` refuses -- the line would be read back as corruption.
313
+ if (!Number.isNaN(parsed.getTime()) && year >= 0 && year <= 9999) return parsed.toISOString();
314
+ }
351
315
  return new Date().toISOString();
352
316
  }
353
317
 
@@ -0,0 +1,229 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as nodeFs from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import process from "node:process";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+
8
+ const spoolFilename = "current.open";
9
+ const maxSegmentBytes = 1024 * 1024;
10
+ /**
11
+ * A lock is held for the milliseconds one append takes; one this old was abandoned. Judged by the
12
+ * wall clock against the lock's mtime, so a clock jump of more than this, or a laptop resumed
13
+ * mid-append, can take over a lock still held. That costs at most one event; the takeover below
14
+ * and the check before a truncate keep it from costing another writer's.
15
+ */
16
+ const staleLockMs = 120_000;
17
+
18
+ /**
19
+ * The file operations the writer performs, injectable so a test can interleave two writers.
20
+ *
21
+ * @typedef {Pick<typeof import("node:fs/promises"), "chmod" | "link" | "mkdir" | "open" | "readFile" | "rename" | "rm" | "stat">} SpoolFs
22
+ */
23
+
24
+ /**
25
+ * Append one event to a spool directory's open segment, under its writer lock.
26
+ *
27
+ * @param {string} spoolDirectory
28
+ * @param {Record<string, unknown>} event
29
+ * @param {SpoolFs} [fs]
30
+ */
31
+ export async function appendEvent(spoolDirectory, event, fs = nodeFs) {
32
+ await fs.mkdir(spoolDirectory, { recursive: true, mode: 0o700 });
33
+ await fs.chmod(spoolDirectory, 0o700);
34
+ const lock = await acquireSpoolLock(spoolDirectory, fs);
35
+ try {
36
+ const file = join(spoolDirectory, spoolFilename);
37
+ try {
38
+ if ((await fs.stat(file)).size >= maxSegmentBytes) {
39
+ await fs.rename(file, join(spoolDirectory, `segment-${Date.now()}-${randomUUID()}.ndjson`));
40
+ }
41
+ } catch (error) {
42
+ if (!hasCode(error, "ENOENT")) throw error;
43
+ }
44
+ const handle = await fs.open(file, "a+", 0o600);
45
+ try {
46
+ // A write cut short -- a full disk, a file-size limit, a killed host -- leaves a line with no
47
+ // newline, and the next event appended to it is glued on and lost with it. Starting on a
48
+ // fresh line confines the damage to the line that was already broken.
49
+ const { size } = await handle.stat();
50
+ const separator = size > 0 && !(await endsWithNewline(handle, size)) ? "\n" : "";
51
+ try {
52
+ await handle.writeFile(`${separator}${JSON.stringify(event)}\n`, "utf8");
53
+ await handle.sync();
54
+ } catch (error) {
55
+ // Take back whatever part of this event landed, so the failure leaves no partial line --
56
+ // only while this writer still holds the lock. One whose lock was taken over may share the
57
+ // file with the writer that took it, and cutting back to `size` would erase that writer's
58
+ // line; a partial line is the lesser loss, and it rejects only itself.
59
+ if (await lock.holds()) await handle.truncate(size).catch(() => {});
60
+ throw error;
61
+ }
62
+ } finally {
63
+ await handle.close();
64
+ }
65
+ await fs.chmod(file, 0o600);
66
+ } finally {
67
+ await lock.release();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Take a spool directory's writer lock.
73
+ *
74
+ * A lock whose writer is gone, or which is older than any append, is taken over. Two writers can
75
+ * judge the same lock abandoned at once, and removing it by path let the slower one delete the lock
76
+ * the faster one had just created in its place, so both held it. A takeover therefore moves the
77
+ * lock aside to a name of its own -- `rename` moves exactly one file -- and only proceeds when what
78
+ * it moved is the lock it judged. Having moved a lock another writer took in the meantime, it puts
79
+ * that lock back with `link`, which never replaces a lock created since.
80
+ *
81
+ * @param {string} spoolDirectory
82
+ * @param {SpoolFs} [fs]
83
+ * @returns {Promise<{holds: () => Promise<boolean>, release: () => Promise<void>}>}
84
+ */
85
+ export async function acquireSpoolLock(spoolDirectory, fs = nodeFs) {
86
+ const lock = join(spoolDirectory, ".writer.lock");
87
+ const token = randomUUID();
88
+ const holds = async () => (await inspectLock(fs, lock))?.owner?.token === token;
89
+ for (let attempt = 0; attempt < 4; attempt += 1) {
90
+ /** @type {import("node:fs/promises").FileHandle} */
91
+ let handle;
92
+ try {
93
+ handle = await fs.open(lock, "wx", 0o600);
94
+ } catch (error) {
95
+ if (!hasCode(error, "EEXIST")) throw error;
96
+ const held = await inspectLock(fs, lock);
97
+ // Our own token in a lock we gave up on is a lock a takeover moved and put back while we
98
+ // were checking it: it is abandoned by definition.
99
+ if (held !== null && (held.owner?.token === token || isAbandoned(held))) {
100
+ await takeOver(fs, lock, held);
101
+ continue;
102
+ }
103
+ if (attempt < 3) await delay(2);
104
+ continue;
105
+ }
106
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}\n`, "utf8");
107
+ await handle.sync();
108
+ if (!(await holds())) {
109
+ await handle.close();
110
+ continue;
111
+ }
112
+ return {
113
+ holds,
114
+ async release() {
115
+ await handle.close();
116
+ const held = await inspectLock(fs, lock);
117
+ if (held?.owner?.token === token) await takeOver(fs, lock, held);
118
+ },
119
+ };
120
+ }
121
+ throw new Error("Spool writer is busy.");
122
+ }
123
+
124
+ /**
125
+ * Remove the lock `judged` described, and nothing else.
126
+ *
127
+ * @param {SpoolFs} fs
128
+ * @param {string} lock
129
+ * @param {LockState} judged
130
+ */
131
+ async function takeOver(fs, lock, judged) {
132
+ const tombstone = `${lock}.${randomUUID()}.stale`;
133
+ try {
134
+ await fs.rename(lock, tombstone);
135
+ } catch (error) {
136
+ // Another writer moved it first; whatever is there now is judged afresh.
137
+ if (hasCode(error, "ENOENT")) return;
138
+ throw error;
139
+ }
140
+ const moved = await inspectLock(fs, tombstone);
141
+ if (
142
+ moved !== null &&
143
+ moved.ino === judged.ino &&
144
+ moved.mtimeMs === judged.mtimeMs &&
145
+ moved.owner?.token === judged.owner?.token
146
+ ) {
147
+ await fs.rm(tombstone, { force: true });
148
+ return;
149
+ }
150
+ // Not the lock that was judged: a writer that took over first holds it. Put it back, unless a
151
+ // lock was created in its place meanwhile, which then stands.
152
+ try {
153
+ await fs.link(tombstone, lock);
154
+ } catch (error) {
155
+ if (!hasCode(error, "EEXIST")) await fs.rename(tombstone, lock).catch(() => {});
156
+ }
157
+ await fs.rm(tombstone, { force: true });
158
+ }
159
+
160
+ /**
161
+ * @typedef {{ino: number, mtimeMs: number, owner: {pid: number, token: string} | null}} LockState
162
+ */
163
+
164
+ /**
165
+ * @param {SpoolFs} fs
166
+ * @param {string} lock
167
+ * @returns {Promise<LockState | null>}
168
+ */
169
+ async function inspectLock(fs, lock) {
170
+ let stats;
171
+ try {
172
+ stats = await fs.stat(lock);
173
+ } catch {
174
+ return null;
175
+ }
176
+ return { ino: stats.ino, mtimeMs: stats.mtimeMs, owner: await readOwner(fs, lock) };
177
+ }
178
+
179
+ /**
180
+ * Age decides before the pid does: a pid that answers `kill(pid, 0)` may have been reused, or
181
+ * belong to another user, and a lock held that long is not being used by anyone.
182
+ *
183
+ * @param {LockState} held
184
+ */
185
+ function isAbandoned(held) {
186
+ return (
187
+ (held.owner !== null && !processIsAlive(held.owner.pid)) ||
188
+ Date.now() - held.mtimeMs > staleLockMs
189
+ );
190
+ }
191
+
192
+ /** @param {SpoolFs} fs @param {string} lock */
193
+ async function readOwner(fs, lock) {
194
+ try {
195
+ const value = JSON.parse(await fs.readFile(lock, "utf8"));
196
+ return typeof value === "object" &&
197
+ value !== null &&
198
+ typeof value.pid === "number" &&
199
+ Number.isSafeInteger(value.pid) &&
200
+ value.pid > 0 &&
201
+ typeof value.token === "string"
202
+ ? { pid: value.pid, token: value.token }
203
+ : null;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /** @param {import("node:fs/promises").FileHandle} handle @param {number} size */
210
+ async function endsWithNewline(handle, size) {
211
+ const last = Buffer.alloc(1);
212
+ await handle.read(last, 0, 1, size - 1);
213
+ return last[0] === 0x0a;
214
+ }
215
+
216
+ /** @param {number} pid */
217
+ function processIsAlive(pid) {
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ } catch (error) {
222
+ return hasCode(error, "EPERM");
223
+ }
224
+ }
225
+
226
+ /** @param {unknown} error @param {string} code */
227
+ function hasCode(error, code) {
228
+ return error instanceof Error && "code" in error && error.code === code;
229
+ }