@abaigram/abai-stub 0.1.0 → 0.2.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/dist/audit.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type DecisionDecl } from "./manifest/manifest.ts";
2
- export type AuditAct = "capability" | "knob" | "decision" | "turn" | "lifecycle" | "counters";
2
+ export type AuditAct = "capability" | "knob" | "decision" | "turn" | "lifecycle" | "counters" | "message";
3
3
  export type AuditOutcome = "ok" | "error" | "forbidden" | "agent" | "default" | "stub" | "failed";
4
4
  export interface AuditRecord {
5
5
  ts: string;
@@ -28,8 +28,40 @@ export interface AuditRecord {
28
28
  /** Counters only. */
29
29
  count?: number;
30
30
  }
31
+ /**
32
+ * One message sent to or received from a member of the run's network
33
+ * (section 10), as the harness hands it to the stub for the audit log. The
34
+ * stub writes it as `act: message` with the counterpart's name as `name`,
35
+ * the envelope id as `id`, the body capped as `value`.
36
+ */
37
+ export interface MessageRecord {
38
+ direction: "sent" | "received";
39
+ /** The counterpart's name as the network's authority issued it, else its address. */
40
+ peer: string;
41
+ addr: string;
42
+ network: string;
43
+ /** The envelope id, when the payload carried one. */
44
+ id?: string;
45
+ thread?: string;
46
+ re?: string;
47
+ /** `msg` is an aiap message; `raw` a plain payload with no envelope. */
48
+ kind: "msg" | "raw";
49
+ body: string;
50
+ outcome: "ok" | "error";
51
+ /** The agent's stated reason, for a send. */
52
+ reason?: string;
53
+ error?: string;
54
+ /** Milliseconds a send took. */
55
+ ms?: number;
56
+ }
57
+ /** Whether a value is a `MessageRecord`: what the sidecar endpoint checks before it writes what a harness sent. */
58
+ export declare function isMessageRecord(value: unknown): value is MessageRecord;
31
59
  /** Bytes a string or the JSON of a value may take in a record before it is cut. */
32
60
  export declare const AUDIT_VALUE_CAP = 512;
61
+ /** Bytes a name, an id or an address from the network may take in a message record. */
62
+ export declare const AUDIT_FIELD_CAP = 256;
63
+ /** A string the harness or the network supplied, cut to `maxBytes` for a record. */
64
+ export declare function capField(value: string, maxBytes?: number): string;
33
65
  /** Bytes `tail` reads from the end of the file. */
34
66
  export declare const AUDIT_TAIL_BYTES: number;
35
67
  /**
@@ -59,8 +91,8 @@ export declare class AuditLog {
59
91
  constructor(path: string, onFailure?: (error: string) => void);
60
92
  /** The error that latched the log off, if any. */
61
93
  get failed(): string | undefined;
62
- /** Append one record. Never throws; a no-op once the log failed or was closed. */
63
- append(record: Omit<AuditRecord, "ts" | "pid">): void;
94
+ /** Append one record. Never throws; a no-op once the log failed or was closed. True when the line was written. */
95
+ append(record: Omit<AuditRecord, "ts" | "pid">): boolean;
64
96
  private fail;
65
97
  /** Close the file descriptor; later appends are dropped. Idempotent; safe inside `process.on("exit")`. */
66
98
  close(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAWA,OAAO,EAAW,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEpE,MAAM,MAAM,QAAQ,GAAG,YAAY,GAAG,MAAM,GAAG,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,UAAU,CAAC;AAC9F,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,OAAO,GAAG,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,QAAQ,CAAC;AAElG,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,QAAQ,CAAC;IACd,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,+CAA+C;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,YAAY,CAAC;IACtB,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4CAA4C;IAC5C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,qBAAqB;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,mFAAmF;AACnF,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,mDAAmD;AACnD,eAAO,MAAM,gBAAgB,QAAc,CAAC;AAM5C;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAWhD;AAMD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAI5F;AAED,MAAM,WAAW,SAAS;IACzB,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,6CAA6C;IAC7C,SAAS,EAAE,OAAO,CAAC;CACnB;AAED,qBAAa,QAAQ;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwC;IAClE,OAAO,CAAC,EAAE,CAAqB;IAC/B,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,MAAM,CAAS;IAEvB,YAAY,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,EAG5D;IAED,kDAAkD;IAClD,IAAI,MAAM,IAAI,MAAM,GAAG,SAAS,CAE/B;IAED,kFAAkF;IAClF,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,KAAK,CAAC,GAAG,IAAI,CAYpD;IAED,OAAO,CAAC,IAAI;IAUZ,0GAA0G;IAC1G,KAAK,IAAI,IAAI,CAUZ;IAED,uFAAuF;IACvF,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CA6C7B;CACD","sourcesContent":["// The audit log (abaigrammar section 10).\n//\n// One JSON line per act of control: a mutate capability call, a knob write\n// by any path, a decision's outcome, and the counters of defaulted decisions\n// flushed at attach, close and exit. Written synchronously, one line per\n// act: acts are rare and a partial line on a crash is worse than a syscall.\n// Nothing here throws into the agent: the first failure latches the log off,\n// reports once through `onFailure`, and every later append is a no-op.\n// `tail` reads the log back for the harness.\nimport { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { cutUtf8, type DecisionDecl } from \"./manifest/manifest.ts\";\n\nexport type AuditAct = \"capability\" | \"knob\" | \"decision\" | \"turn\" | \"lifecycle\" | \"counters\";\nexport type AuditOutcome = \"ok\" | \"error\" | \"forbidden\" | \"agent\" | \"default\" | \"stub\" | \"failed\";\n\nexport interface AuditRecord {\n\tts: string;\n\tpid: number;\n\t/** The connection that acted (`k1`, `k2`, ...); absent for counters. */\n\tconn?: string;\n\tact: AuditAct;\n\t/** The capability, cell, decision or counter name. */\n\tname: string;\n\t/** The request id or hook id. */\n\tid?: string;\n\t/** The parameters (capped, see `capValue`). */\n\tparams?: unknown;\n\t/** A knob's landed value, or a decision answer's summary. */\n\tvalue?: unknown;\n\toutcome: AuditOutcome;\n\t/** Why a default or stub answer applied, or what a counter counts. */\n\tcause?: string;\n\t/** What the harness stated for an act whose policy is `confirm`: approved, auto, or denied. */\n\tconsent?: string;\n\t/** The agent's stated reason. */\n\treason?: unknown;\n\terror?: unknown;\n\t/** Milliseconds from dispatch to settle. */\n\tms?: number;\n\t/** Counters only. */\n\tcount?: number;\n}\n\n/** Bytes a string or the JSON of a value may take in a record before it is cut. */\nexport const AUDIT_VALUE_CAP = 512;\n/** Bytes `tail` reads from the end of the file. */\nexport const AUDIT_TAIL_BYTES = 1024 * 1024;\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Cap a value for a record: a value whose JSON exceeds the cap becomes\n * `{truncated: true, bytes, head}` with the JSON's first 512 characters, so\n * the record stays valid JSON. A value that cannot be serialized becomes a\n * marker string.\n */\nexport function capValue(value: unknown): unknown {\n\tif (value === undefined) return undefined;\n\tlet json: string;\n\ttry {\n\t\tjson = JSON.stringify(value) ?? \"null\";\n\t} catch (error) {\n\t\treturn `<unserializable: ${errorMessage(error)}>`;\n\t}\n\tconst bytes = Buffer.byteLength(json, \"utf8\");\n\tif (bytes <= AUDIT_VALUE_CAP) return value;\n\treturn { truncated: true, bytes, head: cutUtf8(json, AUDIT_VALUE_CAP) };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * What an audit record keeps of a decision answer: never the transcript.\n * An options decision keeps the chosen option; any other answer keeps\n * whether it changed something (a non-empty object).\n */\nexport function summarizeAnswer(decision: DecisionDecl | undefined, answer: unknown): unknown {\n\tif (answer === undefined) return undefined;\n\tif (decision?.options && typeof answer === \"string\") return { option: answer };\n\treturn { changed: Object.keys(isRecord(answer) ? answer : {}).length > 0 };\n}\n\nexport interface AuditTail {\n\trecords: unknown[];\n\t/** More records exist than were returned. */\n\ttruncated: boolean;\n}\n\nexport class AuditLog {\n\treadonly path: string;\n\tprivate readonly onFailure: ((error: string) => void) | undefined;\n\tprivate fd: number | undefined;\n\tprivate _failed: string | undefined;\n\tprivate closed = false;\n\n\tconstructor(path: string, onFailure?: (error: string) => void) {\n\t\tthis.path = path;\n\t\tthis.onFailure = onFailure;\n\t}\n\n\t/** The error that latched the log off, if any. */\n\tget failed(): string | undefined {\n\t\treturn this._failed;\n\t}\n\n\t/** Append one record. Never throws; a no-op once the log failed or was closed. */\n\tappend(record: Omit<AuditRecord, \"ts\" | \"pid\">): void {\n\t\tif (this._failed !== undefined || this.closed) return;\n\t\ttry {\n\t\t\tif (this.fd === undefined) {\n\t\t\t\tmkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });\n\t\t\t\tthis.fd = openSync(this.path, \"a\", 0o600);\n\t\t\t}\n\t\t\tconst full: AuditRecord = { ts: new Date().toISOString(), pid: process.pid, ...record };\n\t\t\twriteSync(this.fd, `${JSON.stringify(full)}\\n`);\n\t\t} catch (error) {\n\t\t\tthis.fail(errorMessage(error));\n\t\t}\n\t}\n\n\tprivate fail(error: string): void {\n\t\tthis._failed = error;\n\t\tthis.close();\n\t\ttry {\n\t\t\tthis.onFailure?.(error);\n\t\t} catch {\n\t\t\t// a failure callback must not fail the caller\n\t\t}\n\t}\n\n\t/** Close the file descriptor; later appends are dropped. Idempotent; safe inside `process.on(\"exit\")`. */\n\tclose(): void {\n\t\tthis.closed = true;\n\t\tif (this.fd === undefined) return;\n\t\tconst fd = this.fd;\n\t\tthis.fd = undefined;\n\t\ttry {\n\t\t\tcloseSync(fd);\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\n\t/** The last `limit` records, read from the last megabyte of the file. Never throws. */\n\ttail(limit: number): AuditTail {\n\t\tlet fd: number | undefined;\n\t\ttry {\n\t\t\tfd = openSync(this.path, \"r\");\n\t\t\tconst size = fstatSync(fd).size;\n\t\t\tconst start = Math.max(0, size - AUDIT_TAIL_BYTES);\n\t\t\tconst buffer = Buffer.alloc(size - start);\n\t\t\tlet read = 0;\n\t\t\twhile (read < buffer.length) {\n\t\t\t\tconst n = readSync(fd, buffer, read, buffer.length - read, start + read);\n\t\t\t\tif (n === 0) break;\n\t\t\t\tread += n;\n\t\t\t}\n\t\t\tconst lines = buffer.toString(\"utf8\", 0, read).split(\"\\n\");\n\t\t\t// the window may start mid-line: drop the first line unless the byte before it ends a line\n\t\t\tif (start > 0) {\n\t\t\t\tconst before = Buffer.alloc(1);\n\t\t\t\tconst n = readSync(fd, before, 0, 1, start - 1);\n\t\t\t\tif (n !== 1 || before[0] !== 0x0a) lines.shift();\n\t\t\t}\n\t\t\tconst records: unknown[] = [];\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (line === \"\") continue;\n\t\t\t\ttry {\n\t\t\t\t\trecords.push(JSON.parse(line));\n\t\t\t\t} catch {\n\t\t\t\t\trecords.push({ raw: line });\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst truncated = start > 0 || records.length > limit;\n\t\t\treturn { records: records.slice(-limit), truncated };\n\t\t} catch (error) {\n\t\t\t// no log yet, or a path that can never hold one (the failure is in `failed`)\n\t\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\t\tif (code === \"ENOENT\" || code === \"ENOTDIR\") return { records: [], truncated: false };\n\t\t\treturn { records: [{ error: errorMessage(error) }], truncated: false };\n\t\t} finally {\n\t\t\tif (fd !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\tcloseSync(fd);\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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","sourcesContent":["// The audit log (abaigrammar section 10).\n//\n// One JSON line per act of control: a mutate capability call, a knob write\n// by any path, a decision's outcome, and the counters of defaulted decisions\n// flushed at attach, close and exit. Written synchronously, one line per\n// act: acts are rare and a partial line on a crash is worse than a syscall.\n// Nothing here throws into the agent: the first failure latches the log off,\n// reports once through `onFailure`, and every later append is a no-op.\n// `tail` reads the log back for the harness.\nimport { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { cutUtf8, type DecisionDecl } from \"./manifest/manifest.ts\";\n\nexport type AuditAct = \"capability\" | \"knob\" | \"decision\" | \"turn\" | \"lifecycle\" | \"counters\" | \"message\";\nexport type AuditOutcome = \"ok\" | \"error\" | \"forbidden\" | \"agent\" | \"default\" | \"stub\" | \"failed\";\n\nexport interface AuditRecord {\n\tts: string;\n\tpid: number;\n\t/** The connection that acted (`k1`, `k2`, ...); absent for counters. */\n\tconn?: string;\n\tact: AuditAct;\n\t/** The capability, cell, decision or counter name. */\n\tname: string;\n\t/** The request id or hook id. */\n\tid?: string;\n\t/** The parameters (capped, see `capValue`). */\n\tparams?: unknown;\n\t/** A knob's landed value, or a decision answer's summary. */\n\tvalue?: unknown;\n\toutcome: AuditOutcome;\n\t/** Why a default or stub answer applied, or what a counter counts. */\n\tcause?: string;\n\t/** What the harness stated for an act whose policy is `confirm`: approved, auto, or denied. */\n\tconsent?: string;\n\t/** The agent's stated reason. */\n\treason?: unknown;\n\terror?: unknown;\n\t/** Milliseconds from dispatch to settle. */\n\tms?: number;\n\t/** Counters only. */\n\tcount?: number;\n}\n\n/**\n * One message sent to or received from a member of the run's network\n * (section 10), as the harness hands it to the stub for the audit log. The\n * stub writes it as `act: message` with the counterpart's name as `name`,\n * the envelope id as `id`, the body capped as `value`.\n */\nexport interface MessageRecord {\n\tdirection: \"sent\" | \"received\";\n\t/** The counterpart's name as the network's authority issued it, else its address. */\n\tpeer: string;\n\taddr: string;\n\tnetwork: string;\n\t/** The envelope id, when the payload carried one. */\n\tid?: string;\n\tthread?: string;\n\tre?: string;\n\t/** `msg` is an aiap message; `raw` a plain payload with no envelope. */\n\tkind: \"msg\" | \"raw\";\n\tbody: string;\n\toutcome: \"ok\" | \"error\";\n\t/** The agent's stated reason, for a send. */\n\treason?: string;\n\terror?: string;\n\t/** Milliseconds a send took. */\n\tms?: number;\n}\n\nfunction optionalString(value: unknown): boolean {\n\treturn value === undefined || typeof value === \"string\";\n}\n\n/** Whether a value is a `MessageRecord`: what the sidecar endpoint checks before it writes what a harness sent. */\nexport function isMessageRecord(value: unknown): value is MessageRecord {\n\tif (typeof value !== \"object\" || value === null) return false;\n\tconst r = value as Record<string, unknown>;\n\treturn (\n\t\t(r.direction === \"sent\" || r.direction === \"received\") &&\n\t\ttypeof r.peer === \"string\" &&\n\t\ttypeof r.addr === \"string\" &&\n\t\ttypeof r.network === \"string\" &&\n\t\t(r.kind === \"msg\" || r.kind === \"raw\") &&\n\t\ttypeof r.body === \"string\" &&\n\t\t(r.outcome === \"ok\" || r.outcome === \"error\") &&\n\t\toptionalString(r.id) &&\n\t\toptionalString(r.thread) &&\n\t\toptionalString(r.re) &&\n\t\toptionalString(r.reason) &&\n\t\toptionalString(r.error) &&\n\t\t(r.ms === undefined || typeof r.ms === \"number\")\n\t);\n}\n\n/** Bytes a string or the JSON of a value may take in a record before it is cut. */\nexport const AUDIT_VALUE_CAP = 512;\n/** Bytes a name, an id or an address from the network may take in a message record. */\nexport const AUDIT_FIELD_CAP = 256;\n\n/** A string the harness or the network supplied, cut to `maxBytes` for a record. */\nexport function capField(value: string, maxBytes: number = AUDIT_FIELD_CAP): string {\n\treturn cutUtf8(value, maxBytes);\n}\n/** Bytes `tail` reads from the end of the file. */\nexport const AUDIT_TAIL_BYTES = 1024 * 1024;\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Cap a value for a record: a value whose JSON exceeds the cap becomes\n * `{truncated: true, bytes, head}` with the JSON's first 512 characters, so\n * the record stays valid JSON. A value that cannot be serialized becomes a\n * marker string.\n */\nexport function capValue(value: unknown): unknown {\n\tif (value === undefined) return undefined;\n\tlet json: string;\n\ttry {\n\t\tjson = JSON.stringify(value) ?? \"null\";\n\t} catch (error) {\n\t\treturn `<unserializable: ${errorMessage(error)}>`;\n\t}\n\tconst bytes = Buffer.byteLength(json, \"utf8\");\n\tif (bytes <= AUDIT_VALUE_CAP) return value;\n\treturn { truncated: true, bytes, head: cutUtf8(json, AUDIT_VALUE_CAP) };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * What an audit record keeps of a decision answer: never the transcript.\n * An options decision keeps the chosen option; any other answer keeps\n * whether it changed something (a non-empty object).\n */\nexport function summarizeAnswer(decision: DecisionDecl | undefined, answer: unknown): unknown {\n\tif (answer === undefined) return undefined;\n\tif (decision?.options && typeof answer === \"string\") return { option: answer };\n\treturn { changed: Object.keys(isRecord(answer) ? answer : {}).length > 0 };\n}\n\nexport interface AuditTail {\n\trecords: unknown[];\n\t/** More records exist than were returned. */\n\ttruncated: boolean;\n}\n\nexport class AuditLog {\n\treadonly path: string;\n\tprivate readonly onFailure: ((error: string) => void) | undefined;\n\tprivate fd: number | undefined;\n\tprivate _failed: string | undefined;\n\tprivate closed = false;\n\n\tconstructor(path: string, onFailure?: (error: string) => void) {\n\t\tthis.path = path;\n\t\tthis.onFailure = onFailure;\n\t}\n\n\t/** The error that latched the log off, if any. */\n\tget failed(): string | undefined {\n\t\treturn this._failed;\n\t}\n\n\t/** Append one record. Never throws; a no-op once the log failed or was closed. True when the line was written. */\n\tappend(record: Omit<AuditRecord, \"ts\" | \"pid\">): boolean {\n\t\tif (this._failed !== undefined || this.closed) return false;\n\t\ttry {\n\t\t\tif (this.fd === undefined) {\n\t\t\t\tmkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });\n\t\t\t\tthis.fd = openSync(this.path, \"a\", 0o600);\n\t\t\t}\n\t\t\tconst full: AuditRecord = { ts: new Date().toISOString(), pid: process.pid, ...record };\n\t\t\twriteSync(this.fd, `${JSON.stringify(full)}\\n`);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tthis.fail(errorMessage(error));\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate fail(error: string): void {\n\t\tthis._failed = error;\n\t\tthis.close();\n\t\ttry {\n\t\t\tthis.onFailure?.(error);\n\t\t} catch {\n\t\t\t// a failure callback must not fail the caller\n\t\t}\n\t}\n\n\t/** Close the file descriptor; later appends are dropped. Idempotent; safe inside `process.on(\"exit\")`. */\n\tclose(): void {\n\t\tthis.closed = true;\n\t\tif (this.fd === undefined) return;\n\t\tconst fd = this.fd;\n\t\tthis.fd = undefined;\n\t\ttry {\n\t\t\tcloseSync(fd);\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\n\t/** The last `limit` records, read from the last megabyte of the file. Never throws. */\n\ttail(limit: number): AuditTail {\n\t\tlet fd: number | undefined;\n\t\ttry {\n\t\t\tfd = openSync(this.path, \"r\");\n\t\t\tconst size = fstatSync(fd).size;\n\t\t\tconst start = Math.max(0, size - AUDIT_TAIL_BYTES);\n\t\t\tconst buffer = Buffer.alloc(size - start);\n\t\t\tlet read = 0;\n\t\t\twhile (read < buffer.length) {\n\t\t\t\tconst n = readSync(fd, buffer, read, buffer.length - read, start + read);\n\t\t\t\tif (n === 0) break;\n\t\t\t\tread += n;\n\t\t\t}\n\t\t\tconst lines = buffer.toString(\"utf8\", 0, read).split(\"\\n\");\n\t\t\t// the window may start mid-line: drop the first line unless the byte before it ends a line\n\t\t\tif (start > 0) {\n\t\t\t\tconst before = Buffer.alloc(1);\n\t\t\t\tconst n = readSync(fd, before, 0, 1, start - 1);\n\t\t\t\tif (n !== 1 || before[0] !== 0x0a) lines.shift();\n\t\t\t}\n\t\t\tconst records: unknown[] = [];\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (line === \"\") continue;\n\t\t\t\ttry {\n\t\t\t\t\trecords.push(JSON.parse(line));\n\t\t\t\t} catch {\n\t\t\t\t\trecords.push({ raw: line });\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst truncated = start > 0 || records.length > limit;\n\t\t\treturn { records: records.slice(-limit), truncated };\n\t\t} catch (error) {\n\t\t\t// no log yet, or a path that can never hold one (the failure is in `failed`)\n\t\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\t\tif (code === \"ENOENT\" || code === \"ENOTDIR\") return { records: [], truncated: false };\n\t\t\treturn { records: [{ error: errorMessage(error) }], truncated: false };\n\t\t} finally {\n\t\t\tif (fd !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\tcloseSync(fd);\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
package/dist/audit.js CHANGED
@@ -10,8 +10,36 @@
10
10
  import { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from "node:fs";
11
11
  import { dirname } from "node:path";
12
12
  import { cutUtf8 } from "./manifest/manifest.js";
13
+ function optionalString(value) {
14
+ return value === undefined || typeof value === "string";
15
+ }
16
+ /** Whether a value is a `MessageRecord`: what the sidecar endpoint checks before it writes what a harness sent. */
17
+ export function isMessageRecord(value) {
18
+ if (typeof value !== "object" || value === null)
19
+ return false;
20
+ const r = value;
21
+ return ((r.direction === "sent" || r.direction === "received") &&
22
+ typeof r.peer === "string" &&
23
+ typeof r.addr === "string" &&
24
+ typeof r.network === "string" &&
25
+ (r.kind === "msg" || r.kind === "raw") &&
26
+ typeof r.body === "string" &&
27
+ (r.outcome === "ok" || r.outcome === "error") &&
28
+ optionalString(r.id) &&
29
+ optionalString(r.thread) &&
30
+ optionalString(r.re) &&
31
+ optionalString(r.reason) &&
32
+ optionalString(r.error) &&
33
+ (r.ms === undefined || typeof r.ms === "number"));
34
+ }
13
35
  /** Bytes a string or the JSON of a value may take in a record before it is cut. */
14
36
  export const AUDIT_VALUE_CAP = 512;
37
+ /** Bytes a name, an id or an address from the network may take in a message record. */
38
+ export const AUDIT_FIELD_CAP = 256;
39
+ /** A string the harness or the network supplied, cut to `maxBytes` for a record. */
40
+ export function capField(value, maxBytes = AUDIT_FIELD_CAP) {
41
+ return cutUtf8(value, maxBytes);
42
+ }
15
43
  /** Bytes `tail` reads from the end of the file. */
16
44
  export const AUDIT_TAIL_BYTES = 1024 * 1024;
17
45
  function errorMessage(error) {
@@ -67,10 +95,10 @@ export class AuditLog {
67
95
  get failed() {
68
96
  return this._failed;
69
97
  }
70
- /** Append one record. Never throws; a no-op once the log failed or was closed. */
98
+ /** Append one record. Never throws; a no-op once the log failed or was closed. True when the line was written. */
71
99
  append(record) {
72
100
  if (this._failed !== undefined || this.closed)
73
- return;
101
+ return false;
74
102
  try {
75
103
  if (this.fd === undefined) {
76
104
  mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
@@ -78,9 +106,11 @@ export class AuditLog {
78
106
  }
79
107
  const full = { ts: new Date().toISOString(), pid: process.pid, ...record };
80
108
  writeSync(this.fd, `${JSON.stringify(full)}\n`);
109
+ return true;
81
110
  }
82
111
  catch (error) {
83
112
  this.fail(errorMessage(error));
113
+ return false;
84
114
  }
85
115
  }
86
116
  fail(error) {
package/dist/audit.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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","sourcesContent":["// The audit log (abaigrammar section 10).\n//\n// One JSON line per act of control: a mutate capability call, a knob write\n// by any path, a decision's outcome, and the counters of defaulted decisions\n// flushed at attach, close and exit. Written synchronously, one line per\n// act: acts are rare and a partial line on a crash is worse than a syscall.\n// Nothing here throws into the agent: the first failure latches the log off,\n// reports once through `onFailure`, and every later append is a no-op.\n// `tail` reads the log back for the harness.\nimport { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { cutUtf8, type DecisionDecl } from \"./manifest/manifest.ts\";\n\nexport type AuditAct = \"capability\" | \"knob\" | \"decision\" | \"turn\" | \"lifecycle\" | \"counters\";\nexport type AuditOutcome = \"ok\" | \"error\" | \"forbidden\" | \"agent\" | \"default\" | \"stub\" | \"failed\";\n\nexport interface AuditRecord {\n\tts: string;\n\tpid: number;\n\t/** The connection that acted (`k1`, `k2`, ...); absent for counters. */\n\tconn?: string;\n\tact: AuditAct;\n\t/** The capability, cell, decision or counter name. */\n\tname: string;\n\t/** The request id or hook id. */\n\tid?: string;\n\t/** The parameters (capped, see `capValue`). */\n\tparams?: unknown;\n\t/** A knob's landed value, or a decision answer's summary. */\n\tvalue?: unknown;\n\toutcome: AuditOutcome;\n\t/** Why a default or stub answer applied, or what a counter counts. */\n\tcause?: string;\n\t/** What the harness stated for an act whose policy is `confirm`: approved, auto, or denied. */\n\tconsent?: string;\n\t/** The agent's stated reason. */\n\treason?: unknown;\n\terror?: unknown;\n\t/** Milliseconds from dispatch to settle. */\n\tms?: number;\n\t/** Counters only. */\n\tcount?: number;\n}\n\n/** Bytes a string or the JSON of a value may take in a record before it is cut. */\nexport const AUDIT_VALUE_CAP = 512;\n/** Bytes `tail` reads from the end of the file. */\nexport const AUDIT_TAIL_BYTES = 1024 * 1024;\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Cap a value for a record: a value whose JSON exceeds the cap becomes\n * `{truncated: true, bytes, head}` with the JSON's first 512 characters, so\n * the record stays valid JSON. A value that cannot be serialized becomes a\n * marker string.\n */\nexport function capValue(value: unknown): unknown {\n\tif (value === undefined) return undefined;\n\tlet json: string;\n\ttry {\n\t\tjson = JSON.stringify(value) ?? \"null\";\n\t} catch (error) {\n\t\treturn `<unserializable: ${errorMessage(error)}>`;\n\t}\n\tconst bytes = Buffer.byteLength(json, \"utf8\");\n\tif (bytes <= AUDIT_VALUE_CAP) return value;\n\treturn { truncated: true, bytes, head: cutUtf8(json, AUDIT_VALUE_CAP) };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * What an audit record keeps of a decision answer: never the transcript.\n * An options decision keeps the chosen option; any other answer keeps\n * whether it changed something (a non-empty object).\n */\nexport function summarizeAnswer(decision: DecisionDecl | undefined, answer: unknown): unknown {\n\tif (answer === undefined) return undefined;\n\tif (decision?.options && typeof answer === \"string\") return { option: answer };\n\treturn { changed: Object.keys(isRecord(answer) ? answer : {}).length > 0 };\n}\n\nexport interface AuditTail {\n\trecords: unknown[];\n\t/** More records exist than were returned. */\n\ttruncated: boolean;\n}\n\nexport class AuditLog {\n\treadonly path: string;\n\tprivate readonly onFailure: ((error: string) => void) | undefined;\n\tprivate fd: number | undefined;\n\tprivate _failed: string | undefined;\n\tprivate closed = false;\n\n\tconstructor(path: string, onFailure?: (error: string) => void) {\n\t\tthis.path = path;\n\t\tthis.onFailure = onFailure;\n\t}\n\n\t/** The error that latched the log off, if any. */\n\tget failed(): string | undefined {\n\t\treturn this._failed;\n\t}\n\n\t/** Append one record. Never throws; a no-op once the log failed or was closed. */\n\tappend(record: Omit<AuditRecord, \"ts\" | \"pid\">): void {\n\t\tif (this._failed !== undefined || this.closed) return;\n\t\ttry {\n\t\t\tif (this.fd === undefined) {\n\t\t\t\tmkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });\n\t\t\t\tthis.fd = openSync(this.path, \"a\", 0o600);\n\t\t\t}\n\t\t\tconst full: AuditRecord = { ts: new Date().toISOString(), pid: process.pid, ...record };\n\t\t\twriteSync(this.fd, `${JSON.stringify(full)}\\n`);\n\t\t} catch (error) {\n\t\t\tthis.fail(errorMessage(error));\n\t\t}\n\t}\n\n\tprivate fail(error: string): void {\n\t\tthis._failed = error;\n\t\tthis.close();\n\t\ttry {\n\t\t\tthis.onFailure?.(error);\n\t\t} catch {\n\t\t\t// a failure callback must not fail the caller\n\t\t}\n\t}\n\n\t/** Close the file descriptor; later appends are dropped. Idempotent; safe inside `process.on(\"exit\")`. */\n\tclose(): void {\n\t\tthis.closed = true;\n\t\tif (this.fd === undefined) return;\n\t\tconst fd = this.fd;\n\t\tthis.fd = undefined;\n\t\ttry {\n\t\t\tcloseSync(fd);\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\n\t/** The last `limit` records, read from the last megabyte of the file. Never throws. */\n\ttail(limit: number): AuditTail {\n\t\tlet fd: number | undefined;\n\t\ttry {\n\t\t\tfd = openSync(this.path, \"r\");\n\t\t\tconst size = fstatSync(fd).size;\n\t\t\tconst start = Math.max(0, size - AUDIT_TAIL_BYTES);\n\t\t\tconst buffer = Buffer.alloc(size - start);\n\t\t\tlet read = 0;\n\t\t\twhile (read < buffer.length) {\n\t\t\t\tconst n = readSync(fd, buffer, read, buffer.length - read, start + read);\n\t\t\t\tif (n === 0) break;\n\t\t\t\tread += n;\n\t\t\t}\n\t\t\tconst lines = buffer.toString(\"utf8\", 0, read).split(\"\\n\");\n\t\t\t// the window may start mid-line: drop the first line unless the byte before it ends a line\n\t\t\tif (start > 0) {\n\t\t\t\tconst before = Buffer.alloc(1);\n\t\t\t\tconst n = readSync(fd, before, 0, 1, start - 1);\n\t\t\t\tif (n !== 1 || before[0] !== 0x0a) lines.shift();\n\t\t\t}\n\t\t\tconst records: unknown[] = [];\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (line === \"\") continue;\n\t\t\t\ttry {\n\t\t\t\t\trecords.push(JSON.parse(line));\n\t\t\t\t} catch {\n\t\t\t\t\trecords.push({ raw: line });\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst truncated = start > 0 || records.length > limit;\n\t\t\treturn { records: records.slice(-limit), truncated };\n\t\t} catch (error) {\n\t\t\t// no log yet, or a path that can never hold one (the failure is in `failed`)\n\t\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\t\tif (code === \"ENOENT\" || code === \"ENOTDIR\") return { records: [], truncated: false };\n\t\t\treturn { records: [{ error: errorMessage(error) }], truncated: false };\n\t\t} finally {\n\t\t\tif (fd !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\tcloseSync(fd);\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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","sourcesContent":["// The audit log (abaigrammar section 10).\n//\n// One JSON line per act of control: a mutate capability call, a knob write\n// by any path, a decision's outcome, and the counters of defaulted decisions\n// flushed at attach, close and exit. Written synchronously, one line per\n// act: acts are rare and a partial line on a crash is worse than a syscall.\n// Nothing here throws into the agent: the first failure latches the log off,\n// reports once through `onFailure`, and every later append is a no-op.\n// `tail` reads the log back for the harness.\nimport { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { cutUtf8, type DecisionDecl } from \"./manifest/manifest.ts\";\n\nexport type AuditAct = \"capability\" | \"knob\" | \"decision\" | \"turn\" | \"lifecycle\" | \"counters\" | \"message\";\nexport type AuditOutcome = \"ok\" | \"error\" | \"forbidden\" | \"agent\" | \"default\" | \"stub\" | \"failed\";\n\nexport interface AuditRecord {\n\tts: string;\n\tpid: number;\n\t/** The connection that acted (`k1`, `k2`, ...); absent for counters. */\n\tconn?: string;\n\tact: AuditAct;\n\t/** The capability, cell, decision or counter name. */\n\tname: string;\n\t/** The request id or hook id. */\n\tid?: string;\n\t/** The parameters (capped, see `capValue`). */\n\tparams?: unknown;\n\t/** A knob's landed value, or a decision answer's summary. */\n\tvalue?: unknown;\n\toutcome: AuditOutcome;\n\t/** Why a default or stub answer applied, or what a counter counts. */\n\tcause?: string;\n\t/** What the harness stated for an act whose policy is `confirm`: approved, auto, or denied. */\n\tconsent?: string;\n\t/** The agent's stated reason. */\n\treason?: unknown;\n\terror?: unknown;\n\t/** Milliseconds from dispatch to settle. */\n\tms?: number;\n\t/** Counters only. */\n\tcount?: number;\n}\n\n/**\n * One message sent to or received from a member of the run's network\n * (section 10), as the harness hands it to the stub for the audit log. The\n * stub writes it as `act: message` with the counterpart's name as `name`,\n * the envelope id as `id`, the body capped as `value`.\n */\nexport interface MessageRecord {\n\tdirection: \"sent\" | \"received\";\n\t/** The counterpart's name as the network's authority issued it, else its address. */\n\tpeer: string;\n\taddr: string;\n\tnetwork: string;\n\t/** The envelope id, when the payload carried one. */\n\tid?: string;\n\tthread?: string;\n\tre?: string;\n\t/** `msg` is an aiap message; `raw` a plain payload with no envelope. */\n\tkind: \"msg\" | \"raw\";\n\tbody: string;\n\toutcome: \"ok\" | \"error\";\n\t/** The agent's stated reason, for a send. */\n\treason?: string;\n\terror?: string;\n\t/** Milliseconds a send took. */\n\tms?: number;\n}\n\nfunction optionalString(value: unknown): boolean {\n\treturn value === undefined || typeof value === \"string\";\n}\n\n/** Whether a value is a `MessageRecord`: what the sidecar endpoint checks before it writes what a harness sent. */\nexport function isMessageRecord(value: unknown): value is MessageRecord {\n\tif (typeof value !== \"object\" || value === null) return false;\n\tconst r = value as Record<string, unknown>;\n\treturn (\n\t\t(r.direction === \"sent\" || r.direction === \"received\") &&\n\t\ttypeof r.peer === \"string\" &&\n\t\ttypeof r.addr === \"string\" &&\n\t\ttypeof r.network === \"string\" &&\n\t\t(r.kind === \"msg\" || r.kind === \"raw\") &&\n\t\ttypeof r.body === \"string\" &&\n\t\t(r.outcome === \"ok\" || r.outcome === \"error\") &&\n\t\toptionalString(r.id) &&\n\t\toptionalString(r.thread) &&\n\t\toptionalString(r.re) &&\n\t\toptionalString(r.reason) &&\n\t\toptionalString(r.error) &&\n\t\t(r.ms === undefined || typeof r.ms === \"number\")\n\t);\n}\n\n/** Bytes a string or the JSON of a value may take in a record before it is cut. */\nexport const AUDIT_VALUE_CAP = 512;\n/** Bytes a name, an id or an address from the network may take in a message record. */\nexport const AUDIT_FIELD_CAP = 256;\n\n/** A string the harness or the network supplied, cut to `maxBytes` for a record. */\nexport function capField(value: string, maxBytes: number = AUDIT_FIELD_CAP): string {\n\treturn cutUtf8(value, maxBytes);\n}\n/** Bytes `tail` reads from the end of the file. */\nexport const AUDIT_TAIL_BYTES = 1024 * 1024;\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Cap a value for a record: a value whose JSON exceeds the cap becomes\n * `{truncated: true, bytes, head}` with the JSON's first 512 characters, so\n * the record stays valid JSON. A value that cannot be serialized becomes a\n * marker string.\n */\nexport function capValue(value: unknown): unknown {\n\tif (value === undefined) return undefined;\n\tlet json: string;\n\ttry {\n\t\tjson = JSON.stringify(value) ?? \"null\";\n\t} catch (error) {\n\t\treturn `<unserializable: ${errorMessage(error)}>`;\n\t}\n\tconst bytes = Buffer.byteLength(json, \"utf8\");\n\tif (bytes <= AUDIT_VALUE_CAP) return value;\n\treturn { truncated: true, bytes, head: cutUtf8(json, AUDIT_VALUE_CAP) };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * What an audit record keeps of a decision answer: never the transcript.\n * An options decision keeps the chosen option; any other answer keeps\n * whether it changed something (a non-empty object).\n */\nexport function summarizeAnswer(decision: DecisionDecl | undefined, answer: unknown): unknown {\n\tif (answer === undefined) return undefined;\n\tif (decision?.options && typeof answer === \"string\") return { option: answer };\n\treturn { changed: Object.keys(isRecord(answer) ? answer : {}).length > 0 };\n}\n\nexport interface AuditTail {\n\trecords: unknown[];\n\t/** More records exist than were returned. */\n\ttruncated: boolean;\n}\n\nexport class AuditLog {\n\treadonly path: string;\n\tprivate readonly onFailure: ((error: string) => void) | undefined;\n\tprivate fd: number | undefined;\n\tprivate _failed: string | undefined;\n\tprivate closed = false;\n\n\tconstructor(path: string, onFailure?: (error: string) => void) {\n\t\tthis.path = path;\n\t\tthis.onFailure = onFailure;\n\t}\n\n\t/** The error that latched the log off, if any. */\n\tget failed(): string | undefined {\n\t\treturn this._failed;\n\t}\n\n\t/** Append one record. Never throws; a no-op once the log failed or was closed. True when the line was written. */\n\tappend(record: Omit<AuditRecord, \"ts\" | \"pid\">): boolean {\n\t\tif (this._failed !== undefined || this.closed) return false;\n\t\ttry {\n\t\t\tif (this.fd === undefined) {\n\t\t\t\tmkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });\n\t\t\t\tthis.fd = openSync(this.path, \"a\", 0o600);\n\t\t\t}\n\t\t\tconst full: AuditRecord = { ts: new Date().toISOString(), pid: process.pid, ...record };\n\t\t\twriteSync(this.fd, `${JSON.stringify(full)}\\n`);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tthis.fail(errorMessage(error));\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate fail(error: string): void {\n\t\tthis._failed = error;\n\t\tthis.close();\n\t\ttry {\n\t\t\tthis.onFailure?.(error);\n\t\t} catch {\n\t\t\t// a failure callback must not fail the caller\n\t\t}\n\t}\n\n\t/** Close the file descriptor; later appends are dropped. Idempotent; safe inside `process.on(\"exit\")`. */\n\tclose(): void {\n\t\tthis.closed = true;\n\t\tif (this.fd === undefined) return;\n\t\tconst fd = this.fd;\n\t\tthis.fd = undefined;\n\t\ttry {\n\t\t\tcloseSync(fd);\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\n\t/** The last `limit` records, read from the last megabyte of the file. Never throws. */\n\ttail(limit: number): AuditTail {\n\t\tlet fd: number | undefined;\n\t\ttry {\n\t\t\tfd = openSync(this.path, \"r\");\n\t\t\tconst size = fstatSync(fd).size;\n\t\t\tconst start = Math.max(0, size - AUDIT_TAIL_BYTES);\n\t\t\tconst buffer = Buffer.alloc(size - start);\n\t\t\tlet read = 0;\n\t\t\twhile (read < buffer.length) {\n\t\t\t\tconst n = readSync(fd, buffer, read, buffer.length - read, start + read);\n\t\t\t\tif (n === 0) break;\n\t\t\t\tread += n;\n\t\t\t}\n\t\t\tconst lines = buffer.toString(\"utf8\", 0, read).split(\"\\n\");\n\t\t\t// the window may start mid-line: drop the first line unless the byte before it ends a line\n\t\t\tif (start > 0) {\n\t\t\t\tconst before = Buffer.alloc(1);\n\t\t\t\tconst n = readSync(fd, before, 0, 1, start - 1);\n\t\t\t\tif (n !== 1 || before[0] !== 0x0a) lines.shift();\n\t\t\t}\n\t\t\tconst records: unknown[] = [];\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (line === \"\") continue;\n\t\t\t\ttry {\n\t\t\t\t\trecords.push(JSON.parse(line));\n\t\t\t\t} catch {\n\t\t\t\t\trecords.push({ raw: line });\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst truncated = start > 0 || records.length > limit;\n\t\t\treturn { records: records.slice(-limit), truncated };\n\t\t} catch (error) {\n\t\t\t// no log yet, or a path that can never hold one (the failure is in `failed`)\n\t\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\t\tif (code === \"ENOENT\" || code === \"ENOTDIR\") return { records: [], truncated: false };\n\t\t\treturn { records: [{ error: errorMessage(error) }], truncated: false };\n\t\t} finally {\n\t\t\tif (fd !== undefined) {\n\t\t\t\ttry {\n\t\t\t\t\tcloseSync(fd);\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"endpoint.d.ts","sourceRoot":"","sources":["../src/endpoint.ts"],"names":[],"mappings":"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","sourcesContent":["// The stub's sidecar endpoint (abaigrammar section 6.3): the receiving end a\n// harness in another process connects to. Wire protocol v1 over a Unix\n// socket, listening from startup so an outside harness reaches a running\n// program that never calls a touchpoint, and so a lost connection is\n// restored by the harness connecting again.\n//\n// One binding, many connections. The first connection attaches the program\n// (one DirectBinding, which every channel runs through); the last one\n// leaving detaches it, so what was open settles as detached. Every act is\n// audited under the connection that made it. Decisions go to the one\n// connection that claimed them; turns and touchpoints are broadcast, and\n// the first end settles one.\n//\n// Nothing here keeps the program alive, and nothing here lets it die\n// mid-touchpoint: the server and its sockets are unref'd, so a program whose\n// work is done exits with a harness still connected, while a pending\n// decision, turn or touchpoint holds a ref'd timer of its own in the binding.\nimport { Buffer } from \"node:buffer\";\nimport { existsSync, unlinkSync } from \"node:fs\";\nimport { createServer, type Server, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport {\n\tCallError,\n\ttype CallErrorCode,\n\ttype Consent,\n\ttype DecisionRequest,\n\ttype DirectBinding,\n\ttype LiveEvent,\n\ttype TouchpointRequest,\n\ttype TurnRequest,\n} from \"./harness-binding.ts\";\nimport { durationMs } from \"./manifest/manifest.ts\";\nimport type { Abaigram } from \"./program.ts\";\nimport {\n\tassertSocketPathLength,\n\tattachCappedLineReader,\n\tDEFAULT_MAX_LINE_BYTES,\n\tdefaultSocketPath,\n\tEXTENSIONS,\n\tensureRunDir,\n\ttype HelloMessage,\n\tMAX_SOCKET_PATH_BYTES,\n\tPROTOCOL_VERSION,\n\tremoveDescriptor,\n\ttype WireErrorCode,\n\ttype WireResponse,\n\twriteDescriptor,\n} from \"./wire/index.ts\";\n\n/**\n * Where the socket goes when the run directory's own path is too long for\n * one (103 bytes on macOS, and a run directory under a deep project path\n * gets there): a private directory under the machine's temp dir. The\n * descriptor in the run directory names the socket wherever it is, which is\n * what its `socket` field is for, so discovery is unchanged.\n */\nexport function shortSocketPath(pid: number): string {\n\t// per user: the temp dir is shared on Linux, and the directory is made private to whoever creates it\n\tconst uid = typeof process.getuid === \"function\" ? String(process.getuid()) : \"u\";\n\treturn join(tmpdir(), `abai-sockets-${uid}`, `${pid}.sock`);\n}\n\nexport interface EndpointOptions {\n\t/** Where the socket and the descriptor go. */\n\trunDir: string;\n\t/** The socket path; default `<runDir>/<pid>.sock`. */\n\tsocketPath?: string;\n\t/** A connection whose unsent output exceeds this many bytes is dropped whole. Default 16 MiB. */\n\tmaxBuffered?: number;\n\t/** An input line past this many bytes drops the connection. Default 64 MiB. */\n\tmaxLineBytes?: number;\n}\n\n/** Unsent bytes a connection may hold before it is dropped: a harness that cannot keep up is not the program's problem. */\nexport const DEFAULT_MAX_BUFFERED = 16 * 1024 * 1024;\n/** Consecutive claim timeouts after which a connection's claims are released: a harness that never answers holds nothing. */\nexport const CONSECUTIVE_TIMEOUTS_LIMIT = 3;\n/** How long a socket gets to flush when the endpoint closes it. */\nconst CLOSE_GRACE_MS = 200;\n\ninterface Connection {\n\tid: string;\n\tsocket: Socket;\n\t/** The event filter: `include` restricts to the listed types; `exclude` drops them. */\n\tinclude: Set<string> | undefined;\n\texclude: Set<string>;\n\tclaims: Set<string>;\n\t/** A claim's own wait, never longer than the manifest's. */\n\tclaimTimeoutMs: number | undefined;\n\tconsecutiveTimeouts: number;\n\t/** Bumped on every timeout; a hook sent before the last timeout counts once with its batch. */\n\ttimeoutEpoch: number;\n\tdetachReader: () => void;\n}\n\ninterface PendingHook {\n\tid: string;\n\tname: string;\n\tconnection: Connection;\n\tepochAtSend: number;\n\t/** The claim's shorter wait, when it has one. */\n\ttimer: NodeJS.Timeout | undefined;\n}\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction line(value: unknown): string {\n\treturn `${JSON.stringify(value)}\\n`;\n}\n\nexport class Endpoint {\n\treadonly runDir: string;\n\treadonly socketPath: string;\n\t/** Resolves once the socket listens and the descriptor is written; rejects with why it could not. */\n\treadonly ready: Promise<void>;\n\tprivate readonly app: Abaigram;\n\tprivate readonly maxBuffered: number;\n\tprivate readonly maxLineBytes: number;\n\tprivate server: Server | undefined;\n\tprivate readonly connections = new Set<Connection>();\n\tprivate readonly claimants = new Map<string, Connection>();\n\tprivate readonly hooks = new Map<string, PendingHook>();\n\t/** Open turns and touchpoints, replayed to a connection that arrives while they are open. */\n\tprivate readonly openTurns = new Map<string, TurnRequest>();\n\tprivate readonly openTouchpoints = new Map<string, TouchpointRequest>();\n\tprivate readonly connectionWaiters: Array<() => void> = [];\n\tprivate binding: DirectBinding | undefined;\n\tprivate unsubscribe: Array<() => void> = [];\n\tprivate nextConnectionId = 1;\n\tprivate closed = false;\n\tprivate readonly onExit: () => void;\n\tprivate settleReady: { resolve: () => void; reject: (error: Error) => void } | undefined;\n\n\tconstructor(app: Abaigram, options: EndpointOptions) {\n\t\tthis.app = app;\n\t\tthis.runDir = options.runDir;\n\t\tconst preferred = options.socketPath ?? defaultSocketPath(options.runDir, process.pid);\n\t\t// an explicit path is used as given; the default falls back to a short one when it does not fit\n\t\tthis.socketPath =\n\t\t\toptions.socketPath === undefined && Buffer.byteLength(preferred) > MAX_SOCKET_PATH_BYTES\n\t\t\t\t? shortSocketPath(process.pid)\n\t\t\t\t: preferred;\n\t\tthis.maxBuffered = options.maxBuffered ?? DEFAULT_MAX_BUFFERED;\n\t\tthis.maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;\n\t\tthis.onExit = () => this.removeFiles();\n\t\tthis.ready = new Promise<void>((resolve, reject) => {\n\t\t\tthis.settleReady = { resolve, reject };\n\t\t});\n\t\tthis.listen();\n\t}\n\n\tprivate resolveReady(): void {\n\t\tconst settle = this.settleReady;\n\t\tthis.settleReady = undefined;\n\t\tsettle?.resolve();\n\t}\n\n\tprivate rejectReady(error: unknown): void {\n\t\tconst settle = this.settleReady;\n\t\tthis.settleReady = undefined;\n\t\tsettle?.reject(error instanceof Error ? error : new Error(String(error)));\n\t}\n\n\tget connectionCount(): number {\n\t\treturn this.connections.size;\n\t}\n\n\t/** The ids of the open connections, in order of arrival. */\n\tconnectionIds(): string[] {\n\t\treturn [...this.connections].map((connection) => connection.id);\n\t}\n\n\tprivate listen(): void {\n\t\ttry {\n\t\t\tassertSocketPathLength(this.socketPath);\n\t\t\tensureRunDir(this.runDir);\n\t\t\tif (this.socketPath !== defaultSocketPath(this.runDir, process.pid)) ensureRunDir(dirname(this.socketPath));\n\t\t\t// a socket file left by an earlier life of this very pid number: nobody else writes to this name.\n\t\t\t// (A second Abaigram under sidecar in the same process would take over the first one's socket here;\n\t\t\t// one program is one endpoint, and a test suite that builds several keeps them in turns mode.)\n\t\t\tif (existsSync(this.socketPath)) unlinkSync(this.socketPath);\n\t\t} catch (error) {\n\t\t\tthis.rejectReady(error);\n\t\t\treturn;\n\t\t}\n\t\tconst server = createServer((socket) => this.accept(socket));\n\t\tthis.server = server;\n\t\tserver.once(\"error\", (error) => this.rejectReady(error));\n\t\tserver.listen(this.socketPath, () => {\n\t\t\tserver.unref();\n\t\t\ttry {\n\t\t\t\twriteDescriptor(this.runDir, {\n\t\t\t\t\tpid: process.pid,\n\t\t\t\t\tsocket: this.socketPath,\n\t\t\t\t\tcwd: process.cwd(),\n\t\t\t\t\tname: this.app.name,\n\t\t\t\t\tmode: this.app.mode,\n\t\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tserver.close();\n\t\t\t\tthis.rejectReady(error);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tprocess.once(\"exit\", this.onExit);\n\t\t\tthis.resolveReady();\n\t\t});\n\t}\n\n\t/** Resolves true as soon as a connection is open, or when one arrives within `timeoutMs`; false when none does. */\n\twaitForConnection(timeoutMs: number): Promise<boolean> {\n\t\tif (this.connections.size > 0) return Promise.resolve(true);\n\t\treturn new Promise<boolean>((resolve) => {\n\t\t\tlet settled = false;\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tconst index = this.connectionWaiters.indexOf(waiter);\n\t\t\t\tif (index !== -1) this.connectionWaiters.splice(index, 1);\n\t\t\t\tresolve(false);\n\t\t\t}, timeoutMs);\n\t\t\tconst waiter = (): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve(true);\n\t\t\t};\n\t\t\tthis.connectionWaiters.push(waiter);\n\t\t});\n\t}\n\n\t/** Stop listening, drop every connection, remove the socket and the descriptor. */\n\tclose(reason = \"the endpoint closed\"): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\t// closed before it listened: whoever awaits `ready` is told, rather than left waiting\n\t\tthis.rejectReady(new Error(reason));\n\t\tfor (const connection of [...this.connections]) this.drop(connection, reason);\n\t\tthis.server?.close();\n\t\tthis.removeFiles();\n\t\tprocess.off(\"exit\", this.onExit);\n\t}\n\n\tprivate removeFiles(): void {\n\t\ttry {\n\t\t\tremoveDescriptor(this.runDir, process.pid);\n\t\t} catch {\n\t\t\t// nothing to remove\n\t\t}\n\t\ttry {\n\t\t\tif (existsSync(this.socketPath)) unlinkSync(this.socketPath);\n\t\t} catch {\n\t\t\t// a socket file that cannot be removed is pruned by the next listing\n\t\t}\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Connections\n\t// -----------------------------------------------------------------------\n\n\tprivate accept(socket: Socket): void {\n\t\tif (this.closed) {\n\t\t\tsocket.destroy();\n\t\t\treturn;\n\t\t}\n\t\t// a connected harness never holds the program open; what holds it is the program's own pending work\n\t\tsocket.unref();\n\t\tsocket.setNoDelay?.(true);\n\t\tconst connection: Connection = {\n\t\t\tid: `k${this.nextConnectionId++}`,\n\t\t\tsocket,\n\t\t\tinclude: undefined,\n\t\t\texclude: new Set(),\n\t\t\tclaims: new Set(),\n\t\t\tclaimTimeoutMs: undefined,\n\t\t\tconsecutiveTimeouts: 0,\n\t\t\ttimeoutEpoch: 0,\n\t\t\tdetachReader: () => {},\n\t\t};\n\t\tlet binding: DirectBinding;\n\t\ttry {\n\t\t\tbinding = this.bindingFor(connection);\n\t\t} catch (error) {\n\t\t\t// the program is attached some other way (an in-process harness): this connection cannot be served,\n\t\t\t// and the one line saying so is flushed before the socket ends\n\t\t\tsocket.end(\n\t\t\t\tline({ type: \"response\", command: \"hello\", success: false, error: errorMessage(error), code: \"error\" }),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tsocket.on(\"error\", () => this.drop(connection, \"the connection failed\"));\n\t\tsocket.on(\"close\", () => this.drop(connection, \"the connection closed\"));\n\t\tconnection.detachReader = attachCappedLineReader(\n\t\t\tsocket,\n\t\t\tthis.maxLineBytes,\n\t\t\t(text) => this.onLine(connection, text),\n\t\t\t() => this.drop(connection, `a line exceeded ${this.maxLineBytes} bytes`),\n\t\t);\n\t\tthis.connections.add(connection);\n\t\tthis.write(connection, line(this.hello(connection, binding)));\n\t\t// a harness that connects while the program waits at a touchpoint or a turn is told at once\n\t\tfor (const turn of this.openTurns.values()) this.write(connection, line(this.turnMessage(turn)));\n\t\tfor (const touchpoint of this.openTouchpoints.values()) {\n\t\t\tthis.write(connection, line(this.touchpointMessage(touchpoint)));\n\t\t}\n\t\tfor (const waiter of this.connectionWaiters.splice(0)) waiter();\n\t}\n\n\t/** The one binding every connection shares, created by the first. Its own label is for acts no connection made. */\n\tprivate bindingFor(_connection: Connection): DirectBinding {\n\t\tif (this.binding) return this.binding;\n\t\tconst binding = this.app.bind(\"endpoint\");\n\t\tthis.binding = binding;\n\t\tthis.unsubscribe = [\n\t\t\tbinding.on(\"event\", (event) => this.broadcastEvent(event)),\n\t\t\tbinding.on(\"decision\", (request) => this.onDecision(request)),\n\t\t\tbinding.on(\"decision_timeout\", (id, name) => this.onDecisionTimeout(id, name)),\n\t\t\tbinding.on(\"turn\", (request) => this.onTurn(request)),\n\t\t\tbinding.on(\"touchpoint\", (request) => this.onTouchpoint(request)),\n\t\t\tbinding.on(\"detach\", (reason) => this.onDetach(reason)),\n\t\t];\n\t\treturn binding;\n\t}\n\n\tprivate hello(connection: Connection, binding: DirectBinding): HelloMessage {\n\t\tconst { manifest } = this.app;\n\t\treturn {\n\t\t\ttype: \"hello\",\n\t\t\tprotocol: PROTOCOL_VERSION,\n\t\t\tpid: process.pid,\n\t\t\tcwd: process.cwd(),\n\t\t\tmode: this.app.mode,\n\t\t\tcapabilities: {\n\t\t\t\thooks: manifest.decisions.filter((d) => d.policy !== \"forbid\").map((d) => d.name),\n\t\t\t\tcommands: manifest.capabilities.filter((c) => c.policy !== \"forbid\").map((c) => c.name),\n\t\t\t},\n\t\t\tlastSeq: this.app.region.lastSeq,\n\t\t\tconnection: connection.id,\n\t\t\tabai: {\n\t\t\t\tname: manifest.name,\n\t\t\t\tabaigrammar: manifest.abaigrammar,\n\t\t\t\tmodes: [...manifest.modes],\n\t\t\t\taudit: binding.auditInfo(),\n\t\t\t\textensions: [...EXTENSIONS],\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate write(connection: Connection, text: string): void {\n\t\tconst { socket } = connection;\n\t\tif (socket.destroyed || !socket.writable) return;\n\t\tsocket.write(text);\n\t\tif (socket.writableLength > this.maxBuffered) this.drop(connection, \"the connection fell too far behind\");\n\t}\n\n\tprivate drop(connection: Connection, reason: string): void {\n\t\tif (!this.connections.delete(connection)) return;\n\t\tconnection.detachReader();\n\t\tfor (const name of connection.claims) {\n\t\t\tif (this.claimants.get(name) === connection) this.claimants.delete(name);\n\t\t}\n\t\tconnection.claims.clear();\n\t\t// what this connection was asked, nobody will answer now\n\t\tfor (const hook of [...this.hooks.values()]) {\n\t\t\tif (hook.connection !== connection) continue;\n\t\t\tthis.forget(hook);\n\t\t\tthis.binding?.defaultDecision(hook.id, \"detached\", connection.id);\n\t\t}\n\t\tif (!connection.socket.destroyed) {\n\t\t\tconnection.socket.end();\n\t\t\tconst timer = setTimeout(() => connection.socket.destroy(), CLOSE_GRACE_MS);\n\t\t\ttimer.unref();\n\t\t}\n\t\tif (this.connections.size === 0 && this.binding) {\n\t\t\tconst binding = this.binding;\n\t\t\tthis.releaseBinding();\n\t\t\tbinding.detach(`the last harness connection closed (${reason})`);\n\t\t}\n\t}\n\n\t/** Forget the binding and everything pending on it, every timer included: a finished program must be free to exit. */\n\tprivate releaseBinding(): void {\n\t\tthis.binding = undefined;\n\t\tfor (const off of this.unsubscribe.splice(0)) off();\n\t\tfor (const hook of [...this.hooks.values()]) this.forget(hook);\n\t\tthis.openTurns.clear();\n\t\tthis.openTouchpoints.clear();\n\t}\n\n\t/** The program detached the binding itself: whoever is still connected is told by the close. */\n\tprivate onDetach(_reason: string): void {\n\t\tif (!this.binding) return;\n\t\tthis.releaseBinding();\n\t\tfor (const connection of [...this.connections]) this.drop(connection, \"the program detached\");\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Lines in\n\t// -----------------------------------------------------------------------\n\n\tprivate onLine(connection: Connection, text: string): void {\n\t\tif (text.trim() === \"\") return;\n\t\tlet message: unknown;\n\t\ttry {\n\t\t\tmessage = JSON.parse(text);\n\t\t} catch {\n\t\t\tthis.respond(connection, { type: \"response\", command: \"unknown\", success: false, error: \"invalid json\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isRecord(message)) {\n\t\t\tthis.respond(connection, { type: \"response\", command: \"unknown\", success: false, error: \"invalid message\" });\n\t\t\treturn;\n\t\t}\n\t\tconst reason = typeof message.reason === \"string\" ? message.reason : undefined;\n\t\tconst consent = isConsent(message.consent) ? message.consent : undefined;\n\t\tswitch (message.type) {\n\t\t\tcase \"request\": {\n\t\t\t\tconst id = message.id;\n\t\t\t\tif (typeof id !== \"string\" || id === \"\") {\n\t\t\t\t\tthis.respond(connection, {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tcommand: \"request\",\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: \"request id must be a non-empty string\",\n\t\t\t\t\t\tcode: \"invalid\",\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tvoid this.dispatch(connection, id, message.command, reason, consent);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcase \"hook_response\":\n\t\t\t\tthis.onHookResponse(connection, message.id, message.result, reason, consent);\n\t\t\t\treturn;\n\t\t\tcase \"turn_end\":\n\t\t\t\tif (typeof message.id === \"string\") {\n\t\t\t\t\tthis.openTurns.delete(message.id);\n\t\t\t\t\tthis.binding?.endTurn(\n\t\t\t\t\t\tmessage.id,\n\t\t\t\t\t\tconnection.id,\n\t\t\t\t\t\ttypeof message.cause === \"string\" ? message.cause : undefined,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\tcase \"touchpoint_end\":\n\t\t\t\tif (typeof message.id === \"string\") {\n\t\t\t\t\tthis.openTouchpoints.delete(message.id);\n\t\t\t\t\tthis.binding?.endTouchpoint(message.id);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\tdefault:\n\t\t\t\tthis.respond(connection, {\n\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t...(typeof message.id === \"string\" ? { id: message.id } : {}),\n\t\t\t\t\tcommand: \"unknown\",\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: `unknown message type: ${String(message.type)}`,\n\t\t\t\t\tcode: \"unknown\",\n\t\t\t\t});\n\t\t}\n\t}\n\n\tprivate respond(connection: Connection, response: WireResponse): void {\n\t\ttry {\n\t\t\tthis.write(connection, line(response));\n\t\t} catch (error) {\n\t\t\t// a result that cannot be serialized becomes an error response; the connection stays\n\t\t\tconst failed: WireResponse = {\n\t\t\t\ttype: \"response\",\n\t\t\t\t...(response.id === undefined ? {} : { id: response.id }),\n\t\t\t\tcommand: response.command,\n\t\t\t\tsuccess: false,\n\t\t\t\terror: `the response could not be serialized: ${errorMessage(error)}`,\n\t\t\t\tcode: \"error\",\n\t\t\t};\n\t\t\tthis.write(connection, line(failed));\n\t\t}\n\t}\n\n\tprivate failure(id: string, command: string, error: string, code: WireErrorCode): WireResponse {\n\t\treturn { type: \"response\", id, command, success: false, error, code };\n\t}\n\n\t/** Run one request, answer it when it settles. Never rejects. */\n\tprivate async dispatch(\n\t\tconnection: Connection,\n\t\tid: string,\n\t\tcommand: unknown,\n\t\treason: string | undefined,\n\t\tconsent: Consent | undefined,\n\t): Promise<void> {\n\t\tif (!isRecord(command) || typeof command.type !== \"string\") {\n\t\t\tthis.respond(connection, this.failure(id, \"unknown\", \"command must be an object with a type\", \"invalid\"));\n\t\t\treturn;\n\t\t}\n\t\tconst type = command.type;\n\t\tconst binding = this.binding;\n\t\tif (!binding) {\n\t\t\tthis.respond(connection, this.failure(id, type, \"the program is no longer attached\", \"error\"));\n\t\t\treturn;\n\t\t}\n\t\tlet response: WireResponse;\n\t\ttry {\n\t\t\tswitch (type) {\n\t\t\t\tcase \"ping\":\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { pid: process.pid, ts: Date.now() },\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"subscribe\":\n\t\t\t\t\tresponse = this.subscribe(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"claim_hooks\":\n\t\t\t\t\tresponse = this.claim(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"release_hooks\":\n\t\t\t\t\tresponse = this.release(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"get_manifest\":\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: { manifest: this.app.manifest } };\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"get_content\": {\n\t\t\t\t\tconst content = binding.content();\n\t\t\t\t\tconst turns: Record<string, string> = {};\n\t\t\t\t\tfor (const turn of this.app.manifest.turns) {\n\t\t\t\t\t\tconst text = content.turn(turn.name);\n\t\t\t\t\t\tif (text !== undefined) turns[turn.name] = text;\n\t\t\t\t\t}\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\tidentity: content.identity,\n\t\t\t\t\t\t\t...(content.install === undefined ? {} : { install: content.install }),\n\t\t\t\t\t\t\t...(content.use === undefined ? {} : { use: content.use }),\n\t\t\t\t\t\t\tturns,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"get_audit\": {\n\t\t\t\t\tconst limit = typeof command.limit === \"number\" && command.limit > 0 ? Math.floor(command.limit) : 100;\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { ...binding.auditInfo(), ...binding.auditTail(limit) },\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"get_cells\": {\n\t\t\t\t\tconst reading = binding.readCells();\n\t\t\t\t\tconst cells: Record<string, unknown> = {};\n\t\t\t\t\tfor (const [name, cell] of Object.entries(reading.cells)) cells[name] = cell.value;\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: { seq: reading.seq, cells } };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"lifecycle\": {\n\t\t\t\t\tif (typeof command.op !== \"string\") {\n\t\t\t\t\t\tresponse = this.failure(id, type, \"lifecycle needs an op\", \"invalid\");\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tconst outcome = binding.lifecycle(command.op, reason ?? \"\", consent, connection.id);\n\t\t\t\t\tresponse = outcome.ok\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\tdata: outcome.data ?? { op: command.op, done: true },\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: this.failure(id, type, outcome.error, outcome.code);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"set_cell\": {\n\t\t\t\t\tif (typeof command.name !== \"string\") {\n\t\t\t\t\t\tresponse = this.failure(id, type, \"set_cell needs a cell name\", \"invalid\");\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tconst outcome = await binding.writeKnob(\n\t\t\t\t\t\tcommand.name,\n\t\t\t\t\t\tcommand.value,\n\t\t\t\t\t\treason ?? \"\",\n\t\t\t\t\t\tconsent,\n\t\t\t\t\t\tconnection.id,\n\t\t\t\t\t);\n\t\t\t\t\tresponse =\n\t\t\t\t\t\toutcome.outcome === \"done\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\t\tdata: { name: command.name, value: outcome.value },\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: this.failure(\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\ttype,\n\t\t\t\t\t\t\t\t\toutcome.error ?? `${command.name} was not written`,\n\t\t\t\t\t\t\t\t\toutcome.code ?? \"error\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tdefault: {\n\t\t\t\t\tconst { type: _type, ...params } = command;\n\t\t\t\t\tconst result = await binding.call(type, params, reason ?? \"\", consent, connection.id);\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: result };\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst code: CallErrorCode = error instanceof CallError ? error.code : \"error\";\n\t\t\tresponse = this.failure(id, type, errorMessage(error), code);\n\t\t}\n\t\tthis.respond(connection, response);\n\t}\n\n\tprivate subscribe(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tconst names = (value: unknown): string[] | undefined =>\n\t\t\tArray.isArray(value) && value.every((v) => typeof v === \"string\") ? (value as string[]) : undefined;\n\t\tconst include = command.include === undefined ? undefined : names(command.include);\n\t\tconst exclude = command.exclude === undefined ? [] : names(command.exclude);\n\t\tif ((command.include !== undefined && include === undefined) || exclude === undefined) {\n\t\t\treturn this.failure(id, \"subscribe\", \"include and exclude must be arrays of event names\", \"invalid\");\n\t\t}\n\t\tconnection.include = include === undefined ? undefined : new Set(include);\n\t\tconnection.exclude = new Set(exclude);\n\t\treturn {\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tcommand: \"subscribe\",\n\t\t\tsuccess: true,\n\t\t\tdata: { ...(include === undefined ? {} : { include }), exclude },\n\t\t};\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Events\n\t// -----------------------------------------------------------------------\n\n\tprivate accepts(connection: Connection, name: string): boolean {\n\t\tif (connection.include !== undefined && !connection.include.has(name)) return false;\n\t\treturn !connection.exclude.has(name);\n\t}\n\n\tprivate broadcastEvent(event: LiveEvent): void {\n\t\tconst targets = [...this.connections].filter((connection) => this.accepts(connection, event.name));\n\t\tif (targets.length === 0) return;\n\t\tlet text: string;\n\t\ttry {\n\t\t\ttext = line({\n\t\t\t\ttype: \"event\",\n\t\t\t\tseq: event.seq,\n\t\t\t\tts: event.ts,\n\t\t\t\tevent: { type: event.name, ...(event.payload ?? {}) },\n\t\t\t\t...(event.truncated ? { truncated: true } : {}),\n\t\t\t});\n\t\t} catch (error) {\n\t\t\ttext = line({\n\t\t\t\ttype: \"serialize_error\",\n\t\t\t\tseq: event.seq,\n\t\t\t\tts: event.ts,\n\t\t\t\teventType: event.name,\n\t\t\t\terror: errorMessage(error),\n\t\t\t});\n\t\t}\n\t\tfor (const connection of targets) this.write(connection, text);\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Decisions as hooks\n\t// -----------------------------------------------------------------------\n\n\tprivate claim(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tconst hooks = command.hooks;\n\t\tconst declared = new Map(this.app.manifest.decisions.map((d) => [d.name, d]));\n\t\tif (!Array.isArray(hooks) || hooks.length === 0 || !hooks.every((h) => typeof h === \"string\")) {\n\t\t\treturn this.failure(id, \"claim_hooks\", \"hooks must be a non-empty array of decision names\", \"invalid\");\n\t\t}\n\t\tconst timeoutMs = command.timeoutMs;\n\t\tif (timeoutMs !== undefined && (typeof timeoutMs !== \"number\" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {\n\t\t\treturn this.failure(id, \"claim_hooks\", \"timeoutMs must be a positive integer\", \"invalid\");\n\t\t}\n\t\t// all or nothing: every name is checked before any is taken\n\t\tfor (const name of hooks as string[]) {\n\t\t\tconst decl = declared.get(name);\n\t\t\tif (!decl) return this.failure(id, \"claim_hooks\", `unknown decision: ${name}`, \"unknown\");\n\t\t\tif (decl.policy === \"forbid\") {\n\t\t\t\treturn this.failure(id, \"claim_hooks\", `decision ${name} is forbidden by policy`, \"forbidden\");\n\t\t\t}\n\t\t\tconst holder = this.claimants.get(name);\n\t\t\tif (holder !== undefined && holder !== connection) {\n\t\t\t\treturn this.failure(id, \"claim_hooks\", `decision ${name} is held by connection ${holder.id}`, \"denied\");\n\t\t\t}\n\t\t}\n\t\tfor (const name of hooks as string[]) {\n\t\t\tthis.claimants.set(name, connection);\n\t\t\tconnection.claims.add(name);\n\t\t}\n\t\t// the wait in force: the claim's own, which may only shorten the shortest declared among what it holds\n\t\tconst shortest = Math.min(\n\t\t\t...[...connection.claims].map((name) => durationMs(declared.get(name)?.timeout ?? \"30s\")),\n\t\t);\n\t\tif (timeoutMs !== undefined) connection.claimTimeoutMs = Math.min(timeoutMs, shortest);\n\t\treturn {\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tcommand: \"claim_hooks\",\n\t\t\tsuccess: true,\n\t\t\tdata: { hooks: [...connection.claims], timeoutMs: Math.min(connection.claimTimeoutMs ?? shortest, shortest) },\n\t\t};\n\t}\n\n\tprivate release(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tlet names: string[];\n\t\tif (command.hooks === undefined) names = [...connection.claims];\n\t\telse if (Array.isArray(command.hooks) && command.hooks.every((h) => typeof h === \"string\")) {\n\t\t\tnames = command.hooks as string[];\n\t\t} else return this.failure(id, \"release_hooks\", \"hooks must be an array of decision names\", \"invalid\");\n\t\tconst released = this.releaseClaims(connection, names);\n\t\treturn { type: \"response\", id, command: \"release_hooks\", success: true, data: { released } };\n\t}\n\n\t/** Free some of a connection's claims; what it was asked under them takes the default now. */\n\tprivate releaseClaims(connection: Connection, names: string[]): string[] {\n\t\tconst released: string[] = [];\n\t\tfor (const name of names) {\n\t\t\tif (!connection.claims.has(name)) continue;\n\t\t\tconnection.claims.delete(name);\n\t\t\tif (this.claimants.get(name) === connection) this.claimants.delete(name);\n\t\t\treleased.push(name);\n\t\t}\n\t\tfor (const hook of [...this.hooks.values()]) {\n\t\t\tif (hook.connection !== connection || !released.includes(hook.name)) continue;\n\t\t\tthis.forget(hook);\n\t\t\t// for this decision its claimant is gone, which is what detached means (section 5.4)\n\t\t\tthis.binding?.defaultDecision(hook.id, \"detached\", connection.id);\n\t\t}\n\t\treturn released;\n\t}\n\n\tprivate forget(hook: PendingHook): void {\n\t\tthis.hooks.delete(hook.id);\n\t\tif (hook.timer !== undefined) clearTimeout(hook.timer);\n\t}\n\n\t/** The program asked a decision: it goes to the claimant, or takes its default now. */\n\tprivate onDecision(request: DecisionRequest): void {\n\t\tconst binding = this.binding;\n\t\tif (!binding) return;\n\t\tconst connection = this.claimants.get(request.name);\n\t\tif (!connection) {\n\t\t\tbinding.defaultDecision(request.id, \"no_harness\");\n\t\t\treturn;\n\t\t}\n\t\tbinding.assignDecision(request.id, connection.id);\n\t\tconst remaining = Math.max(0, request.deadline - this.app.now());\n\t\tconst timeoutMs =\n\t\t\tconnection.claimTimeoutMs === undefined ? remaining : Math.min(remaining, connection.claimTimeoutMs);\n\t\tlet text: string;\n\t\ttry {\n\t\t\ttext = line({ type: \"hook\", id: request.id, hook: request.name, timeoutMs, payload: request.context });\n\t\t} catch (error) {\n\t\t\t// the context cannot cross: the claimant is told, and the program goes on with the default\n\t\t\tconst message = `payload could not be serialized: ${errorMessage(error)}`;\n\t\t\tthis.write(connection, line({ type: \"hook_rejected\", id: request.id, hook: request.name, error: message }));\n\t\t\tbinding.defaultDecision(request.id, \"invalid\", connection.id);\n\t\t\treturn;\n\t\t}\n\t\tconst hook: PendingHook = {\n\t\t\tid: request.id,\n\t\t\tname: request.name,\n\t\t\tconnection,\n\t\t\tepochAtSend: connection.timeoutEpoch,\n\t\t\ttimer: undefined,\n\t\t};\n\t\tif (timeoutMs < remaining) {\n\t\t\t// the claim's own wait, shorter than the manifest's: the default is applied then, through the one path\n\t\t\thook.timer = setTimeout(() => binding.defaultDecision(request.id, \"timeout\", connection.id), timeoutMs);\n\t\t}\n\t\tthis.hooks.set(request.id, hook);\n\t\tthis.write(connection, text);\n\t}\n\n\t/** The binding settled a decision by timeout (its deadline, or the claim's wait through `defaultDecision`). */\n\tprivate onDecisionTimeout(id: string, name: string): void {\n\t\tconst hook = this.hooks.get(id);\n\t\tif (!hook) return;\n\t\tthis.forget(hook);\n\t\tconst { connection } = hook;\n\t\tthis.write(connection, line({ type: \"hook_timeout\", id, hook: name }));\n\t\t// a batch that timed out together counts once\n\t\tif (hook.epochAtSend === connection.timeoutEpoch) connection.consecutiveTimeouts += 1;\n\t\tconnection.timeoutEpoch += 1;\n\t\tif (connection.consecutiveTimeouts >= CONSECUTIVE_TIMEOUTS_LIMIT) {\n\t\t\tconnection.consecutiveTimeouts = 0;\n\t\t\tconst released = this.releaseClaims(connection, [...connection.claims]);\n\t\t\tthis.write(connection, line({ type: \"hook_claims_released\", reason: \"timeouts\", hooks: released }));\n\t\t}\n\t}\n\n\tprivate onHookResponse(\n\t\tconnection: Connection,\n\t\tid: unknown,\n\t\tresult: unknown,\n\t\treason: string | undefined,\n\t\tconsent: Consent | undefined,\n\t): void {\n\t\tif (typeof id !== \"string\") return;\n\t\tconst hook = this.hooks.get(id);\n\t\t// ignored unless this very connection was asked: a late or a stray answer writes nothing\n\t\tif (!hook || hook.connection !== connection) return;\n\t\tthis.forget(hook);\n\t\ttry {\n\t\t\tthis.binding?.answerDecision(\n\t\t\t\tid,\n\t\t\t\tresult ?? null,\n\t\t\t\treason ?? \"no reason stated by the harness\",\n\t\t\t\tconsent,\n\t\t\t\tconnection.id,\n\t\t\t);\n\t\t\t// only an accepted answer resets the streak\n\t\t\tconnection.consecutiveTimeouts = 0;\n\t\t} catch (error) {\n\t\t\tthis.write(connection, line({ type: \"hook_rejected\", id, hook: hook.name, error: errorMessage(error) }));\n\t\t}\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Turns and touchpoints, broadcast\n\t// -----------------------------------------------------------------------\n\n\tprivate turnMessage(request: TurnRequest) {\n\t\treturn {\n\t\t\ttype: \"turn\",\n\t\t\tid: request.id,\n\t\t\tname: request.name,\n\t\t\ttimeoutMs: Math.max(0, request.deadline - this.app.now()),\n\t\t\t...(request.content === undefined ? {} : { content: request.content }),\n\t\t};\n\t}\n\n\tprivate touchpointMessage(request: TouchpointRequest) {\n\t\treturn {\n\t\t\ttype: \"touchpoint\",\n\t\t\tid: request.id,\n\t\t\tkind: request.kind,\n\t\t\t...(request.content === undefined ? {} : { content: request.content }),\n\t\t};\n\t}\n\n\tprivate onTurn(request: TurnRequest): void {\n\t\tthis.openTurns.set(request.id, request);\n\t\t// a turn ends on its own at its deadline, and this map must not remember it past that\n\t\tconst timer = setTimeout(\n\t\t\t() => this.openTurns.delete(request.id),\n\t\t\tMath.max(0, request.deadline - this.app.now()) + 1,\n\t\t);\n\t\ttimer.unref();\n\t\tconst text = line(this.turnMessage(request));\n\t\tfor (const connection of [...this.connections]) this.write(connection, text);\n\t}\n\n\tprivate onTouchpoint(request: TouchpointRequest): void {\n\t\tthis.openTouchpoints.set(request.id, request);\n\t\tconst text = line(this.touchpointMessage(request));\n\t\tfor (const connection of [...this.connections]) this.write(connection, text);\n\t}\n}\n\nfunction isConsent(value: unknown): value is Consent {\n\treturn value === \"approved\" || value === \"auto\" || value === \"denied\";\n}\n"]}
1
+ {"version":3,"file":"endpoint.d.ts","sourceRoot":"","sources":["../src/endpoint.ts"],"names":[],"mappings":"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","sourcesContent":["// The stub's sidecar endpoint (abaigrammar section 6.3): the receiving end a\n// harness in another process connects to. Wire protocol v1 over a Unix\n// socket, listening from startup so an outside harness reaches a running\n// program that never calls a touchpoint, and so a lost connection is\n// restored by the harness connecting again.\n//\n// One binding, many connections. The first connection attaches the program\n// (one DirectBinding, which every channel runs through); the last one\n// leaving detaches it, so what was open settles as detached. Every act is\n// audited under the connection that made it. Decisions go to the one\n// connection that claimed them; turns and touchpoints are broadcast, and\n// the first end settles one.\n//\n// Nothing here keeps the program alive, and nothing here lets it die\n// mid-touchpoint: the server and its sockets are unref'd, so a program whose\n// work is done exits with a harness still connected, while a pending\n// decision, turn or touchpoint holds a ref'd timer of its own in the binding.\nimport { Buffer } from \"node:buffer\";\nimport { existsSync, unlinkSync } from \"node:fs\";\nimport { createServer, type Server, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { isMessageRecord } from \"./audit.ts\";\nimport {\n\tCallError,\n\ttype CallErrorCode,\n\ttype Consent,\n\ttype DecisionRequest,\n\ttype DirectBinding,\n\ttype LiveEvent,\n\ttype TouchpointRequest,\n\ttype TurnRequest,\n} from \"./harness-binding.ts\";\nimport { durationMs } from \"./manifest/manifest.ts\";\nimport type { Abaigram } from \"./program.ts\";\nimport {\n\tassertSocketPathLength,\n\tattachCappedLineReader,\n\tDEFAULT_MAX_LINE_BYTES,\n\tdefaultSocketPath,\n\tEXTENSIONS,\n\tensureRunDir,\n\ttype HelloMessage,\n\tMAX_SOCKET_PATH_BYTES,\n\tPROTOCOL_VERSION,\n\tremoveDescriptor,\n\ttype WireErrorCode,\n\ttype WireResponse,\n\twriteDescriptor,\n} from \"./wire/index.ts\";\n\n/**\n * Where the socket goes when the run directory's own path is too long for\n * one (103 bytes on macOS, and a run directory under a deep project path\n * gets there): a private directory under the machine's temp dir. The\n * descriptor in the run directory names the socket wherever it is, which is\n * what its `socket` field is for, so discovery is unchanged.\n */\nexport function shortSocketPath(pid: number): string {\n\t// per user: the temp dir is shared on Linux, and the directory is made private to whoever creates it\n\tconst uid = typeof process.getuid === \"function\" ? String(process.getuid()) : \"u\";\n\treturn join(tmpdir(), `abai-sockets-${uid}`, `${pid}.sock`);\n}\n\nexport interface EndpointOptions {\n\t/** Where the socket and the descriptor go. */\n\trunDir: string;\n\t/** The socket path; default `<runDir>/<pid>.sock`. */\n\tsocketPath?: string;\n\t/** A connection whose unsent output exceeds this many bytes is dropped whole. Default 16 MiB. */\n\tmaxBuffered?: number;\n\t/** An input line past this many bytes drops the connection. Default 64 MiB. */\n\tmaxLineBytes?: number;\n}\n\n/** Unsent bytes a connection may hold before it is dropped: a harness that cannot keep up is not the program's problem. */\nexport const DEFAULT_MAX_BUFFERED = 16 * 1024 * 1024;\n/** Consecutive claim timeouts after which a connection's claims are released: a harness that never answers holds nothing. */\nexport const CONSECUTIVE_TIMEOUTS_LIMIT = 3;\n/** How long a socket gets to flush when the endpoint closes it. */\nconst CLOSE_GRACE_MS = 200;\n\ninterface Connection {\n\tid: string;\n\tsocket: Socket;\n\t/** The event filter: `include` restricts to the listed types; `exclude` drops them. */\n\tinclude: Set<string> | undefined;\n\texclude: Set<string>;\n\tclaims: Set<string>;\n\t/** A claim's own wait, never longer than the manifest's. */\n\tclaimTimeoutMs: number | undefined;\n\tconsecutiveTimeouts: number;\n\t/** Bumped on every timeout; a hook sent before the last timeout counts once with its batch. */\n\ttimeoutEpoch: number;\n\tdetachReader: () => void;\n}\n\ninterface PendingHook {\n\tid: string;\n\tname: string;\n\tconnection: Connection;\n\tepochAtSend: number;\n\t/** The claim's shorter wait, when it has one. */\n\ttimer: NodeJS.Timeout | undefined;\n}\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction line(value: unknown): string {\n\treturn `${JSON.stringify(value)}\\n`;\n}\n\nexport class Endpoint {\n\treadonly runDir: string;\n\treadonly socketPath: string;\n\t/** Resolves once the socket listens and the descriptor is written; rejects with why it could not. */\n\treadonly ready: Promise<void>;\n\tprivate readonly app: Abaigram;\n\tprivate readonly maxBuffered: number;\n\tprivate readonly maxLineBytes: number;\n\tprivate server: Server | undefined;\n\tprivate readonly connections = new Set<Connection>();\n\tprivate readonly claimants = new Map<string, Connection>();\n\tprivate readonly hooks = new Map<string, PendingHook>();\n\t/** Open turns and touchpoints, replayed to a connection that arrives while they are open. */\n\tprivate readonly openTurns = new Map<string, TurnRequest>();\n\tprivate readonly openTouchpoints = new Map<string, TouchpointRequest>();\n\tprivate readonly connectionWaiters: Array<() => void> = [];\n\tprivate binding: DirectBinding | undefined;\n\tprivate unsubscribe: Array<() => void> = [];\n\tprivate nextConnectionId = 1;\n\tprivate closed = false;\n\tprivate readonly onExit: () => void;\n\tprivate settleReady: { resolve: () => void; reject: (error: Error) => void } | undefined;\n\n\tconstructor(app: Abaigram, options: EndpointOptions) {\n\t\tthis.app = app;\n\t\tthis.runDir = options.runDir;\n\t\tconst preferred = options.socketPath ?? defaultSocketPath(options.runDir, process.pid);\n\t\t// an explicit path is used as given; the default falls back to a short one when it does not fit\n\t\tthis.socketPath =\n\t\t\toptions.socketPath === undefined && Buffer.byteLength(preferred) > MAX_SOCKET_PATH_BYTES\n\t\t\t\t? shortSocketPath(process.pid)\n\t\t\t\t: preferred;\n\t\tthis.maxBuffered = options.maxBuffered ?? DEFAULT_MAX_BUFFERED;\n\t\tthis.maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;\n\t\tthis.onExit = () => this.removeFiles();\n\t\tthis.ready = new Promise<void>((resolve, reject) => {\n\t\t\tthis.settleReady = { resolve, reject };\n\t\t});\n\t\tthis.listen();\n\t}\n\n\tprivate resolveReady(): void {\n\t\tconst settle = this.settleReady;\n\t\tthis.settleReady = undefined;\n\t\tsettle?.resolve();\n\t}\n\n\tprivate rejectReady(error: unknown): void {\n\t\tconst settle = this.settleReady;\n\t\tthis.settleReady = undefined;\n\t\tsettle?.reject(error instanceof Error ? error : new Error(String(error)));\n\t}\n\n\tget connectionCount(): number {\n\t\treturn this.connections.size;\n\t}\n\n\t/** The ids of the open connections, in order of arrival. */\n\tconnectionIds(): string[] {\n\t\treturn [...this.connections].map((connection) => connection.id);\n\t}\n\n\tprivate listen(): void {\n\t\ttry {\n\t\t\tassertSocketPathLength(this.socketPath);\n\t\t\tensureRunDir(this.runDir);\n\t\t\tif (this.socketPath !== defaultSocketPath(this.runDir, process.pid)) ensureRunDir(dirname(this.socketPath));\n\t\t\t// a socket file left by an earlier life of this very pid number: nobody else writes to this name.\n\t\t\t// (A second Abaigram under sidecar in the same process would take over the first one's socket here;\n\t\t\t// one program is one endpoint, and a test suite that builds several keeps them in turns mode.)\n\t\t\tif (existsSync(this.socketPath)) unlinkSync(this.socketPath);\n\t\t} catch (error) {\n\t\t\tthis.rejectReady(error);\n\t\t\treturn;\n\t\t}\n\t\tconst server = createServer((socket) => this.accept(socket));\n\t\tthis.server = server;\n\t\tserver.once(\"error\", (error) => this.rejectReady(error));\n\t\tserver.listen(this.socketPath, () => {\n\t\t\tserver.unref();\n\t\t\ttry {\n\t\t\t\twriteDescriptor(this.runDir, {\n\t\t\t\t\tpid: process.pid,\n\t\t\t\t\tsocket: this.socketPath,\n\t\t\t\t\tcwd: process.cwd(),\n\t\t\t\t\tname: this.app.name,\n\t\t\t\t\tmode: this.app.mode,\n\t\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tserver.close();\n\t\t\t\tthis.rejectReady(error);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tprocess.once(\"exit\", this.onExit);\n\t\t\tthis.resolveReady();\n\t\t});\n\t}\n\n\t/** Resolves true as soon as a connection is open, or when one arrives within `timeoutMs`; false when none does. */\n\twaitForConnection(timeoutMs: number): Promise<boolean> {\n\t\tif (this.connections.size > 0) return Promise.resolve(true);\n\t\treturn new Promise<boolean>((resolve) => {\n\t\t\tlet settled = false;\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tconst index = this.connectionWaiters.indexOf(waiter);\n\t\t\t\tif (index !== -1) this.connectionWaiters.splice(index, 1);\n\t\t\t\tresolve(false);\n\t\t\t}, timeoutMs);\n\t\t\tconst waiter = (): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve(true);\n\t\t\t};\n\t\t\tthis.connectionWaiters.push(waiter);\n\t\t});\n\t}\n\n\t/** Stop listening, drop every connection, remove the socket and the descriptor. */\n\tclose(reason = \"the endpoint closed\"): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\t// closed before it listened: whoever awaits `ready` is told, rather than left waiting\n\t\tthis.rejectReady(new Error(reason));\n\t\tfor (const connection of [...this.connections]) this.drop(connection, reason);\n\t\tthis.server?.close();\n\t\tthis.removeFiles();\n\t\tprocess.off(\"exit\", this.onExit);\n\t}\n\n\tprivate removeFiles(): void {\n\t\ttry {\n\t\t\tremoveDescriptor(this.runDir, process.pid);\n\t\t} catch {\n\t\t\t// nothing to remove\n\t\t}\n\t\ttry {\n\t\t\tif (existsSync(this.socketPath)) unlinkSync(this.socketPath);\n\t\t} catch {\n\t\t\t// a socket file that cannot be removed is pruned by the next listing\n\t\t}\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Connections\n\t// -----------------------------------------------------------------------\n\n\tprivate accept(socket: Socket): void {\n\t\tif (this.closed) {\n\t\t\tsocket.destroy();\n\t\t\treturn;\n\t\t}\n\t\t// a connected harness never holds the program open; what holds it is the program's own pending work\n\t\tsocket.unref();\n\t\tsocket.setNoDelay?.(true);\n\t\tconst connection: Connection = {\n\t\t\tid: `k${this.nextConnectionId++}`,\n\t\t\tsocket,\n\t\t\tinclude: undefined,\n\t\t\texclude: new Set(),\n\t\t\tclaims: new Set(),\n\t\t\tclaimTimeoutMs: undefined,\n\t\t\tconsecutiveTimeouts: 0,\n\t\t\ttimeoutEpoch: 0,\n\t\t\tdetachReader: () => {},\n\t\t};\n\t\tlet binding: DirectBinding;\n\t\ttry {\n\t\t\tbinding = this.bindingFor(connection);\n\t\t} catch (error) {\n\t\t\t// the program is attached some other way (an in-process harness): this connection cannot be served,\n\t\t\t// and the one line saying so is flushed before the socket ends\n\t\t\tsocket.end(\n\t\t\t\tline({ type: \"response\", command: \"hello\", success: false, error: errorMessage(error), code: \"error\" }),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tsocket.on(\"error\", () => this.drop(connection, \"the connection failed\"));\n\t\tsocket.on(\"close\", () => this.drop(connection, \"the connection closed\"));\n\t\tconnection.detachReader = attachCappedLineReader(\n\t\t\tsocket,\n\t\t\tthis.maxLineBytes,\n\t\t\t(text) => this.onLine(connection, text),\n\t\t\t() => this.drop(connection, `a line exceeded ${this.maxLineBytes} bytes`),\n\t\t);\n\t\tthis.connections.add(connection);\n\t\tthis.write(connection, line(this.hello(connection, binding)));\n\t\t// a harness that connects while the program waits at a touchpoint or a turn is told at once\n\t\tfor (const turn of this.openTurns.values()) this.write(connection, line(this.turnMessage(turn)));\n\t\tfor (const touchpoint of this.openTouchpoints.values()) {\n\t\t\tthis.write(connection, line(this.touchpointMessage(touchpoint)));\n\t\t}\n\t\tfor (const waiter of this.connectionWaiters.splice(0)) waiter();\n\t}\n\n\t/** The one binding every connection shares, created by the first. Its own label is for acts no connection made. */\n\tprivate bindingFor(_connection: Connection): DirectBinding {\n\t\tif (this.binding) return this.binding;\n\t\tconst binding = this.app.bind(\"endpoint\");\n\t\tthis.binding = binding;\n\t\tthis.unsubscribe = [\n\t\t\tbinding.on(\"event\", (event) => this.broadcastEvent(event)),\n\t\t\tbinding.on(\"decision\", (request) => this.onDecision(request)),\n\t\t\tbinding.on(\"decision_timeout\", (id, name) => this.onDecisionTimeout(id, name)),\n\t\t\tbinding.on(\"turn\", (request) => this.onTurn(request)),\n\t\t\tbinding.on(\"touchpoint\", (request) => this.onTouchpoint(request)),\n\t\t\tbinding.on(\"detach\", (reason) => this.onDetach(reason)),\n\t\t];\n\t\treturn binding;\n\t}\n\n\tprivate hello(connection: Connection, binding: DirectBinding): HelloMessage {\n\t\tconst { manifest } = this.app;\n\t\treturn {\n\t\t\ttype: \"hello\",\n\t\t\tprotocol: PROTOCOL_VERSION,\n\t\t\tpid: process.pid,\n\t\t\tcwd: process.cwd(),\n\t\t\tmode: this.app.mode,\n\t\t\tcapabilities: {\n\t\t\t\thooks: manifest.decisions.filter((d) => d.policy !== \"forbid\").map((d) => d.name),\n\t\t\t\tcommands: manifest.capabilities.filter((c) => c.policy !== \"forbid\").map((c) => c.name),\n\t\t\t},\n\t\t\tlastSeq: this.app.region.lastSeq,\n\t\t\tconnection: connection.id,\n\t\t\tabai: {\n\t\t\t\tname: manifest.name,\n\t\t\t\tabaigrammar: manifest.abaigrammar,\n\t\t\t\tmodes: [...manifest.modes],\n\t\t\t\taudit: binding.auditInfo(),\n\t\t\t\textensions: [...EXTENSIONS],\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate write(connection: Connection, text: string): void {\n\t\tconst { socket } = connection;\n\t\tif (socket.destroyed || !socket.writable) return;\n\t\tsocket.write(text);\n\t\tif (socket.writableLength > this.maxBuffered) this.drop(connection, \"the connection fell too far behind\");\n\t}\n\n\tprivate drop(connection: Connection, reason: string): void {\n\t\tif (!this.connections.delete(connection)) return;\n\t\tconnection.detachReader();\n\t\tfor (const name of connection.claims) {\n\t\t\tif (this.claimants.get(name) === connection) this.claimants.delete(name);\n\t\t}\n\t\tconnection.claims.clear();\n\t\t// what this connection was asked, nobody will answer now\n\t\tfor (const hook of [...this.hooks.values()]) {\n\t\t\tif (hook.connection !== connection) continue;\n\t\t\tthis.forget(hook);\n\t\t\tthis.binding?.defaultDecision(hook.id, \"detached\", connection.id);\n\t\t}\n\t\tif (!connection.socket.destroyed) {\n\t\t\tconnection.socket.end();\n\t\t\tconst timer = setTimeout(() => connection.socket.destroy(), CLOSE_GRACE_MS);\n\t\t\ttimer.unref();\n\t\t}\n\t\tif (this.connections.size === 0 && this.binding) {\n\t\t\tconst binding = this.binding;\n\t\t\tthis.releaseBinding();\n\t\t\tbinding.detach(`the last harness connection closed (${reason})`);\n\t\t}\n\t}\n\n\t/** Forget the binding and everything pending on it, every timer included: a finished program must be free to exit. */\n\tprivate releaseBinding(): void {\n\t\tthis.binding = undefined;\n\t\tfor (const off of this.unsubscribe.splice(0)) off();\n\t\tfor (const hook of [...this.hooks.values()]) this.forget(hook);\n\t\tthis.openTurns.clear();\n\t\tthis.openTouchpoints.clear();\n\t}\n\n\t/** The program detached the binding itself: whoever is still connected is told by the close. */\n\tprivate onDetach(_reason: string): void {\n\t\tif (!this.binding) return;\n\t\tthis.releaseBinding();\n\t\tfor (const connection of [...this.connections]) this.drop(connection, \"the program detached\");\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Lines in\n\t// -----------------------------------------------------------------------\n\n\tprivate onLine(connection: Connection, text: string): void {\n\t\tif (text.trim() === \"\") return;\n\t\tlet message: unknown;\n\t\ttry {\n\t\t\tmessage = JSON.parse(text);\n\t\t} catch {\n\t\t\tthis.respond(connection, { type: \"response\", command: \"unknown\", success: false, error: \"invalid json\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isRecord(message)) {\n\t\t\tthis.respond(connection, { type: \"response\", command: \"unknown\", success: false, error: \"invalid message\" });\n\t\t\treturn;\n\t\t}\n\t\tconst reason = typeof message.reason === \"string\" ? message.reason : undefined;\n\t\tconst consent = isConsent(message.consent) ? message.consent : undefined;\n\t\tswitch (message.type) {\n\t\t\tcase \"request\": {\n\t\t\t\tconst id = message.id;\n\t\t\t\tif (typeof id !== \"string\" || id === \"\") {\n\t\t\t\t\tthis.respond(connection, {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tcommand: \"request\",\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: \"request id must be a non-empty string\",\n\t\t\t\t\t\tcode: \"invalid\",\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tvoid this.dispatch(connection, id, message.command, reason, consent);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcase \"hook_response\":\n\t\t\t\tthis.onHookResponse(connection, message.id, message.result, reason, consent);\n\t\t\t\treturn;\n\t\t\tcase \"turn_end\":\n\t\t\t\tif (typeof message.id === \"string\") {\n\t\t\t\t\tthis.openTurns.delete(message.id);\n\t\t\t\t\tthis.binding?.endTurn(\n\t\t\t\t\t\tmessage.id,\n\t\t\t\t\t\tconnection.id,\n\t\t\t\t\t\ttypeof message.cause === \"string\" ? message.cause : undefined,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\tcase \"touchpoint_end\":\n\t\t\t\tif (typeof message.id === \"string\") {\n\t\t\t\t\tthis.openTouchpoints.delete(message.id);\n\t\t\t\t\tthis.binding?.endTouchpoint(message.id);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\tdefault:\n\t\t\t\tthis.respond(connection, {\n\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t...(typeof message.id === \"string\" ? { id: message.id } : {}),\n\t\t\t\t\tcommand: \"unknown\",\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: `unknown message type: ${String(message.type)}`,\n\t\t\t\t\tcode: \"unknown\",\n\t\t\t\t});\n\t\t}\n\t}\n\n\tprivate respond(connection: Connection, response: WireResponse): void {\n\t\ttry {\n\t\t\tthis.write(connection, line(response));\n\t\t} catch (error) {\n\t\t\t// a result that cannot be serialized becomes an error response; the connection stays\n\t\t\tconst failed: WireResponse = {\n\t\t\t\ttype: \"response\",\n\t\t\t\t...(response.id === undefined ? {} : { id: response.id }),\n\t\t\t\tcommand: response.command,\n\t\t\t\tsuccess: false,\n\t\t\t\terror: `the response could not be serialized: ${errorMessage(error)}`,\n\t\t\t\tcode: \"error\",\n\t\t\t};\n\t\t\tthis.write(connection, line(failed));\n\t\t}\n\t}\n\n\tprivate failure(id: string, command: string, error: string, code: WireErrorCode): WireResponse {\n\t\treturn { type: \"response\", id, command, success: false, error, code };\n\t}\n\n\t/** Run one request, answer it when it settles. Never rejects. */\n\tprivate async dispatch(\n\t\tconnection: Connection,\n\t\tid: string,\n\t\tcommand: unknown,\n\t\treason: string | undefined,\n\t\tconsent: Consent | undefined,\n\t): Promise<void> {\n\t\tif (!isRecord(command) || typeof command.type !== \"string\") {\n\t\t\tthis.respond(connection, this.failure(id, \"unknown\", \"command must be an object with a type\", \"invalid\"));\n\t\t\treturn;\n\t\t}\n\t\tconst type = command.type;\n\t\tconst binding = this.binding;\n\t\tif (!binding) {\n\t\t\tthis.respond(connection, this.failure(id, type, \"the program is no longer attached\", \"error\"));\n\t\t\treturn;\n\t\t}\n\t\tlet response: WireResponse;\n\t\ttry {\n\t\t\tswitch (type) {\n\t\t\t\tcase \"ping\":\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { pid: process.pid, ts: Date.now() },\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"subscribe\":\n\t\t\t\t\tresponse = this.subscribe(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"claim_hooks\":\n\t\t\t\t\tresponse = this.claim(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"release_hooks\":\n\t\t\t\t\tresponse = this.release(connection, id, command);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"get_manifest\":\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: { manifest: this.app.manifest } };\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"get_content\": {\n\t\t\t\t\tconst content = binding.content();\n\t\t\t\t\tconst turns: Record<string, string> = {};\n\t\t\t\t\tfor (const turn of this.app.manifest.turns) {\n\t\t\t\t\t\tconst text = content.turn(turn.name);\n\t\t\t\t\t\tif (text !== undefined) turns[turn.name] = text;\n\t\t\t\t\t}\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\tidentity: content.identity,\n\t\t\t\t\t\t\t...(content.install === undefined ? {} : { install: content.install }),\n\t\t\t\t\t\t\t...(content.use === undefined ? {} : { use: content.use }),\n\t\t\t\t\t\t\tturns,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"get_audit\": {\n\t\t\t\t\tconst limit = typeof command.limit === \"number\" && command.limit > 0 ? Math.floor(command.limit) : 100;\n\t\t\t\t\tresponse = {\n\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\tid,\n\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { ...binding.auditInfo(), ...binding.auditTail(limit) },\n\t\t\t\t\t};\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"record_message\": {\n\t\t\t\t\t// the audit line for a message over the run's network (the `messages` extension): not an act of control\n\t\t\t\t\t// the agent's reason rides the request envelope like every other act's, or sits in the record\n\t\t\t\t\tconst { type: _type, ...given } = command;\n\t\t\t\t\tconst record = given.reason === undefined && reason !== undefined ? { ...given, reason } : given;\n\t\t\t\t\tif (!isMessageRecord(record)) {\n\t\t\t\t\t\tresponse = this.failure(\n\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\ttype,\n\t\t\t\t\t\t\t\"record_message needs direction, peer, addr, network, kind, body and outcome\",\n\t\t\t\t\t\t\t\"invalid\",\n\t\t\t\t\t\t);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tresponse = binding.recordMessage(record, connection.id)\n\t\t\t\t\t\t? { type: \"response\", id, command: type, success: true, data: {} }\n\t\t\t\t\t\t: this.failure(id, type, \"the audit log is closed or failed: the message was not recorded\", \"error\");\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"get_cells\": {\n\t\t\t\t\tconst reading = binding.readCells();\n\t\t\t\t\tconst cells: Record<string, unknown> = {};\n\t\t\t\t\tfor (const [name, cell] of Object.entries(reading.cells)) cells[name] = cell.value;\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: { seq: reading.seq, cells } };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"lifecycle\": {\n\t\t\t\t\tif (typeof command.op !== \"string\") {\n\t\t\t\t\t\tresponse = this.failure(id, type, \"lifecycle needs an op\", \"invalid\");\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tconst outcome = binding.lifecycle(command.op, reason ?? \"\", consent, connection.id);\n\t\t\t\t\tresponse = outcome.ok\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\tdata: outcome.data ?? { op: command.op, done: true },\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: this.failure(id, type, outcome.error, outcome.code);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"set_cell\": {\n\t\t\t\t\tif (typeof command.name !== \"string\") {\n\t\t\t\t\t\tresponse = this.failure(id, type, \"set_cell needs a cell name\", \"invalid\");\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tconst outcome = await binding.writeKnob(\n\t\t\t\t\t\tcommand.name,\n\t\t\t\t\t\tcommand.value,\n\t\t\t\t\t\treason ?? \"\",\n\t\t\t\t\t\tconsent,\n\t\t\t\t\t\tconnection.id,\n\t\t\t\t\t);\n\t\t\t\t\tresponse =\n\t\t\t\t\t\toutcome.outcome === \"done\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\ttype: \"response\",\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\tcommand: type,\n\t\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\t\tdata: { name: command.name, value: outcome.value },\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: this.failure(\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\ttype,\n\t\t\t\t\t\t\t\t\toutcome.error ?? `${command.name} was not written`,\n\t\t\t\t\t\t\t\t\toutcome.code ?? \"error\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tdefault: {\n\t\t\t\t\tconst { type: _type, ...params } = command;\n\t\t\t\t\tconst result = await binding.call(type, params, reason ?? \"\", consent, connection.id);\n\t\t\t\t\tresponse = { type: \"response\", id, command: type, success: true, data: result };\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst code: CallErrorCode = error instanceof CallError ? error.code : \"error\";\n\t\t\tresponse = this.failure(id, type, errorMessage(error), code);\n\t\t}\n\t\tthis.respond(connection, response);\n\t}\n\n\tprivate subscribe(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tconst names = (value: unknown): string[] | undefined =>\n\t\t\tArray.isArray(value) && value.every((v) => typeof v === \"string\") ? (value as string[]) : undefined;\n\t\tconst include = command.include === undefined ? undefined : names(command.include);\n\t\tconst exclude = command.exclude === undefined ? [] : names(command.exclude);\n\t\tif ((command.include !== undefined && include === undefined) || exclude === undefined) {\n\t\t\treturn this.failure(id, \"subscribe\", \"include and exclude must be arrays of event names\", \"invalid\");\n\t\t}\n\t\tconnection.include = include === undefined ? undefined : new Set(include);\n\t\tconnection.exclude = new Set(exclude);\n\t\treturn {\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tcommand: \"subscribe\",\n\t\t\tsuccess: true,\n\t\t\tdata: { ...(include === undefined ? {} : { include }), exclude },\n\t\t};\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Events\n\t// -----------------------------------------------------------------------\n\n\tprivate accepts(connection: Connection, name: string): boolean {\n\t\tif (connection.include !== undefined && !connection.include.has(name)) return false;\n\t\treturn !connection.exclude.has(name);\n\t}\n\n\tprivate broadcastEvent(event: LiveEvent): void {\n\t\tconst targets = [...this.connections].filter((connection) => this.accepts(connection, event.name));\n\t\tif (targets.length === 0) return;\n\t\tlet text: string;\n\t\ttry {\n\t\t\ttext = line({\n\t\t\t\ttype: \"event\",\n\t\t\t\tseq: event.seq,\n\t\t\t\tts: event.ts,\n\t\t\t\tevent: { type: event.name, ...(event.payload ?? {}) },\n\t\t\t\t...(event.truncated ? { truncated: true } : {}),\n\t\t\t});\n\t\t} catch (error) {\n\t\t\ttext = line({\n\t\t\t\ttype: \"serialize_error\",\n\t\t\t\tseq: event.seq,\n\t\t\t\tts: event.ts,\n\t\t\t\teventType: event.name,\n\t\t\t\terror: errorMessage(error),\n\t\t\t});\n\t\t}\n\t\tfor (const connection of targets) this.write(connection, text);\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Decisions as hooks\n\t// -----------------------------------------------------------------------\n\n\tprivate claim(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tconst hooks = command.hooks;\n\t\tconst declared = new Map(this.app.manifest.decisions.map((d) => [d.name, d]));\n\t\tif (!Array.isArray(hooks) || hooks.length === 0 || !hooks.every((h) => typeof h === \"string\")) {\n\t\t\treturn this.failure(id, \"claim_hooks\", \"hooks must be a non-empty array of decision names\", \"invalid\");\n\t\t}\n\t\tconst timeoutMs = command.timeoutMs;\n\t\tif (timeoutMs !== undefined && (typeof timeoutMs !== \"number\" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {\n\t\t\treturn this.failure(id, \"claim_hooks\", \"timeoutMs must be a positive integer\", \"invalid\");\n\t\t}\n\t\t// all or nothing: every name is checked before any is taken\n\t\tfor (const name of hooks as string[]) {\n\t\t\tconst decl = declared.get(name);\n\t\t\tif (!decl) return this.failure(id, \"claim_hooks\", `unknown decision: ${name}`, \"unknown\");\n\t\t\tif (decl.policy === \"forbid\") {\n\t\t\t\treturn this.failure(id, \"claim_hooks\", `decision ${name} is forbidden by policy`, \"forbidden\");\n\t\t\t}\n\t\t\tconst holder = this.claimants.get(name);\n\t\t\tif (holder !== undefined && holder !== connection) {\n\t\t\t\treturn this.failure(id, \"claim_hooks\", `decision ${name} is held by connection ${holder.id}`, \"denied\");\n\t\t\t}\n\t\t}\n\t\tfor (const name of hooks as string[]) {\n\t\t\tthis.claimants.set(name, connection);\n\t\t\tconnection.claims.add(name);\n\t\t}\n\t\t// the wait in force: the claim's own, which may only shorten the shortest declared among what it holds\n\t\tconst shortest = Math.min(\n\t\t\t...[...connection.claims].map((name) => durationMs(declared.get(name)?.timeout ?? \"30s\")),\n\t\t);\n\t\tif (timeoutMs !== undefined) connection.claimTimeoutMs = Math.min(timeoutMs, shortest);\n\t\treturn {\n\t\t\ttype: \"response\",\n\t\t\tid,\n\t\t\tcommand: \"claim_hooks\",\n\t\t\tsuccess: true,\n\t\t\tdata: { hooks: [...connection.claims], timeoutMs: Math.min(connection.claimTimeoutMs ?? shortest, shortest) },\n\t\t};\n\t}\n\n\tprivate release(connection: Connection, id: string, command: Record<string, unknown>): WireResponse {\n\t\tlet names: string[];\n\t\tif (command.hooks === undefined) names = [...connection.claims];\n\t\telse if (Array.isArray(command.hooks) && command.hooks.every((h) => typeof h === \"string\")) {\n\t\t\tnames = command.hooks as string[];\n\t\t} else return this.failure(id, \"release_hooks\", \"hooks must be an array of decision names\", \"invalid\");\n\t\tconst released = this.releaseClaims(connection, names);\n\t\treturn { type: \"response\", id, command: \"release_hooks\", success: true, data: { released } };\n\t}\n\n\t/** Free some of a connection's claims; what it was asked under them takes the default now. */\n\tprivate releaseClaims(connection: Connection, names: string[]): string[] {\n\t\tconst released: string[] = [];\n\t\tfor (const name of names) {\n\t\t\tif (!connection.claims.has(name)) continue;\n\t\t\tconnection.claims.delete(name);\n\t\t\tif (this.claimants.get(name) === connection) this.claimants.delete(name);\n\t\t\treleased.push(name);\n\t\t}\n\t\tfor (const hook of [...this.hooks.values()]) {\n\t\t\tif (hook.connection !== connection || !released.includes(hook.name)) continue;\n\t\t\tthis.forget(hook);\n\t\t\t// for this decision its claimant is gone, which is what detached means (section 5.4)\n\t\t\tthis.binding?.defaultDecision(hook.id, \"detached\", connection.id);\n\t\t}\n\t\treturn released;\n\t}\n\n\tprivate forget(hook: PendingHook): void {\n\t\tthis.hooks.delete(hook.id);\n\t\tif (hook.timer !== undefined) clearTimeout(hook.timer);\n\t}\n\n\t/** The program asked a decision: it goes to the claimant, or takes its default now. */\n\tprivate onDecision(request: DecisionRequest): void {\n\t\tconst binding = this.binding;\n\t\tif (!binding) return;\n\t\tconst connection = this.claimants.get(request.name);\n\t\tif (!connection) {\n\t\t\tbinding.defaultDecision(request.id, \"no_harness\");\n\t\t\treturn;\n\t\t}\n\t\tbinding.assignDecision(request.id, connection.id);\n\t\tconst remaining = Math.max(0, request.deadline - this.app.now());\n\t\tconst timeoutMs =\n\t\t\tconnection.claimTimeoutMs === undefined ? remaining : Math.min(remaining, connection.claimTimeoutMs);\n\t\tlet text: string;\n\t\ttry {\n\t\t\ttext = line({ type: \"hook\", id: request.id, hook: request.name, timeoutMs, payload: request.context });\n\t\t} catch (error) {\n\t\t\t// the context cannot cross: the claimant is told, and the program goes on with the default\n\t\t\tconst message = `payload could not be serialized: ${errorMessage(error)}`;\n\t\t\tthis.write(connection, line({ type: \"hook_rejected\", id: request.id, hook: request.name, error: message }));\n\t\t\tbinding.defaultDecision(request.id, \"invalid\", connection.id);\n\t\t\treturn;\n\t\t}\n\t\tconst hook: PendingHook = {\n\t\t\tid: request.id,\n\t\t\tname: request.name,\n\t\t\tconnection,\n\t\t\tepochAtSend: connection.timeoutEpoch,\n\t\t\ttimer: undefined,\n\t\t};\n\t\tif (timeoutMs < remaining) {\n\t\t\t// the claim's own wait, shorter than the manifest's: the default is applied then, through the one path\n\t\t\thook.timer = setTimeout(() => binding.defaultDecision(request.id, \"timeout\", connection.id), timeoutMs);\n\t\t}\n\t\tthis.hooks.set(request.id, hook);\n\t\tthis.write(connection, text);\n\t}\n\n\t/** The binding settled a decision by timeout (its deadline, or the claim's wait through `defaultDecision`). */\n\tprivate onDecisionTimeout(id: string, name: string): void {\n\t\tconst hook = this.hooks.get(id);\n\t\tif (!hook) return;\n\t\tthis.forget(hook);\n\t\tconst { connection } = hook;\n\t\tthis.write(connection, line({ type: \"hook_timeout\", id, hook: name }));\n\t\t// a batch that timed out together counts once\n\t\tif (hook.epochAtSend === connection.timeoutEpoch) connection.consecutiveTimeouts += 1;\n\t\tconnection.timeoutEpoch += 1;\n\t\tif (connection.consecutiveTimeouts >= CONSECUTIVE_TIMEOUTS_LIMIT) {\n\t\t\tconnection.consecutiveTimeouts = 0;\n\t\t\tconst released = this.releaseClaims(connection, [...connection.claims]);\n\t\t\tthis.write(connection, line({ type: \"hook_claims_released\", reason: \"timeouts\", hooks: released }));\n\t\t}\n\t}\n\n\tprivate onHookResponse(\n\t\tconnection: Connection,\n\t\tid: unknown,\n\t\tresult: unknown,\n\t\treason: string | undefined,\n\t\tconsent: Consent | undefined,\n\t): void {\n\t\tif (typeof id !== \"string\") return;\n\t\tconst hook = this.hooks.get(id);\n\t\t// ignored unless this very connection was asked: a late or a stray answer writes nothing\n\t\tif (!hook || hook.connection !== connection) return;\n\t\tthis.forget(hook);\n\t\ttry {\n\t\t\tthis.binding?.answerDecision(\n\t\t\t\tid,\n\t\t\t\tresult ?? null,\n\t\t\t\treason ?? \"no reason stated by the harness\",\n\t\t\t\tconsent,\n\t\t\t\tconnection.id,\n\t\t\t);\n\t\t\t// only an accepted answer resets the streak\n\t\t\tconnection.consecutiveTimeouts = 0;\n\t\t} catch (error) {\n\t\t\tthis.write(connection, line({ type: \"hook_rejected\", id, hook: hook.name, error: errorMessage(error) }));\n\t\t}\n\t}\n\n\t// -----------------------------------------------------------------------\n\t// Turns and touchpoints, broadcast\n\t// -----------------------------------------------------------------------\n\n\tprivate turnMessage(request: TurnRequest) {\n\t\treturn {\n\t\t\ttype: \"turn\",\n\t\t\tid: request.id,\n\t\t\tname: request.name,\n\t\t\ttimeoutMs: Math.max(0, request.deadline - this.app.now()),\n\t\t\t...(request.content === undefined ? {} : { content: request.content }),\n\t\t};\n\t}\n\n\tprivate touchpointMessage(request: TouchpointRequest) {\n\t\treturn {\n\t\t\ttype: \"touchpoint\",\n\t\t\tid: request.id,\n\t\t\tkind: request.kind,\n\t\t\t...(request.content === undefined ? {} : { content: request.content }),\n\t\t};\n\t}\n\n\tprivate onTurn(request: TurnRequest): void {\n\t\tthis.openTurns.set(request.id, request);\n\t\t// a turn ends on its own at its deadline, and this map must not remember it past that\n\t\tconst timer = setTimeout(\n\t\t\t() => this.openTurns.delete(request.id),\n\t\t\tMath.max(0, request.deadline - this.app.now()) + 1,\n\t\t);\n\t\ttimer.unref();\n\t\tconst text = line(this.turnMessage(request));\n\t\tfor (const connection of [...this.connections]) this.write(connection, text);\n\t}\n\n\tprivate onTouchpoint(request: TouchpointRequest): void {\n\t\tthis.openTouchpoints.set(request.id, request);\n\t\tconst text = line(this.touchpointMessage(request));\n\t\tfor (const connection of [...this.connections]) this.write(connection, text);\n\t}\n}\n\nfunction isConsent(value: unknown): value is Consent {\n\treturn value === \"approved\" || value === \"auto\" || value === \"denied\";\n}\n"]}
package/dist/endpoint.js CHANGED
@@ -20,6 +20,7 @@ import { existsSync, unlinkSync } from "node:fs";
20
20
  import { createServer } from "node:net";
21
21
  import { tmpdir } from "node:os";
22
22
  import { dirname, join } from "node:path";
23
+ import { isMessageRecord } from "./audit.js";
23
24
  import { CallError, } from "./harness-binding.js";
24
25
  import { durationMs } from "./manifest/manifest.js";
25
26
  import { assertSocketPathLength, attachCappedLineReader, DEFAULT_MAX_LINE_BYTES, defaultSocketPath, EXTENSIONS, ensureRunDir, MAX_SOCKET_PATH_BYTES, PROTOCOL_VERSION, removeDescriptor, writeDescriptor, } from "./wire/index.js";
@@ -488,6 +489,20 @@ export class Endpoint {
488
489
  };
489
490
  break;
490
491
  }
492
+ case "record_message": {
493
+ // the audit line for a message over the run's network (the `messages` extension): not an act of control
494
+ // the agent's reason rides the request envelope like every other act's, or sits in the record
495
+ const { type: _type, ...given } = command;
496
+ const record = given.reason === undefined && reason !== undefined ? { ...given, reason } : given;
497
+ if (!isMessageRecord(record)) {
498
+ response = this.failure(id, type, "record_message needs direction, peer, addr, network, kind, body and outcome", "invalid");
499
+ break;
500
+ }
501
+ response = binding.recordMessage(record, connection.id)
502
+ ? { type: "response", id, command: type, success: true, data: {} }
503
+ : this.failure(id, type, "the audit log is closed or failed: the message was not recorded", "error");
504
+ break;
505
+ }
491
506
  case "get_cells": {
492
507
  const reading = binding.readCells();
493
508
  const cells = {};