@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.
- package/build/api/files.d.ts +5 -0
- package/build/api/files.js +19 -2
- package/build/api/knowledge.d.ts +3 -1
- package/build/lib/db/schemas/042_knowledge_edition_labels.sql +9 -0
- package/build/lib/machines/line.d.ts +3 -0
- package/build/lib/machines/line.js +7 -1
- package/build/lib/machines/protocol/codec.d.ts +6 -0
- package/build/lib/machines/protocol/codec.js +1 -1
- package/build/lib/machines/protocol/contract.d.ts +2 -0
- package/build/lib/machines/protocol/contract.js +12 -7
- package/build/lib/machines/site/mosquitto-conf.d.ts +7 -3
- package/build/lib/machines/site/mosquitto-conf.js +3 -8
- package/build/lib/machines/site/site-conf.d.ts +4 -1
- package/build/lib/machines/site/site-conf.js +5 -1
- package/build/lib/machines/transport/nats-bus.d.ts +2 -0
- package/build/lib/machines/transport/stream-bus.d.ts +5 -1
- package/build/lib/machines/transport/stream-bus.js +12 -4
- package/build/lib/machines/transport/streams.d.ts +10 -2
- package/build/lib/machines/transport/streams.js +32 -5
- package/build/lib/messaging/memory.js +3 -0
- package/build/lib/messaging/nats/streams.d.ts +3 -0
- package/build/lib/messaging/nats/streams.js +16 -0
- package/build/lib/storage/local.d.ts +2 -0
- package/build/lib/storage/local.js +3 -0
- package/build/lib/storage/s3.d.ts +3 -2
- package/build/lib/storage/s3.js +6 -1
- package/build/lib/storage/types.d.ts +7 -1
- package/build/routes/file-browser.js +3 -1
- package/build/routes/knowledge.js +6 -5
- package/build/routes/machines.js +33 -0
- package/build/services/knowledge/index.d.ts +4 -2
- package/build/services/knowledge/index.js +8 -2
- package/build/services/knowledge/lookup-cache.d.ts +8 -0
- package/build/services/knowledge/lookup-cache.js +30 -3
- package/build/services/knowledge/lookup-refs.js +1 -1
- package/build/services/knowledge/sql.d.ts +4 -2
- package/build/services/knowledge/sql.js +8 -3
- package/build/services/machines/config.d.ts +2 -0
- package/build/services/machines/config.js +1 -0
- package/build/services/machines/contracts.d.ts +18 -0
- package/build/services/machines/contracts.js +15 -1
- package/build/services/machines/forms.d.ts +26 -1
- package/build/services/machines/forms.js +17 -1
- package/build/services/machines/gateway/actions.d.ts +13 -1
- package/build/services/machines/gateway/admission.d.ts +15 -1
- package/build/services/machines/gateway/admission.js +47 -0
- package/build/services/machines/gateway/api-escalations.js +4 -0
- package/build/services/machines/gateway/dispatcher.d.ts +9 -4
- package/build/services/machines/gateway/dispatcher.js +42 -14
- package/build/services/machines/gateway/executor.d.ts +2 -0
- package/build/services/machines/gateway/executor.js +18 -2
- package/build/services/machines/gateway/gateway.d.ts +5 -0
- package/build/services/machines/gateway/gateway.js +11 -3
- package/build/services/machines/gateway/log.d.ts +2 -0
- package/build/services/machines/gateway/log.js +2 -0
- package/build/services/machines/gateway/lt-http.js +11 -2
- package/build/services/machines/gateway/pacing.js +1 -1
- package/build/services/machines/gateway/plan-machine.js +26 -11
- package/build/services/machines/gateway/plan-shared.d.ts +5 -3
- package/build/services/machines/gateway/plan-shared.js +6 -4
- package/build/services/machines/gateway/reply-waiter.d.ts +2 -0
- package/build/services/machines/gateway/state.d.ts +24 -1
- package/build/services/machines/gateway/state.js +67 -6
- package/build/services/machines/gateway/wire-window.d.ts +21 -0
- package/build/services/machines/gateway/wire-window.js +61 -0
- package/build/services/machines/gateway/wire.js +1 -0
- package/build/services/machines/roles.d.ts +5 -4
- package/build/services/machines/roles.js +13 -4
- package/build/services/machines/select.d.ts +3 -2
- package/build/services/machines/select.js +4 -3
- package/build/services/machines/surface.d.ts +10 -1
- package/build/services/machines/surface.js +20 -4
- package/build/system/activities/knowledge.d.ts +4 -0
- package/build/system/activities/knowledge.js +15 -0
- package/build/system/activities/sql.d.ts +2 -0
- package/build/system/activities/sql.js +6 -1
- package/build/system/mcp-servers/knowledge.js +4 -2
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/types/escalation.d.ts +9 -6
- package/build/types/escalation.js +8 -2
- package/build/types/machines-view.d.ts +16 -0
- package/build/types/machines.d.ts +1 -0
- package/build/types/messaging.d.ts +2 -0
- package/build/types/startup.d.ts +2 -0
- package/docs/dashboard.md +1 -1
- package/docs/hitl/lookups.md +6 -4
- package/docs/machines.md +25 -16
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
116
|
-
const
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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([{
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
277
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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;
|