@hotmeshio/long-tail 0.28.0 → 0.28.2

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 (92) 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/events/publish.js +3 -2
  6. package/build/lib/machines/line.d.ts +3 -0
  7. package/build/lib/machines/line.js +7 -1
  8. package/build/lib/machines/protocol/codec.d.ts +6 -0
  9. package/build/lib/machines/protocol/codec.js +1 -1
  10. package/build/lib/machines/protocol/contract.d.ts +2 -0
  11. package/build/lib/machines/protocol/contract.js +12 -7
  12. package/build/lib/machines/site/mosquitto-conf.d.ts +7 -3
  13. package/build/lib/machines/site/mosquitto-conf.js +3 -8
  14. package/build/lib/machines/site/site-conf.d.ts +4 -1
  15. package/build/lib/machines/site/site-conf.js +5 -1
  16. package/build/lib/machines/transport/nats-bus.d.ts +4 -0
  17. package/build/lib/machines/transport/stream-bus.d.ts +7 -1
  18. package/build/lib/machines/transport/stream-bus.js +20 -6
  19. package/build/lib/machines/transport/streams.d.ts +11 -2
  20. package/build/lib/machines/transport/streams.js +33 -5
  21. package/build/lib/messaging/memory.js +10 -3
  22. package/build/lib/messaging/nats/permissions.js +1 -0
  23. package/build/lib/messaging/nats/streams.d.ts +21 -4
  24. package/build/lib/messaging/nats/streams.js +71 -10
  25. package/build/lib/storage/local.d.ts +2 -0
  26. package/build/lib/storage/local.js +3 -0
  27. package/build/lib/storage/s3.d.ts +3 -2
  28. package/build/lib/storage/s3.js +6 -1
  29. package/build/lib/storage/types.d.ts +7 -1
  30. package/build/routes/file-browser.js +3 -1
  31. package/build/routes/knowledge.js +6 -5
  32. package/build/routes/machines.js +33 -0
  33. package/build/services/knowledge/index.d.ts +4 -2
  34. package/build/services/knowledge/index.js +8 -2
  35. package/build/services/knowledge/lookup-cache.d.ts +8 -0
  36. package/build/services/knowledge/lookup-cache.js +30 -3
  37. package/build/services/knowledge/lookup-refs.js +1 -1
  38. package/build/services/knowledge/sql.d.ts +4 -2
  39. package/build/services/knowledge/sql.js +8 -3
  40. package/build/services/machines/config.d.ts +2 -0
  41. package/build/services/machines/config.js +1 -0
  42. package/build/services/machines/contracts.d.ts +18 -0
  43. package/build/services/machines/contracts.js +15 -1
  44. package/build/services/machines/forms.d.ts +26 -1
  45. package/build/services/machines/forms.js +17 -1
  46. package/build/services/machines/gateway/actions.d.ts +13 -1
  47. package/build/services/machines/gateway/admission.d.ts +15 -1
  48. package/build/services/machines/gateway/admission.js +47 -0
  49. package/build/services/machines/gateway/api-escalations.js +4 -0
  50. package/build/services/machines/gateway/dispatcher.d.ts +9 -4
  51. package/build/services/machines/gateway/dispatcher.js +42 -14
  52. package/build/services/machines/gateway/executor.d.ts +2 -0
  53. package/build/services/machines/gateway/executor.js +18 -2
  54. package/build/services/machines/gateway/gateway.d.ts +5 -0
  55. package/build/services/machines/gateway/gateway.js +11 -3
  56. package/build/services/machines/gateway/log.d.ts +3 -0
  57. package/build/services/machines/gateway/log.js +3 -0
  58. package/build/services/machines/gateway/lt-http.js +11 -2
  59. package/build/services/machines/gateway/pacing.js +1 -1
  60. package/build/services/machines/gateway/plan-machine.js +26 -11
  61. package/build/services/machines/gateway/plan-shared.d.ts +5 -3
  62. package/build/services/machines/gateway/plan-shared.js +6 -4
  63. package/build/services/machines/gateway/reply-waiter.d.ts +2 -0
  64. package/build/services/machines/gateway/state.d.ts +24 -1
  65. package/build/services/machines/gateway/state.js +67 -6
  66. package/build/services/machines/gateway/wire-window.d.ts +21 -0
  67. package/build/services/machines/gateway/wire-window.js +61 -0
  68. package/build/services/machines/gateway/wire.js +1 -0
  69. package/build/services/machines/leadership.d.ts +2 -0
  70. package/build/services/machines/leadership.js +7 -1
  71. package/build/services/machines/roles.d.ts +5 -4
  72. package/build/services/machines/roles.js +13 -4
  73. package/build/services/machines/select.d.ts +3 -2
  74. package/build/services/machines/select.js +4 -3
  75. package/build/services/machines/surface.d.ts +10 -1
  76. package/build/services/machines/surface.js +27 -5
  77. package/build/system/activities/knowledge.d.ts +4 -0
  78. package/build/system/activities/knowledge.js +15 -0
  79. package/build/system/activities/sql.d.ts +2 -0
  80. package/build/system/activities/sql.js +6 -1
  81. package/build/system/mcp-servers/knowledge.js +4 -2
  82. package/build/tsconfig.tsbuildinfo +1 -1
  83. package/build/types/escalation.d.ts +9 -6
  84. package/build/types/escalation.js +8 -2
  85. package/build/types/machines-view.d.ts +16 -0
  86. package/build/types/machines.d.ts +1 -0
  87. package/build/types/messaging.d.ts +7 -1
  88. package/build/types/startup.d.ts +2 -0
  89. package/docs/dashboard.md +1 -1
  90. package/docs/hitl/lookups.md +6 -4
  91. package/docs/machines.md +26 -17
  92. package/package.json +1 -1
@@ -6,7 +6,7 @@
6
6
  * protocol that resolves it differs.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.cancelCommandId = exports.controlCommandId = exports.dispatchCommandId = exports.COMMAND_STATUS = exports.signalKeys = exports.DECISION = exports.LIFECYCLE_FACET = exports.TRACE_FACET = exports.HANDOFF_ERROR = exports.itemKeys = exports.LINE_ROLE = exports.ROW_TYPE = void 0;
9
+ exports.cancelCommandId = exports.controlCommandId = exports.dispatchCommandId = exports.COMMAND_STATUS = exports.signalKeys = exports.HOLD_DECISIONS = exports.DECISION = exports.LIFECYCLE_FACET = exports.TRACE_FACET = exports.HANDOFF_ERROR = exports.itemKeys = exports.LINE_ROLE = exports.ROW_TYPE = void 0;
10
10
  exports.arrivedUnit = arrivedUnit;
11
11
  exports.handoffProgress = handoffProgress;
12
12
  exports.handoffDecisions = handoffDecisions;
@@ -24,12 +24,16 @@ exports.ROW_TYPE = {
24
24
  CONTRACT_VIOLATION: 'contract-violation',
25
25
  /** An address claims a station the line already knows by another address, or the reverse: a person approves or rejects. */
26
26
  MACHINE_REGISTRATION: 'machine-registration',
27
+ /** Units a machine finished on hold wait for a quality decision: release, rework or scrap. */
28
+ QUALITY_HOLD: 'quality-hold',
27
29
  };
28
30
  exports.LINE_ROLE = {
29
31
  /** People who clear alarms, bring machines back and decide failed handoffs. */
30
32
  OPERATOR: line_1.DEFAULT_OPERATOR_ROLE,
31
33
  /** People who take finished units off the line: the final handoff. */
32
34
  EXIT: line_1.DEFAULT_EXIT_ROLE,
35
+ /** People who decide units a machine finished on hold. */
36
+ QUALITY: line_1.DEFAULT_QUALITY_ROLE,
33
37
  /** The gateway's own identity. */
34
38
  CONTROLLER: 'line-controller',
35
39
  };
@@ -75,6 +79,8 @@ exports.HANDOFF_ERROR = {
75
79
  SOFTWARE_UNSUPPORTED: 'software-unsupported',
76
80
  WITHDRAWN: 'withdrawn',
77
81
  CANCELLED: 'cancelled',
82
+ /** An arrival no open handoff into the machine expects, shown to the operator role once per machine and carrier. */
83
+ UNMATCHED_ARRIVAL: 'unmatched-arrival',
78
84
  ...protocol_1.TRANSFER_FAILURE,
79
85
  };
80
86
  /**
@@ -95,6 +101,10 @@ exports.DECISION = {
95
101
  RESTORED: 'restored',
96
102
  APPROVE: 'approve',
97
103
  REJECT: 'reject',
104
+ /** A quality decision on held units: they continue from where they are. */
105
+ RELEASE: 'release',
106
+ /** A quality decision on held units: they run again from a station on the route. */
107
+ REWORK: 'rework',
98
108
  };
99
109
  /**
100
110
  * Failures a retry cannot repair. Units a sender finished as not ok leave the
@@ -107,11 +117,15 @@ function handoffDecisions(errorCode) {
107
117
  const noRetry = !!errorCode && NO_RETRY.includes(errorCode);
108
118
  return noRetry ? [exports.DECISION.MANUAL, exports.DECISION.SCRAP] : [exports.DECISION.RETRY, exports.DECISION.MANUAL, exports.DECISION.SCRAP];
109
119
  }
120
+ /** The decisions a quality role may take on units a machine finished on hold. */
121
+ exports.HOLD_DECISIONS = [exports.DECISION.RELEASE, exports.DECISION.REWORK, exports.DECISION.SCRAP];
110
122
  /** Signal keys for the unit workflow; every attempt mints a fresh key. */
111
123
  exports.signalKeys = {
112
124
  handoff: (jobKey, step, attempt) => `handoff-${jobKey}-s${step}-a${attempt}`,
113
125
  handoffError: (jobKey, step, attempt, round = 1) => `handoff-error-${jobKey}-s${step}-a${attempt}${round > 1 ? `-r${round}` : ''}`,
114
126
  jobStop: (jobKey, step, attempt) => `job-stop-${jobKey}-s${step}-a${attempt}`,
127
+ hold: (jobKey, step, attempt, round = 1) => `hold-${jobKey}-s${step}-a${attempt}${round > 1 ? `-r${round}` : ''}`,
128
+ disposition: (jobKey, step, attempt) => `disposition-${jobKey}-s${step}-a${attempt}`,
115
129
  };
116
130
  exports.COMMAND_STATUS = { ...protocol_1.REPLY_STATUS, NO_REPLY: 'no-reply', UNSUPPORTED: 'unsupported' };
117
131
  /** A row's dispatch command in its validity round; a later round is a new command (gateway/pacing.ts dispatchWindow). */
@@ -36,7 +36,7 @@ export declare const OPERATOR_FORM_SCHEMA: {
36
36
  properties: {
37
37
  decision: {
38
38
  type: string;
39
- enum: ("reject" | "scrap" | "retry" | "manual" | "acknowledge" | "cleared" | "restored" | "approve")[];
39
+ enum: ("release" | "reject" | "scrap" | "rework" | "retry" | "manual" | "acknowledge" | "cleared" | "restored" | "approve")[];
40
40
  default: "acknowledge";
41
41
  description: string;
42
42
  };
@@ -53,3 +53,28 @@ export declare const OPERATOR_FORM_SCHEMA: {
53
53
  };
54
54
  };
55
55
  };
56
+ export declare const QUALITY_FORM_SCHEMA: {
57
+ title: string;
58
+ description: string;
59
+ 'x-lt-order': string[];
60
+ required: string[];
61
+ properties: {
62
+ decision: {
63
+ type: string;
64
+ enum: ("release" | "scrap" | "rework")[];
65
+ default: "release";
66
+ description: string;
67
+ };
68
+ station: {
69
+ type: string;
70
+ default: string;
71
+ description: string;
72
+ };
73
+ note: {
74
+ type: string;
75
+ format: string;
76
+ default: string;
77
+ description: string;
78
+ };
79
+ };
80
+ };
@@ -6,7 +6,7 @@
6
6
  * handoffs and close alarm and machine-state rows.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.OPERATOR_FORM_SCHEMA = exports.HANDOFF_FORM_SCHEMA = void 0;
9
+ exports.QUALITY_FORM_SCHEMA = exports.OPERATOR_FORM_SCHEMA = exports.HANDOFF_FORM_SCHEMA = void 0;
10
10
  const contracts_1 = require("./contracts");
11
11
  exports.HANDOFF_FORM_SCHEMA = {
12
12
  title: 'Receive units',
@@ -35,3 +35,19 @@ exports.OPERATOR_FORM_SCHEMA = {
35
35
  clearedBy: { type: 'string', default: '', description: 'Who cleared it at the machine, when the machine reports it' },
36
36
  },
37
37
  };
38
+ exports.QUALITY_FORM_SCHEMA = {
39
+ title: 'Quality decision',
40
+ description: 'Units a machine finished on hold: release them to continue, send them back to a station for rework, or scrap them.',
41
+ 'x-lt-order': ['decision', 'station', 'note'],
42
+ required: ['decision'],
43
+ properties: {
44
+ decision: {
45
+ type: 'string',
46
+ enum: [contracts_1.DECISION.RELEASE, contracts_1.DECISION.REWORK, contracts_1.DECISION.SCRAP],
47
+ default: contracts_1.DECISION.RELEASE,
48
+ description: 'Release, rework or scrap',
49
+ },
50
+ station: { type: 'string', default: '', description: 'For rework: the station the units run again from; the holding station when empty' },
51
+ note: { type: 'string', format: 'textarea', default: '', description: 'What was found' },
52
+ },
53
+ };
@@ -18,14 +18,19 @@ export interface CreateRowInput {
18
18
  escalation_payload?: string;
19
19
  }
20
20
  export type Action =
21
- /** `receivedOnAccept`: the units are already at the machine, so its acceptance completes the handoff. */
21
+ /**
22
+ * `receivedOnAccept`: the units are already at the machine, so its acceptance completes the handoff.
23
+ * `ended`: the row already resolved; the job is sent again for units the machine still holds, and no verb touches the row.
24
+ */
22
25
  {
23
26
  kind: 'dispatch';
24
27
  rowId: string;
25
28
  machineId: string;
26
29
  cmdId: string;
27
30
  args: Record<string, any>;
31
+ issuedAt?: number;
28
32
  receivedOnAccept?: boolean;
33
+ ended?: boolean;
29
34
  } | {
30
35
  kind: 'command';
31
36
  machineId: string;
@@ -79,6 +84,7 @@ export type Action =
79
84
  status: string;
80
85
  reason: string;
81
86
  code?: string;
87
+ preparing?: boolean;
82
88
  }
83
89
  /** A machine reported the jobs it holds: answers a status query in flight. */
84
90
  | {
@@ -99,6 +105,12 @@ export type Action =
99
105
  machineId: string;
100
106
  address: string;
101
107
  reason: string;
108
+ }
109
+ /** Jobs a restarted machine lost that this gateway cannot send again: nothing of them is remembered. */
110
+ | {
111
+ kind: 'unrecoverable';
112
+ machineId: string;
113
+ jobs: string[];
102
114
  };
103
115
  /** Once the row holds every one of `keys`: resolve it with `payload`, or run `then` instead. */
104
116
  export interface Completion {
@@ -4,8 +4,9 @@
4
4
  * and under a strict codec a message that breaks the contract is held for a
5
5
  * person instead of acted on.
6
6
  */
7
+ import type { OpenRowQuery } from '../rows';
7
8
  import { type LineDef } from '../../../lib/machines/line';
8
- import type { Inspection } from '../../../lib/machines/protocol';
9
+ import { type Inspection } from '../../../lib/machines/protocol';
9
10
  import { type Disposition, type InboundMessage } from '../../../lib/machines/transport';
10
11
  import type { WireAction } from '../../../types/machines-view';
11
12
  import type { Action } from './actions';
@@ -36,6 +37,8 @@ export interface AdmissionContext {
36
37
  log: GatewayLog;
37
38
  /** Send waiting handoffs that may now go out. */
38
39
  pace(): Promise<void>;
40
+ /** The controller's pending rows of a type, for the rows shown to people once. */
41
+ openRows(query: OpenRowQuery): Promise<Record<string, any>[]>;
39
42
  }
40
43
  /**
41
44
  * The station a message is from (registration.ts). A newly claimed station is
@@ -45,3 +48,14 @@ export interface AdmissionContext {
45
48
  export declare function admitSender(ctx: AdmissionContext, topic: string, raw: Inspection): Promise<Admitted | Applied>;
46
49
  /** Under a strict codec a broken message is not acted on: a person sees it on a row, once. */
47
50
  export declare function holdViolation(ctx: AdmissionContext, msg: InboundMessage, seen: Inspection): Promise<Applied>;
51
+ /** A job arrival: a scan, or a transfer that failed on the carrier read. */
52
+ export declare function isArrival(seen: Inspection): boolean;
53
+ /** An arrival that read no id at all: no row can ever claim it, so it goes to a person on its first delivery. */
54
+ export declare function isUnreadableArrival(seen: Inspection): boolean;
55
+ /**
56
+ * An arrival no open handoff into the machine expects, after every delivery:
57
+ * a person sees it on one handoff-error row per machine and carrier, with the
58
+ * raw arrival, so the unit can be re-scanned or routed. Resolving the row lets
59
+ * the next such arrival raise one again.
60
+ */
61
+ export declare function holdArrival(ctx: AdmissionContext, msg: InboundMessage, seen: Inspection): Promise<Applied>;
@@ -8,8 +8,12 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.admitSender = admitSender;
10
10
  exports.holdViolation = holdViolation;
11
+ exports.isArrival = isArrival;
12
+ exports.isUnreadableArrival = isUnreadableArrival;
13
+ exports.holdArrival = holdArrival;
11
14
  const contracts_1 = require("../contracts");
12
15
  const line_1 = require("../../../lib/machines/line");
16
+ const protocol_1 = require("../../../lib/machines/protocol");
13
17
  const transport_1 = require("../../../lib/machines/transport");
14
18
  const dispatcher_1 = require("./dispatcher");
15
19
  const log_1 = require("./log");
@@ -79,3 +83,46 @@ async function holdViolation(ctx, msg, seen) {
79
83
  return { disposition: transport_1.DISPOSITION.NAK, failed: true, actions: [] };
80
84
  }
81
85
  }
86
+ /** A job arrival: a scan, or a transfer that failed on the carrier read. */
87
+ function isArrival(seen) {
88
+ const event = seen.body?.body?.event;
89
+ return seen.parsed?.kind === protocol_1.MESSAGE_KIND.JOB && (event === protocol_1.JOB_EVENT.ARRIVED || event === protocol_1.JOB_EVENT.TRANSFER_FAILED);
90
+ }
91
+ /** An arrival that read no id at all: no row can ever claim it, so it goes to a person on its first delivery. */
92
+ function isUnreadableArrival(seen) {
93
+ return seen.parsed?.kind === protocol_1.MESSAGE_KIND.JOB && (0, protocol_1.isUnreadableRead)(seen.body?.body ?? {});
94
+ }
95
+ /**
96
+ * An arrival no open handoff into the machine expects, after every delivery:
97
+ * a person sees it on one handoff-error row per machine and carrier, with the
98
+ * raw arrival, so the unit can be re-scanned or routed. Resolving the row lets
99
+ * the next such arrival raise one again.
100
+ */
101
+ async function holdArrival(ctx, msg, seen) {
102
+ const { ident } = seen.parsed;
103
+ const body = seen.body?.body ?? {};
104
+ const carrierId = typeof body.carrierId === 'string' ? body.carrierId : null;
105
+ const unitId = typeof body.unitId === 'string' ? body.unitId : null;
106
+ const detail = `no handoff into ${ident.machineId} expects ${carrierId ? `carrier ${carrierId}` : 'an unreadable carrier'}`;
107
+ try {
108
+ const open = await ctx.openRows({ type: contracts_1.ROW_TYPE.HANDOFF_ERROR, role: (0, line_1.operatorRole)(ctx.line) });
109
+ const shown = open.find((r) => r.metadata?.errorCode === contracts_1.HANDOFF_ERROR.UNMATCHED_ARRIVAL && r.metadata?.machineId === ident.machineId && (r.metadata?.carrierId ?? null) === carrierId);
110
+ if (shown)
111
+ return { disposition: transport_1.DISPOSITION.ACK, failed: false, actions: [{ verb: 'unmatched', rowId: shown.id, detail: `${detail} (already shown to a person)` }] };
112
+ const [made] = await ctx.executor.apply([{ kind: 'createRow', row: {
113
+ type: contracts_1.ROW_TYPE.HANDOFF_ERROR, role: (0, line_1.operatorRole)(ctx.line), priority: 2,
114
+ description: `${ident.machineId} reports an arrival no handoff expects: ${carrierId ? `carrier ${carrierId}` : 'the carrier could not be read'}`,
115
+ metadata: {
116
+ siteId: ident.siteId, lineId: ident.lineId, machineId: ident.machineId, errorCode: contracts_1.HANDOFF_ERROR.UNMATCHED_ARRIVAL,
117
+ carrierId, unitId, event: body.event, topic: msg.topic, messageId: seen.body?.id ?? null, arrival: body,
118
+ },
119
+ escalation_payload: msg.payload,
120
+ } }]);
121
+ return { disposition: transport_1.DISPOSITION.ACK, failed: false, actions: [{ verb: 'create-row', rowId: made?.rowId, detail }] };
122
+ }
123
+ catch (err) {
124
+ if (!(err instanceof dispatcher_1.GatewayStopped))
125
+ ctx.log.error(log_1.GATEWAY_LOG.VERB_FAILED, { topic: msg.topic, error: err.message });
126
+ return { disposition: transport_1.DISPOSITION.NAK, failed: true, actions: [] };
127
+ }
128
+ }
@@ -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";
@@ -30,6 +32,7 @@ export declare const GATEWAY_LOG: {
30
32
  readonly LEADING: "machine_gateway.line.leading";
31
33
  readonly STANDBY: "machine_gateway.line.standby";
32
34
  readonly LEADERSHIP_LOST: "machine_gateway.line.leadership_lost";
35
+ readonly WIRE_LOST: "machine_gateway.line.wire_lost";
33
36
  readonly START_FAILED: "machine_gateway.line.start_failed";
34
37
  };
35
38
  export type GatewayLogEvent = typeof GATEWAY_LOG[keyof typeof GATEWAY_LOG];
@@ -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',
@@ -35,6 +37,7 @@ exports.GATEWAY_LOG = {
35
37
  LEADING: 'machine_gateway.line.leading',
36
38
  STANDBY: 'machine_gateway.line.standby',
37
39
  LEADERSHIP_LOST: 'machine_gateway.line.leadership_lost',
40
+ WIRE_LOST: 'machine_gateway.line.wire_lost',
38
41
  START_FAILED: 'machine_gateway.line.start_failed',
39
42
  };
40
43
  exports.consoleLog = {
@@ -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
  }