@hotmeshio/long-tail 0.25.4 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +1 -0
  2. package/build/index.d.ts +5 -0
  3. package/build/index.js +36 -7
  4. package/build/lib/events/nats.d.ts +11 -0
  5. package/build/lib/events/nats.js +33 -2
  6. package/build/lib/machines/index.d.ts +6 -0
  7. package/build/lib/machines/index.js +22 -0
  8. package/build/lib/machines/line.d.ts +44 -0
  9. package/build/lib/machines/line.js +114 -0
  10. package/build/lib/machines/protocol/codec.d.ts +108 -0
  11. package/build/lib/machines/protocol/codec.js +80 -0
  12. package/build/lib/machines/protocol/constants.d.ts +120 -0
  13. package/build/lib/machines/protocol/constants.js +97 -0
  14. package/build/lib/machines/protocol/contract.d.ts +19 -0
  15. package/build/lib/machines/protocol/contract.js +141 -0
  16. package/build/lib/machines/protocol/envelope.d.ts +14 -0
  17. package/build/lib/machines/protocol/envelope.js +43 -0
  18. package/build/lib/machines/protocol/index.d.ts +5 -0
  19. package/build/lib/machines/protocol/index.js +21 -0
  20. package/build/lib/machines/protocol/topics.d.ts +33 -0
  21. package/build/lib/machines/protocol/topics.js +79 -0
  22. package/build/lib/machines/site/edge-account.d.ts +11 -0
  23. package/build/lib/machines/site/edge-account.js +24 -0
  24. package/build/lib/machines/site/mosquitto-conf.d.ts +27 -0
  25. package/build/lib/machines/site/mosquitto-conf.js +81 -0
  26. package/build/lib/machines/site/site-conf.d.ts +23 -0
  27. package/build/lib/machines/site/site-conf.js +62 -0
  28. package/build/lib/machines/transport/callback-feed.d.ts +18 -0
  29. package/build/lib/machines/transport/callback-feed.js +31 -0
  30. package/build/lib/machines/transport/delivery.d.ts +20 -0
  31. package/build/lib/machines/transport/delivery.js +37 -0
  32. package/build/lib/machines/transport/index.d.ts +13 -0
  33. package/build/lib/machines/transport/index.js +29 -0
  34. package/build/lib/machines/transport/memory-broker.d.ts +35 -0
  35. package/build/lib/machines/transport/memory-broker.js +145 -0
  36. package/build/lib/machines/transport/memory.d.ts +40 -0
  37. package/build/lib/machines/transport/memory.js +79 -0
  38. package/build/lib/machines/transport/mqtt-bus.d.ts +24 -0
  39. package/build/lib/machines/transport/mqtt-bus.js +43 -0
  40. package/build/lib/machines/transport/mqtt-link.d.ts +16 -0
  41. package/build/lib/machines/transport/mqtt-link.js +37 -0
  42. package/build/lib/machines/transport/mqtt-session.d.ts +34 -0
  43. package/build/lib/machines/transport/mqtt-session.js +127 -0
  44. package/build/lib/machines/transport/nats-bus.d.ts +36 -0
  45. package/build/lib/machines/transport/nats-bus.js +47 -0
  46. package/build/lib/machines/transport/nats-feed.d.ts +26 -0
  47. package/build/lib/machines/transport/nats-feed.js +49 -0
  48. package/build/lib/machines/transport/pubsub-feed.d.ts +17 -0
  49. package/build/lib/machines/transport/pubsub-feed.js +39 -0
  50. package/build/lib/machines/transport/serial.d.ts +21 -0
  51. package/build/lib/machines/transport/serial.js +54 -0
  52. package/build/lib/machines/transport/stream-bus.d.ts +40 -0
  53. package/build/lib/machines/transport/stream-bus.js +57 -0
  54. package/build/lib/machines/transport/streams.d.ts +24 -0
  55. package/build/lib/machines/transport/streams.js +49 -0
  56. package/build/lib/messaging/config.d.ts +25 -0
  57. package/build/lib/messaging/config.js +55 -0
  58. package/build/lib/messaging/constants.d.ts +26 -0
  59. package/build/lib/messaging/constants.js +26 -0
  60. package/build/lib/messaging/index.d.ts +24 -0
  61. package/build/lib/messaging/index.js +65 -0
  62. package/build/lib/messaging/memory.d.ts +16 -0
  63. package/build/lib/messaging/memory.js +147 -0
  64. package/build/lib/messaging/nats/permissions.d.ts +13 -0
  65. package/build/lib/messaging/nats/permissions.js +22 -0
  66. package/build/lib/messaging/nats/provider.d.ts +30 -0
  67. package/build/lib/messaging/nats/provider.js +77 -0
  68. package/build/lib/messaging/nats/render-conf.d.ts +68 -0
  69. package/build/lib/messaging/nats/render-conf.js +58 -0
  70. package/build/lib/messaging/nats/streams.d.ts +26 -0
  71. package/build/lib/messaging/nats/streams.js +97 -0
  72. package/build/lib/messaging/subjects.d.ts +8 -0
  73. package/build/lib/messaging/subjects.js +25 -0
  74. package/build/scripts/bambu-boat.d.ts +17 -0
  75. package/build/scripts/bambu-boat.js +134 -0
  76. package/build/scripts/bambu-print.d.ts +18 -0
  77. package/build/scripts/bambu-print.js +114 -0
  78. package/build/scripts/machine-line.d.ts +33 -0
  79. package/build/scripts/machine-line.js +301 -0
  80. package/build/services/machines/commands.d.ts +29 -0
  81. package/build/services/machines/commands.js +37 -0
  82. package/build/services/machines/config.d.ts +49 -0
  83. package/build/services/machines/config.js +94 -0
  84. package/build/services/machines/contracts.d.ts +210 -0
  85. package/build/services/machines/contracts.js +124 -0
  86. package/build/services/machines/forms.d.ts +55 -0
  87. package/build/services/machines/forms.js +37 -0
  88. package/build/services/machines/gateway/actions.d.ts +111 -0
  89. package/build/services/machines/gateway/actions.js +8 -0
  90. package/build/services/machines/gateway/address-book.d.ts +16 -0
  91. package/build/services/machines/gateway/address-book.js +24 -0
  92. package/build/services/machines/gateway/admission.d.ts +35 -0
  93. package/build/services/machines/gateway/admission.js +66 -0
  94. package/build/services/machines/gateway/api-escalations.d.ts +23 -0
  95. package/build/services/machines/gateway/api-escalations.js +101 -0
  96. package/build/services/machines/gateway/dispatcher.d.ts +76 -0
  97. package/build/services/machines/gateway/dispatcher.js +197 -0
  98. package/build/services/machines/gateway/escalations.d.ts +51 -0
  99. package/build/services/machines/gateway/escalations.js +40 -0
  100. package/build/services/machines/gateway/executor.d.ts +60 -0
  101. package/build/services/machines/gateway/executor.js +221 -0
  102. package/build/services/machines/gateway/gateway.d.ts +88 -0
  103. package/build/services/machines/gateway/gateway.js +270 -0
  104. package/build/services/machines/gateway/index.d.ts +10 -0
  105. package/build/services/machines/gateway/index.js +26 -0
  106. package/build/services/machines/gateway/jobs.d.ts +16 -0
  107. package/build/services/machines/gateway/jobs.js +24 -0
  108. package/build/services/machines/gateway/lifecycle.d.ts +21 -0
  109. package/build/services/machines/gateway/lifecycle.js +89 -0
  110. package/build/services/machines/gateway/log.d.ts +41 -0
  111. package/build/services/machines/gateway/log.js +42 -0
  112. package/build/services/machines/gateway/lt-http.d.ts +51 -0
  113. package/build/services/machines/gateway/lt-http.js +120 -0
  114. package/build/services/machines/gateway/pacing.d.ts +43 -0
  115. package/build/services/machines/gateway/pacing.js +158 -0
  116. package/build/services/machines/gateway/plan-escalation.d.ts +19 -0
  117. package/build/services/machines/gateway/plan-escalation.js +210 -0
  118. package/build/services/machines/gateway/plan-machine.d.ts +17 -0
  119. package/build/services/machines/gateway/plan-machine.js +283 -0
  120. package/build/services/machines/gateway/plan-shared.d.ts +18 -0
  121. package/build/services/machines/gateway/plan-shared.js +36 -0
  122. package/build/services/machines/gateway/provision.d.ts +22 -0
  123. package/build/services/machines/gateway/provision.js +79 -0
  124. package/build/services/machines/gateway/receipts.d.ts +33 -0
  125. package/build/services/machines/gateway/receipts.js +65 -0
  126. package/build/services/machines/gateway/reconcile.d.ts +35 -0
  127. package/build/services/machines/gateway/reconcile.js +99 -0
  128. package/build/services/machines/gateway/registration.d.ts +38 -0
  129. package/build/services/machines/gateway/registration.js +121 -0
  130. package/build/services/machines/gateway/reply-waiter.d.ts +24 -0
  131. package/build/services/machines/gateway/reply-waiter.js +65 -0
  132. package/build/services/machines/gateway/state.d.ts +145 -0
  133. package/build/services/machines/gateway/state.js +241 -0
  134. package/build/services/machines/gateway/trace.d.ts +16 -0
  135. package/build/services/machines/gateway/trace.js +43 -0
  136. package/build/services/machines/identities.d.ts +25 -0
  137. package/build/services/machines/identities.js +77 -0
  138. package/build/services/machines/index.d.ts +11 -0
  139. package/build/services/machines/index.js +27 -0
  140. package/build/services/machines/leadership.d.ts +35 -0
  141. package/build/services/machines/leadership.js +100 -0
  142. package/build/services/machines/roles.d.ts +13 -0
  143. package/build/services/machines/roles.js +62 -0
  144. package/build/services/machines/rows.d.ts +30 -0
  145. package/build/services/machines/rows.js +71 -0
  146. package/build/services/machines/select.d.ts +25 -0
  147. package/build/services/machines/select.js +76 -0
  148. package/build/services/machines/surface.d.ts +33 -0
  149. package/build/services/machines/surface.js +104 -0
  150. package/build/services/machines/testing/fake-long-tail.d.ts +79 -0
  151. package/build/services/machines/testing/fake-long-tail.js +158 -0
  152. package/build/services/machines/testing/index.d.ts +3 -0
  153. package/build/services/machines/testing/index.js +19 -0
  154. package/build/services/machines/testing/recording-log.d.ts +14 -0
  155. package/build/services/machines/testing/recording-log.js +22 -0
  156. package/build/services/machines/testing/wait.d.ts +3 -0
  157. package/build/services/machines/testing/wait.js +17 -0
  158. package/build/services/mcp/db-server/schemas.d.ts +2 -2
  159. package/build/start/adapters.js +11 -2
  160. package/build/start/index.js +20 -1
  161. package/build/system/mcp-servers/admin/schemas.d.ts +221 -221
  162. package/build/system/mcp-servers/db-query/schemas.d.ts +2 -2
  163. package/build/system/mcp-servers/human-queue-schemas.d.ts +6 -6
  164. package/build/tsconfig.tsbuildinfo +1 -1
  165. package/build/types/index.d.ts +3 -1
  166. package/build/types/machines.d.ts +167 -0
  167. package/build/types/machines.js +7 -0
  168. package/build/types/messaging.d.ts +72 -0
  169. package/build/types/messaging.js +11 -0
  170. package/build/types/startup.d.ts +49 -1
  171. package/docs/events.md +50 -4
  172. package/docs/machines.md +322 -0
  173. package/package.json +13 -1
package/docs/events.md CHANGED
@@ -16,6 +16,13 @@ await start({
16
16
  events: { nats: { url: 'nats://localhost:4222' } },
17
17
  });
18
18
 
19
+ // The same NATS as Long Tail's messaging, shared with the machine surface
20
+ await start({
21
+ database: { connectionString: process.env.DATABASE_URL },
22
+ workers: [ ... ],
23
+ messaging: { nats: { url: 'nats://nats:4222', token: process.env.NATS_TOKEN, ws: { proxy: 'ws://nats:9222' } } },
24
+ });
25
+
19
26
  // Custom adapters (multiple supported)
20
27
  await start({
21
28
  database: { connectionString: process.env.DATABASE_URL },
@@ -26,6 +33,32 @@ await start({
26
33
 
27
34
  `start()` handles adapter connection and graceful disconnection on shutdown automatically.
28
35
 
36
+ ## Messaging
37
+
38
+ Messaging is one connection that Long Tail opens once and shares: events publish over its pub/sub, and the machine surface keeps the line's durable streams in it. NATS is the provider to deploy, and with JetStream enabled on the server it also provides durable streams. `start()` connects it before events and the machine surface, and closes it after both at shutdown. If it cannot connect, startup logs `messaging.provider.connect_failed` and continues, as events always have.
39
+
40
+ Configure it in one of these ways, in this order of precedence:
41
+
42
+ | Configuration | Meaning |
43
+ |---|---|
44
+ | `messaging.provider` | A provider of your own (`LTMessagingProvider`) |
45
+ | `messaging.nats` | `{ url, token? \| user? + password?, ws? }`; `ws` is `{ url?, proxy?, token? }` for browsers |
46
+ | `messaging.memory` | In process only, for tests and single-process development |
47
+ | `LT_MESSAGING=nats` with `LT_NATS_URL`, `LT_NATS_TOKEN` or `LT_NATS_USER` and `LT_NATS_PASSWORD`, `LT_NATS_WS_URL`, `LT_NATS_WS_PROXY` | The same from the environment; `LT_MESSAGING=memory` for in process |
48
+ | `events.nats` | The same NATS connection, configured as events have been |
49
+
50
+ `events.nats` and `messaging.nats` configure the same connection, so an existing `events: { nats: { url, token, wsProxy } }` keeps working as it is. With none of these, Long Tail runs without messaging, events go over Socket.IO, and anything that asks for messaging (`messagingRegistry.pubsub` or `.streams`) throws, naming what to set.
51
+
52
+ **One server, separate accounts.** A NATS server that only Long Tail and its browsers use needs only a token. When a site's machine broker also connects to it, give each party a NATS account so it reaches only its own subjects. `renderNatsConf` writes such a server's configuration:
53
+
54
+ | Account | Who logs in | What it reaches |
55
+ |---|---|---|
56
+ | `LT` | Long Tail; optionally a subscribe-only dashboard user, and a standalone gateway user scoped by `jsPermissions` to its streams and consumer | Everything of Long Tail's, including the durable streams |
57
+ | `EDGE_<site>_<line>` (`Machines.edgeAccountFor(line, root)`) | The machine broker's bridge, over MQTT only | Publishing the line's subjects; nothing else. `LT` imports exactly those subjects |
58
+ | `SYS` | Monitoring | The server's own |
59
+
60
+ The edge's subjects reach Long Tail's streams, and nothing of Long Tail's reaches the edge.
61
+
29
62
  ## Dashboard Transport Selection
30
63
 
31
64
  The dashboard auto-detects its event transport via `GET /api/settings`. By default, Socket.IO is reported — it works in-process with no additional infrastructure.
@@ -162,15 +195,28 @@ The built-in `NatsEventAdapter` publishes events as JSON-encoded strings to NATS
162
195
  | Option | Default | Description |
163
196
  |-----------------|----------------------------------------------|----------------------------------|
164
197
  | `url` | `process.env.NATS_URL` or `nats://localhost:4222` | NATS server URL |
198
+ | `token` | `process.env.NATS_TOKEN` | NATS token |
199
+ | `wsUrl` | `process.env.NATS_WS_URL` | Public WebSocket URL for browsers |
200
+ | `wsProxy` | | NATS WebSocket target that Long Tail proxies browsers to |
165
201
  | `subjectPrefix` | `lt.events` | Prefix for NATS subject names |
202
+ | `messaging` | | Long Tail's messaging; the adapter publishes over it instead of opening a connection of its own, and skips publishing while it is disconnected |
166
203
 
167
204
  ### Subject Format
168
205
 
169
- Events are published to `{subjectPrefix}.{event.type}`. A milestone event with the default prefix lands on:
206
+ Events are published to `{subjectPrefix}.{event.type}`. Every built-in type starts with `system.`:
170
207
 
171
- ```
172
- lt.events.milestone
173
- ```
208
+ | Event | Subject |
209
+ |---|---|
210
+ | Milestone | `lt.events.system.milestone.<workflowId>` |
211
+ | Task | `lt.events.system.task.<taskId>.<action>` |
212
+ | Escalation | `lt.events.system.escalation.<role>.<escalationId>.<action>` |
213
+ | Activity | `lt.events.system.activity.<workflowId>.<activityName>.<action>` |
214
+ | Workflow | `lt.events.system.workflow.<workflowId>.<action>` |
215
+ | Agent | `lt.events.system.agent.<agentName>.<action>` |
216
+ | Knowledge | `lt.events.system.knowledge.<domain>.<action>` |
217
+ | File | `lt.events.system.file.<action>` |
218
+
219
+ A role is written as one subject token: each run of characters other than letters, digits, `_` and `-` becomes `-`. Subscribe to `lt.events.system.escalation.<role>.>` for one role's escalations.
174
220
 
175
221
  ### Usage
176
222
 
@@ -0,0 +1,322 @@
1
+ # Machines as resolvers
2
+
3
+ A workflow step parks an escalation row and waits. A person resolves it on the dashboard, an API caller resolves it over HTTP, and with the machine surface a machine resolves it over MQTT. The row, the wait and the result are the same in every case. By the time the row resolves, the protocol has been turned into data on the escalation, the same way a form submission is.
4
+
5
+ This page is the contract for a host. A runnable factory lives in [examples/workflows/machine-line](../examples/workflows/machine-line/README.md).
6
+
7
+ ## Declaration
8
+
9
+ ```typescript
10
+ import { start, Machines } from '@hotmeshio/long-tail';
11
+
12
+ await start({
13
+ // ...database, workers, roles
14
+ machines: {
15
+ lines: [Machines.loadLine('/etc/line.json')],
16
+ codec: myCodec, // optional: the machines' wire protocol (default: the generic line protocol)
17
+ rows: myRows, // optional: how your rows read as handoffs (default: the standard handoff facets)
18
+ },
19
+ });
20
+ ```
21
+
22
+ Transports come from the environment (see [Configuration](#configuration)). `mqtt` is an optional peer dependency; install it in the host when a line runs.
23
+
24
+ For each line, one replica leads (a Postgres advisory lock held on its own connection), makes sure the line's bot accounts exist, and runs the gateway. Other replicas wait on the lock and take over when the leader's session ends. A replica whose connection drops or is refused, as while Postgres restarts, contends again every 5 seconds.
25
+
26
+ ## The line
27
+
28
+ ```json
29
+ {
30
+ "siteId": "site-1",
31
+ "lineId": "line-1",
32
+ "roles": { "exit": "inspector", "operator": "line-operator", "receivers": ["harvester"] },
33
+ "machines": [
34
+ { "machineId": "bond-cell-1", "process": "bond", "role": "bond-cell", "carrier": "none",
35
+ "cycleSec": 8, "queueDepth": 4, "recipes": { "RCP-BOND-A": ["2"] }, "allowedSoftware": ["1.4.0"] }
36
+ ]
37
+ }
38
+ ```
39
+
40
+ | Field | Meaning |
41
+ |---|---|
42
+ | `machines[].role` | The role whose handoff rows the machine resolves. Default `line-<process>` |
43
+ | `machines[].address` | The physical machine on the wire: its topic level, client id and certificate name, such as a permanent UUID that stays with the machine if it moves. Default `machineId`, which stays the station that rows, roles and routes name |
44
+ | `addressing` | `declared` (default): each address is `machines[].address`, or the station's id. `learned`: a station with no `address` learns it from the machine that claims it. See [Learned addresses](#learned-addresses) |
45
+ | `roles.exit` | The role of the person who receives units at the end of a route. Default `line-exit` |
46
+ | `roles.operator` | The role that gets failed handoffs, stopping alarms, and offline or quiet machines. Default `line-operator` |
47
+ | `roles.receivers` | Other roles of people who take units straight from a machine. Machine bots join these roles and the exit role, so they can record their releases on those rows |
48
+ | `machines[].allowedSoftware` | Versions the line accepts. A machine reporting another version is refused before any dispatch |
49
+ | `machines[].queueDepth` | Most jobs the machine is sent or holds at once. Unset: no limit. See [Pacing](#pacing) |
50
+ | `machines[].requireArtifact` | The machine needs a job file. A handoff with neither the job's `artifactUrl` and `artifactSha256` nor an `artifact` on every unit is refused before anything is sent |
51
+
52
+ `start({ machines })` declares the station, exit and operator roles unless `roles` already declares them; set `declareRoles: false` to own them all.
53
+
54
+ ## Rows
55
+
56
+ A machine handoff is a row on the receiving machine's role (a handoff to a person goes on one of the line's receiver roles) whose metadata says what to move:
57
+
58
+ | Facet | Meaning |
59
+ |---|---|
60
+ | `siteId`, `lineId` | The line; a gateway acts only on its own line's rows |
61
+ | `units` | `[{ unitId, carrierId, artifact?, artifacts?, parameters? }]`: the units that move together; any of a route's units, all or some. A unit's `artifact` is its own file, such as a per-unit model, and `artifacts` its files when it has several; each is `{ url, sha256, id?, contentType?, sizeBytes? }`. Its `parameters` are its own settings |
62
+ | `jobId`, `senderJobId` | The receiver's job and the sender's. Each open handoff into a machine names its own job; a second open row naming the same job there resolves `dispatch-refused`. A row for some of a sender job's units still receives their releases, however the codec split that job |
63
+ | `fromMachine`, `toMachine` | Machine ids, or null for line entry and for a person |
64
+ | `recipeId`, `recipeVersion`, `process` | What the receiver runs |
65
+ | `artifactUrl`, `artifactSha256` | The job file, by reference |
66
+ | `parameters` | Settings for the job, sent to the machine as given. Long Tail does not read them; what they mean is between the host and its codec |
67
+ | `attempt` | Retry number |
68
+
69
+ The gateway dispatches to `toMachine` when the row is created, or, under a codec that dispatches on arrival, once every unit has arrived there. It claims the row as that machine once the machine accepts. It records each unit's release by the sender and each arrival at the receiver as items. It resolves the row as the receiver once every unit has arrived:
70
+
71
+ ```typescript
72
+ { received: true, receiver: 'cut-01', units: ['U1', 'U2'] }
73
+ ```
74
+
75
+ When a handoff ends without every unit (a failure, a timeout, a cancel), the gateway withdraws only the jobs none of whose units reached the receiver, so nothing queued for an absent unit runs later. A job some of whose units already arrived is under way: it stays, and what happens to it is the workflow's next decision. A workflow that wants it stopped parks a `cancel` command row (see Commands).
76
+
77
+ `Machines.handoffProgress(units, items)` reads where a handoff's units stand from its items (a row's items, or `$accumulated` on a finished wait): `{ released, arrived, missing }`. A workflow reads it to decide what a split means for its own process.
78
+
79
+ On arrival dispatch, the units are already at the receiver, so its acceptance resolves the row the same way.
80
+
81
+ When the handoff ends without a receipt, the row resolves `{ received: false, errorCode, unitId?, reason?, detail }`:
82
+
83
+ | `errorCode` | Cause |
84
+ |---|---|
85
+ | `dispatch-refused` | The machine refused or failed the dispatch (mode, recipe, queue, artifact), or the machine requires an artifact and the row has none. The reason is in `detail` |
86
+ | `dispatch-failed` | The machine accepted the job, then could not prepare it (artifact, recipe). The reason is in `detail` |
87
+ | `no-reply` | No reply after every attempt with the same command id, and the machine's snapshot does not list the job, or it sent none (`detail` then says the state is unknown). A job the snapshot lists counts as accepted. The dispatch is cancelled, so a copy still queued for an absent machine never runs |
88
+ | `software-unsupported` | The machine runs a version the line does not allow |
89
+ | `sender-rejected` | The sender finished a unit as reject, hold, scrap or rework. Its reason is in `reason` and `detail` |
90
+ | `jam`, `incomplete-handshake`, `carrier-mismatch`, `unreadable-id` | The move between machines failed |
91
+ | `withdrawn` | The receiver gave the job back |
92
+
93
+ A row on a person's role resolves the same way, from the dashboard. A workflow can mix both in one route and never tell them apart.
94
+
95
+ ### Traceability
96
+
97
+ When a sender finishes a unit, the gateway records the release on the row and, in the same statement, merges two facets into the row's metadata:
98
+
99
+ | Facet | Holds |
100
+ |---|---|
101
+ | `trace` | One entry per unit: `{ unitId, machineId, jobId, result, materials: [{ material, lot }] }` |
102
+ | `lots` | Every lot those units consumed, once each |
103
+
104
+ Both are containment-searchable, so the rows that used a lot, and with them the units, are one query: `GET /escalations?facets={"lots":["LOT-7"]}`. A unit's whole path is every handoff row whose `units` holds it. The sender's full finished record stays on the row as its `released:<unit>` item. A codec maps its own lot fields into `materials`.
105
+
106
+ ### Job lifecycle on the row
107
+
108
+ With `MACHINE_LIFECYCLE=on`, while a machine works a job, the gateway notes the job's milestones as items on the open row that holds it, as the machine's bot:
109
+ - the dispatch into the machine, until its units arrive;
110
+ - after that, the handoff out of the machine.
111
+
112
+ | Item | When |
113
+ |---|---|
114
+ | `started:<unit>` | The machine reports `started` |
115
+ | `progress:<unit>:<step>` | `progress` crosses a step of `MACHINE_PROGRESS_STEP` percent (25 by default, so 25, 50 and 75); the row's `progress` facet holds the latest `{ percent, remainingSec, stage, detail, at }` |
116
+ | `stage:<unit>:<stage>` | `progress` names a `stage` the job has not reached before, for machines that report named steps rather than a percent |
117
+ | `paused:<alarm>:<raisedAt>` | A stopping alarm is raised at the machine (the alarm row still goes to the operator role) |
118
+ | `resumed:<alarm>:<raisedAt>` | The machine clears that alarm |
119
+
120
+ Every other progress message stays in the stream, so an hour-long job leaves a handful of items on its row. A row parked with a limit on its items (`accumulate: { max }`) gets no notes, so they never take a slot its units need. Off, the rows hold only their releases and arrivals. A machine whose job runs before its units arrive, such as a printer whose part exists only once the print finishes, reports the arrival at the end, and the whole job lands on the one row. A `progress` job event may carry `percent` (0 to 100), `remainingSec`, a `stage` name and a `detail` object.
121
+
122
+ ### Decisions on a failed handoff
123
+
124
+ `Machines.handoffDecisions(errorCode)` gives a default set of decisions for a failed handoff. A failure in the move leaves the units on the line, so `retry`, `manual` and `scrap` all fit. Units the sender finished as not ok (`sender-rejected`) leave the line and never re-enter it, so only `manual` and `scrap` fit. After a `partial-arrival` (a timeout once only some units arrived) the units are split between two stations, and a retry of the same hop would ask the sender for units it no longer holds, so the default offers `manual` and `scrap`. A workflow is free to offer its own decisions instead, such as moving only the missing units on; the platform reports the facts and does not choose.
125
+
126
+ The example route's policy for a split: it stops the receiver's job with a `cancel` command row, then a person collects each group of units where it stopped.
127
+
128
+ A host whose rows already carry their own facets supplies a `HandoffRowAdapter`:
129
+
130
+ ```typescript
131
+ const rows: Machines.HandoffRowAdapter = {
132
+ openRows: () => [{ type: 'station-step' }], // read when a replica takes the line
133
+ toHandoff: (row) => row.type === 'station-step' ? { siteId: row.metadata.plant, /* ... */ } : null,
134
+ };
135
+ ```
136
+
137
+ ## Pacing
138
+
139
+ A machine with a `queueDepth` is sent no more jobs than that at once. A handoff beyond it waits in the machine's role queue, pending and unclaimed, the way work waits for a busy person. The oldest waiting row goes out when the machine has room, and a full machine holds back its later rows even when a smaller one would fit. A machine with nothing in hand always takes the next row, even one with more jobs than the depth.
140
+
141
+ A machine's load is every job it has been sent or still holds:
142
+ - rows dispatched into it;
143
+ - rows whose units still wait on it to move on;
144
+ - units that arrived and have not left.
145
+
146
+ A unit leaves when the machine releases it, when it arrives at the next machine, or when the machine's snapshot no longer lists its job. A replica taking the line reads every open row back before it sends anything, so pacing survives a restart.
147
+
148
+ ## Commands
149
+
150
+ A host asks a machine to start, stop, pause, resume, change over or cancel a job (`cancel` needs `args.jobId`) by parking a `machine-command` row on the machine's role, from a workflow step or through the API, the way it asks a person for anything:
151
+
152
+ ```typescript
153
+ const reply = await conditional<Machines.CommandResolution>(`changeover-${orderId}`, {
154
+ ...Machines.commandRow(Machines.commandTarget(line, 'bond-cell-1'), 'changeover', { recipe: { id: 'RCP-BOND-B', version: '1' } }),
155
+ timeout: '10m',
156
+ });
157
+ ```
158
+
159
+ Asking a machine for a command takes the same access as parking any row on its role: write access to that role, or a workflow that parks the row. A command row on any other role resolves `unsupported`.
160
+
161
+ The gateway sends the command and resolves the row with the reply, as the machine that gave it:
162
+
163
+ ```typescript
164
+ { accepted: true, machineId: 'bond-cell-1', op: 'changeover', status: 'accepted', reason: 'changing over to RCP-BOND-B v1' }
165
+ ```
166
+
167
+ | `status` | Meaning |
168
+ |---|---|
169
+ | `accepted` | The machine took the command |
170
+ | `refused`, `failed` | The machine said no; its reason is in `reason` (a changeover to a version it does not hold is refused) |
171
+ | `no-reply` | No reply after every attempt. The command expired at the end of the asking, so a copy that reaches the machine later is refused, never run |
172
+ | `unsupported` | Not a command a row may ask for, not a machine on this line, or a command the line's codec has no message for; nothing was sent |
173
+
174
+ A command is sent again, under the same `cmdId` and with the same arguments, by a gateway that takes over the line while its row is open. Its `expiresAt` counts in rounds of the asking from the row's creation, and a round begins only once the one before it has expired; a command sent in a later round goes out as a new one, `<row>:command:r<n>`, never as an old one with changed arguments. Dispatches work the same way with `MACHINE_DISPATCH_TTL_MS`, counted from the row's creation, or for `dispatchOn: 'arrival'` from the last unit's arrival, so a machine never holds two valid commands for one row.
175
+
176
+ The example's `machineCommand` workflow is this one step.
177
+
178
+ ## The wire
179
+
180
+ The gateway works in the generic line protocol. A codec translates:
181
+
182
+ ```typescript
183
+ interface LineCodec {
184
+ root: string;
185
+ channels: { events: string[]; telemetry: string[]; commands: string[] }; // MQTT filters under each machine
186
+ topicFor?(scope: { siteId?, lineId?, address? }, channel: string): string; // the topic layout; absent levels are +
187
+ unitsPerDispatch: number | null; // 1: one dispatch per unit
188
+ dispatchOn?: 'created' | 'arrival'; // when a handoff's job goes out
189
+ strict?: boolean; // broken messages go to a person, never acted on
190
+ snapshotOnStart?: boolean; // ask every machine for a snapshot on taking the line
191
+ retainedAlarms?: boolean; // active alarms stay retained on the broker until they clear
192
+ decode(topic: string, payload: string, retained?: boolean): Inspection[];
193
+ encode(ident: MachineIdent, command: MachineCommand): WireMessage[]; // [] when the machines lack the command
194
+ receipt?(ident: MachineIdent, messageIds: string[]): WireMessage[]; // for machines that keep an outbox
195
+ }
196
+ ```
197
+
198
+ - `decode` may return several events for one message. That is how a codec infers a receipt from a different event when the machines report no arrival. `Machines.genericCodec({ root, receiptFrom: 'started' | 'bound' })` does this for the generic protocol.
199
+ - With `unitsPerDispatch: 1` the gateway sends one job per unit, claims the row once every job is accepted, and cancels the accepted jobs if any is refused.
200
+ - `topicFor` lays out the topics, for a protocol with its own levels (a direction before the machine, say). The layout begins with `{root}/{site}/{line}/`. The broker ACL and the stream subjects follow it. Without it the layout is `{root}/{site}/{line}/{address}/{channel}`.
201
+ - `dispatchOn: 'arrival'` is for machines that take work only for units they have scanned in. The job goes out once every unit of the row has arrived, and each unit carries the arrival event its receiver reported (`units[].arrival`), so `encode` can echo the receiver's own visit id.
202
+ - A reply is `accepted` (recorded), and a machine that prepares its jobs may later reply `completed` (ready) or `failed` under the same `cmdId`. A failure after acceptance ends the handoff as `dispatch-failed`. When no reply comes, the gateway asks the machine for its snapshot before deciding.
203
+ - `receipt` confirms the messages the gateway is done with, for machines that resend an event until it is confirmed. `decode` marks each such message with a `receiptId`. The ids go out per machine in batches of up to 100 after `MACHINE_RECEIPT_DELAY_MS`, including messages already applied. A message still waiting for its row is confirmed once it lands.
204
+ - `strict: true` holds a message with contract violations: it is confirmed, never acted on, and one `contract-violation` row shows it to the operator role. Without it, known fields are acted on and the violations are logged.
205
+ - An alarm body may name the catalog edition its code comes from, `catalog: { domain, key, version }`. The alarm row then carries it as a lookup ref, so the row reads the code's meaning as of that edition.
206
+ - Event channels go to the durable stream and telemetry channels are read live. A machine reads its commands on the command channels. The broker ACL is rendered from these channels, so a codec with its own topics gets its own ACL. Unknown body fields are allowed and known ones are checked. Payloads may be up to 256 KB.
207
+ - `snapshotOnStart: true` is for machines that keep no retained state and report only what changes. The gateway asks each machine for a snapshot once it takes the line, so it learns every machine's current job before it acts on a change.
208
+ - `retainedAlarms: true` says active alarms stay retained on the broker until they clear, as in the line protocol. A gateway taking the line then reads every active alarm back, and an alarm row whose alarm is missing has cleared. Without it, a machine's snapshot lists its active alarms (`alarms`), and an alarm row whose alarm a snapshot the machine sends no longer lists resolves `cleared`; a snapshot read back from the broker is never used for this, since it may be older than the alarms.
209
+ - A receiver that reads only the carrier may report `arrived`, or `transfer-failed` with `unreadable-id`, with `carrierId` and no `unitId` or `jobId`. The gateway finds the unit an open handoff into that machine carries on that carrier.
210
+ - Each command reaches `encode` with `attempt`, how many times this gateway has sent that `cmdId`, and `retry`, true when the machine may already have it: sent earlier, or its row existed before this gateway took the line. A protocol with a retransmission flag sets it from `retry`.
211
+ - When `encode` returns no messages, the machines have no such command. The gateway sends nothing and waits for nothing: a machine-command row resolves `unsupported`, and a cancel or snapshot the machines lack is logged as unsupported.
212
+
213
+ The generic protocol's topics are `{root}/{site}/{line}/{machine}/{channel}`. Every message is wrapped in `{ v, id, at, source, body }`.
214
+
215
+ | Channel | Purpose |
216
+ |---|---|
217
+ | `presence` (retained) | Birth and will, with the software version |
218
+ | `snapshot` (retained) | Mode, recipe, jobs, alarms, counters, boot id |
219
+ | `job` | Per unit: `queued`, `ready`, `arrived`, `bound`, `started`, `progress`, `finished`, `released`, `transfer-failed`, `withdrawn` |
220
+ | `alarm/<id>` (retained while active) | Raise, acknowledgement, clear (with who cleared it) |
221
+ | `telemetry` | Readings |
222
+ | `report/<name>` | Production, availability, inventory and other reporting, kept in the stream |
223
+ | `cmd` / `cmd/reply` | `dispatch`, `cancel`, `start`, `stop`, `pause`, `resume`, `changeover`, `ack-alarm`, `snapshot`; replies `accepted`, `completed`, `refused`, `failed`. Every command carries a `cmdId` (a repeat is answered, never run twice) and an expiry |
224
+
225
+ ## Rows for people
226
+
227
+ | Row | Raised when | Resolved when |
228
+ |---|---|---|
229
+ | `line-alarm` | A stopping alarm (critical or emergency) is raised | The machine clears it (with who cleared it). If a person resolves it first, the gateway sends `ack-alarm` |
230
+ | `machine-offline` | Presence goes offline past a debounce | The machine is heard again |
231
+ | `machine-quiet` | A connected machine sends nothing for `MACHINE_QUIET_MS` | It is heard again |
232
+ | `contract-violation` | A strict codec decodes a message that breaks the contract. The row holds the raw message and the violations | A person resolves it |
233
+ | `machine-registration` | On a line that learns addresses, an address claims a station bound to another address, or an address bound to one station claims another | A person resolves it with `{ decision: 'approve' }`, which binds the station to the claimant, or `reject` |
234
+
235
+ A replica taking a line reads back its learned addresses, its open handoff, command, alarm, offline, quiet and registration rows, and each open handoff's arrivals, so the receiver holds those units again. It then reads the machines' retained state, so a clear or a return that happened while no gateway was listening still resolves its row. A machine that restarts with a new boot id gets its accepted jobs dispatched again under the same command ids. An alarm id reused with a new raise time counts as a new alarm.
236
+
237
+ ## Topology and identity
238
+
239
+ ```
240
+ machines ──MQTT, TLS, client cert──▶ machine broker ──bridge──▶ NATS with JetStream
241
+ (Mosquitto) (Long Tail's messaging, or a site NATS)
242
+ ▲ ▲
243
+ └── gateway (commands) └── gateway (streams, read only)
244
+ ```
245
+
246
+ - **Machines:** each machine logs in over MQTT 3.1.1 or 5 with its own certificate; the CN (`{site}-{line}-{address}`) is its username. The broker ACL lets it write only its own channels and read only its own commands. The line controller may command every machine on the line.
247
+ - **Durable streams:** the bridge carries the line's topics one way into NATS, where only the bridge may publish them. JetStream keeps `LINE_EVENTS` (30 days) and `LINE_TELEMETRY` (3 days). The gateway reads through a durable consumer, `<controller>-events`, and may publish only JetStream requests and acks for its own streams and consumer; it cannot delete or purge them.
248
+ - **Where the streams live:** by default in Long Tail's messaging ([events](events.md#messaging)), whose NATS server must run JetStream. The bridge then logs in to that server's edge account for the line (`Machines.edgeAccountFor`), which may publish only the line's subjects, so the edge reaches nothing of Long Tail's. With `MACHINE_SITE_NATS_URL`, the streams live instead in a NATS server of the site's own (`Machines.renderSiteConf`), and Long Tail's NATS stays inside Long Tail. Either way the gateway reads the same streams under the same consumer name.
249
+ - **Escalation events:** the gateway inside Long Tail reads them in process. A standalone gateway subscribes to them over Long Tail's messaging.
250
+ - **Commands:** the gateway publishes commands to the machine broker at QoS 1, so they queue for a machine that is away.
251
+ - **Bot accounts:** in Long Tail each machine is a bot account on its station and receiver roles, so `claimed_by` and `resolved_by` name the machine. The controller bot holds those roles and the operator role, and raises the operator rows. The bots are member accounts with no password, under names the line derives; the line will not start while a person's account holds one of those names.
252
+ - **Rows are the authority:** an escalation event only tells the gateway which row to look at. It reads the row back as the controller and acts on the stored role, status and metadata. A row on a role the controller does not hold, or an event its stored row contradicts, does nothing.
253
+ - **Roles carry the access:** dispatching to a machine or commanding one means parking a row on that machine's role, so Long Tail's role access decides who can. The gateway acts on a handoff into a machine only on that machine's role, and on a handoff to a person only on a receiver role. A row commands only the machine whose role it is on: cancelling a handoff withdraws the receiver's dispatch, and the sender's job is stopped only by a `cancel` command row on the sender's role.
254
+ - **Rendering:** `Machines.renderSiteConf`, `renderMosquittoConf` and `renderMosquittoAcl(line, codec)` render the configuration from the line; the ACL takes its topics from the codec's channels.
255
+ - **Machines that host their own broker:** the machine broker connects out to each machine's broker with a Mosquitto bridge, carrying the machine's messages in and its commands out under the machine's level of the line's topics, and its codec reads them there. The bridge's notification topic, kept on the machine broker, is the machine's presence. `examples/workflows/bambu-printer` does this for 3D printers.
256
+
257
+ ### Learned addresses
258
+
259
+ A line with `addressing: 'learned'` names its stations and lets each machine tell it who it is, so a machine can be commissioned, moved or replaced without editing the line.
260
+
261
+ - **The claim:** every message carries the machine's address (its topic level, which the broker ties to its certificate) and the station it says it is. A codec reports that station as the inspection's `claim`; the line protocol carries it as the envelope's `source.station`.
262
+ - **First contact binds:** a station with no address yet is bound to the first address that claims it. The gateway logs it, keeps it in its `AddressBook`, and sends the station's waiting work. Until then, handoffs into the station wait in its role queue, and command rows for it resolve `unsupported`.
263
+ - **A person settles a disagreement:** a claim on a station already bound to another address, or by an address bound to another station, raises one `machine-registration` row on the operator role. The claimant's messages have no effect, and get no receipt, until a person approves it. Approval binds only a claim the gateway has heard from that address since it took the line, for a station that learns its address; any other approval is logged (`machine_gateway.address.refused`) and changes nothing, and the claimant's next message raises a new row.
264
+ - **Where bindings live:** in process, on each station's bot account as the user property `machineAddress`; `MemoryAddressBook` otherwise. A replica taking the line reads them back before it sends anything.
265
+ - **Broker access:** stations that learn their address get no broker user of their own. `renderMosquittoAcl` writes `pattern` rules on `%u`, so every certificate may use the channels under its own name; each machine's certificate common name must therefore be its address.
266
+ - **Mixed lines:** a station that declares `address` on a learning line keeps it; claims on that station are ignored, and the gateway knows it only on its declared address. `renderMosquittoAcl` denies that station's channels to a certificate named for its address, which the `pattern` rules would otherwise grant.
267
+
268
+ ## Configuration
269
+
270
+ | Variable | Default | Meaning |
271
+ |---|---|---|
272
+ | `MACHINE_TRANSPORT` | | `nats` (durable streams) or `mqtt` (any MQTT broker, no replay beyond the session) |
273
+ | `MACHINE_ESCALATION_FEED` | `in-process` inside Long Tail, `core` for a standalone gateway | Where escalation events come from; `core` subscribes over Long Tail's messaging, or to `NATS_URL` with `NATS_TOKEN` when the gateway has none |
274
+ | `MACHINE_TOPIC_ROOT` | `line/v1` | The generic codec's root |
275
+ | `MACHINE_LINE_CONFIG` | | Path of the line JSON |
276
+ | `MACHINE_SITE_NATS_URL`, `_USER`, `_PASSWORD` | | A site NATS of its own for the streams; unset, `nats` keeps them in Long Tail's messaging |
277
+ | `MACHINE_MQTT_URL`, `_PROTOCOL` | `_PROTOCOL` 5 | Machine broker, for commands and retained reads; 4 for a broker that speaks only MQTT 3.1.1 |
278
+ | `MACHINE_MQTT_CA`, `_CERT`, `_KEY` | | TLS files; `{clientId}` in a path picks each identity's certificate |
279
+ | `MACHINE_REPLY_TIMEOUT_MS` | `5000` | Wait for a command reply |
280
+ | `MACHINE_DISPATCH_ATTEMPTS` | `3` | Tries per command before giving up |
281
+ | `MACHINE_DISPATCH_TTL_MS` | `600000` | How long a dispatch stays valid at the machine |
282
+ | `MACHINE_OFFLINE_DEBOUNCE_MS` | `15000` | Offline this long before a row |
283
+ | `MACHINE_QUIET_MS` | `90000` | Silent this long before a row; `0` turns it off |
284
+ | `MACHINE_RECEIPT_DELAY_MS` | `250` | How long confirmed message ids gather before a receipt goes out |
285
+ | `MACHINE_LIFECYCLE` | `off` | `on` notes each job's start, progress steps and pauses on the row that holds it |
286
+ | `MACHINE_PROGRESS_STEP` | `25` | With lifecycle notes on, progress is noted each time it crosses this many percent; `0` notes none |
287
+ | `MACHINE_CODEC_MODULE` | | Development server only: a module whose `createCodec(line)` speaks the machines' protocol |
288
+
289
+ ## Testing a codec or adapter
290
+
291
+ A host proves its codec and row adapter against the real gateway in memory. The package provides the in-memory transports (`Machines.MemoryBroker`, `MemoryMachineBus`, `MemoryEscalationFeed`), and `@hotmeshio/long-tail/build/services/machines/testing` provides an in-memory Long Tail (`FakeLongTail`, with claim rules and simulated outages), a recording log and `until`. The test plays the machine: it answers the commands the gateway sends and publishes the messages a machine would, for example ones captured from a real machine or a simulator.
292
+
293
+ ```typescript
294
+ import { Machines } from '@hotmeshio/long-tail';
295
+ import { FakeLongTail, until } from '@hotmeshio/long-tail/build/services/machines/testing';
296
+
297
+ const line = Machines.loadLine('./line.json');
298
+ const codec = myCodec;
299
+
300
+ // 1. Broker, escalation feed, Long Tail and the gateway, all in memory
301
+ const broker = new Machines.MemoryBroker({ redeliverMs: 5 });
302
+ const feed = new Machines.MemoryEscalationFeed(broker);
303
+ const lt = new FakeLongTail(feed);
304
+ const gateway = new Machines.MachineGateway({
305
+ bus: new Machines.MemoryMachineBus(broker, Machines.lineFilter(codec.root, line.siteId, line.lineId)),
306
+ feed, lt, line, codec, rows: myRows,
307
+ settings: { topicRoot: codec.root, replyTimeoutMs: 50, dispatchAttempts: 2, offlineDebounceMs: 10, dispatchTtlMs: 60_000, quietMs: 0 },
308
+ });
309
+ await gateway.start();
310
+
311
+ // 2. Play the machine: see what the gateway sends, answer as the machine would
312
+ broker.connect('bond-cell-1');
313
+ broker.subscribe('bond-cell-1', myCommandTopic('bond-cell-1'), (msg) => broker.publish(myReplyTopic, myAcceptedReply(msg)));
314
+
315
+ // 3. Park a row the way a workflow step would, publish the machine's report, read the outcome
316
+ const row = lt.parkRow({ type: 'station-step', role: 'bond-cell', metadata: { /* the host's own facets */ } });
317
+ await until(() => lt.rows.get(row.id)?.assigned_to === 'bond-cell-1', 2_000);
318
+ broker.publish(capturedArrival.topic, capturedArrival.payload);
319
+ expect((await lt.ended(row.id)).resolver_payload).toMatchObject({ received: true, receiver: 'bond-cell-1' });
320
+ ```
321
+
322
+ The same rig covers the unhappy paths: cut the machine off the network for `no-reply`, drop only its replies to see the snapshot keep the job, answer with a refusal for `dispatch-refused`, publish a not-ok finish for `sender-rejected`, or stop and start the gateway to prove a takeover reads its rows back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.25.4",
3
+ "version": "0.27.0",
4
4
  "description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -49,6 +49,9 @@
49
49
  "demo": "bash scripts/demo.sh",
50
50
  "ortho:populate": "ts-node scripts/ortho-populate.ts",
51
51
  "twin:farm": "ts-node scripts/twin-farm.ts",
52
+ "machine:line": "ts-node scripts/machine-line.ts",
53
+ "bambu:print": "ts-node scripts/bambu-print.ts",
54
+ "bambu:boat": "ts-node scripts/bambu-boat.ts",
52
55
  "printers:cycle": "ts-node scripts/printer-cycle.ts",
53
56
  "consolidation:sim": "ts-node scripts/consolidation-sim.ts",
54
57
  "dashboard:install": "cd dashboard && npm install",
@@ -111,11 +114,20 @@
111
114
  "@types/node": "^20.19.33",
112
115
  "@types/pg": "^8.10.0",
113
116
  "dotenv": "^16.3.1",
117
+ "mqtt": "^5.16.1",
114
118
  "rimraf": "^4.4.1",
115
119
  "ts-node": "^10.9.1",
116
120
  "ts-node-dev": "^2.0.0",
117
121
  "tsx": "^4.21.0",
118
122
  "typescript": "^5.0.4",
119
123
  "vitest": "^3.2.4"
124
+ },
125
+ "peerDependencies": {
126
+ "mqtt": "^5.16.1"
127
+ },
128
+ "peerDependenciesMeta": {
129
+ "mqtt": {
130
+ "optional": true
131
+ }
120
132
  }
121
133
  }