@hotmeshio/long-tail 0.26.0 → 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 (91) hide show
  1. package/build/index.d.ts +4 -0
  2. package/build/index.js +30 -8
  3. package/build/lib/events/nats.d.ts +11 -0
  4. package/build/lib/events/nats.js +33 -2
  5. package/build/lib/machines/index.d.ts +1 -0
  6. package/build/lib/machines/index.js +1 -0
  7. package/build/lib/machines/protocol/codec.d.ts +16 -0
  8. package/build/lib/machines/protocol/codec.js +1 -0
  9. package/build/lib/machines/protocol/contract.js +10 -1
  10. package/build/lib/machines/site/edge-account.d.ts +11 -0
  11. package/build/lib/machines/site/edge-account.js +24 -0
  12. package/build/lib/machines/site/site-conf.d.ts +7 -4
  13. package/build/lib/machines/site/site-conf.js +13 -6
  14. package/build/lib/machines/transport/delivery.d.ts +3 -4
  15. package/build/lib/machines/transport/delivery.js +3 -8
  16. package/build/lib/machines/transport/index.d.ts +2 -0
  17. package/build/lib/machines/transport/index.js +2 -0
  18. package/build/lib/machines/transport/nats-bus.d.ts +7 -13
  19. package/build/lib/machines/transport/nats-bus.js +23 -43
  20. package/build/lib/machines/transport/nats-feed.js +2 -2
  21. package/build/lib/machines/transport/pubsub-feed.d.ts +17 -0
  22. package/build/lib/machines/transport/pubsub-feed.js +39 -0
  23. package/build/lib/machines/transport/stream-bus.d.ts +40 -0
  24. package/build/lib/machines/transport/stream-bus.js +57 -0
  25. package/build/lib/machines/transport/streams.d.ts +10 -14
  26. package/build/lib/machines/transport/streams.js +18 -61
  27. package/build/lib/messaging/config.d.ts +25 -0
  28. package/build/lib/messaging/config.js +55 -0
  29. package/build/lib/messaging/constants.d.ts +26 -0
  30. package/build/lib/messaging/constants.js +26 -0
  31. package/build/lib/messaging/index.d.ts +24 -0
  32. package/build/lib/messaging/index.js +65 -0
  33. package/build/lib/messaging/memory.d.ts +16 -0
  34. package/build/lib/messaging/memory.js +147 -0
  35. package/build/lib/messaging/nats/permissions.d.ts +13 -0
  36. package/build/lib/messaging/nats/permissions.js +22 -0
  37. package/build/lib/messaging/nats/provider.d.ts +30 -0
  38. package/build/lib/messaging/nats/provider.js +77 -0
  39. package/build/lib/messaging/nats/render-conf.d.ts +68 -0
  40. package/build/lib/messaging/nats/render-conf.js +58 -0
  41. package/build/lib/messaging/nats/streams.d.ts +26 -0
  42. package/build/lib/messaging/nats/streams.js +97 -0
  43. package/build/lib/messaging/subjects.d.ts +8 -0
  44. package/build/lib/messaging/subjects.js +25 -0
  45. package/build/scripts/bambu-boat.d.ts +17 -0
  46. package/build/scripts/bambu-boat.js +134 -0
  47. package/build/scripts/bambu-print.d.ts +18 -0
  48. package/build/scripts/bambu-print.js +114 -0
  49. package/build/services/machines/config.d.ts +6 -0
  50. package/build/services/machines/config.js +16 -6
  51. package/build/services/machines/contracts.d.ts +19 -2
  52. package/build/services/machines/contracts.js +12 -3
  53. package/build/services/machines/gateway/actions.d.ts +1 -1
  54. package/build/services/machines/gateway/dispatcher.d.ts +16 -3
  55. package/build/services/machines/gateway/dispatcher.js +50 -10
  56. package/build/services/machines/gateway/executor.d.ts +7 -2
  57. package/build/services/machines/gateway/executor.js +35 -9
  58. package/build/services/machines/gateway/gateway.d.ts +6 -0
  59. package/build/services/machines/gateway/gateway.js +33 -10
  60. package/build/services/machines/gateway/lifecycle.d.ts +21 -0
  61. package/build/services/machines/gateway/lifecycle.js +89 -0
  62. package/build/services/machines/gateway/pacing.d.ts +14 -0
  63. package/build/services/machines/gateway/pacing.js +26 -2
  64. package/build/services/machines/gateway/plan-escalation.js +13 -4
  65. package/build/services/machines/gateway/plan-machine.d.ts +5 -4
  66. package/build/services/machines/gateway/plan-machine.js +55 -14
  67. package/build/services/machines/gateway/reconcile.d.ts +2 -0
  68. package/build/services/machines/gateway/reconcile.js +13 -0
  69. package/build/services/machines/gateway/state.d.ts +21 -1
  70. package/build/services/machines/gateway/state.js +16 -3
  71. package/build/services/machines/leadership.d.ts +17 -4
  72. package/build/services/machines/leadership.js +43 -18
  73. package/build/services/machines/rows.js +17 -2
  74. package/build/services/machines/select.d.ts +9 -2
  75. package/build/services/machines/select.js +20 -7
  76. package/build/services/machines/surface.js +8 -2
  77. package/build/services/machines/testing/fake-long-tail.d.ts +1 -0
  78. package/build/services/machines/testing/fake-long-tail.js +6 -3
  79. package/build/start/adapters.js +11 -2
  80. package/build/start/index.js +7 -1
  81. package/build/tsconfig.tsbuildinfo +1 -1
  82. package/build/types/index.d.ts +1 -0
  83. package/build/types/machines.d.ts +2 -1
  84. package/build/types/messaging.d.ts +72 -0
  85. package/build/types/messaging.js +11 -0
  86. package/build/types/startup.d.ts +26 -1
  87. package/docs/events.md +50 -4
  88. package/docs/machines.md +38 -9
  89. package/package.json +3 -1
  90. package/build/lib/machines/transport/nats-durable.d.ts +0 -11
  91. package/build/lib/machines/transport/nats-durable.js +0 -28
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ /**
3
+ * Make the example's first print: a small 3DBenchy (by Creative Tools), sliced
4
+ * for the printer with OrcaSlicer, written to examples/workflows/bambu-printer/files/boat.gcode.3mf.
5
+ * Run it on the host, where OrcaSlicer is installed:
6
+ *
7
+ * npm run bambu:boat
8
+ *
9
+ * 1. Download the model once into files/.
10
+ * 2. Scale it (BAMBU_BOAT_SCALE, 0.5 by default: about ten minutes on a P1S).
11
+ * 3. Slice it with the printer, process and filament presets. Each is an
12
+ * OrcaSlicer system preset name or a path to a preset JSON, which may
13
+ * inherit a system preset by name.
14
+ *
15
+ * Env: BAMBU_BOAT_PRINTER, BAMBU_BOAT_PROCESS, BAMBU_BOAT_FILAMENT, BAMBU_BOAT_SCALE,
16
+ * ORCA_SLICER (the OrcaSlicer executable) and ORCA_PROFILES (its BBL profiles directory).
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ try {
20
+ require('dotenv/config');
21
+ }
22
+ catch { /* dotenv optional */ }
23
+ const child_process_1 = require("child_process");
24
+ const fs_1 = require("fs");
25
+ const os_1 = require("os");
26
+ const path_1 = require("path");
27
+ const env = process.env;
28
+ const FILES = (0, path_1.resolve)('examples/workflows/bambu-printer/files');
29
+ const MODEL_URL = 'https://raw.githubusercontent.com/CreativeTools/3DBenchy/master/Single-part/3DBenchy.stl';
30
+ const ORCA = env.ORCA_SLICER || '/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer';
31
+ const PROFILES = env.ORCA_PROFILES || '/Applications/OrcaSlicer.app/Contents/Resources/profiles/BBL';
32
+ const PRESET = {
33
+ PRINTER: env.BAMBU_BOAT_PRINTER || 'Bambu Lab P1S 0.4 nozzle',
34
+ PROCESS: env.BAMBU_BOAT_PROCESS || '0.20mm Standard @BBL X1C',
35
+ FILAMENT: env.BAMBU_BOAT_FILAMENT || 'Bambu PLA Basic @BBL X1C',
36
+ };
37
+ const PRESET_DIR = { PRINTER: 'machine', PROCESS: 'process', FILAMENT: 'filament' };
38
+ const STL_HEADER = 84;
39
+ const STL_TRIANGLE = 50;
40
+ const say = (msg) => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
41
+ async function fetchModel() {
42
+ const path = (0, path_1.join)(FILES, '3DBenchy.stl');
43
+ if ((0, fs_1.existsSync)(path))
44
+ return path;
45
+ const res = await fetch(MODEL_URL);
46
+ if (!res.ok)
47
+ throw new Error(`GET ${MODEL_URL} -> ${res.status}`);
48
+ (0, fs_1.writeFileSync)(path, Buffer.from(await res.arrayBuffer()));
49
+ return path;
50
+ }
51
+ /** A binary STL with every vertex scaled about the origin; the slicer places it on the bed. */
52
+ function scaleStl(stl, factor) {
53
+ const count = stl.readUInt32LE(STL_HEADER - 4);
54
+ if (stl.length !== STL_HEADER + count * STL_TRIANGLE)
55
+ throw new Error('the model is not a binary STL');
56
+ const out = Buffer.from(stl);
57
+ for (let t = 0; t < count; t++) {
58
+ const at = STL_HEADER + t * STL_TRIANGLE;
59
+ for (let v = 3; v < 12; v++)
60
+ out.writeFloatLE(stl.readFloatLE(at + v * 4) * factor, at + v * 4);
61
+ }
62
+ return out;
63
+ }
64
+ /** The system presets of one kind in ORCA_PROFILES, by name. */
65
+ function systemPresets(kind) {
66
+ const dir = (0, path_1.join)(PROFILES, PRESET_DIR[kind]);
67
+ const byName = new Map();
68
+ for (const file of (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'))) {
69
+ const preset = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf8'));
70
+ if (preset.name)
71
+ byName.set(preset.name, preset);
72
+ }
73
+ return byName;
74
+ }
75
+ /**
76
+ * A preset by path or system preset name, with every setting it inherits
77
+ * written out: OrcaSlicer's command line reads only the keys in the file, so
78
+ * an inherited start, layer-change or end G-code would otherwise be lost.
79
+ */
80
+ function fullPreset(kind, value) {
81
+ const system = systemPresets(kind);
82
+ const own = (0, fs_1.existsSync)(value) ? JSON.parse((0, fs_1.readFileSync)(value, 'utf8')) : system.get(value);
83
+ if (!own)
84
+ throw new Error(`no ${kind.toLowerCase()} preset ${value} (a path, or a name in ${PROFILES}/${PRESET_DIR[kind]})`);
85
+ const chain = [own];
86
+ for (let p = own; p.inherits;) {
87
+ p = system.get(p.inherits);
88
+ if (!p)
89
+ throw new Error(`${kind.toLowerCase()} preset ${value} inherits ${chain[chain.length - 1].inherits}, which is not in ${PROFILES}`);
90
+ chain.unshift(p);
91
+ }
92
+ return Object.assign({}, ...chain, { inherits: '' });
93
+ }
94
+ /** Write a full preset for the slicer; a process or filament is marked compatible with the printer, as OrcaSlicer requires. */
95
+ function writePreset(dir, kind, preset, printerName) {
96
+ const path = (0, path_1.join)(dir, `${PRESET_DIR[kind]}.json`);
97
+ const compatibility = kind === 'PRINTER' ? {} : { from: 'User', compatible_printers: [printerName], compatible_printers_condition: '' };
98
+ (0, fs_1.writeFileSync)(path, JSON.stringify({ ...preset, ...compatibility }));
99
+ return path;
100
+ }
101
+ async function main() {
102
+ if (!(0, fs_1.existsSync)(ORCA))
103
+ throw new Error(`OrcaSlicer is not at ${ORCA}; install it or set ORCA_SLICER`);
104
+ (0, fs_1.mkdirSync)(FILES, { recursive: true });
105
+ const work = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'bambu-boat-'));
106
+ // 1. The model
107
+ const model = await fetchModel();
108
+ // 2. Scaled
109
+ const scale = Number(env.BAMBU_BOAT_SCALE || 0.5);
110
+ if (!(scale > 0))
111
+ throw new Error(`BAMBU_BOAT_SCALE="${env.BAMBU_BOAT_SCALE}" must be a number above 0`);
112
+ const scaled = (0, path_1.join)(work, 'boat.stl');
113
+ (0, fs_1.writeFileSync)(scaled, scaleStl((0, fs_1.readFileSync)(model), scale));
114
+ // 3. Sliced
115
+ const printerPreset = fullPreset('PRINTER', PRESET.PRINTER);
116
+ const printerName = printerPreset.name;
117
+ const printer = writePreset(work, 'PRINTER', printerPreset, printerName);
118
+ const process_ = writePreset(work, 'PROCESS', fullPreset('PROCESS', PRESET.PROCESS), printerName);
119
+ const filament = writePreset(work, 'FILAMENT', fullPreset('FILAMENT', PRESET.FILAMENT), printerName);
120
+ say(`slicing at ${scale}x for ${printerName}`);
121
+ (0, child_process_1.execFileSync)(ORCA, [
122
+ '--load-settings', `${printer};${process_}`, '--load-filaments', filament,
123
+ '--arrange', '1', '--slice', '0', '--outputdir', work, '--export-3mf', 'boat.gcode.3mf', scaled,
124
+ ], { stdio: 'inherit' });
125
+ const target = (0, path_1.join)(FILES, 'boat.gcode.3mf');
126
+ (0, fs_1.renameSync)((0, path_1.join)(work, 'boat.gcode.3mf'), target);
127
+ const gcode = (0, fs_1.readdirSync)(work).find((f) => f.endsWith('.gcode'));
128
+ const time = gcode ? (0, fs_1.readFileSync)((0, path_1.join)(work, gcode), 'utf8').slice(0, 2000).match(/total estimated time: ([^\n]+)/)?.[1] : undefined;
129
+ say(`${target}${time ? ` (about ${time})` : ''}`);
130
+ }
131
+ main().catch((err) => {
132
+ console.error(`failed: ${err.message}`);
133
+ process.exit(1);
134
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Print one file on a Bambu printer through Long Tail, and follow its rows.
3
+ * Run it where the printer is reachable, e.g. in the Long Tail container:
4
+ *
5
+ * docker compose exec long-tail npm run bambu:print -- [examples/workflows/bambu-printer/files/part.gcode.3mf] [station]
6
+ *
7
+ * Without a file it prints the example's boat, a half-size 3DBenchy for a P1S with a 0.8 nozzle and TPU;
8
+ * `npm run bambu:boat` slices one for another printer or filament.
9
+ *
10
+ * 1. Put the file on the printer (it prints only files on its own storage) and hash it: the unit's artifact.
11
+ * 2. Start the unit route: one print step at the station, then a person unloads.
12
+ * 3. Print the route's rows and their lifecycle items as they change, until the
13
+ * route ends. The unload row is resolved by a person on the dashboard.
14
+ *
15
+ * Env: LT_USERNAME and LT_PASSWORD (a Long Tail login that may invoke the route), BASE_URL
16
+ * (http://localhost:3000), BAMBU_SITE_DIR (deploy/bambu-site), and the printers in .env.
17
+ */
18
+ export {};
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ /**
3
+ * Print one file on a Bambu printer through Long Tail, and follow its rows.
4
+ * Run it where the printer is reachable, e.g. in the Long Tail container:
5
+ *
6
+ * docker compose exec long-tail npm run bambu:print -- [examples/workflows/bambu-printer/files/part.gcode.3mf] [station]
7
+ *
8
+ * Without a file it prints the example's boat, a half-size 3DBenchy for a P1S with a 0.8 nozzle and TPU;
9
+ * `npm run bambu:boat` slices one for another printer or filament.
10
+ *
11
+ * 1. Put the file on the printer (it prints only files on its own storage) and hash it: the unit's artifact.
12
+ * 2. Start the unit route: one print step at the station, then a person unloads.
13
+ * 3. Print the route's rows and their lifecycle items as they change, until the
14
+ * route ends. The unload row is resolved by a person on the dashboard.
15
+ *
16
+ * Env: LT_USERNAME and LT_PASSWORD (a Long Tail login that may invoke the route), BASE_URL
17
+ * (http://localhost:3000), BAMBU_SITE_DIR (deploy/bambu-site), and the printers in .env.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ try {
21
+ require('dotenv/config');
22
+ }
23
+ catch { /* dotenv optional */ }
24
+ const crypto_1 = require("crypto");
25
+ const fs_1 = require("fs");
26
+ const path_1 = require("path");
27
+ const env_1 = require("../examples/workflows/bambu-printer/env");
28
+ const files_1 = require("../examples/workflows/bambu-printer/files");
29
+ const route_1 = require("../examples/workflows/machine-line/workflows/route");
30
+ const env = process.env;
31
+ const BASE_URL = env.BASE_URL || 'http://localhost:3000';
32
+ const SITE_DIR = env.BAMBU_SITE_DIR || 'deploy/bambu-site';
33
+ const WATCH_MS = 3_000;
34
+ const BOAT = 'examples/workflows/bambu-printer/boat/p1s-0.8-tpu.gcode.3mf';
35
+ const ts = () => new Date().toISOString().slice(11, 19);
36
+ const say = (msg) => console.log(`[${ts()}] ${msg}`);
37
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
+ let token = '';
39
+ async function api(method, path, body) {
40
+ const res = await fetch(`${BASE_URL}/api${path}`, {
41
+ method,
42
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
43
+ body: body === undefined ? undefined : JSON.stringify(body),
44
+ });
45
+ const data = await res.json().catch(() => ({}));
46
+ if (!res.ok)
47
+ throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(data)}`);
48
+ return data;
49
+ }
50
+ /** The route's rows, and the items each holds, printed as they change; the route's result once it ends. */
51
+ async function follow(orderRef, workflowId) {
52
+ const shown = new Map();
53
+ for (;;) {
54
+ try {
55
+ const result = await look(orderRef, workflowId, shown);
56
+ if (result)
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ // A restart or a database blip is not the route ending: keep following.
61
+ say(`(could not read the route: ${err.message}; trying again)`);
62
+ }
63
+ await sleep(WATCH_MS);
64
+ }
65
+ }
66
+ async function look(orderRef, workflowId, shown) {
67
+ const facets = encodeURIComponent(JSON.stringify({ orderRef }));
68
+ const { escalations } = await api('GET', `/escalations?facets=${facets}&limit=20&sort_by=created_at&order=asc`);
69
+ for (const row of escalations) {
70
+ const { items = [] } = await api('GET', `/escalations/${row.id}/items`);
71
+ const view = `${row.type}/${row.role} ${row.status}${row.assigned_to ? ' claimed' : ''} [${items.map((i) => i.itemKey).join(', ')}]`;
72
+ if (shown.get(row.id) !== view)
73
+ say(`${row.id.slice(0, 8)} ${view}`);
74
+ shown.set(row.id, view);
75
+ }
76
+ const resp = await api('GET', `/workflows/${workflowId}/result`).catch(() => null);
77
+ return resp?.result?.data?.status ? resp.result.data : null;
78
+ }
79
+ async function main() {
80
+ const [file = BOAT, stationArg] = process.argv.slice(2);
81
+ if (!(0, fs_1.existsSync)(file))
82
+ throw new Error(`${file} is missing; usage: npm run bambu:print -- [path/to/part.gcode.3mf] [station]`);
83
+ const line = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(SITE_DIR, 'line.json'), 'utf8'));
84
+ const station = stationArg || line.machines[0].machineId;
85
+ const printer = (0, env_1.printersFromEnv)().find((p) => p.station === station);
86
+ if (!printer || !line.machines.some((m) => m.machineId === station))
87
+ throw new Error(`${station} is not an enabled printer on the line in ${SITE_DIR}/line.json`);
88
+ // 1. The artifact, on the printer
89
+ const routeKey = `P${Date.now().toString(36)}`;
90
+ const sha256 = (0, crypto_1.createHash)('sha256').update((0, fs_1.readFileSync)(file)).digest('hex');
91
+ say(`copying ${(0, path_1.basename)(file)} to ${station} (a minute or two for a few MB)`);
92
+ const url = await (0, files_1.stageFile)(printer, file, `${routeKey}.gcode.3mf`);
93
+ say(`${url} (sha256 ${sha256.slice(0, 12)}…)`);
94
+ // 2. The route
95
+ const { LT_USERNAME: username, LT_PASSWORD: password } = env;
96
+ if (!username || !password)
97
+ throw new Error('set LT_USERNAME and LT_PASSWORD in .env');
98
+ token = (await api('POST', '/auth/login', { username, password })).token;
99
+ const input = {
100
+ siteId: line.siteId, lineId: line.lineId, routeKey, orderRef: `ORD-${routeKey}`,
101
+ units: [{ unitId: `U-${routeKey}`, carrierId: `PLATE-${routeKey}` }],
102
+ steps: [{ machineId: station, process: env_1.PRINT_PROCESS, recipe: { ...env_1.PRINT_RECIPE } }],
103
+ artifact: { url, sha256 }, exitRole: env_1.BAMBU_ROLES.UNLOAD, handoffTimeout: env.BAMBU_HANDOFF_TIMEOUT || '6h',
104
+ };
105
+ const workflowId = `bambu-${routeKey}`;
106
+ await api('POST', `/workflows/${route_1.UNIT_ROUTE_WORKFLOW}/invoke`, { data: input, metadata: { source: 'bambu-print' }, workflowId });
107
+ say(`started ${workflowId} on ${station}; unload the part on the dashboard (${env_1.BAMBU_ROLES.UNLOAD}) when it finishes`);
108
+ // 3. Follow it
109
+ say(`route ended: ${JSON.stringify(await follow(input.orderRef, workflowId))}`);
110
+ }
111
+ main().catch((err) => {
112
+ console.error(`[${ts()}] failed: ${err.message}`);
113
+ process.exit(1);
114
+ });
@@ -37,7 +37,13 @@ export interface MachineLineConfig {
37
37
  dispatchTtlMs: number;
38
38
  /** How long confirmed message ids gather before a receipt goes out. */
39
39
  receiptDelayMs: number;
40
+ /** Note each job's start, progress steps and pauses on the row that holds it (lifecycle.ts). */
41
+ lifecycle: boolean;
42
+ /** With lifecycle notes, progress is noted each time it crosses this many percent; 0 notes none. */
43
+ progressStep: number;
40
44
  ltBaseUrl: string;
41
45
  }
42
46
  type Env = Record<string, string | undefined>;
47
+ /** The generic line protocol's topic root when MACHINE_TOPIC_ROOT is unset. */
48
+ export declare const DEFAULT_TOPIC_ROOT = "line/v1";
43
49
  export declare function loadMachineLineConfig(env?: Env): MachineLineConfig;
@@ -4,11 +4,14 @@
4
4
  * an unknown or incomplete selection throws; nothing falls back silently.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.ESCALATION_FEED = exports.MACHINE_TRANSPORT = void 0;
7
+ exports.DEFAULT_TOPIC_ROOT = exports.ESCALATION_FEED = exports.MACHINE_TRANSPORT = void 0;
8
8
  exports.loadMachineLineConfig = loadMachineLineConfig;
9
9
  const protocol_1 = require("../../lib/machines/protocol");
10
10
  exports.MACHINE_TRANSPORT = { MEMORY: 'memory', NATS: 'nats', MQTT: 'mqtt' };
11
11
  exports.ESCALATION_FEED = { MEMORY: 'memory', CORE: 'core', IN_PROCESS: 'in-process' };
12
+ /** The generic line protocol's topic root when MACHINE_TOPIC_ROOT is unset. */
13
+ exports.DEFAULT_TOPIC_ROOT = 'line/v1';
14
+ const SWITCH = { ON: 'on', OFF: 'off' };
12
15
  function oneOf(name, value, allowed) {
13
16
  if (!allowed.includes(value)) {
14
17
  throw new Error(`${name}="${value}" is not one of ${allowed.join(', ')}`);
@@ -24,6 +27,12 @@ function int(env, name, fallback) {
24
27
  throw new Error(`${name}="${raw}" must be a non-negative integer`);
25
28
  return n;
26
29
  }
30
+ function percentStep(env, name, fallback) {
31
+ const n = int(env, name, fallback);
32
+ if (n > 100)
33
+ throw new Error(`${name}="${n}" must be at most 100`);
34
+ return n;
35
+ }
27
36
  function required(env, name, mode) {
28
37
  const value = env[name];
29
38
  if (!value)
@@ -55,7 +64,7 @@ function loadMachineLineConfig(env = process.env) {
55
64
  const config = {
56
65
  transport,
57
66
  feed,
58
- topicRoot: env.MACHINE_TOPIC_ROOT ?? 'line/v1',
67
+ topicRoot: env.MACHINE_TOPIC_ROOT ?? exports.DEFAULT_TOPIC_ROOT,
59
68
  lineConfigPath: env.MACHINE_LINE_CONFIG || undefined,
60
69
  replyTimeoutMs: int(env, 'MACHINE_REPLY_TIMEOUT_MS', protocol_1.REPLY_DEADLINE_MS),
61
70
  dispatchAttempts: Math.max(1, int(env, 'MACHINE_DISPATCH_ATTEMPTS', 3)),
@@ -64,10 +73,13 @@ function loadMachineLineConfig(env = process.env) {
64
73
  quietMs: int(env, 'MACHINE_QUIET_MS', 90_000),
65
74
  dispatchTtlMs: int(env, 'MACHINE_DISPATCH_TTL_MS', 600_000),
66
75
  receiptDelayMs: int(env, 'MACHINE_RECEIPT_DELAY_MS', 250),
76
+ lifecycle: oneOf('MACHINE_LIFECYCLE', env.MACHINE_LIFECYCLE || SWITCH.OFF, Object.values(SWITCH)) === SWITCH.ON,
77
+ progressStep: percentStep(env, 'MACHINE_PROGRESS_STEP', 25),
67
78
  ltBaseUrl: env.LT_BASE_URL ?? 'http://localhost:3000',
68
79
  };
69
80
  if (transport === exports.MACHINE_TRANSPORT.NATS) {
70
- config.siteNatsUrl = required(env, 'MACHINE_SITE_NATS_URL', transport);
81
+ // A site NATS of its own; without one, the streams are Long Tail's messaging.
82
+ config.siteNatsUrl = env.MACHINE_SITE_NATS_URL || undefined;
71
83
  config.siteNatsUser = env.MACHINE_SITE_NATS_USER;
72
84
  config.siteNatsPassword = env.MACHINE_SITE_NATS_PASSWORD;
73
85
  config.mqtt = mqttEndpoint(env, transport, 5);
@@ -75,10 +87,8 @@ function loadMachineLineConfig(env = process.env) {
75
87
  if (transport === exports.MACHINE_TRANSPORT.MQTT)
76
88
  config.mqtt = mqttEndpoint(env, transport, 5);
77
89
  if (feed === exports.ESCALATION_FEED.CORE) {
78
- config.ltNatsUrl = env.NATS_URL;
90
+ config.ltNatsUrl = env.NATS_URL || undefined;
79
91
  config.ltNatsToken = env.NATS_TOKEN;
80
- if (!config.ltNatsUrl)
81
- throw new Error('MACHINE_ESCALATION_FEED=core needs NATS_URL (Long Tail NATS)');
82
92
  }
83
93
  return config;
84
94
  }
@@ -29,12 +29,18 @@ export declare const LINE_ROLE: {
29
29
  export interface ArtifactRef {
30
30
  url: string;
31
31
  sha256: string;
32
+ /** The host's id for the file. */
33
+ id?: string;
34
+ contentType?: string;
35
+ sizeBytes?: number;
32
36
  }
33
37
  export interface HandoffUnit {
34
38
  unitId: string;
35
39
  carrierId: string | null;
36
40
  /** This unit's own file, when each unit has one (a per-unit model or toolpath). */
37
41
  artifact?: ArtifactRef;
42
+ /** This unit's files, when it has several. */
43
+ artifacts?: ArtifactRef[];
38
44
  /** Parameters for this unit alone, sent to the machine as given. */
39
45
  parameters?: Record<string, unknown>;
40
46
  }
@@ -69,6 +75,11 @@ export interface HandoffFacets {
69
75
  export declare const itemKeys: {
70
76
  released: (unitId: string) => string;
71
77
  arrived: (unitId: string) => string;
78
+ started: (unitId: string) => string;
79
+ progress: (unitId: string, step: number) => string;
80
+ stage: (unitId: string, stage: string) => string;
81
+ paused: (alarmId: string, raisedAt: string) => string;
82
+ resumed: (alarmId: string, raisedAt: string) => string;
72
83
  };
73
84
  /** The unit an arrival item names; null for any other item. */
74
85
  export declare function arrivedUnit(itemKey: string): string | null;
@@ -125,6 +136,10 @@ export declare const TRACE_FACET: {
125
136
  readonly TRACE: "trace";
126
137
  readonly LOTS: "lots";
127
138
  };
139
+ /** The facet a row holding a job carries while it runs: its latest noted progress. */
140
+ export declare const LIFECYCLE_FACET: {
141
+ readonly PROGRESS: "progress";
142
+ };
128
143
  /** What resolves a handoff row, from a machine or from a person's form. */
129
144
  export interface HandoffResolution {
130
145
  received: boolean;
@@ -188,6 +203,8 @@ export interface CommandResolution {
188
203
  status: CommandStatus;
189
204
  reason?: string;
190
205
  }
191
- export declare const dispatchCommandId: (escalationId: string) => string;
192
- export declare const controlCommandId: (escalationId: string) => string;
206
+ /** A row's dispatch command in its validity round; a later round is a new command (gateway/pacing.ts dispatchWindow). */
207
+ export declare const dispatchCommandId: (escalationId: string, round?: number) => string;
208
+ /** A command row's command in its validity round; a later round is a new command (gateway/pacing.ts dispatchWindow). */
209
+ export declare const controlCommandId: (escalationId: string, round?: number) => string;
193
210
  export declare const cancelCommandId: (escalationId: string) => string;
@@ -6,7 +6,7 @@
6
6
  * protocol that resolves it differs.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.cancelCommandId = exports.controlCommandId = exports.dispatchCommandId = exports.COMMAND_STATUS = exports.signalKeys = exports.DECISION = exports.TRACE_FACET = exports.HANDOFF_ERROR = exports.itemKeys = exports.LINE_ROLE = exports.ROW_TYPE = void 0;
9
+ exports.cancelCommandId = exports.controlCommandId = exports.dispatchCommandId = exports.COMMAND_STATUS = exports.signalKeys = exports.DECISION = exports.LIFECYCLE_FACET = exports.TRACE_FACET = exports.HANDOFF_ERROR = exports.itemKeys = exports.LINE_ROLE = exports.ROW_TYPE = void 0;
10
10
  exports.arrivedUnit = arrivedUnit;
11
11
  exports.handoffProgress = handoffProgress;
12
12
  exports.handoffDecisions = handoffDecisions;
@@ -43,6 +43,11 @@ const ARRIVED_PREFIX = 'arrived:';
43
43
  exports.itemKeys = {
44
44
  released: (unitId) => `${RELEASED_PREFIX}${unitId}`,
45
45
  arrived: (unitId) => `${ARRIVED_PREFIX}${unitId}`,
46
+ started: (unitId) => `started:${unitId}`,
47
+ progress: (unitId, step) => `progress:${unitId}:${step}`,
48
+ stage: (unitId, stage) => `stage:${unitId}:${stage}`,
49
+ paused: (alarmId, raisedAt) => `paused:${alarmId}:${raisedAt}`,
50
+ resumed: (alarmId, raisedAt) => `resumed:${alarmId}:${raisedAt}`,
46
51
  };
47
52
  /** The unit an arrival item names; null for any other item. */
48
53
  function arrivedUnit(itemKey) {
@@ -78,6 +83,8 @@ exports.HANDOFF_ERROR = {
78
83
  * containment-searchable, e.g. `{ lots: ['LOT-7'] }`.
79
84
  */
80
85
  exports.TRACE_FACET = { TRACE: 'trace', LOTS: 'lots' };
86
+ /** The facet a row holding a job carries while it runs: its latest noted progress. */
87
+ exports.LIFECYCLE_FACET = { PROGRESS: 'progress' };
81
88
  /** Decisions on line-operator rows: failed handoffs, alarms, dark machines, contested registrations. */
82
89
  exports.DECISION = {
83
90
  RETRY: 'retry',
@@ -107,9 +114,11 @@ exports.signalKeys = {
107
114
  jobStop: (jobKey, step, attempt) => `job-stop-${jobKey}-s${step}-a${attempt}`,
108
115
  };
109
116
  exports.COMMAND_STATUS = { ...protocol_1.REPLY_STATUS, NO_REPLY: 'no-reply', UNSUPPORTED: 'unsupported' };
110
- const dispatchCommandId = (escalationId) => `${escalationId}:dispatch`;
117
+ /** A row's dispatch command in its validity round; a later round is a new command (gateway/pacing.ts dispatchWindow). */
118
+ const dispatchCommandId = (escalationId, round = 1) => (round > 1 ? `${escalationId}:dispatch:r${round}` : `${escalationId}:dispatch`);
111
119
  exports.dispatchCommandId = dispatchCommandId;
112
- const controlCommandId = (escalationId) => `${escalationId}:command`;
120
+ /** A command row's command in its validity round; a later round is a new command (gateway/pacing.ts dispatchWindow). */
121
+ const controlCommandId = (escalationId, round = 1) => (round > 1 ? `${escalationId}:command:r${round}` : `${escalationId}:command`);
113
122
  exports.controlCommandId = controlCommandId;
114
123
  const cancelCommandId = (escalationId) => `${escalationId}:cancel`;
115
124
  exports.cancelCommandId = cancelCommandId;
@@ -39,8 +39,8 @@ export type Action =
39
39
  rowId: string;
40
40
  machineId: string;
41
41
  op: string;
42
- cmdId: string;
43
42
  args: Record<string, any>;
43
+ createdAt?: number;
44
44
  } | {
45
45
  kind: 'claim';
46
46
  as: Actor;
@@ -37,18 +37,31 @@ type CommandAction = Extract<Action, {
37
37
  type ControlAction = Extract<Action, {
38
38
  kind: 'control';
39
39
  }>;
40
+ /** Ends a dispatch or command still running when the gateway stops; the next leader takes it up from the row. */
41
+ export declare class GatewayStopped extends Error {
42
+ constructor();
43
+ }
40
44
  export declare class Dispatcher {
41
45
  private readonly d;
46
+ private readonly sent;
47
+ private readonly stopping;
42
48
  constructor(d: DispatcherDeps);
43
- /** False when the codec has no message for the command. */
44
- publish(machineId: string, op: string, cmdId: string, args: Record<string, any>): Promise<boolean>;
49
+ /** Work still running sends nothing more and changes no row. */
50
+ stop(): void;
51
+ private live;
52
+ /** This send's attempt number, and whether the machine may have the command already. */
53
+ private countSend;
54
+ /** False when the codec has no message for the command. `since` is when the command's row was created. */
55
+ publish(machineId: string, op: string, cmdId: string, args: Record<string, any>, since?: number): Promise<boolean>;
45
56
  /** Publish and wait for the reply, asking again with the same id; null when none came. */
46
- ask(machineId: string, op: string, cmdId: string, args: Record<string, any>): Promise<CommandReply | null>;
57
+ ask(machineId: string, op: string, cmdId: string, args: Record<string, any>, since?: number): Promise<CommandReply | null>;
47
58
  command(a: CommandAction): Promise<void>;
48
59
  /**
49
60
  * A machine-command row: send it, then resolve the row with the reply, as the
50
61
  * machine that gave it. The command expires when the asking ends, so one that
51
62
  * reaches a machine after its row resolved no-reply is refused, never run.
63
+ * Rounds of the asking count from the row's creation, so the command sent
64
+ * again by a new gateway is the same command with the same arguments.
52
65
  */
53
66
  control(a: ControlAction): Promise<void>;
54
67
  dispatch(a: DispatchAction): Promise<void>;
@@ -11,7 +11,7 @@
11
11
  * codec has no message for is unsupported: nothing is sent or awaited.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.Dispatcher = void 0;
14
+ exports.Dispatcher = exports.GatewayStopped = void 0;
15
15
  const promises_1 = require("timers/promises");
16
16
  const contracts_1 = require("../contracts");
17
17
  const registration_1 = require("./registration");
@@ -19,6 +19,7 @@ const protocol_1 = require("../../../lib/machines/protocol");
19
19
  const actions_1 = require("./actions");
20
20
  const escalations_1 = require("./escalations");
21
21
  const jobs_1 = require("./jobs");
22
+ const pacing_1 = require("./pacing");
22
23
  const log_1 = require("./log");
23
24
  const state_1 = require("./state");
24
25
  /** A reply the machine sent, as opposed to none or an unsupported command. */
@@ -29,25 +30,56 @@ function fromMachine(reply) {
29
30
  function took(reply) {
30
31
  return reply?.status === protocol_1.REPLY_STATUS.ACCEPTED || reply?.status === protocol_1.REPLY_STATUS.COMPLETED;
31
32
  }
33
+ /** Most command ids whose sends are counted; the oldest are forgotten first. */
34
+ const SENT_REMEMBERED = 10_000;
35
+ /** Ends a dispatch or command still running when the gateway stops; the next leader takes it up from the row. */
36
+ class GatewayStopped extends Error {
37
+ constructor() {
38
+ super('the gateway stopped');
39
+ }
40
+ }
41
+ exports.GatewayStopped = GatewayStopped;
32
42
  class Dispatcher {
33
43
  constructor(d) {
34
44
  this.d = d;
45
+ this.sent = new Map();
46
+ this.stopping = new AbortController();
47
+ }
48
+ /** Work still running sends nothing more and changes no row. */
49
+ stop() {
50
+ this.stopping.abort();
51
+ }
52
+ live() {
53
+ if (this.stopping.signal.aborted)
54
+ throw new GatewayStopped();
55
+ }
56
+ /** This send's attempt number, and whether the machine may have the command already. */
57
+ countSend(cmdId, since) {
58
+ const attempt = (this.sent.get(cmdId) ?? 0) + 1;
59
+ this.sent.delete(cmdId);
60
+ this.sent.set(cmdId, attempt);
61
+ if (this.sent.size > SENT_REMEMBERED)
62
+ this.sent.delete(this.sent.keys().next().value);
63
+ return { attempt, retry: attempt > 1 || (since !== undefined && since < this.d.state.takenAt) };
35
64
  }
36
- /** False when the codec has no message for the command. */
37
- async publish(machineId, op, cmdId, args) {
38
- const wires = this.d.codec.encode((0, registration_1.identFor)(this.d.state, this.d.line, machineId), { cmdId, op, args });
65
+ /** False when the codec has no message for the command. `since` is when the command's row was created. */
66
+ async publish(machineId, op, cmdId, args, since) {
67
+ this.live();
68
+ const wires = this.d.codec.encode((0, registration_1.identFor)(this.d.state, this.d.line, machineId), { cmdId, op, args, ...this.countSend(cmdId, since) });
39
69
  for (const wire of wires)
40
70
  await this.d.bus.publish(wire.topic, wire.payload);
41
71
  return wires.length > 0;
42
72
  }
43
73
  /** Publish and wait for the reply, asking again with the same id; null when none came. */
44
- async ask(machineId, op, cmdId, args) {
74
+ async ask(machineId, op, cmdId, args, since) {
45
75
  for (let attempt = 1; attempt <= this.d.dispatchAttempts; attempt++) {
76
+ this.live();
46
77
  const waiting = this.d.replies.wait(cmdId, this.d.replyTimeoutMs);
47
- if (!await this.publish(machineId, op, cmdId, args)) {
78
+ if (!await this.publish(machineId, op, cmdId, args, since)) {
48
79
  this.d.replies.settle({ cmdId, status: contracts_1.COMMAND_STATUS.UNSUPPORTED, reason: `the line's codec has no ${op} command` });
49
80
  }
50
81
  const reply = await waiting;
82
+ this.live();
51
83
  if (reply)
52
84
  return reply;
53
85
  }
@@ -67,13 +99,17 @@ class Dispatcher {
67
99
  * A machine-command row: send it, then resolve the row with the reply, as the
68
100
  * machine that gave it. The command expires when the asking ends, so one that
69
101
  * reaches a machine after its row resolved no-reply is refused, never run.
102
+ * Rounds of the asking count from the row's creation, so the command sent
103
+ * again by a new gateway is the same command with the same arguments.
70
104
  */
71
105
  async control(a) {
72
- const expiresAt = Date.now() + this.d.replyTimeoutMs * this.d.dispatchAttempts;
73
- const reply = await this.ask(a.machineId, a.op, a.cmdId, { ...a.args, expiresAt: (0, protocol_1.isoTs)(expiresAt) });
106
+ const now = Date.now();
107
+ const { round, expiresAt } = (0, pacing_1.dispatchWindow)(a.createdAt ?? now, now, this.d.replyTimeoutMs * this.d.dispatchAttempts);
108
+ const reply = await this.ask(a.machineId, a.op, (0, contracts_1.controlCommandId)(a.rowId, round), { ...a.args, expiresAt: (0, protocol_1.isoTs)(expiresAt) }, a.createdAt);
74
109
  // Timers may fire a little early; the command must have expired before the row says no-reply.
75
110
  if (!reply && Date.now() <= expiresAt)
76
- await (0, promises_1.setTimeout)(expiresAt - Date.now() + 1);
111
+ await (0, promises_1.setTimeout)(expiresAt - Date.now() + 1, undefined, { signal: this.stopping.signal }).catch(() => undefined);
112
+ this.live();
77
113
  const outcome = reply
78
114
  ? { accepted: reply.status === protocol_1.REPLY_STATUS.ACCEPTED, machineId: a.machineId, op: a.op, status: reply.status, reason: reply.reason }
79
115
  : { accepted: false, machineId: a.machineId, op: a.op, status: contracts_1.COMMAND_STATUS.NO_REPLY, reason: `no reply after ${this.d.dispatchAttempts} attempts` };
@@ -97,10 +133,12 @@ class Dispatcher {
97
133
  // Known before any reply, so a failure that follows an acceptance finds its row.
98
134
  for (const job of jobs)
99
135
  state.rememberDispatch(`${a.cmdId}${job.suffix}`, a.rowId);
100
- const replies = await Promise.all(jobs.map((job) => this.ask(a.machineId, protocol_1.OP.DISPATCH, `${a.cmdId}${job.suffix}`, { ...a.args, jobId: job.jobId, units: job.units })));
136
+ const since = state.get(a.rowId)?.createdAt;
137
+ const replies = await Promise.all(jobs.map((job) => this.ask(a.machineId, protocol_1.OP.DISPATCH, `${a.cmdId}${job.suffix}`, { ...a.args, jobId: job.jobId, units: job.units }, since)));
101
138
  const refused = replies.find((r) => r && !took(r));
102
139
  const silent = jobs.filter((_, i) => !replies[i]);
103
140
  const held = !refused && silent.length ? await this.heldJobs(a.machineId, a.cmdId) : null;
141
+ this.live();
104
142
  const reconciled = !!held && silent.every((j) => held.has(j.jobId));
105
143
  const row = state.get(a.rowId);
106
144
  if (!refused && (!silent.length || reconciled)) {
@@ -132,12 +170,14 @@ class Dispatcher {
132
170
  }
133
171
  /** The units were already at the machine: its acceptance completes the handoff. */
134
172
  async received(a) {
173
+ this.live();
135
174
  const units = a.args.units.map((u) => u.unitId);
136
175
  this.check(a.rowId, 'resolve', await this.d.lt.resolve(a.machineId, a.rowId, { received: true, receiver: a.machineId, units }));
137
176
  this.d.state.close(a.rowId);
138
177
  }
139
178
  /** Ask a silent machine what it holds; null when it cannot say or does not answer in time. */
140
179
  async heldJobs(machineId, cmdId) {
180
+ this.live();
141
181
  const waiting = this.d.replies.waitSnapshot(machineId, this.d.replyTimeoutMs);
142
182
  if (!await this.publish(machineId, protocol_1.OP.SNAPSHOT, `${cmdId}:status`, {}))
143
183
  this.d.replies.settleSnapshot(machineId, null);
@@ -12,7 +12,7 @@ import type { AddressBook } from './address-book';
12
12
  import { type LineEscalations } from './escalations';
13
13
  import { type GatewayLog } from './log';
14
14
  import type { ReplyWaiter } from './reply-waiter';
15
- import type { GatewayState } from './state';
15
+ import { type GatewayState } from './state';
16
16
  export interface ExecutorDeps {
17
17
  bus: MachineBus;
18
18
  lt: LineEscalations;
@@ -41,7 +41,12 @@ export declare class Executor {
41
41
  stop(): void;
42
42
  private track;
43
43
  private run;
44
- /** A dispatch's second reply: ready, or a failure to prepare that ends the handoff. */
44
+ /**
45
+ * A dispatch's second reply: ready, or a failure to prepare that ends the
46
+ * handoff. When the handoff into the machine has already ended, as one
47
+ * dispatched on arrival does at acceptance, the failure ends the handoff
48
+ * carrying the units out of it.
49
+ */
45
50
  private lateReply;
46
51
  /** Add the item; when the row then holds every key the action waits for, resolve it. */
47
52
  private accumulate;