@yarkivaev/scada 2.3.79 → 2.3.82

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/README.md CHANGED
@@ -83,6 +83,16 @@ edgeApi (:8081) ← metrics batch, checkpoints, retention (edge profile)
83
83
 
84
84
  **Edge site** (`siteServer`): runs supervisor-sink HTTP, plant API, and optional MQTT ingest in one process. Metrics land in PostgreSQL on edge; on central they go to ClickHouse.
85
85
 
86
+ ## Split family
87
+
88
+ One `off` phase can be replaced by contiguous pieces. There is no parent row and no nested segment. Each piece is a normal `segments` row: `kind = phase`, `name = off`, at most one tag. An empty tag is SQL NULL. The family key is `properties.split_of`, the original start in epoch milliseconds. The first piece keeps that `start_time`, so the existing unique key `(machine, kind, start_time)` still identifies it.
89
+
90
+ A later edit sends the whole family again. `timeline.respond` / `PATCH /segments` take `{ splits: [{ start, end, tag, open }] }` addressed by the original start. The supervisor publishes one STOMP body, `type: split-family`, with `origin` in epoch millis and the pieces. The sink deletes family starts the new cut does not use, then upserts the pieces, in one transaction.
91
+
92
+ The replacement must keep the original start and, for a closed off, the latest end of the stored family. Removing every piece but one therefore restores those bounds. `splits: []` is refused before any delete, and the stored family stays. A piece may be any positive length. Only the tail of a still-open off may have `duration = 0`. A duration-0 heartbeat does not extend a closed piece that already has `split_of`.
93
+
94
+ `C0007` grants `DELETE` on `segments` to `supervisor_sink` on the privileged admin path. The sink role does not run that grant itself. Central previously revoked this privilege (`C0002`).
95
+
86
96
  ## Public exports
87
97
 
88
98
  Main entry (`import { … } from '@yarkivaev/scada'`):
@@ -172,7 +182,7 @@ SQL files in `db/migrations/` use profile prefixes:
172
182
  |--------|------------|
173
183
  | `V*` | Both central and edge |
174
184
  | `E*` | Edge only (metrics tables, retention) |
175
- | `C*` | Central only (revoke delete on metrics) |
185
+ | `C*` | Central only (central privileges, including segment DELETE) |
176
186
 
177
187
  Set `SINK_DB_PROFILE=central|edge`. Migrations run at supervisor-sink startup via `POSTGRES_ADMIN_URL`.
178
188
 
@@ -0,0 +1 @@
1
+ GRANT DELETE ON segments TO supervisor_sink;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.79",
3
+ "version": "2.3.82",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,6 +6,7 @@ import memoryTimelineStore, { memoryTimelineRead } from '../infrastructure/persi
6
6
  import ownerTimeline from '../infrastructure/messaging/ownership/ownerTimeline.js';
7
7
  import stompTimeline from '../infrastructure/messaging/stomp/timeline.js';
8
8
  import retagBody from '../infrastructure/messaging/stomp/retagBody.js';
9
+ import { checkSplit, familyCover, loadFamily, replaceSplit, splitRows } from '../domain/segment/splitFamily.js';
9
10
 
10
11
  function parseStart(requestId) {
11
12
  const raw = decodeURIComponent(String(requestId));
@@ -92,6 +93,28 @@ async function persistTags(pool, patch) {
92
93
  return state.rowAt(patch.machine, patch.start);
93
94
  }
94
95
 
96
+ function asPiece(piece) {
97
+ return {
98
+ start: new Date(piece.start),
99
+ end: new Date(piece.end),
100
+ tag: piece.tag || '',
101
+ open: Boolean(piece.open)
102
+ };
103
+ }
104
+
105
+ async function applySplit(scope) {
106
+ if (ownsLocal(scope.owners, scope.machine)) {
107
+ const ordered = checkSplit(familyCover(await loadFamily(scope.pool, scope.machine, scope.start)), scope.splits);
108
+ const pieces = splitRows(scope.machine, ordered);
109
+ await replaceSplit(scope.pool, {
110
+ machine: scope.machine,
111
+ origin: ordered[0].start.getTime(),
112
+ pieces
113
+ });
114
+ }
115
+ await scope.write.respondSplit(scope.start, scope.splits.map(asPiece), scope.audit);
116
+ }
117
+
95
118
  async function confirm(pool, patch, segments) {
96
119
  const row = await persistTags(pool, patch);
97
120
  if (segments) {
@@ -100,6 +123,35 @@ async function confirm(pool, patch, segments) {
100
123
  return row;
101
124
  }
102
125
 
126
+ async function answer(scope, requestId, body, audit) {
127
+ const start = parseStart(requestId);
128
+ if (Array.isArray(body.splits)) {
129
+ await applySplit({
130
+ pool: scope.pool,
131
+ owners: scope.owners,
132
+ machine: scope.name,
133
+ start,
134
+ splits: body.splits,
135
+ write: scope.write,
136
+ audit
137
+ });
138
+ scope.bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
139
+ return { id: requestId, ...body };
140
+ }
141
+ if (ownsLocal(scope.owners, scope.name)) {
142
+ await confirm(scope.pool, {
143
+ machine: scope.name,
144
+ start,
145
+ tags: body.tags,
146
+ properties: body.properties || {},
147
+ resolved: true
148
+ }, scope.segments);
149
+ }
150
+ await scope.write.respond(start, body.tags, body.properties || {}, audit);
151
+ scope.bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
152
+ return { id: requestId, ...body };
153
+ }
154
+
103
155
  /**
104
156
  * Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
105
157
  *
@@ -139,20 +191,8 @@ export default function shopWithTimeline(name, options) {
139
191
  await write.retag(start, tags, properties, audit);
140
192
  bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
141
193
  },
142
- async respond(requestId, body, audit) {
143
- const start = parseStart(requestId);
144
- if (ownsLocal(owners, name)) {
145
- await confirm(pool, {
146
- machine: name,
147
- start,
148
- tags: body.tags,
149
- properties: body.properties || {},
150
- resolved: true
151
- }, segments);
152
- }
153
- await write.respond(start, body.tags, body.properties || {}, audit);
154
- bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
155
- return { id: requestId, ...body };
194
+ respond(requestId, body, audit) {
195
+ return answer({ pool, owners, name, segments, write, bus }, requestId, body, audit);
156
196
  }
157
197
  };
158
198
  }
@@ -1,3 +1,5 @@
1
+ import { replaceSplit } from './splitFamily.js';
2
+
1
3
  /**
2
4
  * Persists a pending heartbeat, optionally inside one pool transaction.
3
5
  *
@@ -45,6 +47,13 @@ async function persistPending(segmentSink, closer, pool, record) {
45
47
  * const route = segmentDispatch(segmentSink, retag, splitSink, closer, pool);
46
48
  * await route.accept({ type: 'segment', machine: 'm1', start_time: '...', duration: 0 });
47
49
  */
50
+ async function acceptFamily(pool, record) {
51
+ if (!pool || typeof pool.connect !== 'function') {
52
+ throw new Error('split-family requires a pool');
53
+ }
54
+ await replaceSplit(pool, record);
55
+ }
56
+
48
57
  export default function segmentDispatch(segmentSink, retag, splitSink, closer, pool) {
49
58
  return {
50
59
  async accept(record) {
@@ -56,6 +65,10 @@ export default function segmentDispatch(segmentSink, retag, splitSink, closer, p
56
65
  await splitSink.write([record]);
57
66
  return;
58
67
  }
68
+ if (record.type === 'split-family') {
69
+ await acceptFamily(pool, record);
70
+ return;
71
+ }
59
72
  if (record.type !== 'segment') {
60
73
  throw new Error(`Segment command type ${record.type} is not defined`);
61
74
  }
@@ -0,0 +1,258 @@
1
+ function dated(piece) {
2
+ return {
3
+ start: new Date(piece.start),
4
+ end: new Date(piece.end),
5
+ tag: piece.tag || '',
6
+ open: Boolean(piece.open)
7
+ };
8
+ }
9
+
10
+ function byStart(left, right) {
11
+ return left.start - right.start;
12
+ }
13
+
14
+ function hasTag(piece) {
15
+ return Boolean(piece.tag);
16
+ }
17
+
18
+ function refuseGap(ordered, piece, index) {
19
+ const last = index === ordered.length - 1;
20
+ if (piece.open && !last) {
21
+ throw new RangeError('open piece is not the tail');
22
+ }
23
+ if (index > 0 && ordered[index - 1].end.getTime() !== piece.start.getTime()) {
24
+ throw new RangeError('split has a gap');
25
+ }
26
+ }
27
+
28
+ function refuseSpan(piece) {
29
+ const span = piece.end.getTime() - piece.start.getTime();
30
+ if (span < 0) {
31
+ throw new RangeError('piece ends before it starts');
32
+ }
33
+ }
34
+
35
+ function refuseCover(parent, tail) {
36
+ if (parent.duration === 0) {
37
+ return;
38
+ }
39
+ const end = new Date(parent.end_time).getTime();
40
+ if (tail.open || tail.end.getTime() !== end) {
41
+ throw new RangeError('closed off is not covered through its end');
42
+ }
43
+ }
44
+
45
+ function orderedPieces(parent, splits) {
46
+ if (!parent || parent.name !== 'off') {
47
+ throw new RangeError(`refusing to split ${parent && parent.name}`);
48
+ }
49
+ if (!Array.isArray(splits) || splits.length === 0) {
50
+ throw new RangeError('refusing an empty split');
51
+ }
52
+ const ordered = splits.map(dated).sort(byStart);
53
+ if (ordered[0].start.getTime() !== new Date(parent.start_time).getTime()) {
54
+ throw new RangeError('split must keep the original start');
55
+ }
56
+ if (!ordered.some(hasTag)) {
57
+ throw new RangeError('split has no tagged piece');
58
+ }
59
+ return ordered;
60
+ }
61
+
62
+ function visit(ordered, piece, index) {
63
+ refuseGap(ordered, piece, index);
64
+ refuseSpan(piece);
65
+ }
66
+
67
+ function rowOf(machine, origin, piece) {
68
+ return {
69
+ type: 'split',
70
+ machine,
71
+ name: 'off',
72
+ kind: 'phase',
73
+ start_time: piece.start.toISOString(),
74
+ end_time: piece.end.toISOString(),
75
+ duration: piece.open ? 0 : (piece.end.getTime() - piece.start.getTime()) / 1000,
76
+ options: null,
77
+ tags: piece.tag ? JSON.stringify([piece.tag]) : null,
78
+ properties: JSON.stringify({ split_of: origin }),
79
+ resolved: true,
80
+ updated_at: new Date().toISOString()
81
+ };
82
+ }
83
+
84
+ function startOf(piece) {
85
+ return piece.start_time;
86
+ }
87
+
88
+ function writePiece(client, piece) {
89
+ return client.query(
90
+ `INSERT INTO segments
91
+ (machine, kind, name, start_time, end_time, duration, options, tags, properties, resolved, updated_at)
92
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
93
+ ON CONFLICT (machine, kind, start_time) DO UPDATE SET
94
+ name = EXCLUDED.name,
95
+ end_time = EXCLUDED.end_time,
96
+ duration = EXCLUDED.duration,
97
+ options = EXCLUDED.options,
98
+ tags = EXCLUDED.tags,
99
+ properties = EXCLUDED.properties,
100
+ resolved = EXCLUDED.resolved,
101
+ updated_at = EXCLUDED.updated_at`,
102
+ [piece.machine, piece.kind, piece.name, piece.start_time, piece.end_time, piece.duration,
103
+ piece.options, piece.tags, piece.properties, piece.resolved, piece.updated_at]
104
+ );
105
+ }
106
+
107
+ function nextPiece(client, chain, piece) {
108
+ return chain.then(() => {
109
+ return writePiece(client, piece);
110
+ });
111
+ }
112
+
113
+ async function writePieces(client, pieces) {
114
+ await pieces.reduce((chain, piece) => {
115
+ return nextPiece(client, chain, piece);
116
+ }, Promise.resolve());
117
+ }
118
+
119
+ function openRow(row) {
120
+ return row.duration === 0 || row.duration === '0';
121
+ }
122
+
123
+ function byRowStart(left, right) {
124
+ return new Date(left.start_time) - new Date(right.start_time);
125
+ }
126
+
127
+ function widen(span, row) {
128
+ const end = new Date(row.end_time).getTime();
129
+ if (row.name !== 'off') {
130
+ throw new RangeError(`refusing to split ${row.name}`);
131
+ }
132
+ return {
133
+ end: end > span.end ? end : span.end,
134
+ pending: span.pending || openRow(row)
135
+ };
136
+ }
137
+
138
+ function refuseEmpty(record) {
139
+ if (!record || !Array.isArray(record.pieces) || record.pieces.length === 0) {
140
+ throw new RangeError('refusing an empty split');
141
+ }
142
+ }
143
+
144
+ async function removeObsolete(client, record) {
145
+ const starts = record.pieces.map(startOf);
146
+ const origin = new Date(Number(record.origin)).toISOString();
147
+ await client.query(
148
+ `DELETE FROM segments
149
+ WHERE machine = $1 AND kind = 'phase'
150
+ AND (start_time = $2 OR (properties IS NOT NULL AND properties::jsonb ->> 'split_of' = $3))
151
+ AND NOT (start_time = ANY($4::timestamptz[]))`,
152
+ [record.machine, origin, String(record.origin), starts]
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Reads every phase row of one split family. The key is the original
158
+ * start: the first piece keeps that start_time, later pieces store it
159
+ * in properties.split_of as epoch millis.
160
+ *
161
+ * @param {object} pool - pg pool
162
+ * @param {string} machine - machine id
163
+ * @param {Date} start - original off start
164
+ * @returns {Promise<Array<object>>} family rows ordered by start
165
+ */
166
+ export async function loadFamily(pool, machine, start) {
167
+ const result = await pool.query(
168
+ `SELECT name, start_time, end_time, duration, properties
169
+ FROM segments
170
+ WHERE machine = $1 AND kind = 'phase'
171
+ AND (start_time = $2 OR (properties IS NOT NULL AND properties::jsonb ->> 'split_of' = $3))
172
+ ORDER BY start_time`,
173
+ [machine, start, String(start.getTime())]
174
+ );
175
+ return result.rows;
176
+ }
177
+
178
+ /**
179
+ * Bounds of one stored family. A later edit must cover this span, so
180
+ * collapsing every piece but one restores the original start and end.
181
+ *
182
+ * @param {Array<object>} rows - phase rows of one family
183
+ * @returns {object} parent off with start_time, end_time, and duration
184
+ *
185
+ * @example
186
+ * const parent = familyCover(await loadFamily(pool, machine, start));
187
+ */
188
+ export function familyCover(rows) {
189
+ if (!Array.isArray(rows) || rows.length === 0) {
190
+ throw new RangeError('split family is empty');
191
+ }
192
+ const ordered = rows.slice().sort(byRowStart);
193
+ const start = new Date(ordered[0].start_time).getTime();
194
+ const span = ordered.reduce(widen, { end: start, pending: false });
195
+ return {
196
+ name: 'off',
197
+ start_time: new Date(start),
198
+ end_time: new Date(span.end),
199
+ duration: span.pending ? 0 : (span.end - start) / 1000
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Checks one operator split against the parent off row.
205
+ * Pieces stay contiguous, the first keeps the parent start, a closed
206
+ * parent is covered through its end, and at least one piece has a tag.
207
+ *
208
+ * @param {object} parent - off row with name, start_time, end_time, duration
209
+ * @param {Array<object>} splits - pieces with start, end, tag, open
210
+ * @returns {Array<object>} pieces ordered by start
211
+ */
212
+ export function checkSplit(parent, splits) {
213
+ const ordered = orderedPieces(parent, splits);
214
+ ordered.forEach((piece, index) => {
215
+ visit(ordered, piece, index);
216
+ });
217
+ refuseCover(parent, ordered[ordered.length - 1]);
218
+ return ordered;
219
+ }
220
+
221
+ /**
222
+ * Builds persistence rows for one checked split. split_of is the parent
223
+ * start in epoch millis so a later edit can find the whole family.
224
+ *
225
+ * @param {string} machine - machine id
226
+ * @param {Array<object>} splits - checked pieces
227
+ * @returns {Array<object>} segment rows
228
+ */
229
+ export function splitRows(machine, splits) {
230
+ const origin = String(splits[0].start.getTime());
231
+ return splits.map((piece) => {
232
+ return rowOf(machine, origin, piece);
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Replaces one split family inside a transaction: drops starts that the
238
+ * new cut no longer uses, then upserts the pieces.
239
+ *
240
+ * @param {object} pool - pg pool with connect()
241
+ * @param {object} record - machine, origin millis, pieces
242
+ * @returns {Promise<void>}
243
+ */
244
+ export async function replaceSplit(pool, record) {
245
+ refuseEmpty(record);
246
+ const client = await pool.connect();
247
+ try {
248
+ await client.query('BEGIN');
249
+ await removeObsolete(client, record);
250
+ await writePieces(client, record.pieces);
251
+ await client.query('COMMIT');
252
+ } catch (error) {
253
+ await client.query('ROLLBACK');
254
+ throw error;
255
+ } finally {
256
+ client.release();
257
+ }
258
+ }
@@ -18,7 +18,14 @@ async function rejectUnknownTags(timeline, parsed, machineId, res, decorate) {
18
18
  return false;
19
19
  }
20
20
  const [gated] = await decorate(machineId, [row]);
21
- if (allowedSegmentTags(gated.options, gated.tags, parsed.tags)) {
21
+ const wanted = Array.isArray(parsed.splits)
22
+ ? parsed.splits.map((piece) => {
23
+ return piece.tag;
24
+ }).filter((id) => {
25
+ return id;
26
+ })
27
+ : parsed.tags;
28
+ if (allowedSegmentTags(gated.options, gated.tags, wanted)) {
22
29
  return false;
23
30
  }
24
31
  errorResponse('BAD_REQUEST', `Tag is not in segment options for ${machineId}`, 400).send(res);
@@ -88,6 +95,10 @@ export default function timelineRoute(basePath, plant, operatorOptions, decorate
88
95
  return;
89
96
  }
90
97
  await handleOperatorWrite(gate, parsed, async (audit) => {
98
+ if (Array.isArray(parsed.splits)) {
99
+ await result.machine.timeline.respond(new Date(parsed.start).toISOString(), parsed, audit);
100
+ return;
101
+ }
91
102
  await result.machine.timeline.retag(new Date(parsed.start), parsed.tags, parsed.properties, audit);
92
103
  });
93
104
  jsonResponse({ status: 'updated' }).send(res);
@@ -11,6 +11,20 @@ import processingErrorLog from '../processingErrorLog.js';
11
11
  * const codec = segmentCodec(collector);
12
12
  * codec.accept({ destination: '/exchange/scada.segments', payload: '{"machine":"m2","name":"on","start":1700000000,"end":1700003600,"duration":3600}' });
13
13
  */
14
+ function familyRecord(parsed) {
15
+ if (typeof parsed.machine !== 'string' || !Array.isArray(parsed.pieces) || parsed.pieces.length === 0) {
16
+ throw new Error('split-family missing machine or pieces');
17
+ }
18
+ return {
19
+ type: 'split-family',
20
+ machine: parsed.machine,
21
+ origin: parsed.origin,
22
+ pieces: parsed.pieces.map((piece) => {
23
+ return segmentNormalize({ ...piece, type: 'split', machine: parsed.machine });
24
+ })
25
+ };
26
+ }
27
+
14
28
  export default function segmentCodec(collector) {
15
29
  if (!collector || typeof collector.accept !== 'function') {
16
30
  throw new Error('Collector must have an accept() method');
@@ -19,6 +33,10 @@ export default function segmentCodec(collector) {
19
33
  async accept(raw) {
20
34
  try {
21
35
  const parsed = JSON.parse(raw.payload);
36
+ if (parsed.type === 'split-family') {
37
+ await collector.accept(familyRecord(parsed));
38
+ return;
39
+ }
22
40
  const record = segmentNormalize(parsed);
23
41
  await collector.accept(record);
24
42
  } catch (error) {
@@ -20,6 +20,7 @@ const privilegedOnly = new Set([
20
20
  'C0004__central_operations_grants.sql',
21
21
  'C0005__central_operators_grants.sql',
22
22
  'C0006__central_operators_registration.sql',
23
+ 'C0007__central_segment_replace_delete.sql',
23
24
  'E0002__edge_retention_delete.sql',
24
25
  'E0003__edge_operations_grants.sql',
25
26
  'E0004__edge_operations_delete.sql'
@@ -18,8 +18,8 @@ export const segmentColumns = ['machine', 'kind', 'name', 'start_time', 'end_tim
18
18
  'options', 'tags', 'properties', 'resolved', 'updated_at'];
19
19
  export const segmentConflict = ['machine', 'kind', 'start_time'];
20
20
  export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved', 'updated_at'];
21
- export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
22
- export const segmentUpdateWhere = 'segments.duration = 0 OR EXCLUDED.duration > 0 OR EXCLUDED.end_time > segments.end_time';
21
+ export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'properties', 'resolved'];
22
+ export const segmentUpdateWhere = '(segments.duration = 0 OR EXCLUDED.duration > 0 OR EXCLUDED.end_time > segments.end_time) AND NOT (segments.duration > 0 AND EXCLUDED.duration = 0 AND COALESCE(segments.properties, \'\') LIKE \'%"split_of"%\')';
23
23
  export const segmentsIngestDestination = '/queue/scada.segments.ingest';
24
24
 
25
25
  export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
@@ -10,6 +10,30 @@ function authHeaders(token) {
10
10
  return headers;
11
11
  }
12
12
 
13
+ function operatorId(body, audit) {
14
+ if (audit && audit.id !== undefined && audit.id !== null) {
15
+ body.operatorId = audit.id;
16
+ }
17
+ return body;
18
+ }
19
+
20
+ function splitBody(splits, audit) {
21
+ return operatorId({
22
+ splits: (splits || []).map((piece) => {
23
+ return {
24
+ start: piece.start.toISOString(),
25
+ end: piece.end.toISOString(),
26
+ tag: piece.tag || '',
27
+ open: Boolean(piece.open)
28
+ };
29
+ })
30
+ }, audit);
31
+ }
32
+
33
+ function respondBody(tags, properties, audit) {
34
+ return operatorId({ tags: tags || [], properties: properties || {} }, audit);
35
+ }
36
+
13
37
  function writeBody(start, tags, properties, audit) {
14
38
  const body = {
15
39
  start: start.toISOString(),
@@ -86,14 +110,11 @@ export default function httpTimeline(site, machineId) {
86
110
  },
87
111
  async respond(start, tags, properties, audit) {
88
112
  const requestId = encodeURIComponent(start.toISOString());
89
- const body = {
90
- tags: tags || [],
91
- properties: properties || {}
92
- };
93
- if (audit && audit.id !== undefined && audit.id !== null) {
94
- body.operatorId = audit.id;
95
- }
96
- await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, body);
113
+ await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, respondBody(tags, properties, audit));
114
+ },
115
+ async respondSplit(start, splits, audit) {
116
+ const requestId = encodeURIComponent(start.toISOString());
117
+ await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, splitBody(splits, audit));
97
118
  }
98
119
  });
99
120
  }
@@ -16,6 +16,9 @@ export default function stompTimeline(decisions, machineId) {
16
16
  },
17
17
  async respond(start, tags, properties, audit) {
18
18
  await decisions.publish(machineId, start, tags, properties, audit);
19
+ },
20
+ async respondSplit(start, splits, audit) {
21
+ await decisions.publishSplit(machineId, start, splits, audit);
19
22
  }
20
23
  };
21
24
  }
@@ -3,6 +3,18 @@ import userDecisionBody from './userDecisionBody.js';
3
3
 
4
4
  const DEFAULT_DESTINATION = '/exchange/scada.user_decisions';
5
5
 
6
+ function splitWire(piece) {
7
+ const row = {
8
+ start: piece.start.getTime() / 1000,
9
+ end: piece.end.getTime() / 1000,
10
+ tag: piece.tag || ''
11
+ };
12
+ if (piece.open) {
13
+ row.open = true;
14
+ }
15
+ return row;
16
+ }
17
+
6
18
  /**
7
19
  * Publishes operator tag decisions to RabbitMQ for Scala supervisor.
8
20
  *
@@ -37,6 +49,11 @@ export default function userDecisions(config) {
37
49
  async publish(machine, start, tags, properties, audit) {
38
50
  const body = userDecisionBody(machine, start, tags, properties, audit);
39
51
  await stompSend(config.stompUrl, destination, body, stompOptions);
52
+ },
53
+ async publishSplit(machine, start, splits, audit) {
54
+ const body = userDecisionBody(machine, start, [], {}, audit);
55
+ body.splits = (splits || []).map(splitWire);
56
+ await stompSend(config.stompUrl, destination, body, stompOptions);
40
57
  }
41
58
  };
42
59
  }