@hotmeshio/long-tail 0.28.0 → 0.28.2

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 (92) 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/events/publish.js +3 -2
  6. package/build/lib/machines/line.d.ts +3 -0
  7. package/build/lib/machines/line.js +7 -1
  8. package/build/lib/machines/protocol/codec.d.ts +6 -0
  9. package/build/lib/machines/protocol/codec.js +1 -1
  10. package/build/lib/machines/protocol/contract.d.ts +2 -0
  11. package/build/lib/machines/protocol/contract.js +12 -7
  12. package/build/lib/machines/site/mosquitto-conf.d.ts +7 -3
  13. package/build/lib/machines/site/mosquitto-conf.js +3 -8
  14. package/build/lib/machines/site/site-conf.d.ts +4 -1
  15. package/build/lib/machines/site/site-conf.js +5 -1
  16. package/build/lib/machines/transport/nats-bus.d.ts +4 -0
  17. package/build/lib/machines/transport/stream-bus.d.ts +7 -1
  18. package/build/lib/machines/transport/stream-bus.js +20 -6
  19. package/build/lib/machines/transport/streams.d.ts +11 -2
  20. package/build/lib/machines/transport/streams.js +33 -5
  21. package/build/lib/messaging/memory.js +10 -3
  22. package/build/lib/messaging/nats/permissions.js +1 -0
  23. package/build/lib/messaging/nats/streams.d.ts +21 -4
  24. package/build/lib/messaging/nats/streams.js +71 -10
  25. package/build/lib/storage/local.d.ts +2 -0
  26. package/build/lib/storage/local.js +3 -0
  27. package/build/lib/storage/s3.d.ts +3 -2
  28. package/build/lib/storage/s3.js +6 -1
  29. package/build/lib/storage/types.d.ts +7 -1
  30. package/build/routes/file-browser.js +3 -1
  31. package/build/routes/knowledge.js +6 -5
  32. package/build/routes/machines.js +33 -0
  33. package/build/services/knowledge/index.d.ts +4 -2
  34. package/build/services/knowledge/index.js +8 -2
  35. package/build/services/knowledge/lookup-cache.d.ts +8 -0
  36. package/build/services/knowledge/lookup-cache.js +30 -3
  37. package/build/services/knowledge/lookup-refs.js +1 -1
  38. package/build/services/knowledge/sql.d.ts +4 -2
  39. package/build/services/knowledge/sql.js +8 -3
  40. package/build/services/machines/config.d.ts +2 -0
  41. package/build/services/machines/config.js +1 -0
  42. package/build/services/machines/contracts.d.ts +18 -0
  43. package/build/services/machines/contracts.js +15 -1
  44. package/build/services/machines/forms.d.ts +26 -1
  45. package/build/services/machines/forms.js +17 -1
  46. package/build/services/machines/gateway/actions.d.ts +13 -1
  47. package/build/services/machines/gateway/admission.d.ts +15 -1
  48. package/build/services/machines/gateway/admission.js +47 -0
  49. package/build/services/machines/gateway/api-escalations.js +4 -0
  50. package/build/services/machines/gateway/dispatcher.d.ts +9 -4
  51. package/build/services/machines/gateway/dispatcher.js +42 -14
  52. package/build/services/machines/gateway/executor.d.ts +2 -0
  53. package/build/services/machines/gateway/executor.js +18 -2
  54. package/build/services/machines/gateway/gateway.d.ts +5 -0
  55. package/build/services/machines/gateway/gateway.js +11 -3
  56. package/build/services/machines/gateway/log.d.ts +3 -0
  57. package/build/services/machines/gateway/log.js +3 -0
  58. package/build/services/machines/gateway/lt-http.js +11 -2
  59. package/build/services/machines/gateway/pacing.js +1 -1
  60. package/build/services/machines/gateway/plan-machine.js +26 -11
  61. package/build/services/machines/gateway/plan-shared.d.ts +5 -3
  62. package/build/services/machines/gateway/plan-shared.js +6 -4
  63. package/build/services/machines/gateway/reply-waiter.d.ts +2 -0
  64. package/build/services/machines/gateway/state.d.ts +24 -1
  65. package/build/services/machines/gateway/state.js +67 -6
  66. package/build/services/machines/gateway/wire-window.d.ts +21 -0
  67. package/build/services/machines/gateway/wire-window.js +61 -0
  68. package/build/services/machines/gateway/wire.js +1 -0
  69. package/build/services/machines/leadership.d.ts +2 -0
  70. package/build/services/machines/leadership.js +7 -1
  71. package/build/services/machines/roles.d.ts +5 -4
  72. package/build/services/machines/roles.js +13 -4
  73. package/build/services/machines/select.d.ts +3 -2
  74. package/build/services/machines/select.js +4 -3
  75. package/build/services/machines/surface.d.ts +10 -1
  76. package/build/services/machines/surface.js +27 -5
  77. package/build/system/activities/knowledge.d.ts +4 -0
  78. package/build/system/activities/knowledge.js +15 -0
  79. package/build/system/activities/sql.d.ts +2 -0
  80. package/build/system/activities/sql.js +6 -1
  81. package/build/system/mcp-servers/knowledge.js +4 -2
  82. package/build/tsconfig.tsbuildinfo +1 -1
  83. package/build/types/escalation.d.ts +9 -6
  84. package/build/types/escalation.js +8 -2
  85. package/build/types/machines-view.d.ts +16 -0
  86. package/build/types/machines.d.ts +1 -0
  87. package/build/types/messaging.d.ts +7 -1
  88. package/build/types/startup.d.ts +2 -0
  89. package/docs/dashboard.md +1 -1
  90. package/docs/hitl/lookups.md +6 -4
  91. package/docs/machines.md +26 -17
  92. package/package.json +1 -1
@@ -10,9 +10,14 @@ export declare function getFileMetadata(input: {
10
10
  export declare function deleteFile(input: {
11
11
  filePath: string;
12
12
  }): Promise<LTApiResult>;
13
+ /**
14
+ * Stores the bytes with their SHA-256. A caller that names the digest it
15
+ * expects gets 422 when the bytes received differ, and nothing is stored.
16
+ */
13
17
  export declare function uploadFile(input: {
14
18
  path: string;
15
19
  buffer: Buffer;
20
+ sha256?: string;
16
21
  }): Promise<LTApiResult>;
17
22
  export declare function generateSignedUrl(input: {
18
23
  filePath: string;
@@ -5,6 +5,7 @@ exports.getFileMetadata = getFileMetadata;
5
5
  exports.deleteFile = deleteFile;
6
6
  exports.uploadFile = uploadFile;
7
7
  exports.generateSignedUrl = generateSignedUrl;
8
+ const crypto_1 = require("crypto");
8
9
  const storage_1 = require("../lib/storage");
9
10
  const mime_1 = require("../lib/storage/mime");
10
11
  const publish_1 = require("../lib/events/publish");
@@ -46,9 +47,25 @@ async function deleteFile(input) {
46
47
  return { status: 500, error: err.message };
47
48
  }
48
49
  }
50
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
51
+ /**
52
+ * Stores the bytes with their SHA-256. A caller that names the digest it
53
+ * expects gets 422 when the bytes received differ, and nothing is stored.
54
+ */
49
55
  async function uploadFile(input) {
56
+ if (input.sha256 !== undefined && !SHA256_HEX.test(input.sha256)) {
57
+ return { status: 400, error: 'sha256 must be 64 hex characters' };
58
+ }
59
+ const sha256 = (0, crypto_1.createHash)('sha256').update(input.buffer).digest('hex');
60
+ if (input.sha256 && input.sha256.toLowerCase() !== sha256) {
61
+ return {
62
+ status: 422,
63
+ error: 'sha256 does not match the bytes received',
64
+ data: { error: 'sha256 does not match the bytes received', path: input.path, expected: input.sha256.toLowerCase(), actual: sha256 },
65
+ };
66
+ }
50
67
  try {
51
- const result = await (0, storage_1.getStorageBackend)().write(input.path, input.buffer);
68
+ const result = await (0, storage_1.getStorageBackend)().write(input.path, input.buffer, { sha256 });
52
69
  const contentType = (0, mime_1.mimeFromPath)(input.path);
53
70
  (0, publish_1.publishFileEvent)({
54
71
  type: 'file.stored',
@@ -58,7 +75,7 @@ async function uploadFile(input) {
58
75
  });
59
76
  return {
60
77
  status: 200,
61
- data: { path: input.path, size: result.size, content_type: contentType },
78
+ data: { path: input.path, size: result.size, content_type: contentType, sha256 },
62
79
  };
63
80
  }
64
81
  catch (err) {
@@ -14,7 +14,8 @@ export declare function getEntry(input: {
14
14
  export declare function getEntryVersion(input: {
15
15
  domain: string;
16
16
  key: string;
17
- version: number;
17
+ /** An edition number, or the label an edition carries. */
18
+ version: number | string;
18
19
  }): Promise<LTApiResult>;
19
20
  export declare function listEntryVersions(input: {
20
21
  domain: string;
@@ -26,6 +27,7 @@ export declare function storeEntry(input: {
26
27
  data: Record<string, any>;
27
28
  tags?: string[];
28
29
  replace?: boolean;
30
+ label?: string;
29
31
  }): Promise<LTApiResult>;
30
32
  export declare function setField(input: {
31
33
  domain: string;
@@ -0,0 +1,9 @@
1
+ -- An edition may carry a label: a name a lookup ref pins instead of the
2
+ -- version number (a catalog revision, say). A label names one edition of an
3
+ -- entry; editions without one are reached by number or as current.
4
+ ALTER TABLE lt_knowledge_versions
5
+ ADD COLUMN IF NOT EXISTS label TEXT;
6
+
7
+ CREATE UNIQUE INDEX IF NOT EXISTS lt_knowledge_versions_label_idx
8
+ ON lt_knowledge_versions (domain, key, label)
9
+ WHERE label IS NOT NULL;
@@ -18,12 +18,13 @@ const index_1 = require("./index");
18
18
  const matching_1 = require("./matching");
19
19
  const logger_1 = require("../logger");
20
20
  /**
21
- * Fire-and-forget publish helper. Swallows errors (best-effort).
21
+ * Fire-and-forget publish helper. Swallows errors (best-effort). The publish
22
+ * is noted at debug: a line's wire can carry many events a second.
22
23
  */
23
24
  function fireAndForget(event) {
24
25
  if (!index_1.eventRegistry.hasAdapters)
25
26
  return Promise.resolve();
26
- logger_1.loggerRegistry.info(`[lt-pub] ${event.type} ${event.workflowId || ''} ${event.escalationId || event.taskId || ''}`);
27
+ logger_1.loggerRegistry.debug(`[lt-pub] ${event.type} ${event.workflowId || ''} ${event.escalationId || event.taskId || ''}`);
27
28
  return index_1.eventRegistry.publish(event).catch(() => { });
28
29
  }
29
30
  /**
@@ -35,6 +35,7 @@ export declare function controllerId(line: LineDef): string;
35
35
  export declare function stationRole(process: string): string;
36
36
  export declare const DEFAULT_EXIT_ROLE = "line-exit";
37
37
  export declare const DEFAULT_OPERATOR_ROLE = "line-operator";
38
+ export declare const DEFAULT_QUALITY_ROLE = "line-quality";
38
39
  /** The Long Tail role a machine resolves handoffs under. */
39
40
  export declare function machineRole(def: Pick<MachineDef, 'role' | 'process'>): string;
40
41
  /** Every station role on the line, once each, in route order. */
@@ -45,3 +46,5 @@ export declare function exitRole(line: LineDef): string;
45
46
  export declare function receiverRoles(line: LineDef): string[];
46
47
  /** The role of the person who decides failed handoffs and closes alarm and machine rows. */
47
48
  export declare function operatorRole(line: LineDef): string;
49
+ /** The role of the people who decide units a machine finished on hold: release, rework or scrap. */
50
+ export declare function qualityRole(line: LineDef): string;
@@ -5,7 +5,7 @@
5
5
  * several separated by commas.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.DEFAULT_OPERATOR_ROLE = exports.DEFAULT_EXIT_ROLE = exports.CARRIER_ROLE = exports.ADDRESSING = void 0;
8
+ exports.DEFAULT_QUALITY_ROLE = exports.DEFAULT_OPERATOR_ROLE = exports.DEFAULT_EXIT_ROLE = exports.CARRIER_ROLE = exports.ADDRESSING = void 0;
9
9
  exports.learnsAddress = learnsAddress;
10
10
  exports.validateLine = validateLine;
11
11
  exports.loadLine = loadLine;
@@ -20,6 +20,7 @@ exports.stationRoles = stationRoles;
20
20
  exports.exitRole = exitRole;
21
21
  exports.receiverRoles = receiverRoles;
22
22
  exports.operatorRole = operatorRole;
23
+ exports.qualityRole = qualityRole;
23
24
  const fs_1 = require("fs");
24
25
  const protocol_1 = require("./protocol");
25
26
  /** How a line knows its machines' addresses (`LineDef.addressing`). */
@@ -109,6 +110,7 @@ function stationRole(process) {
109
110
  }
110
111
  exports.DEFAULT_EXIT_ROLE = 'line-exit';
111
112
  exports.DEFAULT_OPERATOR_ROLE = 'line-operator';
113
+ exports.DEFAULT_QUALITY_ROLE = 'line-quality';
112
114
  /** The Long Tail role a machine resolves handoffs under. */
113
115
  function machineRole(def) {
114
116
  return def.role ?? stationRole(def.process);
@@ -129,3 +131,7 @@ function receiverRoles(line) {
129
131
  function operatorRole(line) {
130
132
  return line.roles?.operator ?? exports.DEFAULT_OPERATOR_ROLE;
131
133
  }
134
+ /** The role of the people who decide units a machine finished on hold: release, rework or scrap. */
135
+ function qualityRole(line) {
136
+ return line.roles?.quality ?? exports.DEFAULT_QUALITY_ROLE;
137
+ }
@@ -20,6 +20,12 @@ export interface MachineCommand {
20
20
  attempt?: number;
21
21
  /** The command may have reached the machine before: sent earlier, or its row predates this gateway's takeover. */
22
22
  retry?: boolean;
23
+ /**
24
+ * When the command was issued, ISO 8601: the same on every send of this `cmdId`, by this gateway or the
25
+ * next, so a protocol that stamps a time repeats the first attempt. A dispatch or a command row takes the
26
+ * start of its validity round; any other command the time of its first send.
27
+ */
28
+ issuedAt?: string;
23
29
  }
24
30
  /** MQTT filters under each machine's topic, e.g. `job` or `alarm/+`. */
25
31
  export interface CodecChannels {
@@ -83,7 +83,7 @@ function genericCodec(opts) {
83
83
  return receiptFrom === exports.RECEIPT_FROM.ARRIVED ? [seen] : inferReceipt(seen, receiptFrom);
84
84
  },
85
85
  encode(ident, command) {
86
- return [{ topic: (0, topics_1.machineTopic)(opts.root, ident, constants_1.CHANNEL.CMD), payload: JSON.stringify((0, envelope_1.buildEnvelope)(ident, command)) }];
86
+ return [{ topic: (0, topics_1.machineTopic)(opts.root, ident, constants_1.CHANNEL.CMD), payload: JSON.stringify((0, envelope_1.buildEnvelope)(ident, command, { at: command.issuedAt })) }];
87
87
  },
88
88
  };
89
89
  }
@@ -17,3 +17,5 @@ export interface Inspection {
17
17
  }
18
18
  /** Validate one message. `retained` is the publisher's flag when the transport reports it. */
19
19
  export declare function inspectMessage(root: string, topic: string, raw: string | Buffer, retained?: boolean): Inspection;
20
+ /** A receiver's report that it could read no id at all: a transfer failure on the read, naming no unit and no carrier. */
21
+ export declare function isUnreadableRead(b: Record<string, any>): boolean;
@@ -10,6 +10,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.inspectMessage = inspectMessage;
13
+ exports.isUnreadableRead = isUnreadableRead;
13
14
  const ajv_1 = __importDefault(require("ajv"));
14
15
  const constants_1 = require("./constants");
15
16
  const envelope_1 = require("./envelope");
@@ -41,7 +42,7 @@ const BODY = {
41
42
  jobs: { type: 'array', items: shape({ jobId: STR, units: { type: 'array', items: STR }, status: STR }) },
42
43
  alarms: { type: 'array', items: STR },
43
44
  cycles: INT0,
44
- }),
45
+ }, ['bootId', 'software', 'recipe', 'cycles']),
45
46
  [topics_1.MESSAGE_KIND.JOB]: shape({
46
47
  event: oneOf(constants_1.JOB_EVENT), jobId: STR, unitId: STR, orderRef: STR, recipe: RECIPE, carrierId: STR_N, peer: STR_N,
47
48
  result: oneOf(constants_1.JOB_RESULT), reason: STR_N,
@@ -52,15 +53,15 @@ const BODY = {
52
53
  materials: { type: 'array', items: shape({ material: STR, lot: STR }) },
53
54
  startedAt: TS, endedAt: TS, durationMs: INT0, failure: oneOf(constants_1.TRANSFER_FAILURE),
54
55
  percent: { type: 'number', minimum: 0, maximum: 100 }, remainingSec: INT0, stage: STR, detail: { type: 'object' },
55
- }, ['jobId', 'unitId', 'peer', 'result', 'reason', 'measurements', 'materials', 'startedAt', 'endedAt', 'durationMs', 'failure', 'percent', 'remainingSec', 'stage', 'detail']),
56
+ }, ['jobId', 'unitId', 'orderRef', 'peer', 'result', 'reason', 'measurements', 'materials', 'startedAt', 'endedAt', 'durationMs', 'failure', 'percent', 'remainingSec', 'stage', 'detail']),
56
57
  [topics_1.MESSAGE_KIND.ALARM]: shape({
57
58
  alarmId: STR, code: STR, level: oneOf(constants_1.LEVEL), text: STR, active: BOOL,
58
59
  raisedAt: TS, clearedAt: TS_N, clearedBy: STR_N, ackedBy: STR_N, ackedAt: TS_N,
59
- zone: STR_N, autoClears: BOOL, action: STR_N, jobId: STR_N,
60
- }),
60
+ zone: STR_N, autoClears: BOOL, action: STR_N, jobId: STR_N, needsPerson: BOOL,
61
+ }, ['needsPerson']),
61
62
  [topics_1.MESSAGE_KIND.TELEMETRY]: shape({ readings: { type: 'object', additionalProperties: NUM } }),
62
63
  [topics_1.MESSAGE_KIND.CMD]: shape({ cmdId: STR, op: oneOf(constants_1.OP), args: { type: 'object' } }),
63
- [topics_1.MESSAGE_KIND.REPLY]: shape({ cmdId: STR, status: oneOf(constants_1.REPLY_STATUS), reason: STR, code: STR }, ['code']),
64
+ [topics_1.MESSAGE_KIND.REPLY]: shape({ cmdId: STR, status: oneOf(constants_1.REPLY_STATUS), reason: STR, code: STR, preparing: BOOL }, ['code', 'preparing']),
64
65
  [topics_1.MESSAGE_KIND.REPORT]: { type: 'object' },
65
66
  };
66
67
  const ajv = new ajv_1.default({ allErrors: true, strict: false });
@@ -95,6 +96,10 @@ function inspectMessage(root, topic, raw, retained) {
95
96
  }
96
97
  /** Job events a receiver may report naming only the carrier it scanned. */
97
98
  const CARRIER_EVENTS = [constants_1.JOB_EVENT.ARRIVED, constants_1.JOB_EVENT.TRANSFER_FAILED];
99
+ /** A receiver's report that it could read no id at all: a transfer failure on the read, naming no unit and no carrier. */
100
+ function isUnreadableRead(b) {
101
+ return b.event === constants_1.JOB_EVENT.TRANSFER_FAILED && b.failure === constants_1.TRANSFER_FAILURE.UNREADABLE_ID && b.unitId === undefined && typeof b.carrierId !== 'string';
102
+ }
98
103
  function crossFieldRules(parsed, env, retained) {
99
104
  const out = [];
100
105
  const b = env.body ?? {};
@@ -105,8 +110,8 @@ function crossFieldRules(parsed, env, retained) {
105
110
  if (kind === topics_1.MESSAGE_KIND.ALARM && b.alarmId !== key)
106
111
  out.push('alarmId disagrees with the topic');
107
112
  if (kind === topics_1.MESSAGE_KIND.JOB) {
108
- // A scan at a receiver may read only the carrier; every other job event names its unit and job.
109
- const byCarrier = CARRIER_EVENTS.includes(b.event) && typeof b.carrierId === 'string';
113
+ // A scan at a receiver may read only the carrier, or nothing at all when the read failed; every other job event names its unit and job.
114
+ const byCarrier = CARRIER_EVENTS.includes(b.event) && (typeof b.carrierId === 'string' || isUnreadableRead(b));
110
115
  if (b.unitId === undefined && !byCarrier)
111
116
  out.push(`${b.event} without a unitId`);
112
117
  if (b.jobId === undefined && !byCarrier)
@@ -7,9 +7,10 @@
7
7
  * NATS, where JetStream keeps them.
8
8
  */
9
9
  import { type LineCodec } from '../protocol/codec';
10
- import { type Lines } from './site-conf';
10
+ import { type LineDef } from '../line';
11
+ import { type Lines, type TopicRoot } from './site-conf';
11
12
  export interface MosquittoConfOptions {
12
- root: string;
13
+ root: TopicRoot;
13
14
  /** Certificate directory as the broker sees it. */
14
15
  certDir: string;
15
16
  /** Where the site NATS MQTT listener is, host:port. */
@@ -25,4 +26,7 @@ export declare function renderMosquittoConf(lines: Lines, opts: MosquittoConfOpt
25
26
  * the channels under its own name (%u), so its certificate's common name must be its address. On a line
26
27
  * that also declares addresses, a certificate named for a declared address is denied that station's channels.
27
28
  */
28
- export declare function renderMosquittoAcl(lines: Lines, codec: Pick<LineCodec, 'root' | 'channels' | 'topicFor'>): string;
29
+ export type AclCodec = Pick<LineCodec, 'root' | 'channels' | 'topicFor'>;
30
+ /** One codec for every line, or the codec of each line. */
31
+ export type AclCodecs = AclCodec | ((line: LineDef) => AclCodec);
32
+ export declare function renderMosquittoAcl(lines: Lines, codec: AclCodecs): string;
@@ -26,7 +26,7 @@ remote_clientid ${(0, site_conf_1.bridgeId)(line)}
26
26
  cleansession false
27
27
  try_private false
28
28
  notifications false
29
- topic ${opts.root}/${line.siteId}/${line.lineId}/# out 1
29
+ topic ${(0, site_conf_1.rootOf)(opts.root, line)}/${line.siteId}/${line.lineId}/# out 1
30
30
  `;
31
31
  }
32
32
  /** The broker for one line or several: one listener, one bridge per line. */
@@ -53,17 +53,12 @@ max_packet_size ${opts.maxPacketBytes ?? 262_144}
53
53
 
54
54
  ${list.map((line) => bridgeBlock(line, opts)).join('\n')}`;
55
55
  }
56
- /**
57
- * The codec's channels decide the topics: a machine writes its events and telemetry and reads its commands.
58
- * A station whose address the line learns has no user of its own: `pattern` rules give every certificate
59
- * the channels under its own name (%u), so its certificate's common name must be its address. On a line
60
- * that also declares addresses, a certificate named for a declared address is denied that station's channels.
61
- */
62
56
  function renderMosquittoAcl(lines, codec) {
63
57
  const list = (0, site_conf_1.linesOf)(lines);
64
58
  if (!list.length)
65
59
  throw new Error('renderMosquittoAcl needs at least one line');
66
- return `# Rendered from the line definition by site/setup.ts. Do not edit by hand.\n\n${list.map((line) => aclRules(line, codec)).join('\n\n')}\n`;
60
+ const codecOf = (line) => (typeof codec === 'function' ? codec(line) : codec);
61
+ return `# Rendered from the line definition by site/setup.ts. Do not edit by hand.\n\n${list.map((line) => aclRules(line, codecOf(line))).join('\n\n')}\n`;
67
62
  }
68
63
  /** One line's rules: its stations, its controller, and the shadows a learning line needs. */
69
64
  function aclRules(line, codec) {
@@ -8,8 +8,11 @@
8
8
  * site NATS stands alone; Long Tail's escalation events stay inside Long Tail.
9
9
  */
10
10
  import { type LineDef } from '../line';
11
+ /** One topic root for every line, or a root per line (lines on different codecs). */
12
+ export type TopicRoot = string | ((line: LineDef) => string);
13
+ export declare function rootOf(root: TopicRoot, line: LineDef): string;
11
14
  export interface SiteConfOptions {
12
- root: string;
15
+ root: TopicRoot;
13
16
  gatewayUser: string;
14
17
  gatewayPassword: string;
15
18
  certDir: string;
@@ -9,6 +9,7 @@
9
9
  * site NATS stands alone; Long Tail's escalation events stay inside Long Tail.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.rootOf = rootOf;
12
13
  exports.bridgeId = bridgeId;
13
14
  exports.gatewayPublish = gatewayPublish;
14
15
  exports.linesOf = linesOf;
@@ -17,6 +18,9 @@ const line_1 = require("../line");
17
18
  const protocol_1 = require("../protocol");
18
19
  const permissions_1 = require("../../messaging/nats/permissions");
19
20
  const streams_1 = require("../transport/streams");
21
+ function rootOf(root, line) {
22
+ return typeof root === 'string' ? root : root(line);
23
+ }
20
24
  const q = JSON.stringify;
21
25
  /** The machine broker's bridge identity for a line. */
22
26
  function bridgeId(line) {
@@ -44,7 +48,7 @@ function renderSiteConf(lines, opts) {
44
48
  const list = linesOf(lines);
45
49
  if (!list.length)
46
50
  throw new Error('renderSiteConf needs at least one line');
47
- const baseOf = (line) => (0, protocol_1.toSubject)(`${opts.root}/${line.siteId}/${line.lineId}`);
51
+ const baseOf = (line) => (0, protocol_1.toSubject)(`${rootOf(opts.root, line)}/${line.siteId}/${line.lineId}`);
48
52
  const bridge = list.map((line) => [
49
53
  ` { user: ${q(`CN=${bridgeId(line)}`)}, allowed_connection_types: ["MQTT"],`,
50
54
  ` permissions: { publish: [${q(`${baseOf(line)}.>`)}], subscribe: { deny: [">"] } } }`,
@@ -17,7 +17,11 @@ export interface NatsBusOptions {
17
17
  lineId: string;
18
18
  /** The codec's topics: which subjects are durable events and which are live telemetry. */
19
19
  codec: StreamCodec;
20
+ /** The codecs of every line on this site, so the streams keep all their subjects. */
21
+ siteCodecs?: StreamCodec[];
20
22
  nakDelayMs?: number;
23
+ /** Told once when the consumer can deliver nothing more, so the line can give its lead up and contend again. */
24
+ onLost?: (reason: string) => void;
21
25
  }
22
26
  export declare class NatsMachineBus implements MachineBus {
23
27
  private readonly opts;
@@ -3,7 +3,9 @@
3
3
  * line's durable consumer on LINE_EVENTS (replayed after a restart); telemetry
4
4
  * is read live, since it only proves a machine is alive. Commands leave through
5
5
  * an MQTT session at QoS 1 on the site broker, which queues them for a machine
6
- * that is away; retained machine state is read there too.
6
+ * that is away; retained machine state is read there too. The streams are
7
+ * ensured with every codec on the site, so lines on different roots share
8
+ * them, and a consumer the stream would feed nothing refuses to start.
7
9
  */
8
10
  import type { LTPubSub, LTStreams } from '../../../types/messaging';
9
11
  import type { MqttEndpoint } from '../../../services/machines/config';
@@ -19,7 +21,11 @@ export interface StreamBusOptions {
19
21
  lineId: string;
20
22
  /** The codec's topics: which subjects are durable events and which are live telemetry. */
21
23
  codec: StreamCodec;
24
+ /** The codecs of every line on this site; the streams keep all their subjects, whichever line starts last. */
25
+ siteCodecs?: StreamCodec[];
22
26
  nakDelayMs?: number;
27
+ /** Told once when the consumer can deliver nothing more, so the line can give its lead up and contend again. */
28
+ onLost?: (reason: string) => void;
23
29
  }
24
30
  export declare class StreamMachineBus implements MachineBus {
25
31
  private readonly opts;
@@ -4,7 +4,9 @@
4
4
  * line's durable consumer on LINE_EVENTS (replayed after a restart); telemetry
5
5
  * is read live, since it only proves a machine is alive. Commands leave through
6
6
  * an MQTT session at QoS 1 on the site broker, which queues them for a machine
7
- * that is away; retained machine state is read there too.
7
+ * that is away; retained machine state is read there too. The streams are
8
+ * ensured with every codec on the site, so lines on different roots share
9
+ * them, and a consumer the stream would feed nothing refuses to start.
8
10
  */
9
11
  Object.defineProperty(exports, "__esModule", { value: true });
10
12
  exports.StreamMachineBus = void 0;
@@ -23,15 +25,27 @@ class StreamMachineBus {
23
25
  return `${this.opts.controllerId}-events`;
24
26
  }
25
27
  async connect() {
26
- const { streams, codec, siteId, lineId, controllerId } = this.opts;
27
- for (const spec of (0, streams_1.lineStreamSpecs)(codec))
28
+ const { streams, codec, siteId, lineId, controllerId, siteCodecs = [] } = this.opts;
29
+ for (const spec of (0, streams_1.lineStreamSpecs)([codec, ...siteCodecs]))
28
30
  await streams.ensureStream(spec);
29
- await streams.ensureConsumer(streams_1.LINE_STREAM.EVENTS, (0, streams_1.lineEventConsumerSpec)(codec, siteId, lineId, this.durableName));
31
+ const consumer = (0, streams_1.lineEventConsumerSpec)(codec, siteId, lineId, this.durableName);
32
+ await streams.ensureConsumer(streams_1.LINE_STREAM.EVENTS, consumer);
33
+ const held = await streams.describeConsumer(streams_1.LINE_STREAM.EVENTS, consumer.name);
34
+ const reads = [...(held?.filterSubjects ?? [])].sort().join(', ');
35
+ const wants = [...consumer.filterSubjects].sort().join(', ');
36
+ if (reads !== wants) {
37
+ throw new Error(`${consumer.name} reads ${reads || 'nothing'} and not ${wants}; the consumer did not follow the line`);
38
+ }
39
+ const events = await streams.describeStream(streams_1.LINE_STREAM.EVENTS);
40
+ const missing = events ? (0, streams_1.uncoveredSubjects)(events, consumer.filterSubjects) : consumer.filterSubjects;
41
+ if (missing.length) {
42
+ throw new Error(`${streams_1.LINE_STREAM.EVENTS} keeps nothing of ${missing.join(', ')}; the stream holds ${events?.subjects.join(', ') ?? 'nothing'}`);
43
+ }
30
44
  this.session = await mqtt_session_1.MqttSession.open(this.opts.mqtt, controllerId);
31
45
  }
32
46
  async consume(handler) {
33
- const { streams, pubsub, codec, siteId, lineId, nakDelayMs = 1_000 } = this.opts;
34
- this.subs.push(await streams.consume(streams_1.LINE_STREAM.EVENTS, this.durableName, (m) => this.queue.run(() => handler({ topic: (0, protocol_1.toTopic)(m.subject), payload: m.payload, deliveries: m.deliveries })), { nakDelayMs }));
47
+ const { streams, pubsub, codec, siteId, lineId, nakDelayMs = 1_000, onLost } = this.opts;
48
+ this.subs.push(await streams.consume(streams_1.LINE_STREAM.EVENTS, this.durableName, (m) => this.queue.run(() => handler({ topic: (0, protocol_1.toTopic)(m.subject), payload: m.payload, deliveries: m.deliveries })), { nakDelayMs, onLost }));
35
49
  for (const subject of (0, streams_1.telemetrySubjects)(codec, siteId, lineId)) {
36
50
  this.subs.push(pubsub.subscribe(subject, (m) => void this.queue.run(() => handler({ topic: (0, protocol_1.toTopic)(m.subject), payload: m.payload, deliveries: 1 }))));
37
51
  }
@@ -13,8 +13,17 @@ export declare const LINE_STREAM: {
13
13
  export type StreamCodec = Pick<LineCodec, 'root' | 'channels' | 'topicFor'>;
14
14
  /** An MQTT filter as a subject: levels become tokens, `+` becomes `*`, `#` becomes `>`. */
15
15
  export declare function filterToSubject(filter: string): string;
16
- /** The line streams, for every line under the codec's root; a root alone means the generic layout. */
17
- export declare function lineStreamSpecs(codec: StreamCodec | string): StreamSpec[];
16
+ /**
17
+ * The site's streams: every line under each codec's root, since lines on
18
+ * different protocols share one site NATS. A root alone means the generic
19
+ * layout; several codecs give the union of their subjects, sorted, so every
20
+ * line asks the site for the same stream whichever codec it lists first.
21
+ */
22
+ export declare function lineStreamSpecs(codecs: StreamCodec | string | (StreamCodec | string)[]): StreamSpec[];
23
+ /** Whether a stream subject pattern keeps everything a consumer filter would read. */
24
+ export declare function subjectCovers(stream: string, filter: string): boolean;
25
+ /** The consumer's filter subjects the stream keeps nothing of. */
26
+ export declare function uncoveredSubjects(stream: Pick<StreamSpec, 'subjects'>, filters: string[]): string[];
18
27
  /** The telemetry subjects for one line, read live rather than replayed. */
19
28
  export declare function telemetrySubjects(codec: StreamCodec | string, siteId: string, lineId: string): string[];
20
29
  /**
@@ -8,6 +8,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.LINE_STREAM = void 0;
9
9
  exports.filterToSubject = filterToSubject;
10
10
  exports.lineStreamSpecs = lineStreamSpecs;
11
+ exports.subjectCovers = subjectCovers;
12
+ exports.uncoveredSubjects = uncoveredSubjects;
11
13
  exports.telemetrySubjects = telemetrySubjects;
12
14
  exports.lineEventConsumerSpec = lineEventConsumerSpec;
13
15
  const codec_1 = require("../protocol/codec");
@@ -26,14 +28,40 @@ function filterToSubject(filter) {
26
28
  function subjectsFor(codec, channels, siteId, lineId) {
27
29
  return channels.map((c) => filterToSubject((0, codec_1.topicOf)(codec, { siteId, lineId }, c)));
28
30
  }
29
- /** The line streams, for every line under the codec's root; a root alone means the generic layout. */
30
- function lineStreamSpecs(codec) {
31
- const c = codecOf(codec);
31
+ /**
32
+ * The site's streams: every line under each codec's root, since lines on
33
+ * different protocols share one site NATS. A root alone means the generic
34
+ * layout; several codecs give the union of their subjects, sorted, so every
35
+ * line asks the site for the same stream whichever codec it lists first.
36
+ */
37
+ function lineStreamSpecs(codecs) {
38
+ const list = (Array.isArray(codecs) ? codecs : [codecs]).map(codecOf);
39
+ const union = (channel) => [...new Set(list.flatMap((c) => subjectsFor(c, c.channels[channel])))].sort();
32
40
  return [
33
- { name: exports.LINE_STREAM.EVENTS, subjects: subjectsFor(c, c.channels.events), maxAgeMs: 30 * DAY_MS },
34
- { name: exports.LINE_STREAM.TELEMETRY, subjects: subjectsFor(c, c.channels.telemetry), maxAgeMs: 3 * DAY_MS },
41
+ { name: exports.LINE_STREAM.EVENTS, subjects: union('events'), maxAgeMs: 30 * DAY_MS },
42
+ { name: exports.LINE_STREAM.TELEMETRY, subjects: union('telemetry'), maxAgeMs: 3 * DAY_MS },
35
43
  ];
36
44
  }
45
+ /** Whether a stream subject pattern keeps everything a consumer filter would read. */
46
+ function subjectCovers(stream, filter) {
47
+ const s = stream.split('.');
48
+ const f = filter.split('.');
49
+ for (let i = 0; i < s.length; i++) {
50
+ if (s[i] === '>')
51
+ return f.length > i;
52
+ if (i >= f.length)
53
+ return false;
54
+ if (f[i] === '>')
55
+ return false;
56
+ if (s[i] !== '*' && s[i] !== f[i])
57
+ return false;
58
+ }
59
+ return s.length === f.length;
60
+ }
61
+ /** The consumer's filter subjects the stream keeps nothing of. */
62
+ function uncoveredSubjects(stream, filters) {
63
+ return filters.filter((f) => !stream.subjects.some((s) => subjectCovers(s, f)));
64
+ }
37
65
  /** The telemetry subjects for one line, read live rather than replayed. */
38
66
  function telemetrySubjects(codec, siteId, lineId) {
39
67
  const c = codecOf(codec);
@@ -36,16 +36,23 @@ class MemoryStreams {
36
36
  else
37
37
  this.streams.set(spec.name, { spec, messages: [], seq: 0, consumers: new Map(), wake: new Set() });
38
38
  }
39
+ async describeStream(name) {
40
+ return this.streams.get(name)?.spec ?? null;
41
+ }
42
+ /** An existing consumer keeps its place while its subjects match; one whose subjects changed starts again. */
39
43
  async ensureConsumer(stream, spec) {
40
44
  const s = this.stream(stream);
41
45
  const existing = s.consumers.get(spec.name);
42
- if (existing) {
43
- existing.spec = { ...existing.spec, filterSubjects: spec.filterSubjects };
46
+ const same = (a, b) => [...a].sort().join('|') === [...b].sort().join('|');
47
+ if (existing && same(existing.spec.filterSubjects, spec.filterSubjects))
44
48
  return;
45
- }
46
49
  const floor = (spec.startAt ?? constants_1.START_AT.NEW) === constants_1.START_AT.ALL ? 0 : s.seq;
47
50
  s.consumers.set(spec.name, { spec, floor, settled: new Set(), deliveries: new Map(), retryAt: new Map() });
48
51
  }
52
+ async describeConsumer(stream, name) {
53
+ const spec = this.streams.get(stream)?.consumers.get(name)?.spec;
54
+ return spec ? { ...spec, filterSubjects: [...spec.filterSubjects] } : null;
55
+ }
49
56
  async append(subject, payload) {
50
57
  const now = Date.now();
51
58
  for (const s of this.streams.values()) {
@@ -15,6 +15,7 @@ function jsPermissions({ streams, durable }) {
15
15
  `$JS.API.CONSUMER.CREATE.${s}.${durable}`,
16
16
  `$JS.API.CONSUMER.CREATE.${s}.${durable}.>`,
17
17
  `$JS.API.CONSUMER.DURABLE.CREATE.${s}.${durable}`,
18
+ `$JS.API.CONSUMER.DELETE.${s}.${durable}`,
18
19
  `$JS.API.CONSUMER.MSG.NEXT.${s}.${durable}`,
19
20
  `$JS.ACK.${s}.${durable}.>`,
20
21
  ]);
@@ -1,16 +1,31 @@
1
1
  /**
2
2
  * Durable streams on JetStream. A spec becomes a file-backed stream or an
3
- * explicit-ack durable consumer; ensuring either is safe on every boot, and an
4
- * existing consumer keeps its position while its subjects follow the spec.
3
+ * explicit-ack durable consumer; ensuring either is safe on every boot and
4
+ * from several lines at once. An existing consumer keeps its position while
5
+ * its subjects match the spec; one whose subjects changed is made again, since
6
+ * a position in another protocol's messages means nothing to this one.
5
7
  */
6
8
  import { type ConsumerConfig, type JetStreamClient, type JetStreamManager, type StreamConfig } from 'nats';
7
9
  import type { ConsumerSpec, Delivery, LTConsumeOptions, LTStreamMessage, LTStreams, LTSubscription, StreamSpec, Subject } from '../../../types/messaging';
8
10
  export declare function streamConfig(spec: StreamSpec): Partial<StreamConfig>;
9
11
  export declare function consumerConfig(spec: ConsumerSpec): Partial<ConsumerConfig>;
10
- /** Create the stream, or update an existing one to the spec. */
12
+ /**
13
+ * Create the stream, or update an existing one to the spec. Two lines may
14
+ * ensure the same stream at once: the create that loses the race finds the
15
+ * stream in use and updates it instead.
16
+ */
11
17
  export declare function ensureStream(jsm: JetStreamManager, spec: StreamSpec): Promise<void>;
12
- /** Create the consumer, or move an existing one to the spec's subjects without losing its place. */
18
+ /** The stream as JetStream holds it, or null when there is none. */
19
+ export declare function describeStream(jsm: JetStreamManager, name: string): Promise<StreamSpec | null>;
20
+ /**
21
+ * Create the consumer, or keep an existing one whose subjects match the spec,
22
+ * position and all. One whose subjects differ is deleted and made again at
23
+ * the stream's newest message, since its old position was in another
24
+ * protocol's messages.
25
+ */
13
26
  export declare function ensureConsumer(jsm: JetStreamManager, stream: string, spec: ConsumerSpec): Promise<void>;
27
+ /** The consumer as JetStream holds it, or null when there is none. */
28
+ export declare function describeConsumer(jsm: JetStreamManager, stream: string, name: string): Promise<ConsumerSpec | null>;
14
29
  export declare function payloadOf(m: {
15
30
  data: Uint8Array;
16
31
  }): string;
@@ -20,6 +35,8 @@ export declare class NatsStreams implements LTStreams {
20
35
  constructor(jsm: JetStreamManager, js: JetStreamClient);
21
36
  ensureStream(spec: StreamSpec): Promise<void>;
22
37
  ensureConsumer(stream: string, spec: ConsumerSpec): Promise<void>;
38
+ describeStream(name: string): Promise<StreamSpec | null>;
39
+ describeConsumer(stream: string, name: string): Promise<ConsumerSpec | null>;
23
40
  append(subject: Subject, payload: string): Promise<void>;
24
41
  /** One message at a time: the next is not taken until the handler settles this one. */
25
42
  consume(stream: string, consumer: string, handler: (m: LTStreamMessage) => Promise<Delivery>, opts?: LTConsumeOptions): Promise<LTSubscription>;