@fastagent-sh/fastagent 0.30.0 → 0.30.1

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.
@@ -13,6 +13,9 @@ export function buildProgram(specs, options = {}) {
13
13
  // The exit-code policy: commander throws only for parse-level events — help/version displays carry exitCode 0 (→
14
14
  // 0), everything else it rejects is a usage error (→ 2).
15
15
  program.exitOverride((err) => exit(err.exitCode === 0 ? 0 : 2));
16
+ // commander reads a program option anywhere by default, a subcommand's values included: an `invoke --session <id>`
17
+ // whose id begins with "-v" (base64url, 1 in 4096) printed the version and exited 0. Only before the subcommand.
18
+ program.enablePositionalOptions();
16
19
  program.configureHelp({
17
20
  ...(options.helpWidth !== undefined ? { helpWidth: options.helpWidth } : {}),
18
21
  styleTitle: title,
@@ -41,6 +41,13 @@ export interface Ticket {
41
41
  }
42
42
  /** What the step's process says while it runs: the place to look when it fails. */
43
43
  export declare const ticketLogPath: (id: string) => string;
44
+ /**
45
+ * End the ticket `id` with `end`, unless something ended it first: the end is a file created once, by a hard link,
46
+ * which the kernel lets only one writer make, so two that both read the ticket as running (the step finishing as a
47
+ * person stops it) end it once. Answers the ticket as whoever ended it ended it. Not public: `update` ends a ticket,
48
+ * and a test seam.
49
+ */
50
+ export declare function endTicket(id: string, end: Partial<Ticket>): Ticket;
44
51
  export interface TicketRequest {
45
52
  subject: string;
46
53
  action: string;
@@ -6,10 +6,11 @@
6
6
  * there is something for the person to do. The command's `--check <ticket>` reads where it stands from any process,
7
7
  * and hands the step the address a browser ended on when that browser could not reach this machine.
8
8
  *
9
- * A ticket is a file in the machine's own state, ~/.fastagent/.state/tickets, written whole (`writeFileAtomic`), but by
10
- * more than one process at once: the starting command until the step starts, then the step, and whoever finds its
11
- * process gone or replaces it (every `--check`, a newer step for the subject). One that has
12
- * ended stays as it ended. A newer step for the same subject stops an older one still waiting on its person, whose
9
+ * A ticket is two files in the machine's own state, ~/.fastagent/.state/tickets. One is the step as its process keeps
10
+ * it, written by one process at a time: the starting command until the step starts, then the step. The other is its
11
+ * end, written once, by whoever ends it first: the step itself, or a process that stops it, replaces it or finds it
12
+ * gone. An end is decided before what it decides is done, so a stop is the end of the step it stops even when the step
13
+ * hears the consequences of being stopped first, and nothing the step writes after reopens it. A newer step for the same subject stops an older one still waiting on its person, whose
13
14
  * process would otherwise hold what it holds (a sign-in's callback port, OpenAI's fixed) until it expires; so does one
14
15
  * still getting ready (reaching a box, opening a tunnel), which may hang there. One finishing what its person did (a
15
16
  * code exchanged, an app created from a pasted token) is never stopped halfway, and the newer attempt is answered
@@ -19,7 +20,7 @@
19
20
  * stopped with it, by `stop` and when it exits.
20
21
  */
21
22
  import { randomBytes } from "node:crypto";
22
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, } from "node:fs";
23
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
23
24
  import { spawn } from "node:child_process";
24
25
  import { join } from "node:path";
25
26
  import { stripVTControlCharacters } from "node:util";
@@ -33,6 +34,7 @@ const ADDRESS_TIMEOUT_MS = 15_000;
33
34
  /** How long a stopped step's process has to let go of what it holds before it is killed. */
34
35
  const STOP_TIMEOUT_MS = 5_000;
35
36
  const POLL_MS = 200;
37
+ const DAY_MS = 24 * 60 * 60 * 1000;
36
38
  /** Ticket files are random hex: anything else names no ticket, and no file outside the directory. */
37
39
  const TICKET_ID = /^[0-9a-f]{16}$/;
38
40
  const ticketsDir = () => join(globalHome(), STATE_DIRNAME, "tickets");
@@ -40,11 +42,10 @@ const ticketPath = (id) => join(ticketsDir(), `${id}.json`);
40
42
  const addressPath = (id) => join(ticketsDir(), `${id}.address`);
41
43
  /** What the step's process says while it runs: the place to look when it fails. */
42
44
  export const ticketLogPath = (id) => join(ticketsDir(), `${id}.log`);
43
- function readTicket(id) {
44
- if (!TICKET_ID.test(id))
45
- return undefined;
45
+ const endPath = (id) => join(ticketsDir(), `${id}.end.json`);
46
+ function readJson(path) {
46
47
  try {
47
- return JSON.parse(readFileSync(ticketPath(id), "utf8"));
48
+ return JSON.parse(readFileSync(path, "utf8"));
48
49
  }
49
50
  catch (error) {
50
51
  if (error.code === "ENOENT")
@@ -52,16 +53,45 @@ function readTicket(id) {
52
53
  throw error;
53
54
  }
54
55
  }
56
+ /** The ticket `id`: the step as its process keeps it, with its end over it once it has one. */
57
+ function readTicket(id) {
58
+ if (!TICKET_ID.test(id))
59
+ return undefined;
60
+ const kept = readJson(ticketPath(id));
61
+ if (kept === undefined)
62
+ return undefined;
63
+ const end = readJson(endPath(id));
64
+ return end === undefined ? kept : { ...kept, ...end };
65
+ }
66
+ /** The step as its process keeps it: one writer at a time, the starting command and then the step. */
67
+ const writeTicket = (ticket) => writeFileAtomic(ticketPath(ticket.ticket), JSON.stringify(ticket));
68
+ const waiting = (ticket) => ticket.status === "starting" || ticket.status === "need_user_action" || ticket.status === "working";
69
+ const ENDS = ["done", "failed", "expired"];
55
70
  /**
56
- * A ticket is written by several processes: the command that started the step, the step's own process, and every
57
- * `--check`. Each writes a temp of its own (`sharedWriters`): the fixed name is for one writer, and two of them rename
58
- * each other's file away or write one temp at once, which a reader sees as `Unexpected non-whitespace character after JSON`.
71
+ * End the ticket `id` with `end`, unless something ended it first: the end is a file created once, by a hard link,
72
+ * which the kernel lets only one writer make, so two that both read the ticket as running (the step finishing as a
73
+ * person stops it) end it once. Answers the ticket as whoever ended it ended it. Not public: `update` ends a ticket,
74
+ * and a test seam.
59
75
  */
60
- const writeTicket = (ticket) => writeFileAtomic(ticketPath(ticket.ticket), JSON.stringify(ticket), undefined, true);
61
- const waiting = (ticket) => ticket.status === "starting" || ticket.status === "need_user_action" || ticket.status === "working";
76
+ export function endTicket(id, end) {
77
+ const draft = `${endPath(id)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
78
+ writeFileSync(draft, JSON.stringify(end));
79
+ try {
80
+ linkSync(draft, endPath(id));
81
+ }
82
+ catch (error) {
83
+ if (error.code !== "EEXIST")
84
+ throw error;
85
+ }
86
+ finally {
87
+ rmSync(draft, { force: true });
88
+ }
89
+ return readTicket(id);
90
+ }
62
91
  /**
63
- * Change the ticket `id` while its step still runs; one that has ended is returned as it ended. A writer that read it
64
- * before it ended (a check that found its process gone the moment it finished) cannot reopen it.
92
+ * Change the ticket `id` while its step still runs; one that has ended is returned as it ended. An end is
93
+ * `endTicket`'s, so the first one written is the one that stays; anything else is the step's own record, which its
94
+ * process alone writes.
65
95
  */
66
96
  function update(id, change) {
67
97
  const current = readTicket(id);
@@ -69,6 +99,8 @@ function update(id, change) {
69
99
  throw new Error(`ticket ${id} is gone from ${ticketsDir()}`);
70
100
  if (!waiting(current))
71
101
  return current;
102
+ if (change.status !== undefined && ENDS.includes(change.status))
103
+ return endTicket(id, change);
72
104
  const next = { ...current, ...change };
73
105
  writeTicket(next);
74
106
  return next;
@@ -148,24 +180,23 @@ async function supersede(subject, now) {
148
180
  if (finishing !== undefined)
149
181
  return finishing;
150
182
  for (const ticket of runs) {
183
+ update(ticket.ticket, { status: "failed", message: `replaced by a newer attempt to ${ticket.action}` });
151
184
  if (ticket.pid !== undefined)
152
185
  await stop(ticket.pid);
153
- update(ticket.ticket, { status: "failed", message: `replaced by a newer attempt to ${ticket.action}` });
154
186
  }
155
187
  return undefined;
156
188
  }
157
- /** Tickets a day past their end: each one's files go. */
189
+ /** Tickets a day past their end: every file of each one goes. */
158
190
  function prune(now) {
159
191
  if (!existsSync(ticketsDir()))
160
192
  return;
161
- for (const file of readdirSync(ticketsDir())) {
162
- const id = file.slice(0, 16);
163
- const ticket = TICKET_ID.test(id) ? readTicket(id) : undefined;
164
- if (ticket !== undefined && ticket.expiresAt < now - 24 * 60 * 60 * 1000) {
165
- for (const path of [ticketPath(id), addressPath(id), ticketLogPath(id)])
166
- rmSync(path, { force: true });
167
- }
168
- }
193
+ const files = readdirSync(ticketsDir());
194
+ const past = new Set(files
195
+ .map((file) => file.slice(0, 16))
196
+ .filter((id) => TICKET_ID.test(id) && (readTicket(id)?.expiresAt ?? Number.POSITIVE_INFINITY) < now - DAY_MS));
197
+ for (const file of files)
198
+ if (past.has(file.slice(0, 16)))
199
+ rmSync(join(ticketsDir(), file), { force: true });
169
200
  }
170
201
  /** A new ticket, its step not started yet. Not public: what `startTicket` starts, and a test seam. */
171
202
  export function createTicket(request) {
@@ -229,13 +260,10 @@ export async function startTicket(input) {
229
260
  // Claimed and at work: the step writes the ticket now, so this command reports it as it is.
230
261
  if (!exited && !input.waitsFirst && ticket.status === "working")
231
262
  return ticket;
232
- // Given up on, unclaimed or giving the person nothing: its process goes first, or it would hold its port and write
233
- // the ticket after this.
263
+ // Given up on, unclaimed or giving the person nothing: the end first, then its process, which would hold its port.
234
264
  const stoppedItself = exited;
235
- if (!stoppedItself && child.pid !== undefined)
236
- await stop(child.pid);
237
265
  const within = `${startTimeoutMs / 1000}s: ${ticketLogPath(id)} says why`;
238
- return update(id, {
266
+ const ended = update(id, {
239
267
  status: "failed",
240
268
  message: stoppedItself
241
269
  ? `it stopped before it started: ${ticketLogPath(id)} says why`
@@ -243,6 +271,9 @@ export async function startTicket(input) {
243
271
  ? `it did not start within ${within}`
244
272
  : `it gave the person nothing to do within ${within}`,
245
273
  });
274
+ if (!stoppedItself && child.pid !== undefined)
275
+ await stop(child.pid);
276
+ return ended;
246
277
  }
247
278
  /**
248
279
  * Where the step `id` stands. `address`: what the person's browser ended on (or the code its page showed), for a step
@@ -278,12 +309,13 @@ export async function stopTicket(id) {
278
309
  throw new Error(`ticket ${id} is gone from ${ticketsDir()}`);
279
310
  if (!waiting(ticket))
280
311
  return ticket;
281
- if (ticket.pid !== undefined)
282
- await stop(ticket.pid);
283
- return update(id, {
312
+ const ended = update(id, {
284
313
  status: "failed",
285
314
  message: `stopped on request; what it was doing may be left half-done (${ticketLogPath(id)} says where it was)`,
286
315
  });
316
+ if (ticket.pid !== undefined)
317
+ await stop(ticket.pid);
318
+ return ended;
287
319
  }
288
320
  /**
289
321
  * Why the command whose `--check` is `check` cannot read ticket `id`: there is none on this machine, or it is another
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on Telegram, Slack, Feishu, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",