@demlik/tea 0.10.0 → 0.11.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.
@@ -38,7 +38,42 @@ interface LaneDriver {
38
38
  readonly session: string;
39
39
  readonly at?: string | null;
40
40
  }
41
- interface ServeOptions extends LaneViewerOptions {
41
+ interface FromDiskOptions {
42
+ /**
43
+ * Who sends each event, applied to every lane.
44
+ *
45
+ * A workflow document records topology and never provenance, so without this
46
+ * the page can see that a task cannot move and not that it is WAITING ON
47
+ * SOMEONE — which is the question a reader opened it to answer. Stated once
48
+ * here rather than copied onto each lane.
49
+ */
50
+ readonly origins?: unknown;
51
+ }
52
+ /**
53
+ * Every lane under `root`, read the way the convention says.
54
+ *
55
+ * A LANE IS A DIRECTORY HOLDING TWO FILES, and that convention is ours, so
56
+ * reading it is ours too. Three separate hosts wrote this same loop before it
57
+ * moved here, each with its own idea of the edges: whether a directory with no
58
+ * `workflow.json` is a lane (it is not — a scratch dir under the root is not
59
+ * something to draw), and whether a lane with no `events.jsonl` is broken (it
60
+ * is not — it was emitted and never run, every task sits where it booted).
61
+ *
62
+ * A lane that cannot be read is SKIPPED rather than thrown, because one bad
63
+ * directory should not take down a fleet view. A root that cannot be listed
64
+ * throws, because a short list presented as "your lanes" is worse than an
65
+ * error.
66
+ */
67
+ declare function lanesFromDisk(root: string, opts?: FromDiskOptions): Promise<LaneFiles[]>;
68
+ interface ServeOptions extends Omit<LaneViewerOptions, "lanes">, FromDiskOptions {
69
+ /** Every lane the reader should see. Omit it and `root` is read instead. */
70
+ readonly lanes?: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;
71
+ /**
72
+ * A directory of lane directories. Supply this and {@link lanes} is not
73
+ * needed — pointing at a folder is the whole setup, which is what most
74
+ * callers want and what all of them were writing by hand.
75
+ */
76
+ readonly root?: string;
42
77
  /** Default 5411. `0` takes any free port and reports it back on `url`. */
43
78
  readonly port?: number;
44
79
  /** Default `127.0.0.1` — this reads a machine's own disk and should stay on it. */
@@ -110,4 +145,4 @@ declare function laneViewer(opts: LaneViewerOptions): (request: Request) => Prom
110
145
  */
111
146
  declare function serveLaneViewer(opts: ServeOptions): Promise<LaneViewerServer>;
112
147
 
113
- export { type LaneDriver, type LaneFiles, type LaneViewerOptions, type LaneViewerServer, type ServeOptions, type TransitionRequest, type TransitionResult, laneViewer, serveLaneViewer };
148
+ export { type FromDiskOptions, type LaneDriver, type LaneFiles, type LaneViewerOptions, type LaneViewerServer, type ServeOptions, type TransitionRequest, type TransitionResult, laneViewer, lanesFromDisk, serveLaneViewer };
@@ -1,9 +1,33 @@
1
- import { readFile } from 'fs/promises';
1
+ import { readdir, stat, readFile } from 'fs/promises';
2
2
  import { createServer } from 'http';
3
3
  import { resolve, normalize, join, extname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
 
6
6
  // src/chart/lane/server.ts
7
+ async function lanesFromDisk(root, opts = {}) {
8
+ const names = await readdir(root);
9
+ const read = await Promise.all(
10
+ names.map(async (id) => {
11
+ const dir = `${root}/${id}`;
12
+ try {
13
+ if (!(await stat(dir)).isDirectory()) return null;
14
+ const workflow = await readFile(`${dir}/workflow.json`, "utf8");
15
+ const events = await readFile(`${dir}/events.jsonl`, "utf8").catch(
16
+ () => ""
17
+ );
18
+ return {
19
+ id,
20
+ workflow,
21
+ events,
22
+ ...opts.origins === void 0 ? {} : { origins: opts.origins }
23
+ };
24
+ } catch {
25
+ return null;
26
+ }
27
+ })
28
+ );
29
+ return read.filter((lane) => lane !== null);
30
+ }
7
31
  var MIME = {
8
32
  ".html": "text/html; charset=utf-8",
9
33
  ".js": "text/javascript; charset=utf-8",
@@ -114,7 +138,21 @@ function laneViewer(opts) {
114
138
  };
115
139
  }
116
140
  async function serveLaneViewer(opts) {
117
- const handle = laneViewer(opts);
141
+ const root = opts.root;
142
+ if (opts.lanes === void 0 && root === void 0) {
143
+ throw new Error(
144
+ "@demlik/tea: serveLaneViewer needs either `root` (a directory of lane directories) or `lanes`"
145
+ );
146
+ }
147
+ const handle = laneViewer({
148
+ ...opts,
149
+ // Re-read on every ask rather than cache: a driver appends to these files
150
+ // while the page is open, and current is the whole point of the page.
151
+ lanes: opts.lanes ?? (() => lanesFromDisk(root, {
152
+ ...opts.origins === void 0 ? {} : { origins: opts.origins }
153
+ })),
154
+ ...opts.source === void 0 && root !== void 0 ? { source: root } : {}
155
+ });
118
156
  const host = opts.host ?? "127.0.0.1";
119
157
  const server = createServer((req, res) => {
120
158
  void (async () => {
@@ -166,6 +204,6 @@ async function serveLaneViewer(opts) {
166
204
  };
167
205
  }
168
206
 
169
- export { laneViewer, serveLaneViewer };
207
+ export { laneViewer, lanesFromDisk, serveLaneViewer };
170
208
  //# sourceMappingURL=server.js.map
171
209
  //# sourceMappingURL=server.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/chart/lane/server.ts"],"names":[],"mappings":";;;;;;AAqHA,IAAM,IAAA,GAAyC;AAAA,EAC7C,OAAA,EAAS,0BAAA;AAAA,EACT,KAAA,EAAO,gCAAA;AAAA,EACP,MAAA,EAAQ,yBAAA;AAAA,EACR,OAAA,EAAS,iCAAA;AAAA,EACT,MAAA,EAAQ,eAAA;AAAA,EACR,QAAA,EAAU;AACZ,CAAA;AAEA,IAAM,IAAA,GAAO,CAAC,IAAA,EAAe,MAAA,GAAS,GAAA,KACpC,IAAI,QAAA,CAAS,IAAA,CAAK,SAAA,CAAU,IAAI,CAAA,EAAG;AAAA,EACjC,MAAA;AAAA,EACA,OAAA,EAAS,EAAE,cAAA,EAAgB,iCAAA;AAC7B,CAAC,CAAA;AAGH,IAAM,SAAS,OAAA,CAAQ,aAAA,CAAc,YAAY,GAAG,CAAA,EAAG,MAAM,QAAQ,CAAA;AAc9D,SAAS,WACd,IAAA,EACyC;AACzC,EAAA,OAAO,OAAO,OAAA,KAAwC;AACpD,IAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,OAAA,CAAQ,GAAG,CAAA;AAC/B,IAAA,MAAM,OAAO,GAAA,CAAI,QAAA;AAEjB,IAAA,IAAI,SAAS,YAAA,EAAc;AACzB,MAAA,OAAO,IAAA,CAAK,EAAE,KAAA,EAAO,MAAM,IAAA,CAAK,KAAA,EAAM,EAAG,MAAA,EAAQ,IAAA,CAAK,MAAA,IAAU,EAAA,EAAI,CAAA;AAAA,IACtE;AAEA,IAAA,IAAI,SAAS,cAAA,EAAgB;AAC3B,MAAA,IAAI,IAAA,CAAK,YAAY,MAAA,EAAW,OAAO,KAAK,EAAE,OAAA,EAAS,EAAC,EAAG,CAAA;AAC3D,MAAA,IAAI;AACF,QAAA,OAAO,KAAK,EAAE,OAAA,EAAS,MAAM,IAAA,CAAK,OAAA,IAAW,CAAA;AAAA,MAC/C,CAAA,CAAA,MAAQ;AAIN,QAAA,OAAO,IAAA,CAAK,EAAE,OAAA,EAAS,IAAI,CAAA;AAAA,MAC7B;AAAA,IACF;AAEA,IAAA,IAAI,SAAS,aAAA,EAAe;AAC1B,MAAA,MAAM,QAAQ,IAAA,CAAK,GAAA,CAAI,GAAA,EAAK,IAAA,CAAK,UAAU,GAAI,CAAA;AAC/C,MAAA,IAAI,IAAA,GAAO,EAAA;AACX,MAAA,IAAI,KAAA;AACJ,MAAA,MAAM,MAAA,GAAS,IAAI,cAAA,CAA2B;AAAA,QAC5C,MAAM,UAAA,EAAY;AAChB,UAAA,MAAM,GAAA,GAAM,IAAI,WAAA,EAAY;AAC5B,UAAA,MAAM,OAAO,YAAY;AACvB,YAAA,IAAI;AACF,cAAA,MAAM,OAAO,IAAA,CAAK,SAAA,CAAU,MAAM,IAAA,CAAK,OAAO,CAAA;AAC9C,cAAA,IAAI,SAAS,IAAA,EAAM;AACnB,cAAA,IAAA,GAAO,IAAA;AACP,cAAA,UAAA,CAAW,OAAA,CAAQ,GAAA,CAAI,MAAA,CAAO,CAAA,MAAA,EAAS,IAAI;;AAAA,CAAM,CAAC,CAAA;AAAA,YACpD,CAAA,CAAA,MAAQ;AAAA,YAER;AAAA,UACF,CAAA;AACA,UAAA,KAAK,IAAA,EAAK;AACV,UAAA,KAAA,GAAQ,WAAA,CAAY,MAAM,KAAK,IAAA,IAAQ,KAAK,CAAA;AAAA,QAC9C,CAAA;AAAA,QACA,MAAA,GAAS;AACP,UAAA,IAAI,KAAA,KAAU,MAAA,EAAW,aAAA,CAAc,KAAK,CAAA;AAAA,QAC9C;AAAA,OACD,CAAA;AACD,MAAA,OAAO,IAAI,SAAS,MAAA,EAAQ;AAAA,QAC1B,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,kCAAA;AAAA,UAChB,eAAA,EAAiB,UAAA;AAAA,UACjB,UAAA,EAAY;AAAA;AACd,OACD,CAAA;AAAA,IACH;AAEA,IAAA,IAAI,SAAS,iBAAA,EAAmB;AAC9B,MAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAQ,OAAO,KAAK,EAAE,EAAA,EAAI,KAAA,EAAM,EAAG,GAAG,CAAA;AAC7D,MAAA,IAAI,IAAA,CAAK,eAAe,MAAA,EAAW;AACjC,QAAA,OAAO,IAAA;AAAA,UACL;AAAA,YACE,EAAA,EAAI,KAAA;AAAA,YACJ,IAAA,EAAM,CAAA;AAAA,YACN,MAAA,EAAQ,EAAA;AAAA,YACR,MAAA,EAAQ;AAAA,WACV;AAAA,UACA;AAAA,SACF;AAAA,MACF;AACA,MAAA,IAAI,IAAA;AACJ,MAAA,IAAI;AACF,QAAA,IAAA,GAAQ,MAAM,QAAQ,IAAA,EAAK;AAAA,MAC7B,CAAA,CAAA,MAAQ;AACN,QAAA,OAAO,IAAA;AAAA,UACL,EAAE,IAAI,KAAA,EAAO,IAAA,EAAM,GAAG,MAAA,EAAQ,EAAA,EAAI,QAAQ,mBAAA,EAAoB;AAAA,UAC9D;AAAA,SACF;AAAA,MACF;AACA,MAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,UAAA,CAAW,IAAI,CAAA;AAGtC,MAAA,OAAO,IAAA,CAAK;AAAA,QACV,IAAI,GAAA,CAAI,EAAA;AAAA,QACR,IAAA,EAAM,GAAA,CAAI,EAAA,GAAK,CAAA,GAAI,CAAA;AAAA,QACnB,MAAA,EAAQ,GAAA,CAAI,EAAA,GAAK,GAAA,CAAI,OAAA,GAAU,EAAA;AAAA,QAC/B,MAAA,EAAQ,GAAA,CAAI,EAAA,GAAK,EAAA,GAAK,GAAA,CAAI;AAAA,OAC3B,CAAA;AAAA,IACH;AAMA,IAAA,MAAM,MAAM,IAAA,KAAS,GAAA,GAAM,eAAe,IAAA,CAAK,OAAA,CAAQ,QAAQ,EAAE,CAAA;AACjE,IAAA,MAAM,IAAA,GAAO,SAAA,CAAU,IAAA,CAAK,MAAA,EAAQ,GAAG,CAAC,CAAA;AACxC,IAAA,IAAI,CAAC,IAAA,CAAK,UAAA,CAAW,MAAM,CAAA,EAAG,OAAO,IAAI,QAAA,CAAS,IAAA,EAAM,EAAE,MAAA,EAAQ,GAAA,EAAK,CAAA;AAEvE,IAAA,IAAI;AACF,MAAA,MAAM,KAAA,GAAQ,MAAM,QAAA,CAAS,IAAI,CAAA;AACjC,MAAA,OAAO,IAAI,QAAA,CAAS,IAAI,UAAA,CAAW,KAAK,CAAA,EAAG;AAAA,QACzC,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,IAAA,CAAK,OAAA,CAAQ,IAAI,CAAC,CAAA,IAAK;AAAA;AACzC,OACD,CAAA;AAAA,IACH,CAAA,CAAA,MAAQ;AACN,MAAA,MAAM,OAAO,MAAM,QAAA,CAAS,IAAA,CAAK,MAAA,EAAQ,YAAY,CAAC,CAAA;AACtD,MAAA,OAAO,IAAI,QAAA,CAAS,IAAI,UAAA,CAAW,IAAI,CAAA,EAAG;AAAA,QACxC,OAAA,EAAS,EAAE,cAAA,EAAgB,IAAA,CAAK,OAAO,CAAA;AAAY,OACpD,CAAA;AAAA,IACH;AAAA,EACF,CAAA;AACF;AAuBA,eAAsB,gBACpB,IAAA,EAC2B;AAC3B,EAAA,MAAM,MAAA,GAAS,WAAW,IAAI,CAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,KAAK,IAAA,IAAQ,WAAA;AAE1B,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,CAAC,GAAA,EAAK,GAAA,KAAQ;AACxC,IAAA,KAAA,CAAM,YAAY;AAChB,MAAA,MAAM,IAAA,GACJ,GAAA,CAAI,MAAA,KAAW,MAAA,IAAU,GAAA,CAAI,MAAA,KAAW,KAAA,GACpC,MAAM,IAAI,OAAA,CAAgB,CAAC,EAAA,KAAO;AAChC,QAAA,IAAI,IAAA,GAAO,EAAA;AACX,QAAA,GAAA,CAAI,EAAA,CAAG,MAAA,EAAQ,CAAC,KAAA,KAAU;AACxB,UAAA,IAAA,IAAQ,KAAA;AAAA,QACV,CAAC,CAAA;AACD,QAAA,GAAA,CAAI,EAAA,CAAG,KAAA,EAAO,MAAM,EAAA,CAAG,IAAI,CAAC,CAAA;AAAA,MAC9B,CAAC,CAAA,GACD,MAAA;AAEN,MAAA,MAAM,MAAM,MAAM,MAAA;AAAA,QAChB,IAAI,QAAQ,CAAA,OAAA,EAAU,IAAI,GAAG,GAAA,CAAI,GAAA,IAAO,GAAG,CAAA,CAAA,EAAI;AAAA,UAC7C,MAAA,EAAQ,IAAI,MAAA,IAAU,KAAA;AAAA,UACtB,GAAI,IAAA,KAAS,MAAA,GAAY,EAAC,GAAI,EAAE,IAAA;AAAK,SACtC;AAAA,OACH;AAEA,MAAA,GAAA,CAAI,UAAU,GAAA,CAAI,MAAA,EAAQ,OAAO,WAAA,CAAY,GAAA,CAAI,OAAO,CAAC,CAAA;AACzD,MAAA,IAAI,GAAA,CAAI,SAAS,IAAA,EAAM;AACrB,QAAA,GAAA,CAAI,GAAA,EAAI;AACR,QAAA;AAAA,MACF;AAGA,MAAA,MAAM,MAAA,GAAS,GAAA,CAAI,IAAA,CAAK,SAAA,EAAU;AAClC,MAAA,IAAI;AACF,QAAA,WAAS;AACP,UAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAM,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,UAAA,IAAI,IAAA,EAAM;AACV,UAAA,GAAA,CAAI,MAAM,KAAK,CAAA;AAAA,QACjB;AAAA,MACF,CAAA,CAAA,MAAQ;AAAA,MAER;AACA,MAAA,GAAA,CAAI,GAAA,EAAI;AAAA,IACV,CAAA,GAAG;AAAA,EACL,CAAC,CAAA;AAED,EAAA,MAAM,OAAO,MAAM,IAAI,OAAA,CAAgB,CAAC,IAAI,EAAA,KAAO;AACjD,IAAA,MAAA,CAAO,IAAA,CAAK,SAAS,EAAE,CAAA;AACvB,IAAA,MAAA,CAAO,MAAA,CAAO,IAAA,CAAK,IAAA,IAAQ,IAAA,EAAM,MAAM,MAAM;AAC3C,MAAA,MAAM,IAAA,GAAO,OAAO,OAAA,EAAQ;AAC5B,MAAA,EAAA;AAAA,QACE,OAAO,SAAS,QAAA,IAAY,IAAA,KAAS,OACjC,IAAA,CAAK,IAAA,GACJ,KAAK,IAAA,IAAQ;AAAA,OACpB;AAAA,IACF,CAAC,CAAA;AAAA,EACH,CAAC,CAAA;AAED,EAAA,OAAO;AAAA,IACL,KAAK,CAAA,OAAA,EAAU,IAAA,KAAS,YAAY,WAAA,GAAc,IAAI,IAAI,IAAI,CAAA,CAAA;AAAA,IAC9D,KAAA,EAAO,MACL,IAAI,OAAA,CAAc,CAAC,EAAA,KAAO;AACxB,MAAA,MAAA,CAAO,mBAAA,IAAsB;AAC7B,MAAA,MAAA,CAAO,KAAA,CAAM,MAAM,EAAA,EAAI,CAAA;AAAA,IACzB,CAAC;AAAA,GACL;AACF","file":"server.js","sourcesContent":["// ═══════════════════════════════════════════════════════════════════════════\n// chart/lane/server — the dashboard, as something a host can SERVE.\n//\n// `./chart/lane/react` gives you the components and assumes you have a\n// bundler. A CLI does not, and the consumer this was written for — fabrika —\n// is a CLI: it already knows where the lanes are, how to fold one and how to\n// record an event, and what it does not have is a page.\n//\n// So this ships the page prebuilt and asks the host for the three facts only\n// the host can know:\n//\n// WHERE THE LANES ARE — it reads its own root; we never touch a filesystem.\n// HOW TO RECORD AN EVENT — its own verb, which stays the ONLY writer. The\n// page proposes and the host disposes; a refusal is an answer, not an\n// error, and it is displayed in the host's own words.\n// WHO HOLDS A LANE — optional, because ownership is not in the lane files at\n// all (fabrika keeps claims on GitHub), so plenty of hosts cannot answer\n// it. Absent means UNKNOWN and the page says nothing, never \"free\".\n//\n// Everything else — folding, deriving what is stuck, drawing the machines,\n// the attention ordering, the scrubbing — is the library's and is already\n// tested here. The host writes an adapter, not a dashboard.\n//\n// The handler is `(Request) => Promise<Response>`, so it mounts under Node's\n// `http`, Bun.serve, Hono, or a Worker without an adapter per framework.\n// ═══════════════════════════════════════════════════════════════════════════\n\nimport { readFile } from \"node:fs/promises\";\nimport { createServer } from \"node:http\";\nimport { extname, join, normalize, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** One lane, as the two files a host already has on disk. */\nexport interface LaneFiles {\n /** The lane key — the issue number, or whatever the host names lanes by. */\n readonly id: string;\n /** `workflow.json`, verbatim. */\n readonly workflow: string;\n /** `events.jsonl`, verbatim. Empty for a lane that has not run yet. */\n readonly events: string;\n /**\n * Who sends each event, when the host knows.\n *\n * A workflow document records topology and never provenance, so without\n * this the page can see that a task cannot move and not that it is WAITING\n * ON SOMEONE — which is the question a reader opened it to answer.\n */\n readonly origins?: unknown;\n}\n\n/** What the host is asked to do when a reader presses a button. */\nexport interface TransitionRequest {\n readonly lane: string;\n readonly event: string;\n /** Absent on a single-task lane, exactly as the CLI verb allows. */\n readonly task?: string;\n}\n\n/**\n * The host's answer. A refusal is a legitimate one — the machine said no —\n * and `message` is shown to the reader verbatim, so write it for them.\n */\nexport interface TransitionResult {\n readonly ok: boolean;\n readonly message: string;\n}\n\n/** Who holds a lane, when the host can say. */\nexport interface LaneDriver {\n /** A person or account — what a reader recognises. */\n readonly login: string;\n /** The claim token, or any stable handle for the session holding it. */\n readonly session: string;\n readonly at?: string | null;\n}\n\nexport interface ServeOptions extends LaneViewerOptions {\n /** Default 5411. `0` takes any free port and reports it back on `url`. */\n readonly port?: number;\n /** Default `127.0.0.1` — this reads a machine's own disk and should stay on it. */\n readonly host?: string;\n}\n\n/** A running dashboard. */\nexport interface LaneViewerServer {\n /** Where to point a browser. Carries the real port when `port: 0` was asked for. */\n readonly url: string;\n readonly close: () => Promise<void>;\n}\n\nexport interface LaneViewerOptions {\n /** Every lane the reader should see. Called on each request; cache if slow. */\n readonly lanes: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;\n /**\n * Record one event. OMIT IT and the page becomes read-only — every lane is\n * still shown, and nothing offers to act. That is a real mode, not a\n * degraded one: a report of a run that finished has nothing to dispatch.\n */\n readonly transition?: (\n req: TransitionRequest,\n ) => TransitionResult | Promise<TransitionResult>;\n /** Ownership, when it is knowable. `null` for a lane nobody holds. */\n readonly drivers?: () => Promise<Readonly<Record<string, LaneDriver | null>>>;\n /** Shown in the footer — where these lanes were read from. */\n readonly source?: string;\n /**\n * How often to re-ask `lanes()` for the live stream, in ms. Default 2000.\n *\n * LIVENESS IS OURS, NOT THE HOST'S. A host could watch its own files and\n * push, and then every host would write that code and get it subtly\n * different. Re-asking is uninteresting and correct: `lanes()` is a read of\n * two files a driver appends to, the answer is compared before anything is\n * sent, and a quiet fleet costs one stat per lane per tick.\n */\n readonly pollMs?: number;\n}\n\nconst MIME: Readonly<Record<string, string>> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".woff2\": \"font/woff2\",\n};\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json; charset=utf-8\" },\n });\n\n/** Where the prebuilt page lives inside this package. */\nconst ASSETS = resolve(fileURLToPath(import.meta.url), \"..\", \"viewer\");\n\n/**\n * The page, plus the small JSON contract it speaks.\n *\n * ```ts\n * const handle = laneViewer({\n * lanes: () => readMyLanes(),\n * transition: (r) => myTransitionVerb(r),\n * });\n * // Node:\n * createServer(async (req, res) => { … await handle(request) … });\n * ```\n */\nexport function laneViewer(\n opts: LaneViewerOptions,\n): (request: Request) => Promise<Response> {\n return async (request: Request): Promise<Response> => {\n const url = new URL(request.url);\n const path = url.pathname;\n\n if (path === \"/api/lanes\") {\n return json({ lanes: await opts.lanes(), source: opts.source ?? \"\" });\n }\n\n if (path === \"/api/drivers\") {\n if (opts.drivers === undefined) return json({ drivers: {} });\n try {\n return json({ drivers: await opts.drivers() });\n } catch {\n // UNKNOWN is not \"nobody\" — an empty answer renders nothing, and a\n // lane shown free while someone holds it is what starts a second\n // driver on one piece of work.\n return json({ drivers: {} });\n }\n }\n\n if (path === \"/api/stream\") {\n const every = Math.max(250, opts.pollMs ?? 2000);\n let last = \"\";\n let timer: ReturnType<typeof setInterval> | undefined;\n const stream = new ReadableStream<Uint8Array>({\n start(controller) {\n const enc = new TextEncoder();\n const beat = async () => {\n try {\n const next = JSON.stringify(await opts.lanes());\n if (next === last) return;\n last = next;\n controller.enqueue(enc.encode(`data: ${next}\\n\\n`));\n } catch {\n // a half-written append is the next event arriving, not a fault\n }\n };\n void beat();\n timer = setInterval(() => void beat(), every);\n },\n cancel() {\n if (timer !== undefined) clearInterval(timer);\n },\n });\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-store\",\n connection: \"keep-alive\",\n },\n });\n }\n\n if (path === \"/api/transition\") {\n if (request.method !== \"POST\") return json({ ok: false }, 405);\n if (opts.transition === undefined) {\n return json(\n {\n ok: false,\n exit: 1,\n stdout: \"\",\n stderr: \"this viewer is read-only\",\n },\n 200,\n );\n }\n let body: TransitionRequest;\n try {\n body = (await request.json()) as TransitionRequest;\n } catch {\n return json(\n { ok: false, exit: 1, stdout: \"\", stderr: \"malformed request\" },\n 400,\n );\n }\n const out = await opts.transition(body);\n // the page reads `{ok, exit, stdout, stderr}` — a host that speaks in\n // sentences rather than exit codes says the same thing in `stderr`.\n return json({\n ok: out.ok,\n exit: out.ok ? 0 : 1,\n stdout: out.ok ? out.message : \"\",\n stderr: out.ok ? \"\" : out.message,\n });\n }\n\n // ── the page itself ────────────────────────────────────────────────\n // Anything that is not the contract is an asset, and anything that is\n // not an asset is the page — a dashboard is one screen, so an unknown\n // path is a reader who typed something, not a 404 worth showing them.\n const rel = path === \"/\" ? \"index.html\" : path.replace(/^\\/+/, \"\");\n const file = normalize(join(ASSETS, rel));\n if (!file.startsWith(ASSETS)) return new Response(\"no\", { status: 403 });\n\n try {\n const bytes = await readFile(file);\n return new Response(new Uint8Array(bytes), {\n headers: {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n },\n });\n } catch {\n const html = await readFile(join(ASSETS, \"index.html\"));\n return new Response(new Uint8Array(html), {\n headers: { \"content-type\": MIME[\".html\"] as string },\n });\n }\n };\n}\n\n/**\n * Start the dashboard. This is the one most callers want.\n *\n * {@link laneViewer} hands back a `(Request) => Response` for a host that already has a server to\n * mount it on. Everything BETWEEN that handler and a running page — creating the server, turning\n * node's `IncomingMessage` into a `Request`, reading a POST body, streaming a response back so the\n * event stream stays open — is the same in every host, and asking each one to write it is asking\n * them to get it subtly wrong. Notably the streaming: buffer the response and `/api/stream` never\n * delivers a frame, which looks like \"the page does not update\" and is nobody's obvious bug.\n *\n * So the three callbacks are the whole integration:\n *\n * ```ts\n * const server = await serveLaneViewer({\n * root: \".fabrika/lanes\", // not read here — see `lanes`\n * lanes: () => readMyLanes(),\n * transition: (r) => myVerb(r),\n * });\n * console.log(server.url);\n * ```\n */\nexport async function serveLaneViewer(\n opts: ServeOptions,\n): Promise<LaneViewerServer> {\n const handle = laneViewer(opts);\n const host = opts.host ?? \"127.0.0.1\";\n\n const server = createServer((req, res) => {\n void (async () => {\n const body =\n req.method === \"POST\" || req.method === \"PUT\"\n ? await new Promise<string>((ok) => {\n let read = \"\";\n req.on(\"data\", (chunk) => {\n read += chunk;\n });\n req.on(\"end\", () => ok(read));\n })\n : undefined;\n\n const out = await handle(\n new Request(`http://${host}${req.url ?? \"/\"}`, {\n method: req.method ?? \"GET\",\n ...(body === undefined ? {} : { body }),\n }),\n );\n\n res.writeHead(out.status, Object.fromEntries(out.headers));\n if (out.body === null) {\n res.end();\n return;\n }\n // STREAMED, never buffered: `/api/stream` holds open for the life of the\n // page, and a buffered response is one that never arrives.\n const reader = out.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n res.write(value);\n }\n } catch {\n // the reader is gone — the page navigated away mid-frame\n }\n res.end();\n })();\n });\n\n const port = await new Promise<number>((ok, no) => {\n server.once(\"error\", no);\n server.listen(opts.port ?? 5411, host, () => {\n const addr = server.address();\n ok(\n typeof addr === \"object\" && addr !== null\n ? addr.port\n : (opts.port ?? 5411),\n );\n });\n });\n\n return {\n url: `http://${host === \"0.0.0.0\" ? \"localhost\" : host}:${port}`,\n close: () =>\n new Promise<void>((ok) => {\n server.closeAllConnections?.();\n server.close(() => ok());\n }),\n };\n}\n"]}
1
+ {"version":3,"sources":["../../../src/chart/lane/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// ═══════════════════════════════════════════════════════════════════════════\n// chart/lane/server — the dashboard, as something a host can SERVE.\n//\n// `./chart/lane/react` gives you the components and assumes you have a\n// bundler. A CLI does not, and the consumer this was written for — fabrika —\n// is a CLI: it already knows where the lanes are, how to fold one and how to\n// record an event, and what it does not have is a page.\n//\n// So this ships the page prebuilt and asks the host for the three facts only\n// the host can know:\n//\n// WHERE THE LANES ARE — it reads its own root; we never touch a filesystem.\n// HOW TO RECORD AN EVENT — its own verb, which stays the ONLY writer. The\n// page proposes and the host disposes; a refusal is an answer, not an\n// error, and it is displayed in the host's own words.\n// WHO HOLDS A LANE — optional, because ownership is not in the lane files at\n// all (fabrika keeps claims on GitHub), so plenty of hosts cannot answer\n// it. Absent means UNKNOWN and the page says nothing, never \"free\".\n//\n// Everything else — folding, deriving what is stuck, drawing the machines,\n// the attention ordering, the scrubbing — is the library's and is already\n// tested here. The host writes an adapter, not a dashboard.\n//\n// The handler is `(Request) => Promise<Response>`, so it mounts under Node's\n// `http`, Bun.serve, Hono, or a Worker without an adapter per framework.\n// ═══════════════════════════════════════════════════════════════════════════\n\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { createServer } from \"node:http\";\nimport { extname, join, normalize, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** One lane, as the two files a host already has on disk. */\nexport interface LaneFiles {\n /** The lane key — the issue number, or whatever the host names lanes by. */\n readonly id: string;\n /** `workflow.json`, verbatim. */\n readonly workflow: string;\n /** `events.jsonl`, verbatim. Empty for a lane that has not run yet. */\n readonly events: string;\n /**\n * Who sends each event, when the host knows.\n *\n * A workflow document records topology and never provenance, so without\n * this the page can see that a task cannot move and not that it is WAITING\n * ON SOMEONE — which is the question a reader opened it to answer.\n */\n readonly origins?: unknown;\n}\n\n/** What the host is asked to do when a reader presses a button. */\nexport interface TransitionRequest {\n readonly lane: string;\n readonly event: string;\n /** Absent on a single-task lane, exactly as the CLI verb allows. */\n readonly task?: string;\n}\n\n/**\n * The host's answer. A refusal is a legitimate one — the machine said no —\n * and `message` is shown to the reader verbatim, so write it for them.\n */\nexport interface TransitionResult {\n readonly ok: boolean;\n readonly message: string;\n}\n\n/** Who holds a lane, when the host can say. */\nexport interface LaneDriver {\n /** A person or account — what a reader recognises. */\n readonly login: string;\n /** The claim token, or any stable handle for the session holding it. */\n readonly session: string;\n readonly at?: string | null;\n}\n\nexport interface FromDiskOptions {\n /**\n * Who sends each event, applied to every lane.\n *\n * A workflow document records topology and never provenance, so without this\n * the page can see that a task cannot move and not that it is WAITING ON\n * SOMEONE — which is the question a reader opened it to answer. Stated once\n * here rather than copied onto each lane.\n */\n readonly origins?: unknown;\n}\n\n/**\n * Every lane under `root`, read the way the convention says.\n *\n * A LANE IS A DIRECTORY HOLDING TWO FILES, and that convention is ours, so\n * reading it is ours too. Three separate hosts wrote this same loop before it\n * moved here, each with its own idea of the edges: whether a directory with no\n * `workflow.json` is a lane (it is not — a scratch dir under the root is not\n * something to draw), and whether a lane with no `events.jsonl` is broken (it\n * is not — it was emitted and never run, every task sits where it booted).\n *\n * A lane that cannot be read is SKIPPED rather than thrown, because one bad\n * directory should not take down a fleet view. A root that cannot be listed\n * throws, because a short list presented as \"your lanes\" is worse than an\n * error.\n */\nexport async function lanesFromDisk(\n root: string,\n opts: FromDiskOptions = {},\n): Promise<LaneFiles[]> {\n const names = await readdir(root);\n const read = await Promise.all(\n names.map(async (id): Promise<LaneFiles | null> => {\n const dir = `${root}/${id}`;\n try {\n if (!(await stat(dir)).isDirectory()) return null;\n const workflow = await readFile(`${dir}/workflow.json`, \"utf8\");\n const events = await readFile(`${dir}/events.jsonl`, \"utf8\").catch(\n () => \"\",\n );\n return {\n id,\n workflow,\n events,\n ...(opts.origins === undefined ? {} : { origins: opts.origins }),\n };\n } catch {\n return null;\n }\n }),\n );\n return read.filter((lane): lane is LaneFiles => lane !== null);\n}\n\nexport interface ServeOptions\n extends Omit<LaneViewerOptions, \"lanes\">,\n FromDiskOptions {\n /** Every lane the reader should see. Omit it and `root` is read instead. */\n readonly lanes?: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;\n /**\n * A directory of lane directories. Supply this and {@link lanes} is not\n * needed — pointing at a folder is the whole setup, which is what most\n * callers want and what all of them were writing by hand.\n */\n readonly root?: string;\n /** Default 5411. `0` takes any free port and reports it back on `url`. */\n readonly port?: number;\n /** Default `127.0.0.1` — this reads a machine's own disk and should stay on it. */\n readonly host?: string;\n}\n\n/** A running dashboard. */\nexport interface LaneViewerServer {\n /** Where to point a browser. Carries the real port when `port: 0` was asked for. */\n readonly url: string;\n readonly close: () => Promise<void>;\n}\n\nexport interface LaneViewerOptions {\n /** Every lane the reader should see. Called on each request; cache if slow. */\n readonly lanes: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;\n /**\n * Record one event. OMIT IT and the page becomes read-only — every lane is\n * still shown, and nothing offers to act. That is a real mode, not a\n * degraded one: a report of a run that finished has nothing to dispatch.\n */\n readonly transition?: (\n req: TransitionRequest,\n ) => TransitionResult | Promise<TransitionResult>;\n /** Ownership, when it is knowable. `null` for a lane nobody holds. */\n readonly drivers?: () => Promise<Readonly<Record<string, LaneDriver | null>>>;\n /** Shown in the footer — where these lanes were read from. */\n readonly source?: string;\n /**\n * How often to re-ask `lanes()` for the live stream, in ms. Default 2000.\n *\n * LIVENESS IS OURS, NOT THE HOST'S. A host could watch its own files and\n * push, and then every host would write that code and get it subtly\n * different. Re-asking is uninteresting and correct: `lanes()` is a read of\n * two files a driver appends to, the answer is compared before anything is\n * sent, and a quiet fleet costs one stat per lane per tick.\n */\n readonly pollMs?: number;\n}\n\nconst MIME: Readonly<Record<string, string>> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".woff2\": \"font/woff2\",\n};\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json; charset=utf-8\" },\n });\n\n/** Where the prebuilt page lives inside this package. */\nconst ASSETS = resolve(fileURLToPath(import.meta.url), \"..\", \"viewer\");\n\n/**\n * The page, plus the small JSON contract it speaks.\n *\n * ```ts\n * const handle = laneViewer({\n * lanes: () => readMyLanes(),\n * transition: (r) => myTransitionVerb(r),\n * });\n * // Node:\n * createServer(async (req, res) => { … await handle(request) … });\n * ```\n */\nexport function laneViewer(\n opts: LaneViewerOptions,\n): (request: Request) => Promise<Response> {\n return async (request: Request): Promise<Response> => {\n const url = new URL(request.url);\n const path = url.pathname;\n\n if (path === \"/api/lanes\") {\n return json({ lanes: await opts.lanes(), source: opts.source ?? \"\" });\n }\n\n if (path === \"/api/drivers\") {\n if (opts.drivers === undefined) return json({ drivers: {} });\n try {\n return json({ drivers: await opts.drivers() });\n } catch {\n // UNKNOWN is not \"nobody\" — an empty answer renders nothing, and a\n // lane shown free while someone holds it is what starts a second\n // driver on one piece of work.\n return json({ drivers: {} });\n }\n }\n\n if (path === \"/api/stream\") {\n const every = Math.max(250, opts.pollMs ?? 2000);\n let last = \"\";\n let timer: ReturnType<typeof setInterval> | undefined;\n const stream = new ReadableStream<Uint8Array>({\n start(controller) {\n const enc = new TextEncoder();\n const beat = async () => {\n try {\n const next = JSON.stringify(await opts.lanes());\n if (next === last) return;\n last = next;\n controller.enqueue(enc.encode(`data: ${next}\\n\\n`));\n } catch {\n // a half-written append is the next event arriving, not a fault\n }\n };\n void beat();\n timer = setInterval(() => void beat(), every);\n },\n cancel() {\n if (timer !== undefined) clearInterval(timer);\n },\n });\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-store\",\n connection: \"keep-alive\",\n },\n });\n }\n\n if (path === \"/api/transition\") {\n if (request.method !== \"POST\") return json({ ok: false }, 405);\n if (opts.transition === undefined) {\n return json(\n {\n ok: false,\n exit: 1,\n stdout: \"\",\n stderr: \"this viewer is read-only\",\n },\n 200,\n );\n }\n let body: TransitionRequest;\n try {\n body = (await request.json()) as TransitionRequest;\n } catch {\n return json(\n { ok: false, exit: 1, stdout: \"\", stderr: \"malformed request\" },\n 400,\n );\n }\n const out = await opts.transition(body);\n // the page reads `{ok, exit, stdout, stderr}` — a host that speaks in\n // sentences rather than exit codes says the same thing in `stderr`.\n return json({\n ok: out.ok,\n exit: out.ok ? 0 : 1,\n stdout: out.ok ? out.message : \"\",\n stderr: out.ok ? \"\" : out.message,\n });\n }\n\n // ── the page itself ────────────────────────────────────────────────\n // Anything that is not the contract is an asset, and anything that is\n // not an asset is the page — a dashboard is one screen, so an unknown\n // path is a reader who typed something, not a 404 worth showing them.\n const rel = path === \"/\" ? \"index.html\" : path.replace(/^\\/+/, \"\");\n const file = normalize(join(ASSETS, rel));\n if (!file.startsWith(ASSETS)) return new Response(\"no\", { status: 403 });\n\n try {\n const bytes = await readFile(file);\n return new Response(new Uint8Array(bytes), {\n headers: {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n },\n });\n } catch {\n const html = await readFile(join(ASSETS, \"index.html\"));\n return new Response(new Uint8Array(html), {\n headers: { \"content-type\": MIME[\".html\"] as string },\n });\n }\n };\n}\n\n/**\n * Start the dashboard. This is the one most callers want.\n *\n * {@link laneViewer} hands back a `(Request) => Response` for a host that already has a server to\n * mount it on. Everything BETWEEN that handler and a running page — creating the server, turning\n * node's `IncomingMessage` into a `Request`, reading a POST body, streaming a response back so the\n * event stream stays open — is the same in every host, and asking each one to write it is asking\n * them to get it subtly wrong. Notably the streaming: buffer the response and `/api/stream` never\n * delivers a frame, which looks like \"the page does not update\" and is nobody's obvious bug.\n *\n * So the three callbacks are the whole integration:\n *\n * ```ts\n * const server = await serveLaneViewer({\n * root: \".fabrika/lanes\", // not read here — see `lanes`\n * lanes: () => readMyLanes(),\n * transition: (r) => myVerb(r),\n * });\n * console.log(server.url);\n * ```\n */\nexport async function serveLaneViewer(\n opts: ServeOptions,\n): Promise<LaneViewerServer> {\n const root = opts.root;\n if (opts.lanes === undefined && root === undefined) {\n throw new Error(\n \"@demlik/tea: serveLaneViewer needs either `root` (a directory of lane directories) or `lanes`\",\n );\n }\n const handle = laneViewer({\n ...opts,\n // Re-read on every ask rather than cache: a driver appends to these files\n // while the page is open, and current is the whole point of the page.\n lanes:\n opts.lanes ??\n (() =>\n lanesFromDisk(root as string, {\n ...(opts.origins === undefined ? {} : { origins: opts.origins }),\n })),\n ...(opts.source === undefined && root !== undefined\n ? { source: root }\n : {}),\n });\n const host = opts.host ?? \"127.0.0.1\";\n\n const server = createServer((req, res) => {\n void (async () => {\n const body =\n req.method === \"POST\" || req.method === \"PUT\"\n ? await new Promise<string>((ok) => {\n let read = \"\";\n req.on(\"data\", (chunk) => {\n read += chunk;\n });\n req.on(\"end\", () => ok(read));\n })\n : undefined;\n\n const out = await handle(\n new Request(`http://${host}${req.url ?? \"/\"}`, {\n method: req.method ?? \"GET\",\n ...(body === undefined ? {} : { body }),\n }),\n );\n\n res.writeHead(out.status, Object.fromEntries(out.headers));\n if (out.body === null) {\n res.end();\n return;\n }\n // STREAMED, never buffered: `/api/stream` holds open for the life of the\n // page, and a buffered response is one that never arrives.\n const reader = out.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n res.write(value);\n }\n } catch {\n // the reader is gone — the page navigated away mid-frame\n }\n res.end();\n })();\n });\n\n const port = await new Promise<number>((ok, no) => {\n server.once(\"error\", no);\n server.listen(opts.port ?? 5411, host, () => {\n const addr = server.address();\n ok(\n typeof addr === \"object\" && addr !== null\n ? addr.port\n : (opts.port ?? 5411),\n );\n });\n });\n\n return {\n url: `http://${host === \"0.0.0.0\" ? \"localhost\" : host}:${port}`,\n close: () =>\n new Promise<void>((ok) => {\n server.closeAllConnections?.();\n server.close(() => ok());\n }),\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demlik/tea",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "TEA-faithful state machine substrate — one package, every host adapter. Core + subs + testing, plus React, Durable Object, Chrome extension, Node, in-memory, work-queue, devtools, and property-testing modules as subpaths.",
5
5
  "keywords": [
6
6
  "tea",