@hotmeshio/long-tail 0.28.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build/api/files.d.ts +5 -0
  2. package/build/api/files.js +19 -2
  3. package/build/api/knowledge.d.ts +3 -1
  4. package/build/lib/db/schemas/042_knowledge_edition_labels.sql +9 -0
  5. package/build/lib/machines/line.d.ts +3 -0
  6. package/build/lib/machines/line.js +7 -1
  7. package/build/lib/machines/protocol/codec.d.ts +6 -0
  8. package/build/lib/machines/protocol/codec.js +1 -1
  9. package/build/lib/machines/protocol/contract.d.ts +2 -0
  10. package/build/lib/machines/protocol/contract.js +12 -7
  11. package/build/lib/machines/site/mosquitto-conf.d.ts +7 -3
  12. package/build/lib/machines/site/mosquitto-conf.js +3 -8
  13. package/build/lib/machines/site/site-conf.d.ts +4 -1
  14. package/build/lib/machines/site/site-conf.js +5 -1
  15. package/build/lib/machines/transport/nats-bus.d.ts +2 -0
  16. package/build/lib/machines/transport/stream-bus.d.ts +5 -1
  17. package/build/lib/machines/transport/stream-bus.js +12 -4
  18. package/build/lib/machines/transport/streams.d.ts +10 -2
  19. package/build/lib/machines/transport/streams.js +32 -5
  20. package/build/lib/messaging/memory.js +3 -0
  21. package/build/lib/messaging/nats/streams.d.ts +3 -0
  22. package/build/lib/messaging/nats/streams.js +16 -0
  23. package/build/lib/storage/local.d.ts +2 -0
  24. package/build/lib/storage/local.js +3 -0
  25. package/build/lib/storage/s3.d.ts +3 -2
  26. package/build/lib/storage/s3.js +6 -1
  27. package/build/lib/storage/types.d.ts +7 -1
  28. package/build/routes/file-browser.js +3 -1
  29. package/build/routes/knowledge.js +6 -5
  30. package/build/routes/machines.js +33 -0
  31. package/build/services/knowledge/index.d.ts +4 -2
  32. package/build/services/knowledge/index.js +8 -2
  33. package/build/services/knowledge/lookup-cache.d.ts +8 -0
  34. package/build/services/knowledge/lookup-cache.js +30 -3
  35. package/build/services/knowledge/lookup-refs.js +1 -1
  36. package/build/services/knowledge/sql.d.ts +4 -2
  37. package/build/services/knowledge/sql.js +8 -3
  38. package/build/services/machines/config.d.ts +2 -0
  39. package/build/services/machines/config.js +1 -0
  40. package/build/services/machines/contracts.d.ts +18 -0
  41. package/build/services/machines/contracts.js +15 -1
  42. package/build/services/machines/forms.d.ts +26 -1
  43. package/build/services/machines/forms.js +17 -1
  44. package/build/services/machines/gateway/actions.d.ts +13 -1
  45. package/build/services/machines/gateway/admission.d.ts +15 -1
  46. package/build/services/machines/gateway/admission.js +47 -0
  47. package/build/services/machines/gateway/api-escalations.js +4 -0
  48. package/build/services/machines/gateway/dispatcher.d.ts +9 -4
  49. package/build/services/machines/gateway/dispatcher.js +42 -14
  50. package/build/services/machines/gateway/executor.d.ts +2 -0
  51. package/build/services/machines/gateway/executor.js +18 -2
  52. package/build/services/machines/gateway/gateway.d.ts +5 -0
  53. package/build/services/machines/gateway/gateway.js +11 -3
  54. package/build/services/machines/gateway/log.d.ts +2 -0
  55. package/build/services/machines/gateway/log.js +2 -0
  56. package/build/services/machines/gateway/lt-http.js +11 -2
  57. package/build/services/machines/gateway/pacing.js +1 -1
  58. package/build/services/machines/gateway/plan-machine.js +26 -11
  59. package/build/services/machines/gateway/plan-shared.d.ts +5 -3
  60. package/build/services/machines/gateway/plan-shared.js +6 -4
  61. package/build/services/machines/gateway/reply-waiter.d.ts +2 -0
  62. package/build/services/machines/gateway/state.d.ts +24 -1
  63. package/build/services/machines/gateway/state.js +67 -6
  64. package/build/services/machines/gateway/wire-window.d.ts +21 -0
  65. package/build/services/machines/gateway/wire-window.js +61 -0
  66. package/build/services/machines/gateway/wire.js +1 -0
  67. package/build/services/machines/roles.d.ts +5 -4
  68. package/build/services/machines/roles.js +13 -4
  69. package/build/services/machines/select.d.ts +3 -2
  70. package/build/services/machines/select.js +4 -3
  71. package/build/services/machines/surface.d.ts +10 -1
  72. package/build/services/machines/surface.js +20 -4
  73. package/build/system/activities/knowledge.d.ts +4 -0
  74. package/build/system/activities/knowledge.js +15 -0
  75. package/build/system/activities/sql.d.ts +2 -0
  76. package/build/system/activities/sql.js +6 -1
  77. package/build/system/mcp-servers/knowledge.js +4 -2
  78. package/build/tsconfig.tsbuildinfo +1 -1
  79. package/build/types/escalation.d.ts +9 -6
  80. package/build/types/escalation.js +8 -2
  81. package/build/types/machines-view.d.ts +16 -0
  82. package/build/types/machines.d.ts +1 -0
  83. package/build/types/messaging.d.ts +2 -0
  84. package/build/types/startup.d.ts +2 -0
  85. package/docs/dashboard.md +1 -1
  86. package/docs/hitl/lookups.md +6 -4
  87. package/docs/machines.md +25 -16
  88. package/package.json +1 -1
@@ -7,6 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.GatewayState = exports.ROW_END = exports.DISPATCH_STATE = void 0;
10
+ const jobs_1 = require("./jobs");
10
11
  const project_1 = require("./project");
11
12
  const trace_1 = require("./trace");
12
13
  /**
@@ -44,6 +45,10 @@ class GatewayState {
44
45
  this.holdDispatch = false;
45
46
  /** Units at each machine that arrived and have not left: machine → unit → job. */
46
47
  this.held = new Map();
48
+ /** Jobs sent and taken, by machine: job → the row that sent it, kept while the machine holds the job's units. */
49
+ this.sentJobs = new Map();
50
+ /** Jobs whose units left each machine since its last snapshot: they ended, a snapshot without them lost nothing. */
51
+ this.endedJobs = new Map();
47
52
  this.openRows = new Map();
48
53
  this.closedRows = new Map();
49
54
  this.alarms = new Map();
@@ -75,13 +80,17 @@ class GatewayState {
75
80
  this.openRows.set(row.id, existing
76
81
  ? {
77
82
  ...row, dispatch: existing.dispatch, trace: (0, trace_1.mergeTrace)(row.trace, existing.trace), arrivals: existing.arrivals,
78
- dueAt: existing.dueAt, noted: existing.noted ?? row.noted,
83
+ dueAt: existing.dueAt, noted: existing.noted ?? row.noted, awaitingReady: existing.awaitingReady,
79
84
  }
80
85
  : row);
81
86
  }
82
87
  get(id) {
83
88
  return this.openRows.get(id);
84
89
  }
90
+ /** A row that ended, while the gateway still remembers it. */
91
+ closedRow(id) {
92
+ return this.closedRows.get(id);
93
+ }
85
94
  close(id, ended = exports.ROW_END.RESOLVED) {
86
95
  const row = this.openRows.get(id);
87
96
  if (!row)
@@ -115,7 +124,53 @@ class GatewayState {
115
124
  this.held.get(machineId).set(unitId, jobId);
116
125
  }
117
126
  letGo(machineId, unitId) {
118
- this.held.get(machineId)?.delete(unitId);
127
+ const units = this.held.get(machineId);
128
+ const jobId = units?.get(unitId);
129
+ units?.delete(unitId);
130
+ if (jobId === undefined || [...units.values()].includes(jobId))
131
+ return;
132
+ this.sentJobs.get(machineId)?.delete(jobId);
133
+ if (!this.endedJobs.has(machineId))
134
+ this.endedJobs.set(machineId, new Set());
135
+ this.endedJobs.get(machineId).add(jobId);
136
+ }
137
+ /** A job the machine took, remembered by its row while the machine holds the job's units. */
138
+ rememberSent(machineId, jobId, rowId) {
139
+ if (!this.sentJobs.has(machineId))
140
+ this.sentJobs.set(machineId, new Map());
141
+ this.sentJobs.get(machineId).set(jobId, rowId);
142
+ }
143
+ /**
144
+ * After a restart, the jobs the machine lost: those its units are held under
145
+ * or its last snapshot listed, that the new snapshot lacks and no open row
146
+ * will send again. `resend` names the rows remembered for them; `unknown`
147
+ * the jobs nothing is remembered for.
148
+ */
149
+ lostAfterReboot(machineId, listed) {
150
+ const link = this.links.get(machineId);
151
+ const before = new Set(this.heldAt(machineId));
152
+ for (const job of link?.jobs ?? [])
153
+ if (typeof job?.jobId === 'string')
154
+ before.add(job.jobId);
155
+ const ended = this.endedJobs.get(machineId) ?? new Set();
156
+ const sendsAgain = new Set();
157
+ for (const row of this.openRows.values()) {
158
+ if (row.toMachine === machineId)
159
+ for (const job of (0, jobs_1.dispatchJobs)(row.jobId, row.units, this.unitsPerDispatch))
160
+ sendsAgain.add(job.jobId);
161
+ }
162
+ const resend = [];
163
+ const unknown = [];
164
+ for (const jobId of before) {
165
+ if (listed.has(jobId) || ended.has(jobId) || sendsAgain.has(jobId))
166
+ continue;
167
+ const row = this.closedRows.get(this.sentJobs.get(machineId)?.get(jobId) ?? '');
168
+ if (row)
169
+ resend.push({ jobId, row });
170
+ else
171
+ unknown.push(jobId);
172
+ }
173
+ return { resend, unknown };
119
174
  }
120
175
  /** The job a unit arrived under at `machineId`, while it is still there. */
121
176
  heldJob(machineId, unitId) {
@@ -127,14 +182,18 @@ class GatewayState {
127
182
  heldUnitsAt(machineId) {
128
183
  return [...(this.held.get(machineId)?.entries() ?? [])].map(([unitId, jobId]) => ({ unitId, jobId }));
129
184
  }
130
- /** Keep only units whose job the machine still lists (its snapshot). */
185
+ /** Keep only units whose job the machine still lists (its snapshot); a job it no longer lists is over. */
131
186
  keepHeld(machineId, jobIds) {
187
+ this.endedJobs.delete(machineId);
132
188
  const units = this.held.get(machineId);
133
189
  if (!units)
134
190
  return;
135
- for (const [unitId, jobId] of units)
136
- if (!jobIds.has(jobId))
137
- units.delete(unitId);
191
+ for (const [unitId, jobId] of units) {
192
+ if (jobIds.has(jobId))
193
+ continue;
194
+ units.delete(unitId);
195
+ this.sentJobs.get(machineId)?.delete(jobId);
196
+ }
138
197
  }
139
198
  /** Keep a sender's finished record until its row is known, so an arrival cannot close the row without it. */
140
199
  rememberFinish(machineId, unitId, body) {
@@ -268,6 +327,8 @@ class GatewayState {
268
327
  retainedAlarms: this.retainedAlarms, readingRetained: this.readingRetained, takenAt: this.takenAt, progressStep: this.progressStep, holdDispatch: this.holdDispatch,
269
328
  });
270
329
  copyInto(this.held, copy.held, (units) => new Map(units));
330
+ copyInto(this.sentJobs, copy.sentJobs, (jobs) => new Map(jobs));
331
+ copyInto(this.endedJobs, copy.endedJobs, (jobs) => new Set(jobs));
271
332
  copyInto(this.openRows, copy.openRows, (row) => ({ ...row, units: [...row.units], ...(row.noted ? { noted: [...row.noted] } : {}) }));
272
333
  copyInto(this.closedRows, copy.closedRows, (row) => ({ ...row }));
273
334
  copyInto(this.alarms, copy.alarms, (track) => ({ ...track, ...(track.noted ? { noted: [...track.noted] } : {}) }));
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The last wire events of a line, kept by its leader so a client reads the
3
+ * tape over HTTP instead of the event stream. A ring of a fixed size; reads
4
+ * are newest first and may narrow by machine, direction, kind and time.
5
+ */
6
+ import type { WireDirection, WireEvent, WireKind, WireWindowQuery } from '../../../types/machines-view';
7
+ export declare const DEFAULT_WIRE_WINDOW = 500;
8
+ export declare const MAX_WIRE_PAGE = 500;
9
+ export declare class WireWindow {
10
+ readonly size: number;
11
+ private readonly events;
12
+ private head;
13
+ private count;
14
+ constructor(size?: number);
15
+ push(event: WireEvent): void;
16
+ /** Newest first, narrowed by the query, at most `limit` (the window's size when absent). */
17
+ read(query?: WireWindowQuery): WireEvent[];
18
+ get length(): number;
19
+ }
20
+ export declare function isWireDirection(v: unknown): v is WireDirection;
21
+ export declare function isWireKind(v: unknown): v is WireKind;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ /**
3
+ * The last wire events of a line, kept by its leader so a client reads the
4
+ * tape over HTTP instead of the event stream. A ring of a fixed size; reads
5
+ * are newest first and may narrow by machine, direction, kind and time.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.WireWindow = exports.MAX_WIRE_PAGE = exports.DEFAULT_WIRE_WINDOW = void 0;
9
+ exports.isWireDirection = isWireDirection;
10
+ exports.isWireKind = isWireKind;
11
+ exports.DEFAULT_WIRE_WINDOW = 500;
12
+ exports.MAX_WIRE_PAGE = 500;
13
+ class WireWindow {
14
+ constructor(size = exports.DEFAULT_WIRE_WINDOW) {
15
+ this.size = size;
16
+ this.events = [];
17
+ this.head = 0;
18
+ this.count = 0;
19
+ if (!Number.isInteger(size) || size < 1)
20
+ throw new Error(`a wire window keeps at least one event (got ${size})`);
21
+ }
22
+ push(event) {
23
+ this.events[this.head] = event;
24
+ this.head = (this.head + 1) % this.size;
25
+ if (this.count < this.size)
26
+ this.count += 1;
27
+ }
28
+ /** Newest first, narrowed by the query, at most `limit` (the window's size when absent). */
29
+ read(query = {}) {
30
+ const limit = Math.min(Math.max(1, query.limit ?? this.size), exports.MAX_WIRE_PAGE);
31
+ const out = [];
32
+ for (let i = 1; i <= this.count && out.length < limit; i++) {
33
+ const event = this.events[(this.head - i + this.size) % this.size];
34
+ if (matches(event, query))
35
+ out.push(event);
36
+ }
37
+ return out;
38
+ }
39
+ get length() {
40
+ return this.count;
41
+ }
42
+ }
43
+ exports.WireWindow = WireWindow;
44
+ function matches(event, q) {
45
+ if (q.machine && event.machineId !== q.machine)
46
+ return false;
47
+ if (q.direction && event.direction !== q.direction)
48
+ return false;
49
+ if (q.kind && event.kind !== q.kind)
50
+ return false;
51
+ if (q.since && event.at <= q.since)
52
+ return false;
53
+ return true;
54
+ }
55
+ function isWireDirection(v) {
56
+ return v === 'in' || v === 'out';
57
+ }
58
+ const KINDS = ['presence', 'snapshot', 'job', 'alarm', 'telemetry', 'report', 'cmd', 'reply', 'receipt', 'unknown'];
59
+ function isWireKind(v) {
60
+ return typeof v === 'string' && KINDS.includes(v);
61
+ }
@@ -165,6 +165,7 @@ function plannedAction(a) {
165
165
  case 'watchOffline': return { verb: 'none', detail: `watching ${a.machineId} for an offline row` };
166
166
  case 'settleReply': return { verb: 'ack', detail: `${a.status} ${a.cmdId}` };
167
167
  case 'settleSnapshot': return { verb: 'ack', detail: `${a.machineId} holds ${count(a.jobIds.length, 'job')}` };
168
+ case 'unrecoverable': return { verb: 'none', detail: `${a.machineId} lost ${a.jobs.join(', ')}; nothing remembered to send again` };
168
169
  case 'bindAddress': return { verb: 'ack', detail: `bind ${a.machineId} to ${a.address}` };
169
170
  case 'refuseBinding': return { verb: 'refuse', detail: a.reason };
170
171
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The roles a line needs, as code-owned role declarations: one station role
3
3
  * per process (a machine resolves the handoffs into it), the exit role (a
4
- * person receives finished units) and the operator role (a person decides
5
- * failed handoffs and closes alarm and machine rows). Each role owns its
6
- * form; only the operator role enforces it on the server, because handoff
7
- * rows also collect machine records the gateway already validates.
4
+ * person receives finished units), the operator role (a person decides
5
+ * failed handoffs and closes alarm and machine rows) and the quality role
6
+ * (a person decides units finished on hold). Each role owns its form; the
7
+ * operator and quality roles enforce it on the server, since handoff rows
8
+ * also collect machine records the gateway already validates.
8
9
  */
9
10
  import type { LTRoleConfig } from '../../types/startup';
10
11
  import { type LineDef } from '../../lib/machines/line';
@@ -2,10 +2,11 @@
2
2
  /**
3
3
  * The roles a line needs, as code-owned role declarations: one station role
4
4
  * per process (a machine resolves the handoffs into it), the exit role (a
5
- * person receives finished units) and the operator role (a person decides
6
- * failed handoffs and closes alarm and machine rows). Each role owns its
7
- * form; only the operator role enforces it on the server, because handoff
8
- * rows also collect machine records the gateway already validates.
5
+ * person receives finished units), the operator role (a person decides
6
+ * failed handoffs and closes alarm and machine rows) and the quality role
7
+ * (a person decides units finished on hold). Each role owns its form; the
8
+ * operator and quality roles enforce it on the server, since handoff rows
9
+ * also collect machine records the gateway already validates.
9
10
  */
10
11
  Object.defineProperty(exports, "__esModule", { value: true });
11
12
  exports.machineRoleConfigs = machineRoleConfigs;
@@ -44,6 +45,14 @@ function machineRoleConfigs(line) {
44
45
  enforce_schema: true,
45
46
  ...BOARD,
46
47
  },
48
+ {
49
+ role: (0, line_1.qualityRole)(line),
50
+ title: 'Line quality',
51
+ description: 'Units a machine finished on hold: release, rework or scrap.',
52
+ form_schema: forms_1.QUALITY_FORM_SCHEMA,
53
+ enforce_schema: true,
54
+ ...BOARD,
55
+ },
47
56
  ];
48
57
  }
49
58
  /** The host's declarations first; line roles only where the host declared none. */
@@ -16,9 +16,10 @@ export type LineMessaging = Pick<typeof messagingRegistry, 'streams' | 'pubsub'>
16
16
  /**
17
17
  * The gateway's view of every machine on the line. With `nats`, events are read
18
18
  * from Long Tail's messaging streams; MACHINE_SITE_NATS_URL instead names a NATS
19
- * server of the site's own, which the bus connects to itself.
19
+ * server of the site's own, which the bus connects to itself. `siteCodecs` are
20
+ * the codecs of every line on the site, so the streams keep all their subjects.
20
21
  */
21
- export declare function createMachineBus(config: MachineLineConfig, line: LineDef, codec?: LineCodec, messaging?: LineMessaging): MachineBus;
22
+ export declare function createMachineBus(config: MachineLineConfig, line: LineDef, codec?: LineCodec, messaging?: LineMessaging, siteCodecs?: LineCodec[]): MachineBus;
22
23
  /** Long Tail's escalation events for the gateway; `in-process` needs the running instance's callback adapter. */
23
24
  export declare function createEscalationFeed(config: MachineLineConfig, line: LineDef, callbacks?: CallbackEventAdapter): EscalationFeed;
24
25
  /** One MQTT link per simulated machine; machines speak MQTT in every networked mode. */
@@ -33,12 +33,13 @@ function defaultCodec(config, line) {
33
33
  /**
34
34
  * The gateway's view of every machine on the line. With `nats`, events are read
35
35
  * from Long Tail's messaging streams; MACHINE_SITE_NATS_URL instead names a NATS
36
- * server of the site's own, which the bus connects to itself.
36
+ * server of the site's own, which the bus connects to itself. `siteCodecs` are
37
+ * the codecs of every line on the site, so the streams keep all their subjects.
37
38
  */
38
- function createMachineBus(config, line, codec = defaultCodec(config, line), messaging = messaging_1.messagingRegistry) {
39
+ function createMachineBus(config, line, codec = defaultCodec(config, line), messaging = messaging_1.messagingRegistry, siteCodecs = []) {
39
40
  switch (config.transport) {
40
41
  case config_1.MACHINE_TRANSPORT.NATS: {
41
- const where = { mqtt: mqttOf(config), controllerId: (0, line_1.controllerId)(line), siteId: line.siteId, lineId: line.lineId, codec };
42
+ const where = { mqtt: mqttOf(config), controllerId: (0, line_1.controllerId)(line), siteId: line.siteId, lineId: line.lineId, codec, siteCodecs };
42
43
  if (config.siteNatsUrl) {
43
44
  return new nats_bus_1.NatsMachineBus({ natsUrl: config.siteNatsUrl, user: config.siteNatsUser, password: config.siteNatsPassword, ...where });
44
45
  }
@@ -11,7 +11,7 @@ import type { CallbackEventAdapter } from '../../lib/events/callback';
11
11
  import { type LineDef } from '../../lib/machines/line';
12
12
  import { type LineCodec } from '../../lib/machines/protocol';
13
13
  import type { InboundMessage } from '../../lib/machines/transport';
14
- import type { InspectResult, LineSummary, LineView } from '../../types/machines-view';
14
+ import type { InspectResult, LineSummary, LineView, WireEvent, WireWindowQuery } from '../../types/machines-view';
15
15
  import { type MachineLineConfig } from './config';
16
16
  import { type GatewayLog } from './gateway/log';
17
17
  import type { HandoffRowAdapter } from './rows';
@@ -23,8 +23,12 @@ export interface MachineSurfaceOptions {
23
23
  callbacks?: CallbackEventAdapter;
24
24
  log?: GatewayLog;
25
25
  codec?: LineCodec;
26
+ /** A codec for one line, keyed `<siteId>/<lineId>`; every other line uses `codec`. */
27
+ codecs?: Record<string, LineCodec>;
26
28
  rows?: HandoffRowAdapter;
27
29
  }
30
+ /** The codec a line runs on: its own, the surface's, or the generic line protocol. */
31
+ export declare function codecFor(opts: Pick<MachineSurfaceOptions, 'codec' | 'codecs'>, config: MachineLineConfig, line: LineDef): LineCodec;
28
32
  /** A message to dry-run against a line. */
29
33
  export type WireProbe = Pick<InboundMessage, 'topic' | 'payload' | 'retained'>;
30
34
  /** What settings and the API read of the surface. */
@@ -35,6 +39,8 @@ export interface LineSurface {
35
39
  describeLine(siteId: string, lineId: string): LineView | null;
36
40
  /** A dry run of one message; null when this replica is not leading the line. */
37
41
  inspect(siteId: string, lineId: string, probe: WireProbe): InspectResult | null;
42
+ /** The leader's last wire events, newest first; null when this replica is not leading the line. */
43
+ wire(siteId: string, lineId: string, query?: WireWindowQuery): WireEvent[] | null;
38
44
  /** The declared line, or undefined. */
39
45
  findLine(siteId: string, lineId: string): LineDef | undefined;
40
46
  /** The ops a machine-command row on this line may ask for. */
@@ -65,8 +71,11 @@ export declare class MachineSurface implements LineSurface {
65
71
  describeLines(): LineSummary[];
66
72
  describeLine(siteId: string, lineId: string): LineView | null;
67
73
  inspect(siteId: string, lineId: string, probe: WireProbe): InspectResult | null;
74
+ wire(siteId: string, lineId: string, query?: WireWindowQuery): WireEvent[] | null;
68
75
  findLine(siteId: string, lineId: string): LineDef | undefined;
69
76
  commandOps(siteId: string, lineId: string): string[];
77
+ /** The codecs every line on a site runs, so one site's streams keep all their subjects. */
78
+ codecsOnSite(siteId: string): LineCodec[];
70
79
  private begin;
71
80
  private run;
72
81
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.MachineSurface = void 0;
13
+ exports.codecFor = codecFor;
13
14
  exports.surfaceConfig = surfaceConfig;
14
15
  exports.summarizeLine = summarizeLine;
15
16
  exports.commandOpsOf = commandOpsOf;
@@ -23,6 +24,10 @@ const log_1 = require("./gateway/log");
23
24
  const identities_1 = require("./identities");
24
25
  const leadership_1 = require("./leadership");
25
26
  const select_1 = require("./select");
27
+ /** The codec a line runs on: its own, the surface's, or the generic line protocol. */
28
+ function codecFor(opts, config, line) {
29
+ return opts.codecs?.[keyOf(line.siteId, line.lineId)] ?? opts.codec ?? (0, select_1.defaultCodec)(config, line);
30
+ }
26
31
  /** Inside Long Tail the gateway reads escalation events in process. */
27
32
  function surfaceConfig(env) {
28
33
  const transport = env.MACHINE_TRANSPORT;
@@ -51,15 +56,18 @@ function commandOpsOf(codec) {
51
56
  return [...new Set([...protocol_1.CONTROL_OPS, protocol_1.OP.SNAPSHOT, ...(0, protocol_1.declaredRowOps)(codec)])];
52
57
  }
53
58
  class LineRun {
54
- constructor(line, config, opts, log) {
59
+ constructor(line, config, opts, log,
60
+ /** The codecs of every line on this line's site, read when the gateway starts. */
61
+ siteCodecs = () => []) {
55
62
  this.line = line;
56
63
  this.config = config;
57
64
  this.opts = opts;
58
65
  this.log = log;
66
+ this.siteCodecs = siteCodecs;
59
67
  this.ready = false;
60
68
  this.stopped = false;
61
69
  this.leadership = new leadership_1.LineLeadership(`lt-machine-line:${line.siteId}/${line.lineId}`);
62
- this.codec = opts.codec ?? (0, select_1.defaultCodec)(config, line);
70
+ this.codec = codecFor(opts, config, line);
63
71
  }
64
72
  start() {
65
73
  const where = { site: this.line.siteId, line: this.line.lineId };
@@ -96,7 +104,7 @@ class LineRun {
96
104
  };
97
105
  const { config, line, codec } = this;
98
106
  this.gateway = new gateway_1.MachineGateway({
99
- bus: (0, select_1.createMachineBus)(config, line, codec),
107
+ bus: (0, select_1.createMachineBus)(config, line, codec, undefined, this.siteCodecs()),
100
108
  codec,
101
109
  rows: this.opts.rows,
102
110
  feed: (0, select_1.createEscalationFeed)(config, line, this.opts.callbacks),
@@ -108,6 +116,7 @@ class LineRun {
108
116
  topicRoot: config.topicRoot, replyTimeoutMs: config.replyTimeoutMs, dispatchAttempts: config.dispatchAttempts,
109
117
  offlineDebounceMs: config.offlineDebounceMs, dispatchTtlMs: config.dispatchTtlMs, quietMs: config.quietMs,
110
118
  receiptDelayMs: config.receiptDelayMs, lifecycle: config.lifecycle, progressStep: config.progressStep, wireTelemetry: config.wireTelemetry,
119
+ wireWindow: config.wireWindow,
111
120
  },
112
121
  });
113
122
  await this.gateway.start();
@@ -162,6 +171,9 @@ class MachineSurface {
162
171
  inspect(siteId, lineId, probe) {
163
172
  return this.run(siteId, lineId)?.leader()?.inspect(probe) ?? null;
164
173
  }
174
+ wire(siteId, lineId, query = {}) {
175
+ return this.run(siteId, lineId)?.leader()?.window.read(query) ?? null;
176
+ }
165
177
  findLine(siteId, lineId) {
166
178
  return this.run(siteId, lineId)?.line;
167
179
  }
@@ -169,8 +181,12 @@ class MachineSurface {
169
181
  const run = this.run(siteId, lineId);
170
182
  return run ? commandOpsOf(run.codec) : [];
171
183
  }
184
+ /** The codecs every line on a site runs, so one site's streams keep all their subjects. */
185
+ codecsOnSite(siteId) {
186
+ return [...new Set([...this.runs.values()].filter((run) => run.line.siteId === siteId).map((run) => run.codec))];
187
+ }
172
188
  begin(line) {
173
- const run = new LineRun(line, this.config, this.opts, (0, log_1.composeLogs)(this.log, (0, log_1.eventsLog)(line)));
189
+ const run = new LineRun(line, this.config, this.opts, (0, log_1.composeLogs)(this.log, (0, log_1.eventsLog)(line)), () => this.codecsOnSite(line.siteId));
174
190
  this.runs.set(keyOf(line.siteId, line.lineId), run);
175
191
  run.start();
176
192
  }
@@ -4,6 +4,8 @@ export declare function storeKnowledge(args: {
4
4
  data: Record<string, any>;
5
5
  tags?: string[];
6
6
  replace?: boolean;
7
+ /** A name for the edition this write leaves current, which lookup refs may pin. */
8
+ label?: string;
7
9
  }): Promise<{
8
10
  id: string;
9
11
  domain: string;
@@ -75,6 +77,8 @@ export declare function appendKnowledge(args: {
75
77
  key: string;
76
78
  path: string;
77
79
  value: any;
80
+ /** A name for the edition this write leaves current, which lookup refs may pin. */
81
+ label?: string;
78
82
  }): Promise<{
79
83
  id: string;
80
84
  domain: string;
@@ -23,10 +23,23 @@ async function withClient(fn) {
23
23
  await client.end();
24
24
  }
25
25
  }
26
+ /** Name the entry's current edition. A label already naming another edition of the entry is refused. */
27
+ async function labelCurrentEdition(client, domain, key, version, label) {
28
+ try {
29
+ await client.query(sql_1.LABEL_KNOWLEDGE_VERSION, [domain, key, label, version]);
30
+ }
31
+ catch (err) {
32
+ if (err?.code === '23505')
33
+ throw new Error(`label "${label}" already names another edition of ${domain}/${key}`);
34
+ throw err;
35
+ }
36
+ }
26
37
  async function storeKnowledge(args) {
27
38
  return withClient(async (client) => {
28
39
  const sql = args.replace ? sql_1.REPLACE_KNOWLEDGE : sql_1.UPSERT_KNOWLEDGE;
29
40
  const { rows } = await client.query(sql, [args.domain, args.key, JSON.stringify(args.data), args.tags || []]);
41
+ if (args.label)
42
+ await labelCurrentEdition(client, args.domain, args.key, rows[0].current_version, args.label);
30
43
  const result = { ...rows[0], updated_at: rows[0].updated_at.toISOString() };
31
44
  (0, publish_1.publishKnowledgeEvent)({ type: 'knowledge.stored', domain: args.domain, key: args.key });
32
45
  return result;
@@ -173,6 +186,8 @@ async function appendKnowledge(args) {
173
186
  initData = { [pathParts[i]]: initData };
174
187
  }
175
188
  const { rows } = await client.query(sql_1.APPEND_KNOWLEDGE, [args.domain, args.key, JSON.stringify(initData), pathArray, valueJson]);
189
+ if (args.label)
190
+ await labelCurrentEdition(client, args.domain, args.key, rows[0].current_version, args.label);
176
191
  return { ...rows[0], updated_at: rows[0].updated_at.toISOString() };
177
192
  });
178
193
  }
@@ -6,3 +6,5 @@ export declare const LIST_DOMAINS = "\n SELECT domain, COUNT(*)::int AS count,
6
6
  export declare const SET_KNOWLEDGE_FIELD = "\n WITH upserted AS (\n INSERT INTO lt_knowledge (domain, key, data, tags)\n VALUES ($1, $2, $3::jsonb, $4)\n ON CONFLICT (domain, key) DO UPDATE SET\n data = jsonb_set(lt_knowledge.data, $5::text[], $6::jsonb, true),\n tags = ARRAY(SELECT DISTINCT unnest(lt_knowledge.tags || EXCLUDED.tags)),\n current_version = CASE\n WHEN jsonb_set(lt_knowledge.data, $5::text[], $6::jsonb, true) IS DISTINCT FROM lt_knowledge.data\n THEN lt_knowledge.current_version + 1\n ELSE lt_knowledge.current_version\n END\n RETURNING id, domain, key, data, tags, current_version, (xmax = 0) AS created, updated_at\n ), snapshot AS (\n INSERT INTO lt_knowledge_versions (domain, key, version, data, tags)\n SELECT domain, key, current_version, data, tags FROM upserted\n ON CONFLICT (domain, key, version) DO NOTHING\n )\n SELECT id, domain, key, current_version, created, updated_at FROM upserted";
7
7
  export declare const REMOVE_KNOWLEDGE_FIELD = "\n WITH updated AS (\n UPDATE lt_knowledge\n SET data = data #- $3::text[],\n current_version = CASE\n WHEN data #- $3::text[] IS DISTINCT FROM data\n THEN current_version + 1\n ELSE current_version\n END\n WHERE domain = $1 AND key = $2\n RETURNING id, domain, key, data, tags, current_version, updated_at\n ), snapshot AS (\n INSERT INTO lt_knowledge_versions (domain, key, version, data, tags)\n SELECT domain, key, current_version, data, tags FROM updated\n ON CONFLICT (domain, key, version) DO NOTHING\n )\n SELECT id, domain, key, current_version, updated_at FROM updated";
8
8
  export declare const APPEND_KNOWLEDGE = "\n WITH upserted AS (\n INSERT INTO lt_knowledge (domain, key, data)\n VALUES ($1, $2, $3::jsonb)\n ON CONFLICT (domain, key) DO UPDATE SET\n data = CASE\n WHEN lt_knowledge.data #> $4::text[] IS NULL\n THEN jsonb_set(lt_knowledge.data, $4::text[], jsonb_build_array($5::jsonb))\n ELSE jsonb_set(lt_knowledge.data, $4::text[], (lt_knowledge.data #> $4::text[]) || jsonb_build_array($5::jsonb))\n END,\n current_version = lt_knowledge.current_version + 1\n RETURNING id, domain, key, data, tags, current_version, updated_at\n ), snapshot AS (\n INSERT INTO lt_knowledge_versions (domain, key, version, data, tags)\n SELECT domain, key, current_version, data, tags FROM upserted\n ON CONFLICT (domain, key, version) DO NOTHING\n )\n SELECT id, domain, key, current_version, updated_at FROM upserted";
9
+ /** Name an edition: the label a lookup ref may pin instead of the number. A label names one edition of an entry. */
10
+ export declare const LABEL_KNOWLEDGE_VERSION = "\n UPDATE lt_knowledge_versions SET label = $3\n WHERE domain = $1 AND key = $2 AND version = $4\n RETURNING version";
@@ -8,7 +8,7 @@
8
8
  // no-op writes snapshot-free). Tags-only changes never bump — the version is
9
9
  // the data's identity.
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.APPEND_KNOWLEDGE = exports.REMOVE_KNOWLEDGE_FIELD = exports.SET_KNOWLEDGE_FIELD = exports.LIST_DOMAINS = exports.DELETE_KNOWLEDGE = exports.GET_KNOWLEDGE = exports.REPLACE_KNOWLEDGE = exports.UPSERT_KNOWLEDGE = void 0;
11
+ exports.LABEL_KNOWLEDGE_VERSION = exports.APPEND_KNOWLEDGE = exports.REMOVE_KNOWLEDGE_FIELD = exports.SET_KNOWLEDGE_FIELD = exports.LIST_DOMAINS = exports.DELETE_KNOWLEDGE = exports.GET_KNOWLEDGE = exports.REPLACE_KNOWLEDGE = exports.UPSERT_KNOWLEDGE = void 0;
12
12
  // Upsert knowledge entry. On conflict (domain+key), merges JSONB data at
13
13
  // the top level — new keys are added, existing keys are overwritten.
14
14
  //
@@ -130,3 +130,8 @@ exports.APPEND_KNOWLEDGE = `
130
130
  ON CONFLICT (domain, key, version) DO NOTHING
131
131
  )
132
132
  SELECT id, domain, key, current_version, updated_at FROM upserted`;
133
+ /** Name an edition: the label a lookup ref may pin instead of the number. A label names one edition of an entry. */
134
+ exports.LABEL_KNOWLEDGE_VERSION = `
135
+ UPDATE lt_knowledge_versions SET label = $3
136
+ WHERE domain = $1 AND key = $2 AND version = $4
137
+ RETURNING version`;
@@ -45,11 +45,12 @@ const storeSchema = zod_1.z.object({
45
45
  field: zod_1.z.string().optional().describe('RECOMMENDED. Names a specific field to set within the entry (e.g. "2026-05-09", "url", "score"). Using field is additive — it merges into existing data without clobbering other fields. Without field, the entire data object is merged at the top level, which can overwrite existing fields with the same keys. Always use field when adding to an entry that may already have data.'),
46
46
  data: zod_1.z.any().describe('The value to store. When field is provided, this can be any type (string, number, boolean, object, array) and is stored as { [field]: data }. When field is omitted, data must be a JSON object whose keys merge into existing data.'),
47
47
  tags: zod_1.z.array(zod_1.z.string()).optional().describe('Categorization tags (unioned on upsert)'),
48
+ label: zod_1.z.string().min(1).optional().describe('A name for the edition this write leaves current; lookup refs may pin it as their version'),
48
49
  });
49
50
  const getSchema = zod_1.z.object({
50
51
  domain: zod_1.z.string().describe('Knowledge domain'),
51
52
  key: zod_1.z.string().describe('Document key'),
52
- version: zod_1.z.number().int().min(1).optional().describe('Fetch this immutable edition instead of the live entry'),
53
+ version: zod_1.z.union([zod_1.z.number().int().min(1), zod_1.z.string().min(1)]).optional().describe('Fetch this immutable edition, by number or by the label it carries, instead of the live entry'),
53
54
  });
54
55
  const listVersionsSchema = zod_1.z.object({
55
56
  domain: zod_1.z.string().describe('Knowledge domain'),
@@ -90,6 +91,7 @@ const appendSchema = zod_1.z.object({
90
91
  key: zod_1.z.string().describe('Document key'),
91
92
  path: zod_1.z.string().describe('JSONB path to array field (e.g. "screenshots" or "analysis.variations")'),
92
93
  value: zod_1.z.any().refine((v) => v !== undefined, 'value is required').describe('Value to append to the array'),
94
+ label: zod_1.z.string().min(1).optional().describe('A name for the edition this write leaves current; lookup refs may pin it as their version'),
93
95
  });
94
96
  function registerTools(server) {
95
97
  server.registerTool('store_knowledge', {
@@ -124,7 +126,7 @@ function registerTools(server) {
124
126
  dataObj = args.data;
125
127
  }
126
128
  const result = await knowledge.storeKnowledge({
127
- domain: args.domain, key: args.key, data: dataObj, tags: args.tags,
129
+ domain: args.domain, key: args.key, data: dataObj, tags: args.tags, label: args.label,
128
130
  });
129
131
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
130
132
  });