omkit 0.8.16 → 0.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,8 @@ Resolution happens **inside** the run, so a prompt and its answer land on the ru
132
132
 
133
133
  An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor scraped from a log: a typed runtime handoff, so steps chain without globals or string-parsing. The first `attach` wins; later ones are ignored. Unlike `.result`, `.ref` **rejects** on failure or cancellation — a consumer waiting on a capability that never arrives fails instead of hanging — and an action that publishes nothing still resolves its `.ref` with `undefined` on success, so awaiting it never wedges.
134
134
 
135
+ The handle `.ref` resolves is a thin, shallow wrapper over the attached value: every reader gets the same one, methods run bound to the real object (private fields work), and each public method call another action makes on it is recorded in the run — who called, from which line, how long it took — so the report can show it. Return values are not wrapped, and recording stops after 500 calls per handle.
136
+
135
137
  ```ts
136
138
  const server = action("server")
137
139
  .ref<number>() // this action publishes a port
@@ -189,7 +191,7 @@ Every action's output, events, asserts, snapshots, and lifecycle land on **one t
189
191
  - `raw.jsonl` — every entry, machine-readable.
190
192
  - `main.log` + one `.log` per action — the human-readable timelines.
191
193
  - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
192
- - `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, each action's source (the `<>` button next to a block), and an Errors tab that collects failures, failed assertions and error output, and can highlight the actions they came from in every graph. Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
194
+ - `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, each action's source (the `<>` button next to a block), an Errors tab that collects failures, failed assertions and error output, and can highlight the actions they came from in every graph, and the links between actions: who waited on whose event (`once`), who cancelled whom, and the calls made on another action's `.ref`. Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
193
195
 
194
196
  `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict), `snapshot(name, value)` (captures JSON state to the run folder), and `artifact(name, file, opts?)` (labels a file the action wrote — name, optional description, MIME inferred from the extension — and returns its absolute path) — so a run is an inspectable artifact, not just an exit code. Write the files you label under `ctx.artifactsFolder`, the run's own output folder.
195
197
 
@@ -72,6 +72,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
72
72
  private attached;
73
73
  private observed;
74
74
  private refObserved;
75
+ /** The traced handle every `.ref` reader shares (see {@link traceHandle}); made on first read. */
76
+ private tracedRef;
75
77
  private cancelLogged;
76
78
  private armedExec;
77
79
  private armedArgs;
@@ -123,4 +125,6 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
123
125
  */
124
126
  private lifecycle;
125
127
  private log;
128
+ /** A cross-node interaction, on this (the targeted) node's log; `source` is the link's op. */
129
+ private link;
126
130
  }
@@ -2,6 +2,9 @@ import { defer } from "../foundation/Deferred.js";
2
2
  import { events } from "../foundation/events.js";
3
3
  import { CancelledError } from "../foundation/CancelledError.js";
4
4
  import { renderValue } from "../foundation/format.js";
5
+ import { callerSite } from "../foundation/callsite.js";
6
+ import { currentNode } from "./context.js";
7
+ import { traceHandle, REF_CALL_CAP } from "./refTrace.js";
5
8
  import { createProc } from "../system/proc.js";
6
9
  import { FolderCache } from "../system/fs/FolderCache.js";
7
10
  /**
@@ -45,6 +48,8 @@ export class ActionRun {
45
48
  attached = false;
46
49
  observed = false;
47
50
  refObserved = false;
51
+ /** The traced handle every `.ref` reader shares (see {@link traceHandle}); made on first read. */
52
+ tracedRef;
48
53
  cancelLogged = false;
49
54
  // The armed body + args, set at launch and run once by `commit()` on a microtask — the
50
55
  // window during which `withCache` may still wrap the body. `committed` closes that window.
@@ -108,7 +113,23 @@ export class ActionRun {
108
113
  /** The attached handle; rejects on failure/cancel. */
109
114
  get ref() {
110
115
  this.refObserved = true;
111
- return this.handle.promise;
116
+ // One traced handle for every reader — identity holds across reads, and each method call
117
+ // another node makes on it is recorded here as a `ref` link.
118
+ if (!this.tracedRef) {
119
+ this.tracedRef = this.handle.promise.then((h) => traceHandle(h, {
120
+ call: (c) => this.link(`${c.key}() ← ${c.from} · ${c.ms}ms${c.outcome === "failed" ? " · failed" : ""}`, {
121
+ op: "ref",
122
+ from: c.from,
123
+ ...(c.site ? { site: c.site } : {}),
124
+ key: c.key,
125
+ ms: c.ms,
126
+ outcome: c.outcome,
127
+ }),
128
+ capped: () => this.log("info", "ref", `ref calls past ${REF_CALL_CAP} are not recorded`),
129
+ }));
130
+ void this.tracedRef.catch(() => { });
131
+ }
132
+ return this.tracedRef;
112
133
  }
113
134
  on(key, handler) {
114
135
  // A late `on("error")` (e.g. attached after an `await`, once the body has already
@@ -119,9 +140,29 @@ export class ActionRun {
119
140
  this.emitter.listen(key, handler);
120
141
  }
121
142
  once(key) {
143
+ // Who waits, from where — recorded on this node as a `once` link when the wait ends.
144
+ const from = currentNode.getStore();
145
+ const site = callerSite();
146
+ const t0 = Date.now();
147
+ const note = (outcome) => {
148
+ if (!from || from.id === this.id)
149
+ return;
150
+ const ms = Date.now() - t0;
151
+ this.link(`${String(key)} ← ${from.id} · ${outcome} after ${ms}ms`, {
152
+ op: "once",
153
+ from: from.id,
154
+ ...(site ? { site } : {}),
155
+ key: String(key),
156
+ ms,
157
+ outcome,
158
+ });
159
+ };
122
160
  // `done` is the outcome itself — identical to `.result` (resolve the value, reject on failure).
123
- if (key === "done")
124
- return this.result;
161
+ if (key === "done") {
162
+ const outcome = this.result;
163
+ outcome.then(() => note("fired"), () => note("failed"));
164
+ return outcome;
165
+ }
125
166
  // Awaiting an event observes the activity: resolve the payload when it fires, but if the
126
167
  // activity settles first, resolve `undefined` on success and **reject** on failure/cancel —
127
168
  // so an event gate surfaces a failure instead of hanging (or, for `error`, hand back the error).
@@ -130,16 +171,21 @@ export class ActionRun {
130
171
  let done = false;
131
172
  const win = () => (done ? false : (done = true));
132
173
  this.emitter.listenOnce(key, (p) => {
133
- if (win())
134
- resolve(p);
174
+ if (!win())
175
+ return;
176
+ note("fired");
177
+ resolve(p);
135
178
  });
136
179
  this.settled.promise.then(() => {
137
- if (win())
138
- resolve(undefined);
180
+ if (!win())
181
+ return;
182
+ note("ended");
183
+ resolve(undefined);
139
184
  }, (error) => {
140
185
  if (!win())
141
186
  return;
142
187
  // Awaiting the `error` event hands back the error; any other gate rejects on failure.
188
+ note(key === "error" ? "fired" : "failed");
143
189
  if (key === "error")
144
190
  resolve(error);
145
191
  else
@@ -165,6 +211,16 @@ export class ActionRun {
165
211
  return this;
166
212
  }
167
213
  cancel() {
214
+ // A cancel from another node is a link: who stopped this one, and from where.
215
+ const from = currentNode.getStore();
216
+ if (from && from.id !== this.id && this.parentId !== null && this.status === "running") {
217
+ const site = callerSite();
218
+ this.link(`by ${from.id}${site ? ` @ ${site}` : ""}`, {
219
+ op: "cancel",
220
+ from: from.id,
221
+ ...(site ? { site } : {}),
222
+ });
223
+ }
168
224
  this.noteCancelled(); // a direct cancel of this activity
169
225
  this.controller.abort();
170
226
  }
@@ -335,6 +391,17 @@ export class ActionRun {
335
391
  log(level, source, message) {
336
392
  this.store.append({ nodeId: this.id, path: this.path, level, source, message });
337
393
  }
394
+ /** A cross-node interaction, on this (the targeted) node's log; `source` is the link's op. */
395
+ link(message, link) {
396
+ this.store.append({
397
+ nodeId: this.id,
398
+ path: this.path,
399
+ level: "link",
400
+ source: link.op,
401
+ message,
402
+ link,
403
+ });
404
+ }
338
405
  }
339
406
  const messageOf = (error) => error instanceof Error ? (error.stack ?? error.message) : String(error);
340
407
  const firstLine = (message) => message.split("\n", 1)[0] ?? message;
@@ -0,0 +1,22 @@
1
+ /** Calls recorded per handle; past this the trace goes quiet, so a polling loop can't flood the log. */
2
+ export declare const REF_CALL_CAP = 500;
3
+ /** One method call on a traced handle, as {@link traceHandle} reports it. */
4
+ export interface RefCall {
5
+ /** The calling node's id. */
6
+ readonly from: string;
7
+ readonly site: string | undefined;
8
+ readonly key: string;
9
+ readonly ms: number;
10
+ readonly outcome: "ok" | "failed";
11
+ }
12
+ /**
13
+ * Wrap an attached handle so each public method call on it is reported — who called (the
14
+ * ambient node), from where, how long it took, and whether it threw. Shallow by design: what
15
+ * a method returns is handed back untraced. Methods run bound to the real object, so private
16
+ * fields keep working; symbol, `_`-prefixed and {@link QUIET} members pass through unrecorded.
17
+ * A primitive or nullish handle is returned as is.
18
+ */
19
+ export declare const traceHandle: <H>(handle: H, report: {
20
+ call: (call: RefCall) => void;
21
+ capped: () => void;
22
+ }) => H;
@@ -0,0 +1,71 @@
1
+ import { callerSite } from "../foundation/callsite.js";
2
+ import { currentNode } from "./context.js";
3
+ /** Calls recorded per handle; past this the trace goes quiet, so a polling loop can't flood the log. */
4
+ export const REF_CALL_CAP = 500;
5
+ /** Members never recorded: promise/serialization/inspection plumbing, not the caller's intent. */
6
+ const QUIET = new Set([
7
+ "then",
8
+ "catch",
9
+ "finally",
10
+ "toJSON",
11
+ "toString",
12
+ "valueOf",
13
+ "inspect",
14
+ "constructor",
15
+ ]);
16
+ /**
17
+ * Wrap an attached handle so each public method call on it is reported — who called (the
18
+ * ambient node), from where, how long it took, and whether it threw. Shallow by design: what
19
+ * a method returns is handed back untraced. Methods run bound to the real object, so private
20
+ * fields keep working; symbol, `_`-prefixed and {@link QUIET} members pass through unrecorded.
21
+ * A primitive or nullish handle is returned as is.
22
+ */
23
+ export const traceHandle = (handle, report) => {
24
+ if (handle === null || (typeof handle !== "object" && typeof handle !== "function"))
25
+ return handle;
26
+ const cache = new Map();
27
+ let count = 0;
28
+ return new Proxy(handle, {
29
+ get(target, prop) {
30
+ const value = Reflect.get(target, prop, target);
31
+ if (typeof value !== "function")
32
+ return value;
33
+ // a non-configurable, read-only property must come back as itself (a Proxy invariant)
34
+ const own = Reflect.getOwnPropertyDescriptor(target, prop);
35
+ if (own && !own.configurable && !own.writable)
36
+ return value;
37
+ const hit = cache.get(prop);
38
+ if (hit && hit.orig === value)
39
+ return hit.fn;
40
+ const name = String(prop);
41
+ const quiet = typeof prop === "symbol" || name.startsWith("_") || QUIET.has(name);
42
+ const method = value;
43
+ const fn = (...args) => {
44
+ const from = currentNode.getStore()?.id;
45
+ if (quiet || !from || count >= REF_CALL_CAP)
46
+ return method.apply(target, args);
47
+ if (++count === REF_CALL_CAP)
48
+ report.capped();
49
+ const site = callerSite();
50
+ const t0 = Date.now();
51
+ const done = (outcome) => report.call({ from, site, key: name, ms: Date.now() - t0, outcome });
52
+ let out;
53
+ try {
54
+ out = method.apply(target, args);
55
+ }
56
+ catch (error) {
57
+ done("failed");
58
+ throw error;
59
+ }
60
+ if (out !== null && typeof out?.then === "function") {
61
+ out.then(() => done("ok"), () => done("failed"));
62
+ }
63
+ else
64
+ done("ok");
65
+ return out;
66
+ };
67
+ cache.set(prop, { orig: value, fn });
68
+ return fn;
69
+ },
70
+ });
71
+ };
@@ -13,11 +13,31 @@ export interface LogEntry {
13
13
  readonly nodeId: string;
14
14
  /** The owning node's execution path (`main/chromePage_9f3c`). */
15
15
  readonly path: string;
16
- /** `info` | `error` | `event` | `assert` | `snapshot` | `artifact` | `tag` | `child` | `run`. */
16
+ /** `info` | `error` | `event` | `assert` | `snapshot` | `artifact` | `tag` | `child` | `run` | `link`. */
17
17
  readonly level: string;
18
18
  /** Finer origin within the node (proc name, `event`, `run`, …). */
19
19
  readonly source: string;
20
20
  readonly message: string;
21
+ /** Set on `level: "link"` entries — the other side of a cross-node interaction. */
22
+ readonly link?: LogLink;
23
+ }
24
+ /**
25
+ * A cross-node interaction, recorded on the node it targets: another node waited on one of
26
+ * its events (`once`), cancelled it (`cancel`), or called a method on its attached handle
27
+ * (`ref`). The entry's `ts` is when the interaction ended, so a timed one spans `ts - ms … ts`.
28
+ */
29
+ export interface LogLink {
30
+ readonly op: "once" | "cancel" | "ref";
31
+ /** The calling node's id. */
32
+ readonly from: string;
33
+ /** `file:line` of the call, when the stack had one. */
34
+ readonly site?: string;
35
+ /** The awaited event key (`once`) or the called method (`ref`). */
36
+ readonly key?: string;
37
+ /** How long the wait or call lasted (`once`, `ref`). */
38
+ readonly ms?: number;
39
+ /** How it ended — `fired` | `ended` | `failed` for a once, `ok` | `failed` for a ref call. */
40
+ readonly outcome?: string;
21
41
  }
22
42
  /** What an appender supplies; the store stamps `sequence` and `ts`. */
23
43
  export type LogInput = Omit<LogEntry, "sequence" | "ts">;
@@ -147,10 +147,11 @@ button { font: inherit; color: inherit; }
147
147
  .row .label .tcall { font-size: 11.5px; }
148
148
  .row .label .sep, .row .l2 .sep { color: var(--text-3); }
149
149
  .row .l2 { font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; }
150
- .row .l2 .st { display: inline-flex; align-items: center; gap: 4px; flex: none; }
150
+ .row .l2 .meta { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-left: auto; padding-right: 4px; }
151
+ .row .l2 .st { display: inline-flex; align-items: center; gap: 4px; }
151
152
  .row .l2 .dot { width: 7px; height: 7px; }
152
153
  .row .l2 .du { flex: none; }
153
- .row .l2 .chips { flex: 0 1 auto; flex-wrap: nowrap; overflow: hidden; margin-left: 2px; }
154
+ .row .l2 .chips { flex: 0 1 auto; flex-wrap: nowrap; overflow: hidden; }
154
155
  .row .l2 .chip { flex: 0 1 auto; min-width: 24px; font-size: 10px; line-height: 14px; }
155
156
  .row .l2 .chip.more { flex: none; }
156
157
  .row .twist { width: 16px; height: 16px; flex: none; display: grid; place-items: center; color: var(--text-3); border: 0; background: none; padding: 0; cursor: pointer; }
@@ -471,6 +472,18 @@ a.icon-btn { text-decoration: none; }
471
472
  #app.hl-on .flow-card.err-path rect.box { stroke: var(--fail); stroke-width: 1.2; stroke-dasharray: 4 3; }
472
473
  #app.hl-on .tree .row.err { background: rgba(239, 68, 68, 0.1); }
473
474
  .chart g.r, .flow-card, .tree .row { transition: opacity 0.15s; }
475
+ /* cross-node links: waits (dashed), ref calls (solid), connectors between rows / cards */
476
+ .xl-span { stroke-width: 3; stroke-linecap: round; cursor: pointer; }
477
+ .xl-span.once { stroke-width: 2.5; stroke-dasharray: 4 3; stroke-linecap: butt; }
478
+ .xl-wire { fill: none; stroke-width: 1.4; stroke-dasharray: 3 3; opacity: 0.85; cursor: pointer; }
479
+ .xl-span:hover { stroke-width: 4.5; }
480
+ .xl-hit { fill: none; stroke: transparent; stroke-width: 10; cursor: pointer; }
481
+ #xl-arrow path { fill: var(--text-2); }
482
+ .flow-xlink { cursor: pointer; }
483
+ .flow-xlink path { fill: none; stroke-width: 1.4; stroke-dasharray: 4 4; opacity: 0.8; }
484
+ .flow-xlink path.hit { stroke: transparent; stroke-width: 12; stroke-dasharray: none; }
485
+ .flow-xlink:hover path:not(.hit) { stroke-width: 2.6; opacity: 1; }
486
+ .flow-xlink text { font-size: 10.5px; fill: var(--text-2); stroke: var(--panel); stroke-width: 3px; paint-order: stroke; }
474
487
 
475
488
  @media (max-width: 1100px) {
476
489
  .bottom { grid-template-columns: minmax(0, 1fr); }
@@ -63,6 +63,7 @@
63
63
  cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
64
64
  lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
65
65
  run: { color: "--text-3", icon: "root", label: "Run" },
66
+ link: { color: "--text-3", icon: "flow", label: "Link" },
66
67
  };
67
68
 
68
69
  // ── formatting ───────────────────────────────────────────
@@ -156,7 +157,7 @@
156
157
  items.push(item);
157
158
  if (item.node) {
158
159
  item.node.entries.push(item);
159
- if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
160
+ if (kind !== "info" && kind !== "lifecycle" && kind !== "link") item.node.marks.push(item);
160
161
  } else runEntries.push(item);
161
162
  }
162
163
 
@@ -186,6 +187,15 @@
186
187
  }
187
188
  }
188
189
 
190
+ // cross-node interactions, recorded on their target: who waited on, cancelled, or called into whom
191
+ const links = [];
192
+ for (const it of items) {
193
+ const l = it.kind === "link" && it.node && it.e.link;
194
+ const from = l && byId.get(l.from);
195
+ if (!from) continue;
196
+ links.push({ item: it, op: l.op, from, to: it.node, t: it.t, t0: it.t - (l.ms || 0), key: l.key || "", ms: l.ms, outcome: l.outcome || "", site: l.site || "" });
197
+ }
198
+
189
199
  const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
190
200
  const errors = collectErrors(items, nodes, run, tEnd - t0);
191
201
  const errorsBy = new Map();
@@ -209,6 +219,7 @@
209
219
  byId,
210
220
  root: nodes[0],
211
221
  items,
222
+ links,
212
223
  runEntries,
213
224
  gallery,
214
225
  asserts,
@@ -270,6 +281,7 @@
270
281
  return String(s).split("\n")[0];
271
282
  }
272
283
  function kindOf(e) {
284
+ if (e.level === "link") return "link";
273
285
  if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
274
286
  if (e.level === "snapshot") return "snapshot";
275
287
  if (e.level === "artifact") return "artifact";
@@ -476,7 +488,7 @@
476
488
  <span class="l1"><span class="label">${labelHtml(n)}</span>${
477
489
  model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
478
490
  }</span>
479
- <span class="l2"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span>${tags}</span>
491
+ <span class="l2">${tags}<span class="meta"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span></span></span>
480
492
  </span>
481
493
  </div>
482
494
  </div>`;
@@ -503,7 +515,21 @@
503
515
  .on("mouseleave", () => moveCursor(null));
504
516
  chartSvg.append("g").attr("class", "grid");
505
517
  chartSvg.append("g").attr("class", "links");
518
+ chartSvg
519
+ .append("defs")
520
+ .append("marker")
521
+ .attr("id", "xl-arrow")
522
+ .attr("viewBox", "0 0 10 10")
523
+ .attr("refX", 9)
524
+ .attr("refY", 5)
525
+ .attr("markerWidth", 6)
526
+ .attr("markerHeight", 6)
527
+ .attr("orient", "auto")
528
+ .append("path")
529
+ .attr("d", "M0,1 L10,5 L0,9 z");
506
530
  chartSvg.append("g").attr("class", "rows");
531
+ // over the rows: their hover targets would otherwise swallow the link strips
532
+ chartSvg.append("g").attr("class", "xlinks");
507
533
  chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
508
534
  chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
509
535
  chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
@@ -619,11 +645,98 @@
619
645
  g.select("g.marks").each(function (n) {
620
646
  drawMarks(d3.select(this), n, x);
621
647
  });
648
+ drawLinks(x, index);
622
649
 
623
650
  chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
624
651
  if (app.classList.contains("hl-on") || state.errHl) applyHl();
625
652
  }
626
653
 
654
+ /** The visible row standing in for `n`: its own, or its nearest expanded ancestor's. */
655
+ function rowIndexOf(n, index) {
656
+ for (let p = n; p; p = p.parent) if (index.has(p.id)) return index.get(p.id);
657
+ return -1;
658
+ }
659
+ /**
660
+ * Cross-node links on the timeline. A strip under the caller's bar holds its waits
661
+ * (`once`, dashed) and its calls on another action's ref (solid), in the target's colour;
662
+ * a dashed connector joins the two rows where a wait ended (target → waiter) and where a
663
+ * cancel landed (canceller → target).
664
+ */
665
+ function drawLinks(x, index) {
666
+ const STRIP_Y = BAR_Y + BAR_H + 3;
667
+ const spans = [];
668
+ const wires = [];
669
+ for (const l of model.links) {
670
+ const fi = rowIndexOf(l.from, index);
671
+ const ti = rowIndexOf(l.to, index);
672
+ if (fi < 0) continue;
673
+ if (l.op !== "cancel") spans.push({ l, fi });
674
+ if (l.op !== "ref" && ti >= 0 && ti !== fi) wires.push({ l, fi, ti });
675
+ }
676
+ const layer = chartSvg.select(".xlinks");
677
+ layer
678
+ .selectAll("line.xl-span")
679
+ .data(spans)
680
+ .join("line")
681
+ .attr("class", (d) => `xl-span ${d.l.op}`)
682
+ .attr("x1", (d) => x(d.l.t0))
683
+ .attr("x2", (d) => Math.max(x(d.l.t), x(d.l.t0) + 2))
684
+ .attr("y1", (d) => d.fi * ROW_H + STRIP_Y)
685
+ .attr("y2", (d) => d.fi * ROW_H + STRIP_Y)
686
+ .attr("stroke", (d) => colorOf(d.l.to))
687
+ .call(bindLink);
688
+ const wirePath = (d) => {
689
+ const px = x(d.l.t);
690
+ const fy = d.fi * ROW_H + STRIP_Y;
691
+ const ty = d.ti * ROW_H + BAR_Y + BAR_H / 2;
692
+ const [y1, y2] = d.l.op === "once" ? [ty, fy] : [fy, ty];
693
+ return `M${px},${y1} C${px + 10},${(2 * y1 + y2) / 3} ${px + 10},${(y1 + 2 * y2) / 3} ${px},${y2}`;
694
+ };
695
+ layer
696
+ .selectAll("path.xl-wire")
697
+ .data(wires)
698
+ .join("path")
699
+ .attr("class", (d) => `xl-wire ${d.l.op}`)
700
+ .attr("d", wirePath)
701
+ .attr("stroke", (d) => (d.l.op === "cancel" ? css("--cancel") : colorOf(d.l.to)))
702
+ .attr("marker-end", "url(#xl-arrow)");
703
+ // a wide transparent twin over each wire, so a 1px dashed line is easy to hover
704
+ layer.selectAll("path.xl-hit").data(wires).join("path").attr("class", "xl-hit").attr("d", wirePath).call(bindLink);
705
+ }
706
+ function bindLink(sel) {
707
+ sel
708
+ .on("click", (ev, d) => {
709
+ ev.stopPropagation();
710
+ setPin(d.l.t);
711
+ select(d.l.from.id, "links");
712
+ })
713
+ .on("mouseenter", (ev, d) => showTip(ev, linkTip(d.l), true))
714
+ .on("mousemove", (ev) => moveTip(ev))
715
+ .on("mouseleave", hideTip);
716
+ }
717
+ /** A link in words: who did what to whom, how it ended, and the line that did it. */
718
+ function linkTip(l, count) {
719
+ const from = esc(displayName(l.from));
720
+ const to = esc(displayName(l.to));
721
+ const title = l.op === "once" ? `once · ${esc(l.key)}` : l.op === "cancel" ? "cancel" : `ref · ${esc(l.key)}()`;
722
+ const what =
723
+ l.op === "once"
724
+ ? `<b>${from}</b> waited for <b>${esc(l.key)}</b> on <b>${to}</b>`
725
+ : l.op === "cancel"
726
+ ? `<b>${from}</b> cancelled <b>${to}</b>`
727
+ : `<b>${from}</b> called <b>${esc(l.key)}()</b> on <b>${to}</b>'s ref`;
728
+ const meta = [
729
+ count > 1 ? `×${count}` : "",
730
+ l.outcome ? esc(l.outcome) : "",
731
+ l.ms != null ? (l.op === "once" ? `after ${fmtDur(l.ms)}` : fmtDur(l.ms)) : "",
732
+ fmtRel(l.t),
733
+ ].filter(Boolean);
734
+ const line = (/:(\d+)$/.exec(l.site) || [])[1];
735
+ return `<div class="tt">${title}</div><div class="summary">${what}</div>
736
+ <div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
737
+ ${l.site ? `<div class="mono small" style="margin-top:6px">${esc(relOf(l.site))}${line ? ":" + line : ""}</div>` : ""}`;
738
+ }
739
+
627
740
  function drawMarks(g, n, x) {
628
741
  const cy = BAR_Y + BAR_H / 2;
629
742
  // cluster marks closer than 4px so a chatty node draws a readable line of dots
@@ -852,6 +965,70 @@
852
965
  }
853
966
 
854
967
  const nodes = [...pos.keys()].map((id) => model.byId.get(id));
968
+ // cross-node links: one curve per (caller, target, kind), arcing out past the cards' right
969
+ // edges; drawn first so the cards sit on top of their ends
970
+ const agg = new Map();
971
+ for (const l of model.links) {
972
+ if (!pos.has(l.from.id) || !pos.has(l.to.id) || l.from === l.to) continue;
973
+ const k = `${l.from.id}\0${l.to.id}\0${l.op}`;
974
+ if (!agg.has(k)) agg.set(k, { op: l.op, from: l.from, to: l.to, list: [] });
975
+ agg.get(k).list.push(l);
976
+ }
977
+ const xl = flowG.append("g").attr("class", "flow-xlinks");
978
+ const fan = new Map(); // edges between the same two cards fan out instead of overlapping
979
+ for (const a of agg.values()) {
980
+ const f = pos.get(a.from.id);
981
+ const t = pos.get(a.to.id);
982
+ const [s, e] = a.op === "once" ? [t, f] : [f, t];
983
+ const pair = [a.from.id, a.to.id].sort().join("");
984
+ const k = fan.get(pair) || 0;
985
+ fan.set(pair, k + 1);
986
+ const off = (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 10;
987
+ let x1, y1, x2, y2, path, lx, ly;
988
+ if (Math.abs(s.x - e.x) < CW) {
989
+ // same column: arc out past the right edges
990
+ x1 = s.x + CW;
991
+ x2 = e.x + CW;
992
+ y1 = s.y + CH / 2 + off;
993
+ y2 = e.y + CH / 2 + off;
994
+ const bulge = 36 + Math.min(140, Math.abs(y2 - y1) * 0.2) + Math.abs(off);
995
+ path = `M${x1},${y1} C${x1 + bulge},${y1} ${x2 + bulge},${y2} ${x2 + 2},${y2}`;
996
+ lx = (x1 + x2) / 2 + bulge * 0.75 + 4;
997
+ ly = (y1 + y2) / 2 + 4;
998
+ } else {
999
+ // different columns: facing sides, an S-curve between them, below the tree's own links
1000
+ const rightward = e.x > s.x;
1001
+ x1 = rightward ? s.x + CW : s.x;
1002
+ x2 = rightward ? e.x : e.x + CW;
1003
+ y1 = s.y + CH * 0.72 + off;
1004
+ y2 = e.y + CH * 0.72 + off;
1005
+ const mx = (x1 + x2) / 2;
1006
+ path = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2 + (rightward ? -2 : 2)},${y2}`;
1007
+ lx = mx + 4;
1008
+ ly = (y1 + y2) / 2 - 4;
1009
+ }
1010
+ const keys = [...new Set(a.list.map((l) => l.key))];
1011
+ const label =
1012
+ a.op === "cancel" ? "cancel" : a.op === "once" ? `once ${keys.join(", ")}` : keys.length === 1 ? `${keys[0]}() ×${a.list.length}` : `ref ×${a.list.length}`;
1013
+ const g = xl
1014
+ .append("g")
1015
+ .attr("class", `flow-xlink ${a.op}`)
1016
+ .on("click", (ev) => {
1017
+ ev.stopPropagation();
1018
+ select(a.from.id, "links");
1019
+ })
1020
+ .on("mouseenter", (ev) => showTip(ev, linkTip(a.list[a.list.length - 1], a.list.length), true))
1021
+ .on("mousemove", (ev) => moveTip(ev))
1022
+ .on("mouseleave", hideTip);
1023
+ g.append("path")
1024
+ .attr("d", path)
1025
+ .attr("stroke", a.op === "cancel" ? css("--cancel") : colorOf(a.to))
1026
+ .attr("marker-end", "url(#flow-arrow)");
1027
+ g.append("path").attr("class", "hit").attr("d", path);
1028
+ // between neighbouring columns there is no room for words — the tooltip carries them
1029
+ if (Math.abs(x2 - x1) >= 90) g.append("text").attr("x", lx).attr("y", ly).text(truncate(label, 28));
1030
+ }
1031
+
855
1032
  const card = flowG
856
1033
  .append("g")
857
1034
  .selectAll("g.flow-card")
@@ -1235,8 +1412,9 @@
1235
1412
  function detailSections(n) {
1236
1413
  const errs = model.errors.filter((e) => e.node === n);
1237
1414
  const params = schemaFields(n.v.inputSchema);
1238
- const evs = n.entries.filter((i) => i.kind !== "info");
1415
+ const evs = n.entries.filter((i) => i.kind !== "info" && i.kind !== "link");
1239
1416
  const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1417
+ const lks = model.links.filter((l) => l.from === n || l.to === n);
1240
1418
  const shots = model.gallery.filter((x) => x.node === n);
1241
1419
  const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
1242
1420
  const logLink = `<div class="acc-file">${svgIcon("file", ' width="14" height="14"')}<a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div>`;
@@ -1277,6 +1455,13 @@
1277
1455
  empty: !evs.length,
1278
1456
  body: () => `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>`,
1279
1457
  },
1458
+ {
1459
+ key: "links",
1460
+ title: "Links",
1461
+ count: lks.length,
1462
+ empty: !lks.length,
1463
+ body: () => linksHtml(n, lks),
1464
+ },
1280
1465
  {
1281
1466
  key: "logs",
1282
1467
  title: "Logs",
@@ -1295,6 +1480,50 @@
1295
1480
  ];
1296
1481
  }
1297
1482
 
1483
+ /**
1484
+ * The details panel's links: what `n` did to other actions and what they did to it. Ref
1485
+ * calls fold into one row per (other action, method), with a count and their total time.
1486
+ */
1487
+ function linksHtml(n, lks) {
1488
+ const rows = [];
1489
+ const refs = new Map();
1490
+ for (const l of lks) {
1491
+ if (l.op === "ref") {
1492
+ const k = `${l.from.id}\0${l.to.id}\0${l.key}`;
1493
+ if (!refs.has(k)) {
1494
+ refs.set(k, { l, n: 0, ms: 0 });
1495
+ rows.push(refs.get(k));
1496
+ }
1497
+ const r = refs.get(k);
1498
+ r.n++;
1499
+ r.ms += l.ms || 0;
1500
+ } else rows.push({ l, n: 1, ms: l.ms || 0 });
1501
+ }
1502
+ const name = (x) => `<b>${esc(displayName(x))}</b>`;
1503
+ return `<ul class="ev-list">${rows
1504
+ .map(({ l, n: count, ms }) => {
1505
+ const out = l.from === n;
1506
+ const text =
1507
+ l.op === "once"
1508
+ ? out
1509
+ ? `waited for ${esc(l.key)} on ${name(l.to)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
1510
+ : `${name(l.from)} waited for ${esc(l.key)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
1511
+ : l.op === "cancel"
1512
+ ? out
1513
+ ? `cancelled ${name(l.to)}`
1514
+ : `cancelled by ${name(l.from)}`
1515
+ : out
1516
+ ? `${name(l.to)}.${esc(l.key)}() ×${count} · ${fmtDur(ms)}`
1517
+ : `${name(l.from)} called ${esc(l.key)}() ×${count} · ${fmtDur(ms)}`;
1518
+ const color = l.op === "cancel" ? css("--cancel") : colorOf(out ? l.to : l.from);
1519
+ return `<li data-link="${model.links.indexOf(l)}" style="cursor:pointer">
1520
+ <span class="t">${fmtRel(l.t - n.start)}</span>
1521
+ <span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(l.op === "cancel" ? "stop" : l.op === "once" ? "pulse" : "code")}</span>
1522
+ <span class="m">${out ? "→ " : "← "}${text}</span></li>`;
1523
+ })
1524
+ .join("")}</ul>`;
1525
+ }
1526
+
1298
1527
  /** A snapshot or file in the details panel: a small thumb with its name and when it landed. */
1299
1528
  function shotHtml(g, n) {
1300
1529
  const i = model.gallery.indexOf(g);
@@ -1329,6 +1558,9 @@
1329
1558
  root.querySelectorAll("[data-gallery]").forEach((t) =>
1330
1559
  t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
1331
1560
  );
1561
+ root.querySelectorAll("[data-link]").forEach((t) =>
1562
+ t.addEventListener("click", () => setPin(model.links[+t.dataset.link].t))
1563
+ );
1332
1564
  root.querySelectorAll("[data-item]").forEach((t) =>
1333
1565
  t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
1334
1566
  );
@@ -1548,7 +1780,7 @@
1548
1780
  ? (i) => i.kind === "info" || i.kind === "error"
1549
1781
  : tab === "asserts"
1550
1782
  ? (i) => i.kind === "pass" || i.kind === "fail"
1551
- : (i) => i.kind !== "info";
1783
+ : (i) => i.kind !== "info" && !(i.kind === "link" && i.e.link && i.e.link.op === "ref");
1552
1784
  const rows = [];
1553
1785
  for (let idx = 0; idx < model.items.length; idx++) {
1554
1786
  const i = model.items[idx];
@@ -2089,6 +2321,8 @@
2089
2321
  ${legendItem("--fail", "Failure / error", "dot")}
2090
2322
  ${legendItem("--cancel", "Cancelled", "dash")}
2091
2323
  ${legendItem("--accent", "Pinned time", "line")}
2324
+ ${legendItem("--text-2", "Waits for an event", "dash")}
2325
+ ${legendItem("--text-2", "Call on a ref", "tick")}
2092
2326
  </div>
2093
2327
  <div class="muted" style="margin-top:12px;font-size:11px">Ctrl + wheel to zoom · drag to pan · double-click a bar to focus · ↑/↓ to step</div>
2094
2328
  </div>
@@ -2105,7 +2339,9 @@
2105
2339
  const sw =
2106
2340
  shape === "bar"
2107
2341
  ? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
2108
- : shape === "dash"
2342
+ : shape === "tick"
2343
+ ? `<span style="width:16px;height:3px;border-radius:2px;background:${c}"></span>`
2344
+ : shape === "dash"
2109
2345
  ? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
2110
2346
  : shape === "line"
2111
2347
  ? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",