@hotmeshio/long-tail 0.28.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build/api/files.d.ts +5 -0
  2. package/build/api/files.js +19 -2
  3. package/build/api/knowledge.d.ts +3 -1
  4. package/build/lib/db/schemas/042_knowledge_edition_labels.sql +9 -0
  5. package/build/lib/machines/line.d.ts +3 -0
  6. package/build/lib/machines/line.js +7 -1
  7. package/build/lib/machines/protocol/codec.d.ts +6 -0
  8. package/build/lib/machines/protocol/codec.js +1 -1
  9. package/build/lib/machines/protocol/contract.d.ts +2 -0
  10. package/build/lib/machines/protocol/contract.js +12 -7
  11. package/build/lib/machines/site/mosquitto-conf.d.ts +7 -3
  12. package/build/lib/machines/site/mosquitto-conf.js +3 -8
  13. package/build/lib/machines/site/site-conf.d.ts +4 -1
  14. package/build/lib/machines/site/site-conf.js +5 -1
  15. package/build/lib/machines/transport/nats-bus.d.ts +2 -0
  16. package/build/lib/machines/transport/stream-bus.d.ts +5 -1
  17. package/build/lib/machines/transport/stream-bus.js +12 -4
  18. package/build/lib/machines/transport/streams.d.ts +10 -2
  19. package/build/lib/machines/transport/streams.js +32 -5
  20. package/build/lib/messaging/memory.js +3 -0
  21. package/build/lib/messaging/nats/streams.d.ts +3 -0
  22. package/build/lib/messaging/nats/streams.js +16 -0
  23. package/build/lib/storage/local.d.ts +2 -0
  24. package/build/lib/storage/local.js +3 -0
  25. package/build/lib/storage/s3.d.ts +3 -2
  26. package/build/lib/storage/s3.js +6 -1
  27. package/build/lib/storage/types.d.ts +7 -1
  28. package/build/routes/file-browser.js +3 -1
  29. package/build/routes/knowledge.js +6 -5
  30. package/build/routes/machines.js +33 -0
  31. package/build/services/knowledge/index.d.ts +4 -2
  32. package/build/services/knowledge/index.js +8 -2
  33. package/build/services/knowledge/lookup-cache.d.ts +8 -0
  34. package/build/services/knowledge/lookup-cache.js +30 -3
  35. package/build/services/knowledge/lookup-refs.js +1 -1
  36. package/build/services/knowledge/sql.d.ts +4 -2
  37. package/build/services/knowledge/sql.js +8 -3
  38. package/build/services/machines/config.d.ts +2 -0
  39. package/build/services/machines/config.js +1 -0
  40. package/build/services/machines/contracts.d.ts +18 -0
  41. package/build/services/machines/contracts.js +15 -1
  42. package/build/services/machines/forms.d.ts +26 -1
  43. package/build/services/machines/forms.js +17 -1
  44. package/build/services/machines/gateway/actions.d.ts +13 -1
  45. package/build/services/machines/gateway/admission.d.ts +15 -1
  46. package/build/services/machines/gateway/admission.js +47 -0
  47. package/build/services/machines/gateway/api-escalations.js +4 -0
  48. package/build/services/machines/gateway/dispatcher.d.ts +9 -4
  49. package/build/services/machines/gateway/dispatcher.js +42 -14
  50. package/build/services/machines/gateway/executor.d.ts +2 -0
  51. package/build/services/machines/gateway/executor.js +18 -2
  52. package/build/services/machines/gateway/gateway.d.ts +5 -0
  53. package/build/services/machines/gateway/gateway.js +11 -3
  54. package/build/services/machines/gateway/log.d.ts +2 -0
  55. package/build/services/machines/gateway/log.js +2 -0
  56. package/build/services/machines/gateway/lt-http.js +11 -2
  57. package/build/services/machines/gateway/pacing.js +1 -1
  58. package/build/services/machines/gateway/plan-machine.js +26 -11
  59. package/build/services/machines/gateway/plan-shared.d.ts +5 -3
  60. package/build/services/machines/gateway/plan-shared.js +6 -4
  61. package/build/services/machines/gateway/reply-waiter.d.ts +2 -0
  62. package/build/services/machines/gateway/state.d.ts +24 -1
  63. package/build/services/machines/gateway/state.js +67 -6
  64. package/build/services/machines/gateway/wire-window.d.ts +21 -0
  65. package/build/services/machines/gateway/wire-window.js +61 -0
  66. package/build/services/machines/gateway/wire.js +1 -0
  67. package/build/services/machines/roles.d.ts +5 -4
  68. package/build/services/machines/roles.js +13 -4
  69. package/build/services/machines/select.d.ts +3 -2
  70. package/build/services/machines/select.js +4 -3
  71. package/build/services/machines/surface.d.ts +10 -1
  72. package/build/services/machines/surface.js +20 -4
  73. package/build/system/activities/knowledge.d.ts +4 -0
  74. package/build/system/activities/knowledge.js +15 -0
  75. package/build/system/activities/sql.d.ts +2 -0
  76. package/build/system/activities/sql.js +6 -1
  77. package/build/system/mcp-servers/knowledge.js +4 -2
  78. package/build/tsconfig.tsbuildinfo +1 -1
  79. package/build/types/escalation.d.ts +9 -6
  80. package/build/types/escalation.js +8 -2
  81. package/build/types/machines-view.d.ts +16 -0
  82. package/build/types/machines.d.ts +1 -0
  83. package/build/types/messaging.d.ts +2 -0
  84. package/build/types/startup.d.ts +2 -0
  85. package/docs/dashboard.md +1 -1
  86. package/docs/hitl/lookups.md +6 -4
  87. package/docs/machines.md +25 -16
  88. package/package.json +1 -1
@@ -42,6 +42,8 @@ const escalationApi = __importStar(require("../../../api/escalations"));
42
42
  const uuid_1 = require("../../../lib/uuid");
43
43
  const escalations_1 = require("./escalations");
44
44
  const actions_1 = require("./actions");
45
+ /** A row that was never given items (one parked by hand) answers 400; it holds none. */
46
+ const NO_ITEMS_STATUS = 400;
45
47
  class ApiLineEscalations {
46
48
  constructor(userFor) {
47
49
  this.userFor = userFor;
@@ -75,6 +77,8 @@ class ApiLineEscalations {
75
77
  }
76
78
  async items(id) {
77
79
  const result = await escalationApi.getEscalationItems({ id }, this.auth(actions_1.CONTROLLER));
80
+ if (result.status === NO_ITEMS_STATUS)
81
+ return [];
78
82
  if (result.status >= 300)
79
83
  throw new Error(`items failed ${result.status}: ${result.error}`);
80
84
  return result.data.items.map((i) => ({ itemKey: i.itemKey, payload: i.payload ?? {} }));
@@ -52,12 +52,15 @@ export declare class Dispatcher {
52
52
  /** Work still running sends nothing more and changes no row. */
53
53
  stop(): void;
54
54
  private live;
55
- /** This send's attempt number, and whether the machine may have the command already. */
55
+ /**
56
+ * This send's attempt number, whether the machine may have the command already, and when it was
57
+ * issued: the time given (a round's start, the same for every gateway), else the first send's.
58
+ */
56
59
  private countSend;
57
60
  /** False when the codec has no message for the command. `since` is when the command's row was created. */
58
- publish(machineId: string, op: string, cmdId: string, args: Record<string, any>, since?: number): Promise<boolean>;
61
+ publish(machineId: string, op: string, cmdId: string, args: Record<string, any>, since?: number, issuedAt?: number): Promise<boolean>;
59
62
  /** Publish and wait for the reply, asking again with the same id; null when none came. */
60
- ask(machineId: string, op: string, cmdId: string, args: Record<string, any>, since?: number): Promise<CommandReply | null>;
63
+ ask(machineId: string, op: string, cmdId: string, args: Record<string, any>, since?: number, issuedAt?: number): Promise<CommandReply | null>;
61
64
  command(a: CommandAction): Promise<void>;
62
65
  /**
63
66
  * A machine-command row: send it, then resolve the row with the reply, as the
@@ -68,7 +71,9 @@ export declare class Dispatcher {
68
71
  */
69
72
  control(a: ControlAction): Promise<void>;
70
73
  dispatch(a: DispatchAction): Promise<void>;
71
- /** The units were already at the machine: its acceptance completes the handoff. */
74
+ /** A job sent again after the machine restarted, for a row that already ended: the reply is only logged. */
75
+ private resent;
76
+ /** The units were already at the machine: a job it has taken and is ready for completes the handoff. */
72
77
  private received;
73
78
  /** Ask a silent machine what it holds; null when it cannot say or does not answer in time. */
74
79
  private heldJobs;
@@ -57,20 +57,25 @@ class Dispatcher {
57
57
  if (this.stopping.signal.aborted)
58
58
  throw new GatewayStopped();
59
59
  }
60
- /** This send's attempt number, and whether the machine may have the command already. */
61
- countSend(cmdId, since) {
62
- const attempt = (this.sent.get(cmdId) ?? 0) + 1;
60
+ /**
61
+ * This send's attempt number, whether the machine may have the command already, and when it was
62
+ * issued: the time given (a round's start, the same for every gateway), else the first send's.
63
+ */
64
+ countSend(cmdId, since, issuedAt) {
65
+ const before = this.sent.get(cmdId);
66
+ const record = { attempt: (before?.attempt ?? 0) + 1, issuedAt: issuedAt ?? before?.issuedAt ?? Date.now() };
63
67
  this.sent.delete(cmdId);
64
- this.sent.set(cmdId, attempt);
68
+ this.sent.set(cmdId, record);
65
69
  if (this.sent.size > SENT_REMEMBERED)
66
70
  this.sent.delete(this.sent.keys().next().value);
67
- return { attempt, retry: attempt > 1 || (since !== undefined && since < this.d.state.takenAt) };
71
+ const retry = record.attempt > 1 || (since !== undefined && since < this.d.state.takenAt);
72
+ return { attempt: record.attempt, retry, issuedAt: (0, protocol_1.isoTs)(record.issuedAt) };
68
73
  }
69
74
  /** False when the codec has no message for the command. `since` is when the command's row was created. */
70
- async publish(machineId, op, cmdId, args, since) {
75
+ async publish(machineId, op, cmdId, args, since, issuedAt) {
71
76
  this.live();
72
77
  const ident = (0, registration_1.identFor)(this.d.state, this.d.line, machineId);
73
- const command = { cmdId, op, args, ...this.countSend(cmdId, since) };
78
+ const command = { cmdId, op, args, ...this.countSend(cmdId, since, issuedAt) };
74
79
  const wires = this.d.codec.encode(ident, command);
75
80
  for (const wire of wires) {
76
81
  await this.d.bus.publish(wire.topic, wire.payload);
@@ -79,11 +84,11 @@ class Dispatcher {
79
84
  return wires.length > 0;
80
85
  }
81
86
  /** Publish and wait for the reply, asking again with the same id; null when none came. */
82
- async ask(machineId, op, cmdId, args, since) {
87
+ async ask(machineId, op, cmdId, args, since, issuedAt) {
83
88
  for (let attempt = 1; attempt <= this.d.dispatchAttempts; attempt++) {
84
89
  this.live();
85
90
  const waiting = this.d.replies.wait(cmdId, this.d.replyTimeoutMs);
86
- if (!await this.publish(machineId, op, cmdId, args, since)) {
91
+ if (!await this.publish(machineId, op, cmdId, args, since, issuedAt)) {
87
92
  this.d.replies.settle({ cmdId, status: contracts_1.COMMAND_STATUS.UNSUPPORTED, reason: `the line's codec has no ${op} command` });
88
93
  }
89
94
  const reply = await waiting;
@@ -112,8 +117,9 @@ class Dispatcher {
112
117
  */
113
118
  async control(a) {
114
119
  const now = Date.now();
115
- const { round, expiresAt } = (0, pacing_1.dispatchWindow)(a.createdAt ?? now, now, this.d.replyTimeoutMs * this.d.dispatchAttempts);
116
- 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);
120
+ const ttlMs = this.d.replyTimeoutMs * this.d.dispatchAttempts;
121
+ const { round, expiresAt } = (0, pacing_1.dispatchWindow)(a.createdAt ?? now, now, ttlMs);
122
+ 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, expiresAt - ttlMs);
117
123
  // Timers may fire a little early; the command must have expired before the row says no-reply.
118
124
  if (!reply && Date.now() <= expiresAt)
119
125
  await (0, promises_1.setTimeout)(expiresAt - Date.now() + 1, undefined, { signal: this.stopping.signal }).catch(() => undefined);
@@ -142,22 +148,36 @@ class Dispatcher {
142
148
  for (const job of jobs)
143
149
  state.rememberDispatch(`${a.cmdId}${job.suffix}`, a.rowId);
144
150
  const since = state.get(a.rowId)?.createdAt;
145
- 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)));
151
+ 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, a.issuedAt)));
146
152
  const refused = replies.find((r) => r && !took(r));
147
153
  const silent = jobs.filter((_, i) => !replies[i]);
148
154
  const held = !refused && silent.length ? await this.heldJobs(a.machineId, a.cmdId) : null;
149
155
  this.live();
150
156
  const reconciled = !!held && silent.every((j) => held.has(j.jobId));
157
+ if (a.ended) {
158
+ this.resent(a, jobs, refused ?? null, !refused && (!silent.length || reconciled));
159
+ return;
160
+ }
151
161
  const row = state.get(a.rowId);
152
162
  if (!refused && (!silent.length || reconciled)) {
153
163
  if (row)
154
164
  row.dispatch = state_1.DISPATCH_STATE.ACCEPTED;
165
+ for (const job of jobs)
166
+ state.rememberSent(a.machineId, job.jobId, a.rowId);
155
167
  if (reconciled)
156
168
  log.info(log_1.GATEWAY_LOG.DISPATCH_RECONCILED, { row: a.rowId, machine: a.machineId, jobs: silent.map((j) => j.jobId) });
157
169
  log.info(log_1.GATEWAY_LOG.DISPATCHED, { row: a.rowId, machine: a.machineId, reason: replies.map((r) => r?.reason ?? 'held').join('; ') });
158
170
  this.check(a.rowId, 'claim', await lt.claim(a.machineId, a.rowId));
159
- if (a.receivedOnAccept)
171
+ if (!a.receivedOnAccept)
172
+ return;
173
+ const preparing = jobs.filter((job, i) => replies[i]?.status === protocol_1.REPLY_STATUS.ACCEPTED && replies[i]?.preparing).map((job) => `${a.cmdId}${job.suffix}`);
174
+ if (!preparing.length) {
160
175
  await this.received(a);
176
+ return;
177
+ }
178
+ if (row)
179
+ row.awaitingReady = new Set(preparing);
180
+ log.info(log_1.GATEWAY_LOG.DISPATCH_PREPARING, { row: a.rowId, machine: a.machineId, jobs: preparing });
161
181
  return;
162
182
  }
163
183
  // The handoff may have ended meanwhile: a machine that ran the job anyway reports the arrival.
@@ -176,7 +196,15 @@ class Dispatcher {
176
196
  return;
177
197
  await this.withdraw(a, jobs.filter((_, i) => !replies[i] || took(replies[i])));
178
198
  }
179
- /** The units were already at the machine: its acceptance completes the handoff. */
199
+ /** A job sent again after the machine restarted, for a row that already ended: the reply is only logged. */
200
+ resent(a, jobs, refused, taken) {
201
+ const context = { row: a.rowId, machine: a.machineId, jobs: jobs.map((j) => j.jobId), resent: true };
202
+ if (taken)
203
+ this.d.log.info(log_1.GATEWAY_LOG.DISPATCHED, { ...context, reason: 'sent again after a restart' });
204
+ else
205
+ this.d.log.warn(log_1.GATEWAY_LOG.DISPATCH_REFUSED, { ...context, reason: refused ? `${refused.status}: ${refused.reason}` : 'no reply' });
206
+ }
207
+ /** The units were already at the machine: a job it has taken and is ready for completes the handoff. */
180
208
  async received(a) {
181
209
  this.live();
182
210
  const units = a.args.units.map((u) => u.unitId);
@@ -57,6 +57,8 @@ export declare class Executor {
57
57
  * carrying the units out of it.
58
58
  */
59
59
  private lateReply;
60
+ /** On arrival dispatch, a row that waited for the machine to prepare is received once every job it sent is ready. */
61
+ private readyForArrival;
60
62
  /** Add the item; when the row then holds every key the action waits for, resolve it. */
61
63
  private accumulate;
62
64
  private check;
@@ -109,7 +109,7 @@ class Executor {
109
109
  this.watchOffline(a.machineId);
110
110
  return [(0, wire_1.plannedAction)(a)];
111
111
  case 'settleReply':
112
- if (replies.settle({ cmdId: a.cmdId, status: a.status, reason: a.reason, ...(a.code ? { code: a.code } : {}) }))
112
+ if (replies.settle({ cmdId: a.cmdId, status: a.status, reason: a.reason, ...(a.code ? { code: a.code } : {}), ...(a.preparing ? { preparing: true } : {}) }))
113
113
  return [(0, wire_1.plannedAction)(a)];
114
114
  return [{ verb: 'ack', detail: `late ${a.status} ${a.cmdId}` }, ...await this.lateReply(a)];
115
115
  case 'settleSnapshot':
@@ -123,6 +123,9 @@ class Executor {
123
123
  case 'refuseBinding':
124
124
  this.d.log.warn(log_1.GATEWAY_LOG.ADDRESS_REFUSED, { machine: a.machineId, address: a.address, reason: a.reason });
125
125
  return [(0, wire_1.plannedAction)(a)];
126
+ case 'unrecoverable':
127
+ this.d.log.warn(log_1.GATEWAY_LOG.DISPATCH_UNRECOVERABLE, { machine: a.machineId, jobs: a.jobs });
128
+ return [(0, wire_1.plannedAction)(a)];
126
129
  }
127
130
  }
128
131
  /**
@@ -140,7 +143,7 @@ class Executor {
140
143
  const context = { row: into.id, machine: m, cmdId: a.cmdId, reason: a.reason };
141
144
  if (a.status === protocol_1.REPLY_STATUS.COMPLETED) {
142
145
  log.info(log_1.GATEWAY_LOG.DISPATCH_READY, context);
143
- return [];
146
+ return this.readyForArrival(into, m, a.cmdId);
144
147
  }
145
148
  if (a.status !== protocol_1.REPLY_STATUS.FAILED)
146
149
  return [];
@@ -152,6 +155,19 @@ class Executor {
152
155
  received: false, errorCode: contracts_1.HANDOFF_ERROR.DISPATCH_FAILED, detail: `${m} could not prepare the job: ${a.reason}`, ...(a.code ? { code: a.code } : {}),
153
156
  }));
154
157
  }
158
+ /** On arrival dispatch, a row that waited for the machine to prepare is received once every job it sent is ready. */
159
+ async readyForArrival(into, m, cmdId) {
160
+ const { lt, state } = this.d;
161
+ if (into.ended || !into.awaitingReady?.has(cmdId))
162
+ return [];
163
+ into.awaitingReady.delete(cmdId);
164
+ if (into.awaitingReady.size)
165
+ return [];
166
+ const units = into.units.map((u) => u.unitId);
167
+ const resolved = await lt.resolve(m, into.id, { received: true, receiver: m, units });
168
+ state.close(into.id);
169
+ return [outcomeOf('resolve', into.id, resolved)];
170
+ }
155
171
  /** Add the item; when the row then holds every key the action waits for, resolve it. */
156
172
  async accumulate(a) {
157
173
  const { lt, state } = this.d;
@@ -15,6 +15,7 @@ import type { LineEscalations } from './escalations';
15
15
  import { type GatewayLog } from './log';
16
16
  import { GatewayState } from './state';
17
17
  import { type WireSink } from './wire';
18
+ import { WireWindow } from './wire-window';
18
19
  export interface GatewaySettings {
19
20
  topicRoot: string;
20
21
  replyTimeoutMs: number;
@@ -32,6 +33,8 @@ export interface GatewaySettings {
32
33
  progressStep?: number;
33
34
  /** Record telemetry messages on the wire too; off unless set. */
34
35
  wireTelemetry?: boolean;
36
+ /** How many of the latest wire events the gateway keeps for reading over HTTP; 500 by default. */
37
+ wireWindow?: number;
35
38
  }
36
39
  export interface GatewayDeps {
37
40
  bus: MachineBus;
@@ -60,6 +63,8 @@ export declare class MachineGateway {
60
63
  private readonly addresses;
61
64
  private readonly admission;
62
65
  private readonly wire;
66
+ /** The line's last wire events, newest first on read. */
67
+ readonly window: WireWindow;
63
68
  private quietTimer?;
64
69
  constructor(deps: GatewayDeps);
65
70
  start(): Promise<void>;
@@ -28,6 +28,7 @@ const plan_machine_1 = require("./plan-machine");
28
28
  const state_1 = require("./state");
29
29
  const receipts_1 = require("./receipts");
30
30
  const wire_1 = require("./wire");
31
+ const wire_window_1 = require("./wire-window");
31
32
  class MachineGateway {
32
33
  constructor(deps) {
33
34
  this.deps = deps;
@@ -42,14 +43,16 @@ class MachineGateway {
42
43
  this.state.retainedAlarms = this.codec.retainedAlarms === true;
43
44
  this.state.progressStep = deps.settings.progressStep ?? this.state.progressStep;
44
45
  this.addresses = deps.addresses ?? new address_book_1.MemoryAddressBook();
45
- this.wire = new wire_1.WireRecorder(deps.line, deps.wire ?? publish_1.publishMachineWireEvent, { telemetry: deps.settings.wireTelemetry === true });
46
+ this.window = new wire_window_1.WireWindow(deps.settings.wireWindow);
47
+ const sink = deps.wire ?? publish_1.publishMachineWireEvent;
48
+ this.wire = new wire_1.WireRecorder(deps.line, (event) => { this.window.push(event); sink(event); }, { telemetry: deps.settings.wireTelemetry === true });
46
49
  this.executor = new executor_1.Executor({
47
50
  addresses: this.addresses, wire: this.wire,
48
51
  bus: deps.bus, lt: deps.lt, replies: new reply_waiter_1.ReplyWaiter(), state: this.state, line: deps.line,
49
52
  codec: this.codec, replyTimeoutMs: deps.settings.replyTimeoutMs,
50
53
  dispatchAttempts: deps.settings.dispatchAttempts, offlineDebounceMs: deps.settings.offlineDebounceMs, log: this.log,
51
54
  });
52
- this.admission = { state: this.state, line: deps.line, executor: this.executor, log: this.log, pace: () => this.pace() };
55
+ this.admission = { state: this.state, line: deps.line, executor: this.executor, log: this.log, pace: () => this.pace(), openRows: (q) => deps.lt.listOpen(q) };
53
56
  this.receipts = new receipts_1.ReceiptBatcher({ bus: deps.bus, codec: this.codec, delayMs: deps.settings.receiptDelayMs ?? DEFAULT_RECEIPT_DELAY_MS, log: this.log, wire: this.wire });
54
57
  }
55
58
  async start() {
@@ -231,9 +234,14 @@ class MachineGateway {
231
234
  if (seen.violations.length && msg.deliveries === 1) {
232
235
  this.log.warn(log_1.GATEWAY_LOG.CONTRACT_VIOLATION, { topic: msg.topic, violations: seen.violations });
233
236
  }
234
- const applied = seen.violations.length && this.codec.strict
237
+ let applied = seen.violations.length && this.codec.strict
235
238
  ? await (0, admission_1.holdViolation)(this.admission, msg, seen)
236
239
  : await this.applyMachine(msg.topic, seen.parsed, seen.body);
240
+ // An arrival still waiting for its row after every delivery is shown to a person instead; one that read no id at once.
241
+ const waited = msg.deliveries >= wire_1.MAX_DELIVERIES || (0, admission_1.isUnreadableArrival)(seen);
242
+ if (applied.disposition === transport_1.DISPOSITION.NAK && !applied.failed && waited && (0, admission_1.isArrival)(seen)) {
243
+ applied = await (0, admission_1.holdArrival)(this.admission, msg, seen);
244
+ }
237
245
  failed ||= applied.failed;
238
246
  outcome = worse(outcome, applied.disposition);
239
247
  this.wire.inbound(msg, seen, { ...applied, actions: [...admitted.actions, ...applied.actions] }, msg.deliveries);
@@ -5,8 +5,10 @@ export declare const GATEWAY_LOG: {
5
5
  readonly DISPATCHED: "machine_gateway.dispatch.accepted";
6
6
  readonly DISPATCH_REFUSED: "machine_gateway.dispatch.refused";
7
7
  readonly DISPATCH_READY: "machine_gateway.dispatch.ready";
8
+ readonly DISPATCH_PREPARING: "machine_gateway.dispatch.preparing";
8
9
  readonly DISPATCH_FAILED: "machine_gateway.dispatch.failed";
9
10
  readonly DISPATCH_RECONCILED: "machine_gateway.dispatch.reconciled";
11
+ readonly DISPATCH_UNRECOVERABLE: "machine_gateway.dispatch.unrecoverable";
10
12
  readonly VERB_INVALID: "machine_gateway.verb.invalid";
11
13
  readonly VERB_FAILED: "machine_gateway.verb.failed";
12
14
  readonly ROW_NOT_VISIBLE: "machine_gateway.verb.row_not_visible";
@@ -10,8 +10,10 @@ exports.GATEWAY_LOG = {
10
10
  DISPATCHED: 'machine_gateway.dispatch.accepted',
11
11
  DISPATCH_REFUSED: 'machine_gateway.dispatch.refused',
12
12
  DISPATCH_READY: 'machine_gateway.dispatch.ready',
13
+ DISPATCH_PREPARING: 'machine_gateway.dispatch.preparing',
13
14
  DISPATCH_FAILED: 'machine_gateway.dispatch.failed',
14
15
  DISPATCH_RECONCILED: 'machine_gateway.dispatch.reconciled',
16
+ DISPATCH_UNRECOVERABLE: 'machine_gateway.dispatch.unrecoverable',
15
17
  VERB_INVALID: 'machine_gateway.verb.invalid',
16
18
  VERB_FAILED: 'machine_gateway.verb.failed',
17
19
  ROW_NOT_VISIBLE: 'machine_gateway.verb.row_not_visible',
@@ -56,6 +56,8 @@ class LtHttp {
56
56
  }
57
57
  }
58
58
  exports.LtHttp = LtHttp;
59
+ /** A row that was never given items (one parked by hand) answers 400; it holds none. */
60
+ const NO_ITEMS_STATUS = 400;
59
61
  /** Escalation verbs over HTTP, each run with the bearer token of the actor it runs as. */
60
62
  class HttpLineEscalations {
61
63
  constructor(http, tokenFor, claimMinutes = 24 * 60) {
@@ -80,8 +82,15 @@ class HttpLineEscalations {
80
82
  return (await this.items(id)).map((i) => i.itemKey);
81
83
  }
82
84
  async items(id) {
83
- const view = await this.http.request('GET', `/escalations/${id}/items`, this.tokenFor(actions_1.CONTROLLER));
84
- return view.items.map((i) => ({ itemKey: i.itemKey, payload: i.payload ?? {} }));
85
+ try {
86
+ const view = await this.http.request('GET', `/escalations/${id}/items`, this.tokenFor(actions_1.CONTROLLER));
87
+ return view.items.map((i) => ({ itemKey: i.itemKey, payload: i.payload ?? {} }));
88
+ }
89
+ catch (err) {
90
+ if (err instanceof LtHttpError && err.status === NO_ITEMS_STATUS)
91
+ return [];
92
+ throw err;
93
+ }
85
94
  }
86
95
  async get(id) {
87
96
  if (!(0, uuid_1.isUuid)(id))
@@ -66,7 +66,7 @@ function dispatchAction(row, machineId, dispatchTtlMs) {
66
66
  row.createdAt ??= now;
67
67
  const { round, expiresAt } = dispatchWindow(row.dueAt ?? row.createdAt, now, dispatchTtlMs);
68
68
  return {
69
- kind: 'dispatch', rowId: row.id, machineId, cmdId: (0, contracts_1.dispatchCommandId)(row.id, round), args: dispatchArgs(row, expiresAt),
69
+ kind: 'dispatch', rowId: row.id, machineId, cmdId: (0, contracts_1.dispatchCommandId)(row.id, round), args: dispatchArgs(row, expiresAt), issuedAt: expiresAt - dispatchTtlMs,
70
70
  ...(row.dispatch === state_1.DISPATCH_STATE.ARRIVING ? { receivedOnAccept: true } : {}),
71
71
  };
72
72
  }
@@ -36,7 +36,10 @@ function planMachineMessage(state, parsed, env, dispatchTtlMs) {
36
36
  function planByKind(state, m, parsed, env, b, ttl) {
37
37
  switch (parsed.kind) {
38
38
  case protocol_1.MESSAGE_KIND.REPLY:
39
- return ack([{ kind: 'settleReply', cmdId: b.cmdId, status: b.status, reason: b.reason, ...(typeof b.code === 'string' ? { code: b.code } : {}) }]);
39
+ return ack([{
40
+ kind: 'settleReply', cmdId: b.cmdId, status: b.status, reason: b.reason,
41
+ ...(typeof b.code === 'string' ? { code: b.code } : {}), ...(b.preparing === true ? { preparing: true } : {}),
42
+ }]);
40
43
  case protocol_1.MESSAGE_KIND.JOB:
41
44
  return planJob(state, m, b, env?.at, ttl);
42
45
  case protocol_1.MESSAGE_KIND.ALARM:
@@ -191,7 +194,9 @@ function planAlarm(state, parsed, alarmId, env) {
191
194
  payload: { decision: contracts_1.DECISION.CLEARED, clearedBy: b.clearedBy ?? null, note: `cleared at ${m}${b.clearedBy ? ` by ${b.clearedBy}` : ''}` },
192
195
  }]);
193
196
  }
194
- if (!protocol_1.STOPPING_LEVELS.includes(b.level))
197
+ // A stopping level needs a person; so does any alarm the machine flags, at the level it said.
198
+ const stopping = protocol_1.STOPPING_LEVELS.includes(b.level);
199
+ if (!stopping && b.needsPerson !== true)
195
200
  return ack();
196
201
  const known = state.alarms.get(key);
197
202
  if (known && (!known.raisedAt || !b.raisedAt || known.raisedAt === b.raisedAt))
@@ -201,7 +206,7 @@ function planAlarm(state, parsed, alarmId, env) {
201
206
  : [];
202
207
  const track = { machineId: m, alarmId, raisedAt: b.raisedAt, level: b.level, open: true };
203
208
  state.alarms.set(key, track);
204
- return ack([...superseded, ...(0, lifecycle_1.notePause)(state, track, b), {
209
+ return ack([...superseded, ...(stopping ? (0, lifecycle_1.notePause)(state, track, b) : []), {
205
210
  kind: 'createAlarmRow', machineId: m, alarmId,
206
211
  row: {
207
212
  type: contracts_1.ROW_TYPE.ALARM, subtype: b.level, role: contracts_1.LINE_ROLE.OPERATOR, priority: b.level === protocol_1.LEVEL.EMERGENCY ? 1 : 2,
@@ -254,27 +259,37 @@ function planPresence(state, m, b) {
254
259
  }
255
260
  return ack(actions);
256
261
  }
257
- /** A new boot id means the machine restarted and lost its queue: send its open dispatches again. */
262
+ /**
263
+ * A new boot id means the machine restarted and lost its queue: send its open
264
+ * dispatches again, and the jobs it had ready for units it still holds, from
265
+ * the rows that sent them. A lost job nothing is remembered for is logged.
266
+ */
258
267
  function planSnapshot(state, m, b, ttl) {
259
268
  const link = state.link(m);
260
269
  if (b.software)
261
270
  link.software = b.software;
262
271
  const jobIds = Array.isArray(b.jobs) ? b.jobs.map((j) => j?.jobId).filter((id) => typeof id === 'string') : [];
272
+ // A snapshot without a boot id says nothing about restarts.
273
+ const rebooted = typeof b.bootId === 'string' && !!link.bootId && link.bootId !== b.bootId;
274
+ const held = new Set(jobIds);
275
+ const lost = rebooted ? state.lostAfterReboot(m, held) : { resend: [], unknown: [] };
263
276
  if (Array.isArray(b.jobs)) {
264
- state.keepHeld(m, new Set(jobIds));
277
+ state.keepHeld(m, new Set([...jobIds, ...lost.resend.map((l) => l.jobId)]));
265
278
  link.jobs = b.jobs;
266
279
  }
280
+ if (typeof b.bootId === 'string')
281
+ link.bootId = b.bootId;
267
282
  const fresh = !state.retainedAlarms && !state.readingRetained && Array.isArray(b.alarms);
268
283
  const cleared = fresh ? alarmsNoLongerListed(state, m, b.alarms) : [];
269
284
  const answer = { kind: 'settleSnapshot', machineId: m, jobIds };
270
- const previous = link.bootId;
271
- link.bootId = b.bootId;
272
- if (!previous || previous === b.bootId)
285
+ if (!rebooted)
273
286
  return ack([...cleared, answer]);
274
- const held = new Set(jobIds);
275
287
  const missing = (r) => (0, jobs_1.dispatchJobs)(r.jobId, r.units, state.unitsPerDispatch).some((j) => !held.has(j.jobId));
276
- const lost = state.open().filter((r) => r.toMachine === m && r.dispatch === state_1.DISPATCH_STATE.ACCEPTED && missing(r));
277
- return ack([...cleared, answer, ...lost.map((r) => (0, pacing_1.dispatchAction)(r, m, ttl))]);
288
+ const open = state.open().filter((r) => r.toMachine === m && r.dispatch === state_1.DISPATCH_STATE.ACCEPTED && missing(r));
289
+ const rows = new Map(lost.resend.map((l) => [l.row.id, l.row]));
290
+ const again = [...rows.values()].map((r) => ({ ...(0, pacing_1.dispatchAction)(r, m, ttl), ended: true }));
291
+ const unknown = lost.unknown.length ? [{ kind: 'unrecoverable', machineId: m, jobs: lost.unknown }] : [];
292
+ return ack([...cleared, answer, ...open.map((r) => (0, pacing_1.dispatchAction)(r, m, ttl)), ...again, ...unknown]);
278
293
  }
279
294
  /** Alarm rows for `m` whose alarm its snapshot no longer lists: they cleared at the machine. */
280
295
  function alarmsNoLongerListed(state, m, listed) {
@@ -5,9 +5,11 @@ import { type GatewayState, type HandoffRow } from './state';
5
5
  /** A cancel for each job a handoff's units became at `machineId`, or for those `keep` leaves out. */
6
6
  export declare function cancelJobs(state: GatewayState, row: HandoffRow, machineId: string, jobId: string, tag?: string, keep?: (job: DispatchJob) => boolean): Action[];
7
7
  /**
8
- * Withdraw a dispatch that may still sit in the receiver's queue. A job some
9
- * of whose units already reached the receiver is under way: it stays, and
10
- * what happens to it is the workflow's next decision (a machine-command row).
8
+ * Withdraw a dispatch that may still sit in the receiver's queue: one sent or
9
+ * accepted. A job that never went out (the row waiting for room, or for its
10
+ * units to arrive) leaves the receiver nothing to withdraw. A job some of whose
11
+ * units already reached the receiver is under way: it stays, and what happens
12
+ * to it is the workflow's next decision (a machine-command row).
11
13
  */
12
14
  export declare function cancelDispatch(state: GatewayState, row: HandoffRow): Action[];
13
15
  /**
@@ -14,13 +14,15 @@ function cancelJobs(state, row, machineId, jobId, tag = '', keep = () => false)
14
14
  }));
15
15
  }
16
16
  /**
17
- * Withdraw a dispatch that may still sit in the receiver's queue. A job some
18
- * of whose units already reached the receiver is under way: it stays, and
19
- * what happens to it is the workflow's next decision (a machine-command row).
17
+ * Withdraw a dispatch that may still sit in the receiver's queue: one sent or
18
+ * accepted. A job that never went out (the row waiting for room, or for its
19
+ * units to arrive) leaves the receiver nothing to withdraw. A job some of whose
20
+ * units already reached the receiver is under way: it stays, and what happens
21
+ * to it is the workflow's next decision (a machine-command row).
20
22
  */
21
23
  function cancelDispatch(state, row) {
22
24
  const to = row.toMachine;
23
- if (!to || row.dispatch === state_1.DISPATCH_STATE.NONE || row.dispatch === state_1.DISPATCH_STATE.REFUSED)
25
+ if (!to || (row.dispatch !== state_1.DISPATCH_STATE.PENDING && row.dispatch !== state_1.DISPATCH_STATE.ACCEPTED))
24
26
  return [];
25
27
  return cancelJobs(state, row, to, row.jobId, '', (job) => job.units.some((u) => state.heldJob(to, u.unitId) === job.jobId));
26
28
  }
@@ -10,6 +10,8 @@ export interface CommandReply {
10
10
  reason: string;
11
11
  /** The machine's structured code for the outcome, when its reply carried one. */
12
12
  code?: string;
13
+ /** An acceptance the machine will follow with `completed` or `failed` once the job is prepared. */
14
+ preparing?: boolean;
13
15
  }
14
16
  /** The jobs a machine reports holding, queued or in work. */
15
17
  export type HeldJobs = Set<string>;
@@ -35,6 +35,8 @@ export interface HandoffRow extends HandoffFacets {
35
35
  trace?: TraceEntry[];
36
36
  /** Each unit's arrival event, as its receiver reported it (dispatch on arrival). */
37
37
  arrivals?: Record<string, Record<string, any>>;
38
+ /** Dispatch commands accepted as preparing; on arrival dispatch the row is received once each is ready. */
39
+ awaitingReady?: Set<string>;
38
40
  /** Lifecycle item keys already on the row (lifecycle.ts). */
39
41
  noted?: string[];
40
42
  /** Parked with a limit on its items, which lifecycle notes must never use up. */
@@ -98,6 +100,10 @@ export declare class GatewayState {
98
100
  holdDispatch: boolean;
99
101
  /** Units at each machine that arrived and have not left: machine → unit → job. */
100
102
  private held;
103
+ /** Jobs sent and taken, by machine: job → the row that sent it, kept while the machine holds the job's units. */
104
+ private sentJobs;
105
+ /** Jobs whose units left each machine since its last snapshot: they ended, a snapshot without them lost nothing. */
106
+ private endedJobs;
101
107
  private openRows;
102
108
  private closedRows;
103
109
  readonly alarms: Map<string, AlarmTrack>;
@@ -122,6 +128,8 @@ export declare class GatewayState {
122
128
  private strangers;
123
129
  index(row: HandoffRow): void;
124
130
  get(id: string): HandoffRow | undefined;
131
+ /** A row that ended, while the gateway still remembers it. */
132
+ closedRow(id: string): HandoffRow | undefined;
125
133
  close(id: string, ended?: RowEnd): HandoffRow | undefined;
126
134
  isClosed(id: string): boolean;
127
135
  open(): HandoffRow[];
@@ -130,11 +138,26 @@ export declare class GatewayState {
130
138
  closedFor(unitId: string, pred: (r: HandoffRow) => boolean): HandoffRow | undefined;
131
139
  hold(machineId: string, unitId: string, jobId: string): void;
132
140
  letGo(machineId: string, unitId: string): void;
141
+ /** A job the machine took, remembered by its row while the machine holds the job's units. */
142
+ rememberSent(machineId: string, jobId: string, rowId: string): void;
143
+ /**
144
+ * After a restart, the jobs the machine lost: those its units are held under
145
+ * or its last snapshot listed, that the new snapshot lacks and no open row
146
+ * will send again. `resend` names the rows remembered for them; `unknown`
147
+ * the jobs nothing is remembered for.
148
+ */
149
+ lostAfterReboot(machineId: string, listed: Set<string>): {
150
+ resend: {
151
+ jobId: string;
152
+ row: HandoffRow;
153
+ }[];
154
+ unknown: string[];
155
+ };
133
156
  /** The job a unit arrived under at `machineId`, while it is still there. */
134
157
  heldJob(machineId: string, unitId: string): string | undefined;
135
158
  heldAt(machineId: string): string[];
136
159
  heldUnitsAt(machineId: string): HeldUnit[];
137
- /** Keep only units whose job the machine still lists (its snapshot). */
160
+ /** Keep only units whose job the machine still lists (its snapshot); a job it no longer lists is over. */
138
161
  keepHeld(machineId: string, jobIds: Set<string>): void;
139
162
  /** Keep a sender's finished record until its row is known, so an arrival cannot close the row without it. */
140
163
  rememberFinish(machineId: string, unitId: string, body: Record<string, any>): void;