@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
@@ -1,20 +1,27 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Durable streams on JetStream. A spec becomes a file-backed stream or an
4
- * explicit-ack durable consumer; ensuring either is safe on every boot, and an
5
- * existing consumer keeps its position while its subjects follow the spec.
4
+ * explicit-ack durable consumer; ensuring either is safe on every boot and
5
+ * from several lines at once. An existing consumer keeps its position while
6
+ * its subjects match the spec; one whose subjects changed is made again, since
7
+ * a position in another protocol's messages means nothing to this one.
6
8
  */
7
9
  Object.defineProperty(exports, "__esModule", { value: true });
8
10
  exports.NatsStreams = void 0;
9
11
  exports.streamConfig = streamConfig;
10
12
  exports.consumerConfig = consumerConfig;
11
13
  exports.ensureStream = ensureStream;
14
+ exports.describeStream = describeStream;
12
15
  exports.ensureConsumer = ensureConsumer;
16
+ exports.describeConsumer = describeConsumer;
13
17
  exports.payloadOf = payloadOf;
14
18
  const nats_1 = require("nats");
15
19
  const constants_1 = require("../constants");
16
20
  const DEFAULT_ACK_WAIT_MS = 30_000;
17
21
  const DEFAULT_NAK_DELAY_MS = 1_000;
22
+ /** Consumer events after which the consumer delivers nothing more: its stream or itself is gone, or the server fell silent. */
23
+ const CONSUMER_LOST = new Set(['consumer_deleted', 'consumer_not_found', 'stream_not_found', 'heartbeats_missed']);
24
+ const sameSubjects = (a = [], b = []) => [...a].sort().join('|') === [...b].sort().join('|');
18
25
  function streamConfig(spec) {
19
26
  return { name: spec.name, subjects: spec.subjects, max_age: (0, nats_1.nanos)(spec.maxAgeMs), storage: nats_1.StorageType.File };
20
27
  }
@@ -27,7 +34,11 @@ function consumerConfig(spec) {
27
34
  filter_subjects: spec.filterSubjects,
28
35
  };
29
36
  }
30
- /** Create the stream, or update an existing one to the spec. */
37
+ /**
38
+ * Create the stream, or update an existing one to the spec. Two lines may
39
+ * ensure the same stream at once: the create that loses the race finds the
40
+ * stream in use and updates it instead.
41
+ */
31
42
  async function ensureStream(jsm, spec) {
32
43
  const config = streamConfig(spec);
33
44
  try {
@@ -36,12 +47,35 @@ async function ensureStream(jsm, spec) {
36
47
  catch (err) {
37
48
  if (!/stream not found/i.test(err?.message ?? ''))
38
49
  throw err;
39
- await jsm.streams.add(config);
40
- return;
50
+ try {
51
+ await jsm.streams.add(config);
52
+ return;
53
+ }
54
+ catch (added) {
55
+ if (!/already in use/i.test(added?.message ?? ''))
56
+ throw added;
57
+ }
41
58
  }
42
59
  await jsm.streams.update(spec.name, config);
43
60
  }
44
- /** Create the consumer, or move an existing one to the spec's subjects without losing its place. */
61
+ /** The stream as JetStream holds it, or null when there is none. */
62
+ async function describeStream(jsm, name) {
63
+ try {
64
+ const info = await jsm.streams.info(name);
65
+ return { name, subjects: info.config.subjects ?? [], maxAgeMs: Math.round((info.config.max_age ?? 0) / 1_000_000) };
66
+ }
67
+ catch (err) {
68
+ if (/stream not found/i.test(err?.message ?? ''))
69
+ return null;
70
+ throw err;
71
+ }
72
+ }
73
+ /**
74
+ * Create the consumer, or keep an existing one whose subjects match the spec,
75
+ * position and all. One whose subjects differ is deleted and made again at
76
+ * the stream's newest message, since its old position was in another
77
+ * protocol's messages.
78
+ */
45
79
  async function ensureConsumer(jsm, stream, spec) {
46
80
  let existing;
47
81
  try {
@@ -53,10 +87,22 @@ async function ensureConsumer(jsm, stream, spec) {
53
87
  await jsm.consumers.add(stream, consumerConfig(spec));
54
88
  return;
55
89
  }
56
- const want = [...spec.filterSubjects].sort();
57
- const have = [...(existing.config.filter_subjects ?? [])].sort();
58
- if (want.join('|') !== have.join('|'))
59
- await jsm.consumers.update(stream, spec.name, { filter_subjects: spec.filterSubjects });
90
+ if (sameSubjects(spec.filterSubjects, existing.config.filter_subjects))
91
+ return;
92
+ await jsm.consumers.delete(stream, spec.name);
93
+ await jsm.consumers.add(stream, consumerConfig(spec));
94
+ }
95
+ /** The consumer as JetStream holds it, or null when there is none. */
96
+ async function describeConsumer(jsm, stream, name) {
97
+ try {
98
+ const info = await jsm.consumers.info(stream, name);
99
+ return { name, filterSubjects: info.config.filter_subjects ?? (info.config.filter_subject ? [info.config.filter_subject] : []) };
100
+ }
101
+ catch (err) {
102
+ if (/consumer not found|stream not found/i.test(err?.message ?? ''))
103
+ return null;
104
+ throw err;
105
+ }
60
106
  }
61
107
  function payloadOf(m) {
62
108
  return Buffer.from(m.data).toString('utf8');
@@ -72,6 +118,12 @@ class NatsStreams {
72
118
  ensureConsumer(stream, spec) {
73
119
  return ensureConsumer(this.jsm, stream, spec);
74
120
  }
121
+ describeStream(name) {
122
+ return describeStream(this.jsm, name);
123
+ }
124
+ describeConsumer(stream, name) {
125
+ return describeConsumer(this.jsm, stream, name);
126
+ }
75
127
  async append(subject, payload) {
76
128
  await this.js.publish(subject, payload);
77
129
  }
@@ -79,6 +131,15 @@ class NatsStreams {
79
131
  async consume(stream, consumer, handler, opts = {}) {
80
132
  const nakDelayMs = opts.nakDelayMs ?? DEFAULT_NAK_DELAY_MS;
81
133
  const messages = await (await this.js.consumers.get(stream, consumer)).consume();
134
+ // The consumer's own status: once its stream or itself is gone, or the server fell silent, the reader is told.
135
+ void (async () => {
136
+ for await (const status of await messages.status()) {
137
+ if (!CONSUMER_LOST.has(String(status.type)))
138
+ continue;
139
+ opts.onLost?.(String(status.type));
140
+ return;
141
+ }
142
+ })().catch(() => { });
82
143
  void (async () => {
83
144
  for await (const m of messages) {
84
145
  const delivery = await handler({ subject: m.subject, payload: payloadOf(m), deliveries: m.info.deliveryCount })
@@ -32,10 +32,12 @@ export declare class LocalStorageBackend implements StorageBackend {
32
32
  directories: string[];
33
33
  nextToken?: string;
34
34
  }>;
35
+ /** The digest is read from the file itself, so it is always the stored bytes' own. */
35
36
  getMetadata(key: string): Promise<{
36
37
  size: number;
37
38
  modified_at: string;
38
39
  content_type: string;
40
+ sha256: string;
39
41
  }>;
40
42
  getSignedUrl(key: string, expiresInSeconds: number): Promise<string>;
41
43
  }
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.LocalStorageBackend = void 0;
40
+ const crypto_1 = require("crypto");
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
@@ -168,6 +169,7 @@ class LocalStorageBackend {
168
169
  nextToken: hasMore ? String(offset + limit) : undefined,
169
170
  };
170
171
  }
172
+ /** The digest is read from the file itself, so it is always the stored bytes' own. */
171
173
  async getMetadata(key) {
172
174
  const resolved = resolveAndValidate(key);
173
175
  if (!fs.existsSync(resolved)) {
@@ -178,6 +180,7 @@ class LocalStorageBackend {
178
180
  size: stat.size,
179
181
  modified_at: stat.mtime.toISOString(),
180
182
  content_type: (0, mime_1.mimeFromPath)(key),
183
+ sha256: (0, crypto_1.createHash)('sha256').update(fs.readFileSync(resolved)).digest('hex'),
181
184
  };
182
185
  }
183
186
  async getSignedUrl(key, expiresInSeconds) {
@@ -1,4 +1,4 @@
1
- import type { StorageBackend } from './types';
1
+ import type { StorageBackend, WriteOptions } from './types';
2
2
  export declare class S3StorageBackend implements StorageBackend {
3
3
  private client;
4
4
  private signingClient;
@@ -6,7 +6,7 @@ export declare class S3StorageBackend implements StorageBackend {
6
6
  private bucketReady;
7
7
  constructor();
8
8
  private ensureBucket;
9
- write(key: string, data: Buffer): Promise<{
9
+ write(key: string, data: Buffer, options?: WriteOptions): Promise<{
10
10
  ref: string;
11
11
  size: number;
12
12
  }>;
@@ -42,6 +42,7 @@ export declare class S3StorageBackend implements StorageBackend {
42
42
  size: number;
43
43
  modified_at: string;
44
44
  content_type: string;
45
+ sha256?: string;
45
46
  }>;
46
47
  getSignedUrl(key: string, expiresInSeconds: number): Promise<string>;
47
48
  }
@@ -39,6 +39,8 @@ const os = __importStar(require("os"));
39
39
  const path = __importStar(require("path"));
40
40
  const client_s3_1 = require("@aws-sdk/client-s3");
41
41
  const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
42
+ /** The object metadata key the digest is kept under. */
43
+ const SHA256_METADATA = 'sha256';
42
44
  const mime_1 = require("./mime");
43
45
  const STAGING_DIR = path.join(os.tmpdir(), 'lt-staging');
44
46
  class S3StorageBackend {
@@ -91,12 +93,13 @@ class S3StorageBackend {
91
93
  }
92
94
  }
93
95
  }
94
- async write(key, data) {
96
+ async write(key, data, options) {
95
97
  await this.bucketReady;
96
98
  await this.client.send(new client_s3_1.PutObjectCommand({
97
99
  Bucket: this.bucket,
98
100
  Key: normalizeKey(key),
99
101
  Body: data,
102
+ ...(options?.sha256 ? { Metadata: { [SHA256_METADATA]: options.sha256 } } : {}),
100
103
  }));
101
104
  return { ref: key, size: data.length };
102
105
  }
@@ -222,10 +225,12 @@ class S3StorageBackend {
222
225
  Bucket: this.bucket,
223
226
  Key: normalizeKey(key),
224
227
  }));
228
+ const sha256 = resp.Metadata?.[SHA256_METADATA];
225
229
  return {
226
230
  size: resp.ContentLength || 0,
227
231
  modified_at: resp.LastModified?.toISOString() || new Date().toISOString(),
228
232
  content_type: resp.ContentType || (0, mime_1.mimeFromPath)(key),
233
+ ...(sha256 ? { sha256 } : {}),
229
234
  };
230
235
  }
231
236
  async getSignedUrl(key, expiresInSeconds) {
@@ -1,6 +1,10 @@
1
+ export interface WriteOptions {
2
+ /** The SHA-256 of `data`, hex, kept with the object. */
3
+ sha256?: string;
4
+ }
1
5
  export interface StorageBackend {
2
6
  /** Write data to storage. Returns the key and size. */
3
- write(key: string, data: Buffer): Promise<{
7
+ write(key: string, data: Buffer, options?: WriteOptions): Promise<{
4
8
  ref: string;
5
9
  size: number;
6
10
  }>;
@@ -53,6 +57,8 @@ export interface StorageBackend {
53
57
  size: number;
54
58
  modified_at: string;
55
59
  content_type: string;
60
+ /** The object's SHA-256, hex, when the backend knows it. */
61
+ sha256?: string;
56
62
  }>;
57
63
  /** Generate a time-limited signed URL for unauthenticated file access. */
58
64
  getSignedUrl(key: string, expiresInSeconds: number): Promise<string>;
@@ -128,6 +128,7 @@ router.get('/download/{*filePath}', auth_1.requireBuilder, async (req, res) => {
128
128
  * POST /api/file-browser/upload
129
129
  * Upload a file to storage.
130
130
  * Query: ?path=images/photo.png (target path including filename)
131
+ * &sha256=<hex> (optional; the upload is refused with 422 when the bytes differ)
131
132
  * Body: raw file bytes (Content-Type should match the file type)
132
133
  */
133
134
  router.post('/upload', auth_1.requireBuilder, async (req, res) => {
@@ -146,7 +147,8 @@ router.post('/upload', auth_1.requireBuilder, async (req, res) => {
146
147
  res.status(400).json({ error: 'Empty file body' });
147
148
  return;
148
149
  }
149
- const result = await api.uploadFile({ path: targetPath, buffer });
150
+ const sha256 = typeof req.query.sha256 === 'string' ? req.query.sha256 : undefined;
151
+ const result = await api.uploadFile({ path: targetPath, buffer, sha256 });
150
152
  res.status(result.status).json(result.data ?? { error: result.error });
151
153
  }
152
154
  catch (err) {
@@ -74,8 +74,8 @@ router.get('/entries', async (req, res) => {
74
74
  /**
75
75
  * GET /api/knowledge/entry
76
76
  * Get a single knowledge entry — the live edition, or an immutable snapshot
77
- * when ?version=N is present.
78
- * Query: ?domain=...&key=...&version=N
77
+ * when ?version=N (or ?version=<label>) is present.
78
+ * Query: ?domain=...&key=...&version=N|label
79
79
  */
80
80
  router.get('/entry', async (req, res) => {
81
81
  const domain = req.query.domain;
@@ -85,9 +85,10 @@ router.get('/entry', async (req, res) => {
85
85
  return;
86
86
  }
87
87
  if (req.query.version !== undefined) {
88
- const version = Number(req.query.version);
89
- if (!Number.isInteger(version) || version < 1) {
90
- res.status(400).json({ error: 'version must be a positive integer' });
88
+ const raw = String(req.query.version);
89
+ const version = /^[1-9][0-9]*$/.test(raw) ? Number(raw) : raw;
90
+ if (version === '' || version === 'current') {
91
+ res.status(400).json({ error: 'version must be a positive integer or an edition label' });
91
92
  return;
92
93
  }
93
94
  const result = await api.getEntryVersion({ domain, key, version });
@@ -42,6 +42,8 @@ const registry_1 = require("../services/machines/registry");
42
42
  const registry_sync_1 = require("../services/machines/registry-sync");
43
43
  const line_index_1 = require("../services/machines/line-index");
44
44
  const topics_1 = require("../lib/machines/protocol/topics");
45
+ const browser_view_1 = require("../lib/events/browser-view");
46
+ const wire_window_1 = require("../services/machines/gateway/wire-window");
45
47
  const router = (0, http_1.Router)();
46
48
  /**
47
49
  * The machine surface for the dashboard's Lines view. Reads and the dry run
@@ -170,6 +172,37 @@ router.get('/lines/:siteId/:lineId', auth_1.requireRoleManager, (req, res) => {
170
172
  };
171
173
  res.status(200).json(body);
172
174
  });
175
+ /** The event as this viewer reads it: builders see the payload, everyone else the record without it. */
176
+ function wireForViewer(event, builder) {
177
+ if (builder)
178
+ return event;
179
+ const { payload: _payload, ...rest } = event;
180
+ return rest;
181
+ }
182
+ router.get('/lines/:siteId/:lineId/wire', auth_1.requireRoleManager, async (req, res) => {
183
+ const { surface, siteId, lineId, line } = lineOf(req);
184
+ if (!surface || !line) {
185
+ res.status(404).json({ error: surface ? `no line ${siteId}/${lineId}` : NO_LINES });
186
+ return;
187
+ }
188
+ const { direction, kind } = req.query;
189
+ if (direction !== undefined && !(0, wire_window_1.isWireDirection)(direction)) {
190
+ res.status(400).json({ error: 'direction must be in or out' });
191
+ return;
192
+ }
193
+ if (kind !== undefined && !(0, wire_window_1.isWireKind)(kind)) {
194
+ res.status(400).json({ error: 'kind is not a wire kind' });
195
+ return;
196
+ }
197
+ const query = {
198
+ limit: queryInt(req.query.limit), machine: queryString(req.query.machine), since: queryString(req.query.since),
199
+ ...(direction !== undefined ? { direction } : {}), ...(kind !== undefined ? { kind } : {}),
200
+ };
201
+ const events = surface.wire(siteId, lineId, query);
202
+ const viewer = await (0, browser_view_1.viewerFor)(req.auth?.userId);
203
+ const body = { siteId, lineId, leading: events !== null, events: (events ?? []).map((e) => wireForViewer(e, viewer.builder)) };
204
+ res.status(200).json(body);
205
+ });
173
206
  router.post('/lines/:siteId/:lineId/inspect', auth_1.requireRoleManager, (req, res) => {
174
207
  const { surface, siteId, lineId, line } = lineOf(req);
175
208
  if (!surface || !line) {
@@ -2,15 +2,17 @@ export * from './lookup-cache';
2
2
  export * from './lookup-refs';
3
3
  export interface KnowledgeVersionMeta {
4
4
  version: number;
5
+ label: string | null;
5
6
  change_summary: string | null;
6
7
  created_at: string;
7
8
  is_current: boolean;
8
9
  }
9
- /** The immutable snapshot at (domain, key, version), uncached full shape. */
10
- export declare function getKnowledgeVersion(domain: string, key: string, version: number): Promise<{
10
+ /** The immutable snapshot at (domain, key, version), by number or by the edition's label; uncached full shape. */
11
+ export declare function getKnowledgeVersion(domain: string, key: string, version: number | string): Promise<{
11
12
  domain: string;
12
13
  key: string;
13
14
  version: number;
15
+ label: string | null;
14
16
  data: Record<string, unknown>;
15
17
  tags: string[];
16
18
  created_at: string;
@@ -22,19 +22,24 @@ exports.listKnowledgeVersions = listKnowledgeVersions;
22
22
  * writes produce, and resolves escalation lookup refs against them.
23
23
  */
24
24
  const db_1 = require("../../lib/db");
25
+ const lookup_cache_1 = require("./lookup-cache");
25
26
  const sql_1 = require("./sql");
26
27
  __exportStar(require("./lookup-cache"), exports);
27
28
  __exportStar(require("./lookup-refs"), exports);
28
- /** The immutable snapshot at (domain, key, version), uncached full shape. */
29
+ /** The immutable snapshot at (domain, key, version), by number or by the edition's label; uncached full shape. */
29
30
  async function getKnowledgeVersion(domain, key, version) {
30
31
  const pool = (0, db_1.getPool)();
31
- const { rows } = await pool.query(sql_1.GET_KNOWLEDGE_VERSION, [domain, key, version]);
32
+ const number = typeof version === 'string' ? await (0, lookup_cache_1.editionOfLabel)(domain, key, version) : version;
33
+ if (number === null)
34
+ return null;
35
+ const { rows } = await pool.query(sql_1.GET_KNOWLEDGE_VERSION, [domain, key, number]);
32
36
  if (!rows[0])
33
37
  return null;
34
38
  return {
35
39
  domain: rows[0].domain,
36
40
  key: rows[0].key,
37
41
  version: rows[0].version,
42
+ label: rows[0].label ?? null,
38
43
  data: rows[0].data ?? {},
39
44
  tags: rows[0].tags ?? [],
40
45
  created_at: rows[0].created_at.toISOString(),
@@ -46,6 +51,7 @@ async function listKnowledgeVersions(domain, key) {
46
51
  const { rows } = await pool.query(sql_1.LIST_KNOWLEDGE_VERSIONS, [domain, key]);
47
52
  return rows.map((r) => ({
48
53
  version: r.version,
54
+ label: r.label ?? null,
49
55
  change_summary: r.change_summary ?? null,
50
56
  created_at: r.created_at.toISOString(),
51
57
  is_current: r.is_current === true,
@@ -10,6 +10,8 @@ export interface ResolvedLookup {
10
10
  version: number | null;
11
11
  /** The ref follows the newest edition rather than pinning one. */
12
12
  current?: true;
13
+ /** The ref pins an edition by this label. */
14
+ label?: string;
13
15
  as?: string;
14
16
  data: Record<string, unknown> | null;
15
17
  missing?: boolean;
@@ -25,6 +27,12 @@ export declare function resolveRefEdition(ref: EscalationLookupRef, asOf?: strin
25
27
  version: number;
26
28
  snapshot: KnowledgeSnapshot;
27
29
  } | null>;
30
+ /**
31
+ * The edition a label names, or, for a label that is a whole number no edition
32
+ * carries, that edition's number. Null when the label names nothing; that is
33
+ * never cached, so a label given later becomes visible.
34
+ */
35
+ export declare function editionOfLabel(domain: string, key: string, label: string): Promise<number | null>;
28
36
  /**
29
37
  * Resolve a metadata `lookups` array into the form-context `lookup` domain:
30
38
  * { [as ?? key]: data }. Returns null when there is nothing to resolve, so
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getKnowledgeSnapshot = getKnowledgeSnapshot;
4
4
  exports.resolveRefEdition = resolveRefEdition;
5
+ exports.editionOfLabel = editionOfLabel;
5
6
  exports.resolveLookupContext = resolveLookupContext;
6
7
  exports.resolveLookupRefs = resolveLookupRefs;
7
8
  /**
@@ -18,6 +19,8 @@ const sql_1 = require("./sql");
18
19
  const SNAPSHOT_CACHE_MAX_ENTRIES = 256;
19
20
  // Map preserves insertion order — delete+set on read keeps it LRU.
20
21
  const snapshotCache = new Map();
22
+ // A label names one edition for good, so the number it resolves to is kept too.
23
+ const labelCache = new Map();
21
24
  function remember(cacheKey, snapshot) {
22
25
  snapshotCache.set(cacheKey, snapshot);
23
26
  while (snapshotCache.size > SNAPSHOT_CACHE_MAX_ENTRIES) {
@@ -52,7 +55,9 @@ async function getKnowledgeSnapshot(domain, key, version) {
52
55
  */
53
56
  async function resolveRefEdition(ref, asOf) {
54
57
  if (!(0, escalation_1.isCurrentLookupRef)(ref)) {
55
- const version = ref.version;
58
+ const version = (0, escalation_1.isLabelLookupRef)(ref) ? await editionOfLabel(ref.domain, ref.key, ref.version) : ref.version;
59
+ if (version === null)
60
+ return null;
56
61
  const snapshot = await getKnowledgeSnapshot(ref.domain, ref.key, version);
57
62
  return snapshot ? { version, snapshot } : null;
58
63
  }
@@ -71,6 +76,26 @@ async function resolveRefEdition(ref, asOf) {
71
76
  remember(cacheKey, snapshot);
72
77
  return { version, snapshot };
73
78
  }
79
+ /**
80
+ * The edition a label names, or, for a label that is a whole number no edition
81
+ * carries, that edition's number. Null when the label names nothing; that is
82
+ * never cached, so a label given later becomes visible.
83
+ */
84
+ async function editionOfLabel(domain, key, label) {
85
+ const cacheKey = `${domain} ${key} #${label}`;
86
+ const hit = labelCache.get(cacheKey);
87
+ if (hit !== undefined)
88
+ return hit;
89
+ const { rows } = await (0, db_1.getPool)().query(sql_1.GET_KNOWLEDGE_VERSION_BY_LABEL, [domain, key, label]);
90
+ if (rows[0]) {
91
+ const version = Number(rows[0].version);
92
+ labelCache.set(cacheKey, version);
93
+ while (labelCache.size > SNAPSHOT_CACHE_MAX_ENTRIES)
94
+ labelCache.delete(labelCache.keys().next().value);
95
+ return version;
96
+ }
97
+ return /^[1-9][0-9]*$/.test(label) ? Number(label) : null;
98
+ }
74
99
  /** Structural check for one ref — malformed entries in stored metadata are skipped, not fatal. */
75
100
  function isLookupRef(ref) {
76
101
  if (!ref || typeof ref !== 'object')
@@ -78,7 +103,7 @@ function isLookupRef(ref) {
78
103
  const r = ref;
79
104
  return typeof r.domain === 'string' && r.domain.length > 0
80
105
  && typeof r.key === 'string' && r.key.length > 0
81
- && ((0, escalation_1.isCurrentLookupRef)(r) || (typeof r.version === 'number' && Number.isInteger(r.version) && r.version >= 1))
106
+ && ((0, escalation_1.isCurrentLookupRef)(r) || (0, escalation_1.isLabelLookupRef)(r) || (typeof r.version === 'number' && Number.isInteger(r.version) && r.version >= 1))
82
107
  && (r.as === undefined || (typeof r.as === 'string' && r.as.length > 0));
83
108
  }
84
109
  /**
@@ -112,12 +137,14 @@ async function resolveLookupRefs(refs, asOf) {
112
137
  if (!isLookupRef(ref))
113
138
  continue;
114
139
  const current = (0, escalation_1.isCurrentLookupRef)(ref);
140
+ const label = (0, escalation_1.isLabelLookupRef)(ref) ? ref.version : undefined;
115
141
  const edition = await resolveRefEdition(ref, asOf);
116
142
  resolved.push({
117
143
  domain: ref.domain,
118
144
  key: ref.key,
119
- version: edition?.version ?? (current ? null : ref.version),
145
+ version: edition?.version ?? (current || label !== undefined ? null : ref.version),
120
146
  ...(current ? { current: true } : {}),
147
+ ...(label !== undefined ? { label } : {}),
121
148
  ...(ref.as ? { as: ref.as } : {}),
122
149
  data: edition ? edition.snapshot.data : null,
123
150
  ...(edition ? {} : { missing: true }),
@@ -21,7 +21,7 @@ async function describeMissingLookupRefs(refs) {
21
21
  const where = versions.length
22
22
  ? `editions: ${versions.map((v) => `v${v}`).join(', ')}`
23
23
  : `no knowledge entry ${ref.domain}/${ref.key}`;
24
- const named = (0, escalation_1.isCurrentLookupRef)(ref) ? 'current' : `v${ref.version}`;
24
+ const named = (0, escalation_1.isCurrentLookupRef)(ref) ? 'current' : (0, escalation_1.isLabelLookupRef)(ref) ? `label ${JSON.stringify(ref.version)}` : `v${ref.version}`;
25
25
  problems.push(`Lookup ref ${ref.domain}/${ref.key} ${named} names no edition (${where})`);
26
26
  }
27
27
  return problems;
@@ -1,4 +1,6 @@
1
- export declare const GET_KNOWLEDGE_VERSION = "\n SELECT domain, key, version, data, tags, change_summary, created_at\n FROM lt_knowledge_versions\n WHERE domain = $1 AND key = $2 AND version = $3";
2
- export declare const LIST_KNOWLEDGE_VERSIONS = "\n SELECT v.version, v.change_summary, v.created_at,\n (v.version = k.current_version) AS is_current\n FROM lt_knowledge_versions v\n JOIN lt_knowledge k ON k.domain = v.domain AND k.key = v.key\n WHERE v.domain = $1 AND v.key = $2\n ORDER BY v.version DESC";
1
+ export declare const GET_KNOWLEDGE_VERSION = "\n SELECT domain, key, version, label, data, tags, change_summary, created_at\n FROM lt_knowledge_versions\n WHERE domain = $1 AND key = $2 AND version = $3";
2
+ /** The edition a label names, when one does. */
3
+ export declare const GET_KNOWLEDGE_VERSION_BY_LABEL = "\n SELECT version\n FROM lt_knowledge_versions\n WHERE domain = $1 AND key = $2 AND label = $3";
4
+ export declare const LIST_KNOWLEDGE_VERSIONS = "\n SELECT v.version, v.label, v.change_summary, v.created_at,\n (v.version = k.current_version) AS is_current\n FROM lt_knowledge_versions v\n JOIN lt_knowledge k ON k.domain = v.domain AND k.key = v.key\n WHERE v.domain = $1 AND v.key = $2\n ORDER BY v.version DESC";
3
5
  /** The newest edition, or the newest created at or before $3 when it is set. */
4
6
  export declare const GET_LATEST_KNOWLEDGE_VERSION = "\n SELECT version, data, tags\n FROM lt_knowledge_versions\n WHERE domain = $1 AND key = $2\n AND ($3::timestamptz IS NULL OR created_at <= $3)\n ORDER BY version DESC\n LIMIT 1";
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GET_LATEST_KNOWLEDGE_VERSION = exports.LIST_KNOWLEDGE_VERSIONS = exports.GET_KNOWLEDGE_VERSION = void 0;
3
+ exports.GET_LATEST_KNOWLEDGE_VERSION = exports.LIST_KNOWLEDGE_VERSIONS = exports.GET_KNOWLEDGE_VERSION_BY_LABEL = exports.GET_KNOWLEDGE_VERSION = void 0;
4
4
  exports.GET_KNOWLEDGE_VERSION = `
5
- SELECT domain, key, version, data, tags, change_summary, created_at
5
+ SELECT domain, key, version, label, data, tags, change_summary, created_at
6
6
  FROM lt_knowledge_versions
7
7
  WHERE domain = $1 AND key = $2 AND version = $3`;
8
+ /** The edition a label names, when one does. */
9
+ exports.GET_KNOWLEDGE_VERSION_BY_LABEL = `
10
+ SELECT version
11
+ FROM lt_knowledge_versions
12
+ WHERE domain = $1 AND key = $2 AND label = $3`;
8
13
  exports.LIST_KNOWLEDGE_VERSIONS = `
9
- SELECT v.version, v.change_summary, v.created_at,
14
+ SELECT v.version, v.label, v.change_summary, v.created_at,
10
15
  (v.version = k.current_version) AS is_current
11
16
  FROM lt_knowledge_versions v
12
17
  JOIN lt_knowledge k ON k.domain = v.domain AND k.key = v.key
@@ -42,6 +42,8 @@ export interface MachineLineConfig {
42
42
  progressStep: number;
43
43
  /** Telemetry messages are published as wire events too; off by default, since they are read live and many. */
44
44
  wireTelemetry: boolean;
45
+ /** How many of the line's last wire events the leader keeps for GET .../wire. */
46
+ wireWindow: number;
45
47
  ltBaseUrl: string;
46
48
  }
47
49
  type Env = Record<string, string | undefined>;
@@ -75,6 +75,7 @@ function loadMachineLineConfig(env = process.env) {
75
75
  lifecycle: oneOf('MACHINE_LIFECYCLE', env.MACHINE_LIFECYCLE || SWITCH.OFF, Object.values(SWITCH)) === SWITCH.ON,
76
76
  progressStep: percentStep(env, 'MACHINE_PROGRESS_STEP', 25),
77
77
  wireTelemetry: oneOf('MACHINE_WIRE_TELEMETRY', env.MACHINE_WIRE_TELEMETRY || SWITCH.OFF, Object.values(SWITCH)) === SWITCH.ON,
78
+ wireWindow: Math.max(1, int(env, 'MACHINE_WIRE_WINDOW', 500)),
78
79
  ltBaseUrl: env.LT_BASE_URL ?? 'http://localhost:3000',
79
80
  };
80
81
  if (transport === exports.MACHINE_TRANSPORT.NATS) {
@@ -16,12 +16,16 @@ export declare const ROW_TYPE: {
16
16
  readonly CONTRACT_VIOLATION: "contract-violation";
17
17
  /** An address claims a station the line already knows by another address, or the reverse: a person approves or rejects. */
18
18
  readonly MACHINE_REGISTRATION: "machine-registration";
19
+ /** Units a machine finished on hold wait for a quality decision: release, rework or scrap. */
20
+ readonly QUALITY_HOLD: "quality-hold";
19
21
  };
20
22
  export declare const LINE_ROLE: {
21
23
  /** People who clear alarms, bring machines back and decide failed handoffs. */
22
24
  readonly OPERATOR: "line-operator";
23
25
  /** People who take finished units off the line: the final handoff. */
24
26
  readonly EXIT: "line-exit";
27
+ /** People who decide units a machine finished on hold. */
28
+ readonly QUALITY: "line-quality";
25
29
  /** The gateway's own identity. */
26
30
  readonly CONTROLLER: "line-controller";
27
31
  };
@@ -116,6 +120,8 @@ export declare const HANDOFF_ERROR: {
116
120
  readonly SOFTWARE_UNSUPPORTED: "software-unsupported";
117
121
  readonly WITHDRAWN: "withdrawn";
118
122
  readonly CANCELLED: "cancelled";
123
+ /** An arrival no open handoff into the machine expects, shown to the operator role once per machine and carrier. */
124
+ readonly UNMATCHED_ARRIVAL: "unmatched-arrival";
119
125
  };
120
126
  export type HandoffErrorCode = typeof HANDOFF_ERROR[keyof typeof HANDOFF_ERROR];
121
127
  /** A material lot a machine consumed for a unit. */
@@ -172,6 +178,10 @@ export declare const DECISION: {
172
178
  readonly RESTORED: "restored";
173
179
  readonly APPROVE: "approve";
174
180
  readonly REJECT: "reject";
181
+ /** A quality decision on held units: they continue from where they are. */
182
+ readonly RELEASE: "release";
183
+ /** A quality decision on held units: they run again from a station on the route. */
184
+ readonly REWORK: "rework";
175
185
  };
176
186
  export type Decision = typeof DECISION[keyof typeof DECISION];
177
187
  /** The decisions an operator may take on a failed handoff; retry only while every unit is still at the sender. */
@@ -180,11 +190,19 @@ export interface OperatorResolution {
180
190
  decision: Decision;
181
191
  note?: string;
182
192
  }
193
+ /** The decisions a quality role may take on units a machine finished on hold. */
194
+ export declare const HOLD_DECISIONS: readonly Decision[];
195
+ export interface QualityResolution extends OperatorResolution {
196
+ /** For `rework`: the station the units run again from; the holding station when absent. */
197
+ station?: string;
198
+ }
183
199
  /** Signal keys for the unit workflow; every attempt mints a fresh key. */
184
200
  export declare const signalKeys: {
185
201
  handoff: (jobKey: string, step: number, attempt: number) => string;
186
202
  handoffError: (jobKey: string, step: number, attempt: number, round?: number) => string;
187
203
  jobStop: (jobKey: string, step: number, attempt: number) => string;
204
+ hold: (jobKey: string, step: number, attempt: number, round?: number) => string;
205
+ disposition: (jobKey: string, step: number, attempt: number) => string;
188
206
  };
189
207
  /** Metadata on a machine-command row. */
190
208
  export interface CommandFacets {