@assuredloop/orca-bot-kit 0.26.1 → 0.28.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/book.js CHANGED
@@ -6,12 +6,15 @@
6
6
  // theirs. Beside each session's tab it holds the harness session id that
7
7
  // session is running under, and every id it ran under before.
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
11
12
  import { DatabaseSync } from 'node:sqlite';
12
13
  import { isDeepStrictEqual } from 'node:util';
13
14
  import { parse, stringify } from 'yaml';
14
15
 
16
+ import { kitFolders } from './launch.js';
17
+
15
18
  const HEADER = `# What Orca calls this bot on this machine, and where each of its sessions
16
19
  # lives. \`obk up\` writes this file; it is committed with the rest of the repo.
17
20
  #
@@ -83,11 +86,18 @@ function withHistoryListed(entry) {
83
86
  * is, never half of it, and a writer that dies leaves the old file in place:
84
87
  * the new text goes to a file of this process's own and is moved over the book,
85
88
  * which is one operation the file system either did or did not do.
89
+ *
90
+ * Whatever is already at that file's name is taken away and the file is made
91
+ * new, never written through: a session can leave a link there in its own bot
92
+ * folder, which would take the book's text out of it, and the move would make
93
+ * the book that link (#599). A link made there in between makes the write fail,
94
+ * and the old book stays whole.
86
95
  */
87
96
  export function writeBook(home, book) {
88
97
  const file = bookFile(home);
89
98
  const half = `${file}.${process.pid}.part`;
90
- writeFileSync(half, `${HEADER}${stringify(book)}`);
99
+ rmSync(half, { force: true });
100
+ writeFileSync(half, `${HEADER}${stringify(book)}`, { flag: 'wx' });
91
101
  renameSync(half, file);
92
102
  }
93
103
 
@@ -151,7 +161,7 @@ function takeLock(home) {
151
161
  try {
152
162
  return lockOn(lockFile(home), WAIT_MS);
153
163
  } catch (error) {
154
- throw waitedTooLong(home, error);
164
+ throw error instanceof LockNotWritable ? error : waitedTooLong(home, error);
155
165
  }
156
166
  }
157
167
 
@@ -162,10 +172,47 @@ function takeLock(home) {
162
172
  * `EXCLUSIVE`, alone, once every `SHARED` holder has let go.
163
173
  */
164
174
  function lockOn(file, waitMs, how = 'IMMEDIATE') {
165
- mkdirSync(path.dirname(file), { recursive: true });
175
+ // SQLite opens a file it cannot write read-only, without a word, and a
176
+ // read-only connection's BEGIN passes while another process holds the lock:
177
+ // seen in Codex's sandbox (#534). So the file is opened for writing first,
178
+ // and a lock that cannot be held is an error, never a turn that is not one.
179
+ // Only while this process holds no turn on it: closing any descriptor of a
180
+ // file lets go of every lock the process holds on that file, and SQLite's
181
+ // own are among them. A file a turn is held on was written already.
182
+ //
183
+ // A session can write its bot's folder of locks, so a link, a FIFO or a
184
+ // folder may sit at the lock's name, and a lock taken through a link would
185
+ // make or write a file anywhere the kit may write (#605). So the kit opens
186
+ // the file itself without following a link, and SQLite opens it only if it
187
+ // is there and makes nothing. A link put at the name only while SQLite
188
+ // opens it is followed, and SQLite names the file it opened, so a file of
189
+ // any other name is closed before it is locked or written. And the turn
190
+ // ends with ROLLBACK, so SQLite never writes the file it locks.
191
+ //
192
+ // What is left, accepted by the architect on #605: SQLite reads the first
193
+ // 100 bytes of the file it opens before the kit can ask its name, so such a
194
+ // link can have a file outside read into SQLite's memory and dropped; and a
195
+ // FIFO put there that instant can hang the command. A lock taken by name
196
+ // cannot close that without a lock Node does not have.
197
+ if (!heldHere.has(file)) {
198
+ try {
199
+ mkdirSync(path.dirname(file), { recursive: true });
200
+ plainFile(file);
201
+ } catch (error) {
202
+ throw new LockNotWritable(file, error);
203
+ }
204
+ }
166
205
 
167
- const db = new DatabaseSync(file);
206
+ let db;
207
+ try {
208
+ db = new DatabaseSync(new URL(`${pathToFileURL(file).href}?mode=rw`));
209
+ } catch (error) {
210
+ throw new LockNotWritable(file, error);
211
+ }
168
212
  try {
213
+ if (db.location() !== path.join(realpathSync(path.dirname(file)), path.basename(file))) {
214
+ throw new LockNotWritable(file, notPlain(`a link at it led SQLite to ${db.location()}`));
215
+ }
169
216
  // A writer that arrives while another is working waits for it rather than
170
217
  // failing at once, and gives up saying so rather than waiting for ever.
171
218
  db.exec(`PRAGMA busy_timeout = ${waitMs}`);
@@ -181,17 +228,48 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
181
228
  throw error;
182
229
  }
183
230
 
231
+ heldHere.set(file, (heldHere.get(file) ?? 0) + 1);
184
232
  return {
185
233
  release() {
186
234
  try {
187
- db.exec('COMMIT');
235
+ db.exec('ROLLBACK');
188
236
  } finally {
189
237
  db.close();
238
+ const left = heldHere.get(file) - 1;
239
+ if (left > 0) heldHere.set(file, left);
240
+ else heldHere.delete(file);
190
241
  }
191
242
  },
192
243
  };
193
244
  }
194
245
 
246
+ /**
247
+ * Make the lock file at `file` if it is not there, and open it for writing
248
+ * without following a link or waiting on a FIFO. Anything at the name but a
249
+ * plain file is refused.
250
+ */
251
+ function plainFile(file) {
252
+ let fd;
253
+ try {
254
+ fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
255
+ } catch (error) {
256
+ // A link, a folder and a FIFO with no reader, in that order.
257
+ if (['ELOOP', 'EISDIR', 'ENXIO'].includes(error.code)) throw notPlain(error.code);
258
+ throw error;
259
+ }
260
+ try {
261
+ if (!fstatSync(fd).isFile()) throw notPlain('not a plain file');
262
+ } finally {
263
+ closeSync(fd);
264
+ }
265
+ }
266
+
267
+ const NOT_PLAIN = 'ENOTPLAIN';
268
+ const notPlain = (message) => Object.assign(new Error(message), { code: NOT_PLAIN });
269
+
270
+ /** How many turns this process holds on each lock file, by its path. */
271
+ const heldHere = new Map();
272
+
195
273
  /**
196
274
  * Where a writer takes its turn: a folder of the kit's own beside the bots repo,
197
275
  * carrying that folder's own name, the way start-prompt files sit beside it
@@ -213,14 +291,56 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
213
291
  * the bots folder through a link and another by its real path, and two paths
214
292
  * would be two locks for one book (#375).
215
293
  */
216
- const lockFile = (home) => {
294
+ const lockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.lock`);
295
+
296
+ /**
297
+ * The bot's own folder of locks, `<bots>.locks/<bot>/`: the one a Codex
298
+ * session of the bot is given to write, beside its mail and its start prompts
299
+ * (#534). One folder per bot, so a bot's sessions can take each other's turns
300
+ * and no other bot's.
301
+ */
302
+ const botLocks = (home) => {
217
303
  const real = realpathSync(home);
218
- return path.join(
219
- `${path.dirname(path.dirname(real))}.locks`,
220
- `${encodeURIComponent(path.basename(real))}.lock`,
221
- );
304
+ return kitFolders(path.dirname(path.dirname(real)), path.basename(real)).locks;
222
305
  };
223
306
 
307
+ /**
308
+ * A lock the kit cannot write. Inside Codex's sandbox that is a session
309
+ * started before the kit gave its bot a folder of locks, so it says what puts
310
+ * it right.
311
+ */
312
+ class LockNotWritable extends Error {
313
+ constructor(file, why) {
314
+ super(why.code === NOT_PLAIN
315
+ ? `something other than a plain file is at the name of the kit's lock ${file} (${why.message}), so the kit did not take the turn, and nothing was done. Remove ${file}; the kit makes it again.`
316
+ : `the kit cannot write its lock ${file} (${why.code ?? why.message}), so it did not take the turn, and nothing was done. A Codex session started before this version of the kit cannot write its bot's folder of locks: restart this session with the kit. Elsewhere, make ${path.dirname(file)} writable.`);
317
+ this.code = why.code;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The right to change the bot's `bot.yaml` (#595), held from the read a command
323
+ * makes its change on until that change is written. Two commands that change
324
+ * one bot at the same time then each change what the other left, and neither
325
+ * change is lost.
326
+ *
327
+ * The book's kind of lock, on a file of its own beside the book's: `temp make`
328
+ * writes bot.yaml while it holds the book's, and nothing takes the book's while
329
+ * it holds this one, so neither waits on the other.
330
+ */
331
+ export function takeBotTurn(home) {
332
+ const file = botLockFile(home);
333
+ try {
334
+ return lockOn(file, WAIT_MS);
335
+ } catch (error) {
336
+ if (error instanceof LockNotWritable) throw error;
337
+ throw new Error(`waited ${WAIT_MS / 1000} seconds for something else to finish writing ${path.join(home, 'bot.yaml')} and it did not (${error.message}). bot.yaml was not changed. Try again; if nothing is running, remove ${file}, which is where writers take their turn.`);
338
+ }
339
+ }
340
+
341
+ /** Beside the book's lock, with the bot's name: the lock files of its sessions carry one more part. */
342
+ const botLockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.bot.lock`);
343
+
224
344
  /**
225
345
  * One session's turn at its mailbox (#321): held by `session mailbox` for its
226
346
  * whole step, by `message check` in the session's own tab for its bind, read and
@@ -286,11 +406,11 @@ export function takeTypingTurn(home, session, waitMs) {
286
406
 
287
407
  /**
288
408
  * A session's turn for one whole line, sent with its return in one go, as the
289
- * mail nudge is: through the typing turn's gate, waited for `waitMs` at most,
409
+ * mail interrupt is: through the typing turn's gate, waited for `waitMs` at most,
290
410
  * and then held beside any other line, so whole lines never wait on each
291
411
  * other, only on typing one key at a time. Returns `{ release }`, or undefined
292
412
  * when the gate did not come within `waitMs`. Every whole line the kit types
293
- * takes it: the nudge, the list line, the skills reload and the grooming line (#482).
413
+ * takes it: the mail interrupt, the list line, the skills reload and the grooming line (#482).
294
414
  */
295
415
  export function takeLineTurn(home, session, waitMs) {
296
416
  const gate = turnOn(home, session, 'typing', waitMs);
@@ -312,8 +432,9 @@ export const TYPING_WAIT_MS = 5000;
312
432
  export const TYPING_HELD = `the kit is typing into it, and it was still at it after ${TYPING_WAIT_MS / 1000} s, so nothing was typed`;
313
433
 
314
434
  /**
315
- * How long a typing turn waits for the lines already on their way: a nudge
316
- * holds its line's turn while Orca waits up to 5 s to see it start a turn.
435
+ * How long a typing turn waits for the lines already on their way: a line
436
+ * re-issued to Orca holds its turn while Orca waits up to 5 s to see it start
437
+ * a turn.
317
438
  */
318
439
  const LINES_WAIT_MS = 10_000;
319
440
 
@@ -344,10 +465,8 @@ const SQLITE_BUSY = 5;
344
465
  * link by one command and by its real path by another, and two paths would be
345
466
  * two turns.
346
467
  */
347
- const mailboxLockFile = (home, session) => {
348
- const real = realpathSync(home);
349
- return path.join(`${path.dirname(path.dirname(real))}.locks`, `${path.basename(real)}.${session}.mailbox.lock`);
350
- };
468
+ const mailboxLockFile = (home, session) =>
469
+ path.join(botLocks(home), `${path.basename(realpathSync(home))}.${session}.mailbox.lock`);
351
470
 
352
471
  /**
353
472
  * Something else has been writing the book for longer than this run is prepared
@@ -429,10 +548,11 @@ export function rememberSession(entry = {}, id, ended) {
429
548
 
430
549
  /**
431
550
  * The session entry once the id it held has turned out to have no conversation
432
- * behind it: the id goes into the history with that reason, and the entry holds
433
- * none until the harness names the new one.
551
+ * behind it on the session's harness: the id goes into the history with that
552
+ * reason, and anything else worth knowing about it, such as the harness it ran
553
+ * on, and the entry holds none until the harness names the new one.
434
554
  */
435
- export function forgetSession(entry, ended) {
555
+ export function forgetSession(entry, ended, more = {}) {
436
556
  const { session, ...rest } = entry;
437
- return { ...rest, history: [...(entry.history ?? []), { session, ended, at: new Date().toISOString() }] };
557
+ return { ...rest, history: [...(entry.history ?? []), { session, ended, ...more, at: new Date().toISOString() }] };
438
558
  }