@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
@@ -15,6 +15,7 @@ const escalations_1 = require("./escalations");
15
15
  const log_1 = require("./log");
16
16
  const dispatcher_1 = require("./dispatcher");
17
17
  const plan_shared_1 = require("./plan-shared");
18
+ const state_1 = require("./state");
18
19
  const NOT_FOUND = 404;
19
20
  const CONFLICT = 409;
20
21
  class Executor {
@@ -47,10 +48,14 @@ class Executor {
47
48
  for (const t of this.timers)
48
49
  clearTimeout(t);
49
50
  this.timers.clear();
51
+ this.dispatcher.stop();
50
52
  this.d.replies.cancelAll();
51
53
  }
52
54
  track(work) {
53
- const tracked = work.catch((err) => this.d.log.error(log_1.GATEWAY_LOG.VERB_FAILED, { error: String(err?.message ?? err) }))
55
+ const tracked = work.catch((err) => {
56
+ if (!(err instanceof dispatcher_1.GatewayStopped))
57
+ this.d.log.error(log_1.GATEWAY_LOG.VERB_FAILED, { error: String(err?.message ?? err) });
58
+ })
54
59
  .finally(() => this.inflight.delete(tracked));
55
60
  this.inflight.add(tracked);
56
61
  }
@@ -90,19 +95,29 @@ class Executor {
90
95
  this.d.log.warn(log_1.GATEWAY_LOG.ADDRESS_REFUSED, { machine: a.machineId, address: a.address, reason: a.reason });
91
96
  }
92
97
  }
93
- /** A dispatch's second reply: ready, or a failure to prepare that ends the handoff. */
98
+ /**
99
+ * A dispatch's second reply: ready, or a failure to prepare that ends the
100
+ * handoff. When the handoff into the machine has already ended, as one
101
+ * dispatched on arrival does at acceptance, the failure ends the handoff
102
+ * carrying the units out of it.
103
+ */
94
104
  async lateReply(a) {
95
- const row = this.d.state.rowForDispatch(a.cmdId);
96
- if (!row?.toMachine)
105
+ const { state, log } = this.d;
106
+ const into = state.rowForDispatch(a.cmdId);
107
+ const m = into?.toMachine;
108
+ if (!into || !m)
97
109
  return;
98
- const context = { row: row.id, machine: row.toMachine, cmdId: a.cmdId, reason: a.reason };
110
+ const context = { row: into.id, machine: m, cmdId: a.cmdId, reason: a.reason };
99
111
  if (a.status === protocol_1.REPLY_STATUS.COMPLETED)
100
- return this.d.log.info(log_1.GATEWAY_LOG.DISPATCH_READY, context);
112
+ return log.info(log_1.GATEWAY_LOG.DISPATCH_READY, context);
101
113
  if (a.status !== protocol_1.REPLY_STATUS.FAILED)
102
114
  return;
103
- this.d.log.warn(log_1.GATEWAY_LOG.DISPATCH_FAILED, context);
104
- await this.apply((0, plan_shared_1.failHandoff)(this.d.state, row, row.toMachine, {
105
- received: false, errorCode: contracts_1.HANDOFF_ERROR.DISPATCH_FAILED, detail: `${row.toMachine} could not prepare the job: ${a.reason}`,
115
+ const row = into.ended ? onwardRow(state, into, m) : into;
116
+ log.warn(log_1.GATEWAY_LOG.DISPATCH_FAILED, { ...context, ...(into.ended ? { onward: row?.id ?? null } : {}) });
117
+ if (!row)
118
+ return;
119
+ await this.apply((0, plan_shared_1.failHandoff)(state, row, m, {
120
+ received: false, errorCode: contracts_1.HANDOFF_ERROR.DISPATCH_FAILED, detail: `${m} could not prepare the job: ${a.reason}`,
106
121
  }));
107
122
  }
108
123
  /** Add the item; when the row then holds every key the action waits for, resolve it. */
@@ -193,3 +208,14 @@ class Executor {
193
208
  }
194
209
  }
195
210
  exports.Executor = Executor;
211
+ /** The open handoff out of `m` for units a closed handoff brought into it. */
212
+ function onwardRow(state, into, m) {
213
+ if (into.ended !== state_1.ROW_END.RESOLVED || into.dispatch !== state_1.DISPATCH_STATE.ACCEPTED)
214
+ return undefined;
215
+ for (const u of into.units) {
216
+ const row = state.openFor(u.unitId, (r) => r.fromMachine === m);
217
+ if (row)
218
+ return row;
219
+ }
220
+ return undefined;
221
+ }
@@ -23,6 +23,10 @@ export interface GatewaySettings {
23
23
  quietMs: number;
24
24
  /** How long confirmed message ids gather before a receipt goes out (codecs with receipts). */
25
25
  receiptDelayMs?: number;
26
+ /** Note each job's start, progress steps and pauses on the row that holds it; off unless set. */
27
+ lifecycle?: boolean;
28
+ /** With lifecycle notes, progress is noted each time it crosses this many percent; 0 notes none. */
29
+ progressStep?: number;
26
30
  }
27
31
  export interface GatewayDeps {
28
32
  bus: MachineBus;
@@ -52,6 +56,8 @@ export declare class MachineGateway {
52
56
  constructor(deps: GatewayDeps);
53
57
  start(): Promise<void>;
54
58
  stop(): Promise<void>;
59
+ /** Machines that report only changes are asked for their full state once the gateway listens. */
60
+ private askEveryMachine;
55
61
  /** Resolves when no dispatch is in flight (tests). */
56
62
  idle(): Promise<void>;
57
63
  /**
@@ -32,6 +32,9 @@ class MachineGateway {
32
32
  this.codec = deps.codec ?? (0, protocol_1.genericCodec)({ root: deps.settings.topicRoot, line: deps.line });
33
33
  this.state.unitsPerDispatch = this.codec.unitsPerDispatch;
34
34
  this.state.dispatchOnArrival = this.codec.dispatchOn === protocol_1.DISPATCH_ON.ARRIVAL;
35
+ this.state.lifecycle = deps.settings.lifecycle === true;
36
+ this.state.retainedAlarms = this.codec.retainedAlarms === true;
37
+ this.state.progressStep = deps.settings.progressStep ?? this.state.progressStep;
35
38
  this.addresses = deps.addresses ?? new address_book_1.MemoryAddressBook();
36
39
  this.executor = new executor_1.Executor({
37
40
  addresses: this.addresses,
@@ -45,9 +48,12 @@ class MachineGateway {
45
48
  async start() {
46
49
  await this.deps.bus.connect();
47
50
  await this.deps.feed.connect();
51
+ this.state.takenAt = Date.now();
48
52
  await this.reconcile();
49
53
  await this.deps.feed.consume((event) => this.onEscalation(event));
50
54
  await this.deps.bus.consume((msg) => this.onMachine(msg));
55
+ if (this.codec.snapshotOnStart)
56
+ await this.askEveryMachine();
51
57
  const { quietMs } = this.deps.settings;
52
58
  if (quietMs > 0) {
53
59
  const machines = this.deps.line.machines.map((m) => m.machineId);
@@ -59,10 +65,18 @@ class MachineGateway {
59
65
  if (this.quietTimer)
60
66
  clearInterval(this.quietTimer);
61
67
  this.executor.stop();
68
+ await this.executor.idle();
62
69
  await this.receipts.flush();
63
70
  await this.deps.feed.close();
64
71
  await this.deps.bus.close();
65
72
  }
73
+ /** Machines that report only changes are asked for their full state once the gateway listens. */
74
+ async askEveryMachine() {
75
+ const at = Date.now();
76
+ await this.executor.apply(this.deps.line.machines.map((m) => ({
77
+ kind: 'command', machineId: m.machineId, op: protocol_1.OP.SNAPSHOT, cmdId: `${m.machineId}:takeover:${at}`, args: {},
78
+ })));
79
+ }
66
80
  /** Resolves when no dispatch is in flight (tests). */
67
81
  idle() {
68
82
  return this.executor.idle();
@@ -80,6 +94,7 @@ class MachineGateway {
80
94
  const addresses = await (0, reconcile_1.restoreAddresses)(this.state, this.addresses, this.deps.line);
81
95
  const handoffs = await this.reconcileHandoffs();
82
96
  await this.executor.apply(await (0, reconcile_1.arrivalsOnRows)(this.state, this.deps.lt, this.deps.settings.dispatchTtlMs));
97
+ await (0, reconcile_1.notesOnRows)(this.state, this.deps.lt);
83
98
  const commands = await this.reconcileCommands();
84
99
  const restored = await (0, reconcile_1.restoreLineRows)(this.state, this.deps.lt, this.deps.line);
85
100
  await this.resync();
@@ -121,18 +136,26 @@ class MachineGateway {
121
136
  async resync() {
122
137
  const { line } = this.deps;
123
138
  const active = new Set();
124
- for (const msg of await this.deps.bus.retained((0, protocol_1.lineFilter)(this.codec.root, line.siteId, line.lineId))) {
125
- for (const raw of this.decode(msg.topic, msg.payload, true)) {
126
- if (!raw.parsed)
127
- continue;
128
- const seen = await (0, admission_1.admitSender)(this.admission, msg.topic, raw);
129
- if (!('parsed' in seen) || !seen.parsed)
130
- continue;
131
- if (seen.parsed.kind === protocol_1.MESSAGE_KIND.ALARM && seen.body?.body?.active)
132
- active.add(this.state.alarmKey(seen.parsed.ident.machineId, seen.parsed.key));
133
- await this.applyMachine(msg.topic, seen.parsed, seen.body);
139
+ this.state.readingRetained = true;
140
+ try {
141
+ for (const msg of await this.deps.bus.retained((0, protocol_1.lineFilter)(this.codec.root, line.siteId, line.lineId))) {
142
+ for (const raw of this.decode(msg.topic, msg.payload, true)) {
143
+ if (!raw.parsed)
144
+ continue;
145
+ const seen = await (0, admission_1.admitSender)(this.admission, msg.topic, raw);
146
+ if (!('parsed' in seen) || !seen.parsed)
147
+ continue;
148
+ if (seen.parsed.kind === protocol_1.MESSAGE_KIND.ALARM && seen.body?.body?.active)
149
+ active.add(this.state.alarmKey(seen.parsed.ident.machineId, seen.parsed.key));
150
+ await this.applyMachine(msg.topic, seen.parsed, seen.body);
151
+ }
134
152
  }
135
153
  }
154
+ finally {
155
+ this.state.readingRetained = false;
156
+ }
157
+ if (!this.state.retainedAlarms)
158
+ return;
136
159
  for (const gone of (0, reconcile_1.alarmsClearedMeanwhile)(this.state, active)) {
137
160
  this.state.alarms.delete(gone.key);
138
161
  await this.executor.apply([{ kind: 'resolve', as: actions_1.CONTROLLER, rowId: gone.rowId, payload: { decision: contracts_1.DECISION.CLEARED, note: `cleared at ${gone.machineId} while the gateway was away` } }]);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Job lifecycle on the row. A machine's started and progress events, and the
3
+ * alarms that stop it, are noted as items on the open row that holds the job:
4
+ * the accepted dispatch into the machine until its units arrive, then the
5
+ * handoff out of it. Progress is noted only when it crosses a step, or reaches
6
+ * a named stage it has not reached before, so a row carries a handful of items
7
+ * while the stream keeps every report.
8
+ *
9
+ * Off unless the line turns it on (MACHINE_LIFECYCLE=on). A row parked with a
10
+ * limit on its items gets no notes, so they never use a slot its units need.
11
+ */
12
+ import type { Action } from './actions';
13
+ import { type AlarmTrack, type GatewayState } from './state';
14
+ /** The progress step `percent` has reached; null below the first step, at 100, or when steps are off. */
15
+ export declare function progressStepOf(percent: unknown, step: number): number | null;
16
+ /** A started or progress event, noted on the row that holds the job. */
17
+ export declare function noteJobEvent(state: GatewayState, m: string, b: Record<string, any>, at: string | undefined): Action[];
18
+ /** Rows a stopping alarm pauses: those running or leaving with its job, or every row the machine holds. */
19
+ export declare function notePause(state: GatewayState, track: AlarmTrack, b: Record<string, any>): Action[];
20
+ /** The machine cleared an alarm: the rows it paused, still open, note the resume. */
21
+ export declare function noteResume(state: GatewayState, track: AlarmTrack, b: Record<string, any>): Action[];
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /**
3
+ * Job lifecycle on the row. A machine's started and progress events, and the
4
+ * alarms that stop it, are noted as items on the open row that holds the job:
5
+ * the accepted dispatch into the machine until its units arrive, then the
6
+ * handoff out of it. Progress is noted only when it crosses a step, or reaches
7
+ * a named stage it has not reached before, so a row carries a handful of items
8
+ * while the stream keeps every report.
9
+ *
10
+ * Off unless the line turns it on (MACHINE_LIFECYCLE=on). A row parked with a
11
+ * limit on its items gets no notes, so they never use a slot its units need.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.progressStepOf = progressStepOf;
15
+ exports.noteJobEvent = noteJobEvent;
16
+ exports.notePause = notePause;
17
+ exports.noteResume = noteResume;
18
+ const contracts_1 = require("../contracts");
19
+ const protocol_1 = require("../../../lib/machines/protocol");
20
+ const jobs_1 = require("./jobs");
21
+ const state_1 = require("./state");
22
+ const owns = (jobId, reported) => !jobId || typeof reported !== 'string' || (0, jobs_1.jobOwns)(jobId, reported);
23
+ const runningAt = (r, m, jobId) => r.toMachine === m && r.dispatch === state_1.DISPATCH_STATE.ACCEPTED && owns(r.jobId, jobId);
24
+ const leavingFrom = (r, m, jobId) => r.fromMachine === m && !!r.senderJobId && owns(r.senderJobId, jobId);
25
+ /** The open row holding `unitId` at `m`: its accepted dispatch, else the handoff out of `m`. */
26
+ function holderRow(state, m, unitId, jobId) {
27
+ return state.openFor(unitId, (r) => runningAt(r, m, jobId)) ?? state.openFor(unitId, (r) => leavingFrom(r, m, jobId));
28
+ }
29
+ /** The progress step `percent` has reached; null below the first step, at 100, or when steps are off. */
30
+ function progressStepOf(percent, step) {
31
+ if (step <= 0 || typeof percent !== 'number' || !Number.isFinite(percent))
32
+ return null;
33
+ const reached = Math.floor(percent / step) * step;
34
+ return reached > 0 && reached < 100 ? reached : null;
35
+ }
36
+ /** An item the row does not hold yet, added as the machine. */
37
+ function note(row, m, itemKey, payload, metadata) {
38
+ if (row.capped || row.noted?.includes(itemKey))
39
+ return [];
40
+ row.noted = [...(row.noted ?? []), itemKey];
41
+ return [{ kind: 'accumulate', as: m, rowId: row.id, itemKey, payload, ...(metadata ? { metadata } : {}) }];
42
+ }
43
+ /** A started or progress event, noted on the row that holds the job. */
44
+ function noteJobEvent(state, m, b, at) {
45
+ const unitId = b.unitId;
46
+ if (!state.lifecycle || typeof unitId !== 'string')
47
+ return [];
48
+ const row = holderRow(state, m, unitId, b.jobId);
49
+ if (!row)
50
+ return [];
51
+ const when = at ?? new Date().toISOString();
52
+ if (b.event === protocol_1.JOB_EVENT.STARTED) {
53
+ return note(row, m, contracts_1.itemKeys.started(unitId), { unitId, jobId: b.jobId ?? null, at: when });
54
+ }
55
+ const step = progressStepOf(b.percent, state.progressStep);
56
+ const stage = typeof b.stage === 'string' && b.stage ? b.stage : null;
57
+ if (step === null && stage === null)
58
+ return [];
59
+ const progress = {
60
+ ...(typeof b.percent === 'number' ? { percent: b.percent } : {}), remainingSec: b.remainingSec ?? null, ...(stage ? { stage } : {}), at: when,
61
+ ...(b.detail && typeof b.detail === 'object' ? { detail: b.detail } : {}),
62
+ };
63
+ const facet = { [contracts_1.LIFECYCLE_FACET.PROGRESS]: progress };
64
+ const job = { unitId, jobId: b.jobId ?? null };
65
+ return [
66
+ ...(step === null ? [] : note(row, m, contracts_1.itemKeys.progress(unitId, step), { ...job, step, ...progress }, facet)),
67
+ ...(stage === null ? [] : note(row, m, contracts_1.itemKeys.stage(unitId, stage), { ...job, ...progress }, facet)),
68
+ ];
69
+ }
70
+ /** Rows a stopping alarm pauses: those running or leaving with its job, or every row the machine holds. */
71
+ function notePause(state, track, b) {
72
+ if (!state.lifecycle)
73
+ return [];
74
+ const m = track.machineId;
75
+ const jobId = typeof b.jobId === 'string' ? b.jobId : undefined;
76
+ const rows = state.open().filter((r) => runningAt(r, m, jobId) || leavingFrom(r, m, jobId));
77
+ track.noted = rows.map((r) => r.id);
78
+ const payload = { alarmId: track.alarmId, code: b.code ?? null, level: b.level ?? null, text: b.text ?? null, raisedAt: b.raisedAt ?? null };
79
+ return rows.flatMap((r) => note(r, m, contracts_1.itemKeys.paused(track.alarmId, String(b.raisedAt ?? '')), payload));
80
+ }
81
+ /** The machine cleared an alarm: the rows it paused, still open, note the resume. */
82
+ function noteResume(state, track, b) {
83
+ const key = contracts_1.itemKeys.resumed(track.alarmId, String(track.raisedAt ?? ''));
84
+ const payload = { alarmId: track.alarmId, clearedAt: b.clearedAt ?? new Date().toISOString(), clearedBy: b.clearedBy ?? null };
85
+ return (track.noted ?? [])
86
+ .map((id) => state.get(id))
87
+ .filter((r) => !!r && !r.ended)
88
+ .flatMap((r) => note(r, track.machineId, key, payload));
89
+ }
@@ -11,6 +11,20 @@ import { type Action } from './actions';
11
11
  import { type GatewayState, type HandoffRow } from './state';
12
12
  /** Dispatch arguments for a handoff row. */
13
13
  export declare function dispatchArgs(row: HandoffRow, expiresAt: number): Record<string, any>;
14
+ /**
15
+ * The validity round a dispatch belongs to. Rounds of one validity period count
16
+ * from when the dispatch became due, and a round begins only when the one
17
+ * before it has expired, so a machine never holds two valid commands for one
18
+ * row. Within a round a dispatch sent again, by a retry, a restarted machine or
19
+ * a restarted gateway, is the same command with the same arguments; a new round
20
+ * is a new command, never an old one with changed arguments.
21
+ */
22
+ export declare function dispatchWindow(dueAt: number, now: number, ttlMs: number): {
23
+ round: number;
24
+ expiresAt: number;
25
+ };
26
+ /** The later of a row's due time and an arrival's time; an unreadable time leaves it as it was. */
27
+ export declare function latestArrival(dueAt: number | undefined, at: unknown): number | undefined;
14
28
  export declare function dispatchAction(row: HandoffRow, machineId: string, dispatchTtlMs: number): Action;
15
29
  /** A machine on a version the line does not allow is refused before any dispatch. */
16
30
  export declare function softwareRefusal(state: GatewayState, line: LineDef, machineId: string): string | null;
@@ -9,6 +9,8 @@
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.dispatchArgs = dispatchArgs;
12
+ exports.dispatchWindow = dispatchWindow;
13
+ exports.latestArrival = latestArrival;
12
14
  exports.dispatchAction = dispatchAction;
13
15
  exports.softwareRefusal = softwareRefusal;
14
16
  exports.artifactRefusal = artifactRefusal;
@@ -39,9 +41,31 @@ function dispatchArgs(row, expiresAt) {
39
41
  ...(row.parameters ? { parameters: row.parameters } : {}),
40
42
  };
41
43
  }
44
+ /**
45
+ * The validity round a dispatch belongs to. Rounds of one validity period count
46
+ * from when the dispatch became due, and a round begins only when the one
47
+ * before it has expired, so a machine never holds two valid commands for one
48
+ * row. Within a round a dispatch sent again, by a retry, a restarted machine or
49
+ * a restarted gateway, is the same command with the same arguments; a new round
50
+ * is a new command, never an old one with changed arguments.
51
+ */
52
+ function dispatchWindow(dueAt, now, ttlMs) {
53
+ const round = Math.max(1, Math.floor((now - dueAt) / ttlMs) + 1);
54
+ return { round, expiresAt: dueAt + round * ttlMs };
55
+ }
56
+ /** The later of a row's due time and an arrival's time; an unreadable time leaves it as it was. */
57
+ function latestArrival(dueAt, at) {
58
+ const t = typeof at === 'string' ? Date.parse(at) : NaN;
59
+ if (!Number.isFinite(t))
60
+ return dueAt;
61
+ return dueAt === undefined ? t : Math.max(dueAt, t);
62
+ }
42
63
  function dispatchAction(row, machineId, dispatchTtlMs) {
64
+ const now = Date.now();
65
+ row.createdAt ??= now;
66
+ const { round, expiresAt } = dispatchWindow(row.dueAt ?? row.createdAt, now, dispatchTtlMs);
43
67
  return {
44
- kind: 'dispatch', rowId: row.id, machineId, cmdId: (0, contracts_1.dispatchCommandId)(row.id), args: dispatchArgs(row, Date.now() + dispatchTtlMs),
68
+ kind: 'dispatch', rowId: row.id, machineId, cmdId: (0, contracts_1.dispatchCommandId)(row.id, round), args: dispatchArgs(row, expiresAt),
45
69
  ...(row.dispatch === state_1.DISPATCH_STATE.ARRIVING ? { receivedOnAccept: true } : {}),
46
70
  };
47
71
  }
@@ -57,7 +81,7 @@ function softwareRefusal(state, line, machineId) {
57
81
  function artifactRefusal(line, row) {
58
82
  if (!(0, line_1.machineById)(line, row.toMachine).requireArtifact || (row.artifactUrl && row.artifactSha256))
59
83
  return null;
60
- if (row.units.every((u) => u.artifact))
84
+ if (row.units.every((u) => u.artifact || u.artifacts?.length))
61
85
  return null;
62
86
  return `${row.toMachine} requires an artifact (url and sha256), for the job or for every unit; the row has none`;
63
87
  }
@@ -29,9 +29,17 @@ const VERB = { CREATED: 'created', CLAIMED: 'claimed', RESOLVED: 'resolved', CAN
29
29
  const STATUS_FOR_VERB = {
30
30
  [VERB.CREATED]: 'pending', [VERB.CLAIMED]: 'pending', [VERB.RESOLVED]: 'resolved', [VERB.CANCELLED]: 'cancelled', [VERB.EXPIRED]: 'expired',
31
31
  };
32
+ /** The metadata key an accumulator row parked with an item limit carries. */
33
+ const ACCUMULATE_MAX = 'accumulate_max';
32
34
  const LINE_ROW_TYPES = [contracts_1.ROW_TYPE.ALARM, contracts_1.ROW_TYPE.MACHINE_OFFLINE, contracts_1.ROW_TYPE.MACHINE_QUIET, contracts_1.ROW_TYPE.MACHINE_COMMAND, contracts_1.ROW_TYPE.MACHINE_REGISTRATION];
33
- function handoffRow(id, role, facets, metadata) {
34
- return { ...facets, id, role, dispatch: state_1.DISPATCH_STATE.NONE, trace: (0, trace_1.readTrace)(metadata) };
35
+ function handoffRow(id, role, facets, data) {
36
+ const metadata = data?.metadata;
37
+ const capped = metadata?.[ACCUMULATE_MAX] != null;
38
+ const createdAt = data?.created_at ? new Date(data.created_at).getTime() : NaN;
39
+ return {
40
+ ...facets, id, role, dispatch: state_1.DISPATCH_STATE.NONE, trace: (0, trace_1.readTrace)(metadata),
41
+ ...(capped ? { capped } : {}), ...(Number.isFinite(createdAt) ? { createdAt } : {}),
42
+ };
35
43
  }
36
44
  /** Rows for another line belong to that line's gateway. */
37
45
  function onThisLine(line, at) {
@@ -108,7 +116,8 @@ function planCommandRow(state, line, event) {
108
116
  if (refusal) {
109
117
  return [{ kind: 'resolve', as: actions_1.CONTROLLER, rowId: id, payload: { accepted: false, machineId: c.machineId, op: c.op, status: contracts_1.COMMAND_STATUS.UNSUPPORTED, reason: refusal } }];
110
118
  }
111
- return [{ kind: 'control', rowId: id, machineId: c.machineId, op: c.op, cmdId: (0, contracts_1.controlCommandId)(id), args: c.args }];
119
+ const createdAt = event.data?.created_at ? new Date(event.data.created_at).getTime() : NaN;
120
+ return [{ kind: 'control', rowId: id, machineId: c.machineId, op: c.op, args: c.args, ...(Number.isFinite(createdAt) ? { createdAt } : {}) }];
112
121
  }
113
122
  function onLine(line, machineId) {
114
123
  return !!machineId && line.machines.some((m) => m.machineId === machineId);
@@ -122,7 +131,7 @@ function planHandoff(state, line, event, facets, dispatchTtlMs) {
122
131
  if (event.verb === VERB.CREATED || event.verb === VERB.CLAIMED) {
123
132
  if (state.isClosed(id) || !facets)
124
133
  return [];
125
- state.index(handoffRow(id, event.role, facets, event.data?.metadata));
134
+ state.index(handoffRow(id, event.role, facets, event.data));
126
135
  const row = state.get(id);
127
136
  // The units now wait on the sender as this row's sender job, when the sender holds them under it.
128
137
  if (row.fromMachine)
@@ -2,10 +2,11 @@
2
2
  * Machine messages to gateway actions: the escalation verbs a person would
3
3
  * perform, performed for the machine. Each unit's arrival and the sender's
4
4
  * finished record accumulate on the handoff row, and the last expected item
5
- * resolves it. A failure on either side resolves it with an error. Alarms that
6
- * stop work and machines that go dark or quiet become rows for people. A
7
- * restarted machine gets back the dispatches it lost. Everything else stays
8
- * in the stream.
5
+ * resolves it. A failure on either side resolves it with an error. A job's
6
+ * start, its progress steps, and the alarms that pause it are noted on the row
7
+ * that holds it (lifecycle.ts). Alarms that stop work and machines that go
8
+ * dark or quiet become rows for people. A restarted machine gets back the
9
+ * dispatches it lost. Everything else stays in the stream.
9
10
  *
10
11
  * A message whose row does not exist yet is nak'd and arrives again; one whose
11
12
  * row already closed is acked, so a replay never resolves anything twice.
@@ -3,10 +3,11 @@
3
3
  * Machine messages to gateway actions: the escalation verbs a person would
4
4
  * perform, performed for the machine. Each unit's arrival and the sender's
5
5
  * finished record accumulate on the handoff row, and the last expected item
6
- * resolves it. A failure on either side resolves it with an error. Alarms that
7
- * stop work and machines that go dark or quiet become rows for people. A
8
- * restarted machine gets back the dispatches it lost. Everything else stays
9
- * in the stream.
6
+ * resolves it. A failure on either side resolves it with an error. A job's
7
+ * start, its progress steps, and the alarms that pause it are noted on the row
8
+ * that holds it (lifecycle.ts). Alarms that stop work and machines that go
9
+ * dark or quiet become rows for people. A restarted machine gets back the
10
+ * dispatches it lost. Everything else stays in the stream.
10
11
  *
11
12
  * A message whose row does not exist yet is nak'd and arrives again; one whose
12
13
  * row already closed is acked, so a replay never resolves anything twice.
@@ -21,6 +22,7 @@ const actions_1 = require("./actions");
21
22
  const pacing_1 = require("./pacing");
22
23
  const plan_shared_1 = require("./plan-shared");
23
24
  const jobs_1 = require("./jobs");
25
+ const lifecycle_1 = require("./lifecycle");
24
26
  const trace_1 = require("./trace");
25
27
  const state_1 = require("./state");
26
28
  const ack = (actions = []) => ({ actions, disposition: transport_1.DISPOSITION.ACK });
@@ -68,12 +70,29 @@ function ownsJob(jobId, reported) {
68
70
  return (0, jobs_1.jobOwns)(jobId, reported);
69
71
  }
70
72
  /** Keep the receiver's arrival event for the unit, to go out with its dispatch. */
71
- function withArrival(row, unitId, b) {
73
+ function withArrival(row, unitId, b, at) {
72
74
  row.arrivals = { ...row.arrivals, [unitId]: b };
75
+ row.dueAt = (0, pacing_1.latestArrival)(row.dueAt, at);
73
76
  return row;
74
77
  }
78
+ /** The unit an open handoff into `m` carries on `carrierId`, for a scan that read only the carrier. */
79
+ function unitOnCarrier(state, m, carrierId) {
80
+ if (typeof carrierId !== 'string')
81
+ return undefined;
82
+ for (const r of state.open()) {
83
+ if (r.toMachine !== m)
84
+ continue;
85
+ const unit = r.units.find((u) => u.carrierId === carrierId);
86
+ if (unit)
87
+ return unit.unitId;
88
+ }
89
+ return undefined;
90
+ }
75
91
  function planJob(state, m, b, at, ttl) {
76
- const unitId = b.unitId;
92
+ const unitId = typeof b.unitId === 'string' ? b.unitId : unitOnCarrier(state, m, b.carrierId);
93
+ // A carrier no open handoff here carries yet: its row may still be on the way.
94
+ if (unitId === undefined)
95
+ return nak;
77
96
  // An event from an earlier attempt's job never touches a later attempt's row.
78
97
  const toHere = (r) => r.toMachine === m && ownsJob(r.jobId, b.jobId);
79
98
  const fromHere = (r) => r.fromMachine === m && ownsJob(r.senderJobId, b.jobId);
@@ -95,13 +114,14 @@ function planJob(state, m, b, at, ttl) {
95
114
  if (row.fromMachine)
96
115
  state.letGo(row.fromMachine, unitId);
97
116
  const units = row.units.map((u) => u.unitId);
117
+ const arrivedAt = at ?? new Date().toISOString();
98
118
  const complete = row.dispatch === state_1.DISPATCH_STATE.ARRIVING
99
- ? { keys: units.map(contracts_1.itemKeys.arrived), then: [(0, pacing_1.dispatchAction)(withArrival(row, unitId, b), m, ttl)] }
119
+ ? { keys: units.map(contracts_1.itemKeys.arrived), then: [(0, pacing_1.dispatchAction)(withArrival(row, unitId, b, arrivedAt), m, ttl)] }
100
120
  : { keys: units.map(contracts_1.itemKeys.arrived), payload: { received: true, receiver: m, units } };
101
121
  return ack([...early, {
102
122
  kind: 'accumulate', as: m, rowId: row.id, itemKey: contracts_1.itemKeys.arrived(unitId),
103
123
  payload: {
104
- unitId, carrierId: b.carrierId ?? null, from: b.peer ?? null, at: at ?? new Date().toISOString(),
124
+ unitId, carrierId: b.carrierId ?? null, from: b.peer ?? null, at: arrivedAt,
105
125
  ...(row.dispatch === state_1.DISPATCH_STATE.ARRIVING ? { arrival: b } : {}),
106
126
  },
107
127
  complete,
@@ -140,6 +160,8 @@ function planJob(state, m, b, at, ttl) {
140
160
  return ack();
141
161
  return ack((0, plan_shared_1.failHandoff)(state, row, m, { received: false, unitId, errorCode: contracts_1.HANDOFF_ERROR.WITHDRAWN, detail: `${m} gave up ${b.jobId}` }));
142
162
  }
163
+ if (b.event === protocol_1.JOB_EVENT.STARTED || b.event === protocol_1.JOB_EVENT.PROGRESS)
164
+ return ack((0, lifecycle_1.noteJobEvent)(state, m, b, at));
143
165
  return ack();
144
166
  }
145
167
  function releasedAction(row, m, b) {
@@ -160,9 +182,10 @@ function planAlarm(state, parsed, alarmId, env) {
160
182
  if (!env || b.active === false) {
161
183
  const track = state.alarms.get(key);
162
184
  state.alarms.delete(key);
185
+ const resumed = track ? (0, lifecycle_1.noteResume)(state, track, b) : [];
163
186
  if (!track?.rowId || !track.open)
164
- return ack();
165
- return ack([{
187
+ return ack(resumed);
188
+ return ack([...resumed, {
166
189
  kind: 'resolve', as: actions_1.CONTROLLER, rowId: track.rowId,
167
190
  payload: { decision: contracts_1.DECISION.CLEARED, clearedBy: b.clearedBy ?? null, note: `cleared at ${m}${b.clearedBy ? ` by ${b.clearedBy}` : ''}` },
168
191
  }]);
@@ -175,8 +198,9 @@ function planAlarm(state, parsed, alarmId, env) {
175
198
  const superseded = known?.rowId && known.open
176
199
  ? [{ kind: 'resolve', as: actions_1.CONTROLLER, rowId: known.rowId, payload: { decision: contracts_1.DECISION.CLEARED, note: `${m} raised ${alarmId} again; this one ended` } }]
177
200
  : [];
178
- state.alarms.set(key, { machineId: m, alarmId, raisedAt: b.raisedAt, open: true });
179
- return ack([...superseded, {
201
+ const track = { machineId: m, alarmId, raisedAt: b.raisedAt, open: true };
202
+ state.alarms.set(key, track);
203
+ return ack([...superseded, ...(0, lifecycle_1.notePause)(state, track, b), {
180
204
  kind: 'createAlarmRow', machineId: m, alarmId,
181
205
  row: {
182
206
  type: contracts_1.ROW_TYPE.ALARM, subtype: b.level, role: contracts_1.LINE_ROLE.OPERATOR, priority: b.level === protocol_1.LEVEL.EMERGENCY ? 1 : 2,
@@ -230,13 +254,30 @@ function planSnapshot(state, m, b, ttl) {
230
254
  const jobIds = Array.isArray(b.jobs) ? b.jobs.map((j) => j?.jobId).filter((id) => typeof id === 'string') : [];
231
255
  if (Array.isArray(b.jobs))
232
256
  state.keepHeld(m, new Set(jobIds));
257
+ const fresh = !state.retainedAlarms && !state.readingRetained && Array.isArray(b.alarms);
258
+ const cleared = fresh ? alarmsNoLongerListed(state, m, b.alarms) : [];
233
259
  const answer = { kind: 'settleSnapshot', machineId: m, jobIds };
234
260
  const previous = link.bootId;
235
261
  link.bootId = b.bootId;
236
262
  if (!previous || previous === b.bootId)
237
- return ack([answer]);
263
+ return ack([...cleared, answer]);
238
264
  const held = new Set(jobIds);
239
265
  const missing = (r) => (0, jobs_1.dispatchJobs)(r.jobId, r.units, state.unitsPerDispatch).some((j) => !held.has(j.jobId));
240
266
  const lost = state.open().filter((r) => r.toMachine === m && r.dispatch === state_1.DISPATCH_STATE.ACCEPTED && missing(r));
241
- return ack([answer, ...lost.map((r) => (0, pacing_1.dispatchAction)(r, m, ttl))]);
267
+ return ack([...cleared, answer, ...lost.map((r) => (0, pacing_1.dispatchAction)(r, m, ttl))]);
268
+ }
269
+ /** Alarm rows for `m` whose alarm its snapshot no longer lists: they cleared at the machine. */
270
+ function alarmsNoLongerListed(state, m, listed) {
271
+ const active = new Set(listed.filter((id) => typeof id === 'string'));
272
+ const actions = [];
273
+ for (const [key, track] of [...state.alarms.entries()]) {
274
+ if (track.machineId !== m || active.has(track.alarmId))
275
+ continue;
276
+ state.alarms.delete(key);
277
+ actions.push(...(0, lifecycle_1.noteResume)(state, track, {}));
278
+ if (track.open && track.rowId) {
279
+ actions.push({ kind: 'resolve', as: actions_1.CONTROLLER, rowId: track.rowId, payload: { decision: contracts_1.DECISION.CLEARED, note: `cleared at ${m}; its snapshot no longer lists it` } });
280
+ }
281
+ }
282
+ return actions;
242
283
  }
@@ -31,3 +31,5 @@ export declare function alarmsClearedMeanwhile(state: GatewayState, active: Set<
31
31
  * arrivals it holds.
32
32
  */
33
33
  export declare function arrivalsOnRows(state: GatewayState, lt: LineEscalations, dispatchTtlMs: number): Promise<Action[]>;
34
+ /** With lifecycle notes on, the items each handoff out of a machine already holds, so a restart notes nothing twice. */
35
+ export declare function notesOnRows(state: GatewayState, lt: LineEscalations): Promise<void>;
@@ -10,6 +10,7 @@ exports.restoreLineRows = restoreLineRows;
10
10
  exports.restoreAddresses = restoreAddresses;
11
11
  exports.alarmsClearedMeanwhile = alarmsClearedMeanwhile;
12
12
  exports.arrivalsOnRows = arrivalsOnRows;
13
+ exports.notesOnRows = notesOnRows;
13
14
  const contracts_1 = require("../contracts");
14
15
  const line_1 = require("../../../lib/machines/line");
15
16
  const jobs_1 = require("./jobs");
@@ -65,6 +66,7 @@ async function arrivalsOnRows(state, lt, dispatchTtlMs) {
65
66
  if (!to || (row.dispatch !== state_1.DISPATCH_STATE.ARRIVING && row.dispatch !== state_1.DISPATCH_STATE.ACCEPTED))
66
67
  continue;
67
68
  const items = await lt.items(row.id);
69
+ row.noted = items.map((i) => i.itemKey);
68
70
  const units = row.units.map((u) => u.unitId);
69
71
  const { arrived, missing } = (0, contracts_1.handoffProgress)(units, items);
70
72
  for (const job of (0, jobs_1.dispatchJobs)(row.jobId, row.units, state.unitsPerDispatch)) {
@@ -80,7 +82,18 @@ async function arrivalsOnRows(state, lt, dispatchTtlMs) {
80
82
  }
81
83
  const byUnit = new Map(items.filter((i) => (0, contracts_1.arrivedUnit)(i.itemKey)).map((i) => [i.payload.unitId, i.payload]));
82
84
  row.arrivals = Object.fromEntries(units.map((u) => [u, byUnit.get(u)?.arrival ?? { unitId: u, carrierId: byUnit.get(u)?.carrierId ?? null }]));
85
+ for (const arrival of byUnit.values())
86
+ row.dueAt = (0, pacing_1.latestArrival)(row.dueAt, arrival.at);
83
87
  actions.push((0, pacing_1.dispatchAction)(row, to, dispatchTtlMs));
84
88
  }
85
89
  return actions;
86
90
  }
91
+ /** With lifecycle notes on, the items each handoff out of a machine already holds, so a restart notes nothing twice. */
92
+ async function notesOnRows(state, lt) {
93
+ if (!state.lifecycle)
94
+ return;
95
+ for (const row of state.open()) {
96
+ if (row.fromMachine && !row.noted)
97
+ row.noted = (await lt.items(row.id)).map((i) => i.itemKey);
98
+ }
99
+ }