@casecore/release-agent 0.1.3 → 0.1.6

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
@@ -13,7 +13,7 @@
13
13
  ## Подключение
14
14
 
15
15
  ```sh
16
- pnpm add --save-exact @casecore/release-agent@0.1.0
16
+ pnpm add --save-exact @casecore/release-agent@0.1.6
17
17
  ```
18
18
 
19
19
  В проекте фиксируются точная версия и `pnpm-lock.yaml`. Поддерживается Node.js
@@ -140,6 +140,12 @@ Alvori — `release-control-source.json`. Для старого формата A
140
140
  в миллисекундах UTC и доверенный `acquireGuard(command, context)`.
141
141
  Срок восстановления ограничен 30 секундами и не меняет исходные T0 и deadline.
142
142
 
143
+ Начиная с SDK 0.1.4, `tenantId`, `workspaceId` и `parentRunId` принимают
144
+ каноническую запись UUID из PostgreSQL без ограничения битов версии и варианта.
145
+ `operationGeneration` сохраняет строгую проверку сгенерированного UUID.
146
+ Значения области доступа не преобразуются: факт должен точно совпасть
147
+ с удержанным контекстом и каноническим владением CaseCore.
148
+
143
149
  Адаптер удерживает общую блокировку приёма, worker, конфигурации и публикации;
144
150
  подтверждает остановку всех исходных обработчиков и операций; исключает поздний
145
151
  приём; проверяет отсутствие команды в том же защищённом журнале и получает
@@ -275,3 +281,45 @@ RELEASE_AGENT_TARBALL=/absolute/path/casecore-release-agent-0.1.0.tgz \
275
281
 
276
282
  Запуск проверок и сборок должен учитывать действующее разрешение владельца задачи;
277
283
  наличие команд и тестов не является подтверждением их успешного выполнения.
284
+
285
+ ## Завершение исходной попытки публикации
286
+
287
+ Начиная с 0.1.6 адаптер может реализовать `publicationTerminal(command, context)`.
288
+ Движок вызывает его после сохранения подтверждённого результата `deployed` либо
289
+ штатного `failed` до установки. Адаптер закрывает свой исходный журнал под своей
290
+ блокировкой; общий движок и исходные часы выполнения остаются единственными.
291
+
292
+ `readPublicationTerminalAuthority(context.authority, context.bindings)` возвращает
293
+ подтверждённый результат с точной исходной командой, исполнителем, кандидатом и
294
+ исходными часами. Поле `artifactIdentity` отдельно хранит подписанную идентичность
295
+ артефакта: её SHA может отличаться от SHA исходных изменений. Authority действует
296
+ только внутри ограниченного 30 секундами callback; скопированный объект, snapshot
297
+ или digest полномочия не дают. Перед записью адаптер проверяет `context.signal`.
298
+
299
+ Ошибка callback сохраняет фактический результат установки и записывается в
300
+ диагностику; она не подтверждает закрытие исходного журнала. Повторный
301
+ `reconcile(commandId)` и восстановление после перезапуска получают новое
302
+ полномочие только после свежей независимой проверки production либо штатного
303
+ восстановления FAILED с native ACK. Запись адаптера должна быть идемпотентной для
304
+ той же исходной попытки. Публикация 0.1.6 и обновление установок проверяются отдельно
305
+ от локальной готовности этого исходного контракта.
306
+
307
+ Для первой установки уже собранного артефакта тот же callback вызывается с
308
+ `bindings.action: 'deploy'`. Его `repository` и `ref` берутся из сохранённого
309
+ артефактного кандидата; исходные шесть полей `delivery` остаются неизменными.
310
+ Адаптер обязан реализовать `verifyArtifactDelivery(command, identity, delivery,
311
+ source, context)` и подтвердить подпись, исходную операцию и происхождение
312
+ артефакта. `context.completedAt` — сохранённое время завершения установки.
313
+ Повторное подтверждение после истечения срока проверяет этот результат и свежий
314
+ production readback; новую установку или продление срока оно не разрешает.
315
+
316
+ `await sealPublicationTerminalReceipt(authority, bindings, managementToken)`
317
+ сохраняет заверенный результат в существующем журнале движка перед возвратом.
318
+ `verifyPublicationTerminalReceipt(receipt, managementToken)` проверяет HMAC и
319
+ возвращает данные, которые не являются authority. Токен управления уже должен
320
+ быть настроен у обоих участников; передавать его с результатом нельзя.
321
+ Авторизованный `GET /v1/release-control/publications/:commandId/terminal` читает
322
+ сохранённый результат без нового workflow claim или запуска. Для результата,
323
+ принятого через существующий report, адаптер может предоставить
324
+ `readTerminalReceipt(commandId)`. Неизвестный результат возвращает 404;
325
+ неподтверждённые ошибки установки не получают такой результат.
@@ -18,6 +18,9 @@ function metadata(producer) {
18
18
  assert.ok(state, 'DELIVERY_OPERATION_BARRIER_REQUIRED');
19
19
  return state;
20
20
  }
21
+ export function assertProducerOperation(producer) {
22
+ assert.ok(metadata(producer).activity > 0, 'DELIVERY_OPERATION_AUTHORITY_REQUIRED');
23
+ }
21
24
  function pendingFailures(producer, state) {
22
25
  return [
23
26
  ...Object.values(producer.state.commands),
@@ -151,6 +151,9 @@ export class ProducerAdmission extends ProducerState {
151
151
  command: structuredClone(input),
152
152
  baseline: structuredClone(current.installed),
153
153
  ...(publication && candidate ? { sourceCandidate: structuredClone(candidate) } : {}),
154
+ ...(!publication && input.action === 'deploy' && candidate?.deliveryExecution
155
+ ? { artifactCandidate: structuredClone(candidate) }
156
+ : {}),
154
157
  ...(candidate?.deliveryExecution ? { deliveryExecution: candidate.deliveryExecution } : {}),
155
158
  ack,
156
159
  };
@@ -6,7 +6,7 @@ export async function verifyPublicationDelivery(
6
6
  producer,
7
7
  command,
8
8
  delivery,
9
- { fresh = true } = {}
9
+ { fresh = true, verification } = {}
10
10
  ) {
11
11
  assert.equal(command.action, 'publish');
12
12
  validateOriginalDelivery(delivery, command.expectedSource.sourceCommit);
@@ -19,7 +19,7 @@ export async function verifyPublicationDelivery(
19
19
  assert.deepEqual(item.deliveryExecution, delivery, 'Original published delivery changed');
20
20
  if (typeof producer.provider.verifyPublicationDelivery === 'function')
21
21
  assert.equal(
22
- await producer.provider.verifyPublicationDelivery(command, delivery),
22
+ await producer.provider.verifyPublicationDelivery(command, delivery, verification),
23
23
  true,
24
24
  'Original delivery correlation was not verified'
25
25
  );
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { notifyPublicationTerminal } from './publication-terminal.mjs';
2
3
  import { canonical } from './protocol-internals.mjs';
3
4
  import { sameIdentity, acceptedProduction } from './protocol.mjs';
4
5
  import { blocker, failureMessage, failedProduction } from './producer-support.mjs';
@@ -6,10 +7,7 @@ import { ProducerPublication } from './producer-publication.mjs';
6
7
 
7
8
  export class ProducerExecution extends ProducerPublication {
8
9
  async qualifyForwardRecovery(run, command) {
9
- if (
10
- command.action === 'publish' &&
11
- !this.state.commands[command.commandId]?.artifactIdentity
12
- )
10
+ if (command.action === 'publish' && !this.state.commands[command.commandId]?.artifactIdentity)
13
11
  return;
14
12
  command = this.resolvedCommand(command);
15
13
  if (
@@ -49,33 +47,27 @@ export class ProducerExecution extends ProducerPublication {
49
47
  run.committedReadback = fact;
50
48
  run.completedAt ??= this.now().toISOString();
51
49
  for (const row of run.stages.filter((s) =>
52
- ['running', 'unverified'].includes(s.status),
50
+ ['running', 'unverified'].includes(s.status)
53
51
  )) {
54
52
  row.status = 'failed';
55
53
  row.completedAt ??= run.completedAt;
56
54
  row.elapsedMs = row.startedAt
57
- ? Math.max(
58
- 0,
59
- Date.parse(row.completedAt) - Date.parse(row.startedAt),
60
- )
55
+ ? Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt))
61
56
  : null;
62
- row.failure = blocker(
63
- 'INTERRUPTED_PROOF',
64
- 'Подтверждение результата было прервано',
65
- );
57
+ row.failure = blocker('INTERRUPTED_PROOF', 'Подтверждение результата было прервано');
66
58
  }
67
59
  run.blockers.push(
68
60
  blocker(
69
61
  'FORWARD_CORRECTION_REQUIRED',
70
- 'Установка завершена, приёмка не пройдена; требуется исправляющий релиз',
71
- ),
62
+ 'Установка завершена, приёмка не пройдена; требуется исправляющий релиз'
63
+ )
72
64
  );
73
65
  this.event(
74
66
  run.id,
75
67
  null,
76
68
  'failure',
77
69
  'Разрешён исправляющий релиз; исходная выкатка завершилась ошибкой',
78
- 'error',
70
+ 'error'
79
71
  );
80
72
  }
81
73
  } catch (qualificationError) {
@@ -87,13 +79,12 @@ export class ProducerExecution extends ProducerPublication {
87
79
  }
88
80
  async execute(command, { activated = false } = {}) {
89
81
  const run = this.state.runs.find((r) => r.commandId === command.commandId);
90
- const remaining = () =>
91
- Date.parse(command.deadlineAt) - this.now().getTime();
82
+ const remaining = () => Date.parse(command.deadlineAt) - this.now().getTime();
92
83
  let suspended = false;
93
84
  const stage = async (
94
85
  id,
95
86
  operation,
96
- { inputsDigest = null, reusedFromRunId = null, queuedAt = null } = {},
87
+ { inputsDigest = null, reusedFromRunId = null, queuedAt = null } = {}
97
88
  ) => {
98
89
  assert.ok(remaining() > 0, 'Original deadline exceeded');
99
90
  if (id === 'deploy') run.recoveryState = 'unverified';
@@ -103,8 +94,7 @@ export class ProducerExecution extends ProducerPublication {
103
94
  command.action === 'publish' &&
104
95
  this.provider.remotePublication === true &&
105
96
  row.startedAt !== null;
106
- if (!retained || !['passed', 'reused'].includes(row.status))
107
- row.status = 'running';
97
+ if (!retained || !['passed', 'reused'].includes(row.status)) row.status = 'running';
108
98
  if (!retained) row.startedAt = this.now().toISOString();
109
99
  if (!retained) row.inputsDigest = inputsDigest;
110
100
  if (!retained)
@@ -149,10 +139,7 @@ export class ProducerExecution extends ProducerPublication {
149
139
  } finally {
150
140
  if (!suspended) {
151
141
  row.completedAt = this.now().toISOString();
152
- row.elapsedMs = Math.max(
153
- 0,
154
- Date.parse(row.completedAt) - Date.parse(row.startedAt),
155
- );
142
+ row.elapsedMs = Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt));
156
143
  this.event(run.id, id, 'stage', `${row.title}: ${row.status}`);
157
144
  }
158
145
  await this.save();
@@ -160,21 +147,15 @@ export class ProducerExecution extends ProducerPublication {
160
147
  };
161
148
  try {
162
149
  if (command.lane === 'scheduled' && !activated) {
163
- assert.ok(
164
- this.provider.announce,
165
- 'Scheduled notice transport is not configured',
166
- );
150
+ assert.ok(this.provider.announce, 'Scheduled notice transport is not configured');
167
151
  await this.provider.announce(command);
168
152
  run.noticeAt = this.now().toISOString();
169
- if (
170
- Date.parse(command.activationAt) <
171
- Date.parse(run.noticeAt) + 86400000
172
- ) {
153
+ if (Date.parse(command.activationAt) < Date.parse(run.noticeAt) + 86400000) {
173
154
  run.status = 'blocked';
174
155
  run.blockers = [
175
156
  blocker(
176
157
  'NOTICE_WINDOW_TOO_SHORT',
177
- 'От фактического уведомления до установки должно пройти минимум 24 часа',
158
+ 'От фактического уведомления до установки должно пройти минимум 24 часа'
178
159
  ),
179
160
  ];
180
161
  await this.save();
@@ -184,11 +165,7 @@ export class ProducerExecution extends ProducerPublication {
184
165
  // Scheduling is durable; reconciliation resumes after the activation time.
185
166
  run.status = 'queued';
186
167
  run.blockers = [
187
- blocker(
188
- 'SCHEDULED_ACTIVATION',
189
- 'Ожидается согласованное окно установки',
190
- true,
191
- ),
168
+ blocker('SCHEDULED_ACTIVATION', 'Ожидается согласованное окно установки', true),
192
169
  ];
193
170
  await this.save();
194
171
  return;
@@ -202,27 +179,23 @@ export class ProducerExecution extends ProducerPublication {
202
179
  admitted?.ack.state === 'accepted' &&
203
180
  canonical(admitted.command) === canonical(command) &&
204
181
  Number.isFinite(Date.parse(run.noticeAt)) &&
205
- Date.parse(command.activationAt) >=
206
- Date.parse(run.noticeAt) + 86400000 &&
182
+ Date.parse(command.activationAt) >= Date.parse(run.noticeAt) + 86400000 &&
207
183
  Date.parse(command.activationAt) <= this.now().getTime();
208
184
  await this.provider.execute(command, {
209
185
  stage,
210
186
  remainingMs: remaining,
211
187
  admittedScheduled,
212
- bindDelivery: (delivery) =>
213
- this.bindPublicationDelivery(command, delivery),
188
+ bindDelivery: (delivery) => this.bindPublicationDelivery(command, delivery),
214
189
  bindArtifact: (identity, options) =>
215
190
  this.bindPublicationArtifact(command, identity, options),
216
191
  event: async (fact) => {
217
- assert.ok(
218
- ['source_ready', 'quality', 'source_merge'].includes(fact.stage),
219
- );
192
+ assert.ok(['source_ready', 'quality', 'source_merge'].includes(fact.stage));
220
193
  assert.ok(['running', 'completed', 'failed'].includes(fact.status));
221
194
  this.event(
222
195
  run.id,
223
196
  fact.stage === 'source_ready' ? 'source_ready' : 'ci',
224
197
  'log',
225
- `${fact.stage}: ${fact.status}`,
198
+ `${fact.stage}: ${fact.status}`
226
199
  );
227
200
  await this.save();
228
201
  },
@@ -232,7 +205,7 @@ export class ProducerExecution extends ProducerPublication {
232
205
  run.stages
233
206
  .filter((s) => s.id !== 'production_acceptance')
234
207
  .every((s) => ['passed', 'reused'].includes(s.status)),
235
- 'Every prerequisite stage needs actual proof',
208
+ 'Every prerequisite stage needs actual proof'
236
209
  );
237
210
  const readback = await stage('production_acceptance', async (signal) => {
238
211
  const fact = await this.provider.readProduction(resolved, { signal });
@@ -243,15 +216,13 @@ export class ProducerExecution extends ProducerPublication {
243
216
  }
244
217
  assert.ok(
245
218
  acceptedProduction(fact, resolved, this.now()),
246
- 'Independent production identity and functional proof required',
219
+ 'Independent production identity and functional proof required'
247
220
  );
248
221
  return fact;
249
222
  });
250
223
  run.recoveryState = 'verified';
251
224
  if (command.action === 'rollback') {
252
- const previous = this.state.runs.find(
253
- (r) => r.id === command.previousRunId,
254
- );
225
+ const previous = this.state.runs.find((r) => r.id === command.previousRunId);
255
226
  if (previous) {
256
227
  previous.status = 'failed';
257
228
  previous.recoveryState = 'verified';
@@ -259,7 +230,7 @@ export class ProducerExecution extends ProducerPublication {
259
230
  previous.blockers = [
260
231
  blocker(
261
232
  'RECOVERY_VERIFIED',
262
- 'Предыдущая установка восстановлена; исходная выкатка завершилась ошибкой',
233
+ 'Предыдущая установка восстановлена; исходная выкатка завершилась ошибкой'
263
234
  ),
264
235
  ];
265
236
  }
@@ -270,7 +241,7 @@ export class ProducerExecution extends ProducerPublication {
270
241
  run.id,
271
242
  null,
272
243
  'readback',
273
- 'Производственная версия и функциональная проверка подтверждены',
244
+ 'Производственная версия и функциональная проверка подтверждены'
274
245
  );
275
246
  } catch (error) {
276
247
  if (
@@ -288,19 +259,17 @@ export class ProducerExecution extends ProducerPublication {
288
259
  remaining() <= 0 ? 'DEADLINE_EXCEEDED' : 'RECONCILIATION_REQUIRED',
289
260
  remaining() <= 0
290
261
  ? 'Исходный срок выполнения истёк'
291
- : 'Выполнение требует проверки фактического результата',
262
+ : 'Выполнение требует проверки фактического результата'
292
263
  ),
293
264
  ];
294
265
  this.event(run.id, null, 'failure', run.blockers[0].message, 'error');
295
266
  await this.qualifyForwardRecovery(run, command);
296
267
  } finally {
297
- if (
298
- !suspended &&
299
- !run.blockers.some((b) => b.code === 'SCHEDULED_ACTIVATION')
300
- )
268
+ if (!suspended && !run.blockers.some((b) => b.code === 'SCHEDULED_ACTIVATION'))
301
269
  run.completedAt = this.now().toISOString();
302
270
  await this.save();
303
271
  }
272
+ await notifyPublicationTerminal(this, this.state.commands[command.commandId], run);
304
273
  }
305
274
  async resumeScheduled() {
306
275
  if (this.scheduling) return;
@@ -310,11 +279,11 @@ export class ProducerExecution extends ProducerPublication {
310
279
  (r) =>
311
280
  r.status === 'queued' &&
312
281
  r.lane === 'scheduled' &&
313
- Date.parse(r.activationAt) <= this.now().getTime(),
282
+ Date.parse(r.activationAt) <= this.now().getTime()
314
283
  )) {
315
284
  const command = this.state.commands[run.commandId].command;
316
285
  const candidate = (await this.snapshot()).candidates.find(
317
- (c) => c.id === command.candidateId,
286
+ (c) => c.id === command.candidateId
318
287
  );
319
288
  if (
320
289
  Date.parse(command.deadlineAt) <= this.now().getTime() ||
@@ -325,22 +294,19 @@ export class ProducerExecution extends ProducerPublication {
325
294
  run.blockers = [
326
295
  blocker(
327
296
  'SCHEDULED_CANDIDATE_INVALID',
328
- 'Срок истёк или подготовленный кандидат изменился',
297
+ 'Срок истёк или подготовленный кандидат изменился'
329
298
  ),
330
299
  ];
331
300
  await this.save();
332
301
  continue;
333
302
  }
334
303
  run.blockers = [];
335
- if (
336
- Date.parse(command.activationAt) <
337
- Date.parse(run.noticeAt) + 86400000
338
- ) {
304
+ if (Date.parse(command.activationAt) < Date.parse(run.noticeAt) + 86400000) {
339
305
  run.status = 'blocked';
340
306
  run.blockers = [
341
307
  blocker(
342
308
  'NOTICE_WINDOW_TOO_SHORT',
343
- 'Недостаточно времени после фактического уведомления',
309
+ 'Недостаточно времени после фактического уведомления'
344
310
  ),
345
311
  ];
346
312
  await this.save();
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { hash, canonical, keys, uuid } from './protocol-internals.mjs';
3
3
  import { validateRunnerCommand, validateProducerSnapshot } from './protocol.mjs';
4
+ import { validatePublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
4
5
  import {
5
6
  validatePublicationFailureRecord,
6
7
  validatePublicationFailureReadback,
@@ -29,6 +30,46 @@ export function validProducerState(state, installationKey, sourcePolicy) {
29
30
  assert.equal(item.ack.protocolVersion, 1);
30
31
  assert.ok(['accepted', 'rejected'].includes(item.ack.state));
31
32
  assert.match(item.ack.runId, uuid);
33
+ if (item.artifactCandidate) {
34
+ assert.equal(item.command.action, 'deploy');
35
+ assert.equal(
36
+ canonical(item.artifactCandidate.identity),
37
+ canonical(item.command.expectedIdentity)
38
+ );
39
+ assert.equal(
40
+ canonical(item.artifactCandidate.deliveryExecution),
41
+ canonical(item.deliveryExecution)
42
+ );
43
+ }
44
+ if (item.deliveryTerminalReceipt) {
45
+ const proof = validatePublicationTerminalReceipt(item.deliveryTerminalReceipt).proof;
46
+ assert.equal(proof.bindings.commandId, id);
47
+ assert.equal(proof.bindings.runId, item.ack.runId);
48
+ assert.equal(proof.bindings.installationKey, installationKey);
49
+ assert.equal(proof.bindings.action, item.command.action);
50
+ assert.equal(proof.bindings.candidateId, item.command.candidateId);
51
+ assert.equal(proof.bindings.provider, item.command.executionProvider);
52
+ assert.equal(canonical(proof.bindings.delivery), canonical(item.deliveryExecution));
53
+ const source =
54
+ item.command.action === 'publish'
55
+ ? item.command.expectedSource
56
+ : item.artifactCandidate?.source;
57
+ assert.equal(proof.bindings.repository, source?.repository);
58
+ assert.equal(proof.bindings.ref, source?.ref);
59
+ assert.equal(
60
+ canonical({ identity: proof.artifactIdentity }),
61
+ canonical({
62
+ identity:
63
+ item.command.action === 'publish'
64
+ ? (item.artifactIdentity ?? null)
65
+ : item.command.expectedIdentity,
66
+ })
67
+ );
68
+ const run = state.runs.find((value) => value.id === item.ack.runId);
69
+ assert.equal(run?.status, proof.status);
70
+ assert.equal(run?.completedAt, proof.completedAt);
71
+ assert.equal(run?.recoveryState, 'verified');
72
+ }
32
73
  if (item.publicationFailureReconciliation !== undefined)
33
74
  validatePublicationFailureRecord(
34
75
  item.publicationFailureReconciliation,
@@ -11,6 +11,7 @@ import { acceptedProduction, sameIdentity } from './protocol.mjs';
11
11
  import { validateOriginalDelivery, blocker } from './producer-support.mjs';
12
12
  import { verifyPublicationDelivery } from './producer-delivery.mjs';
13
13
  import { reservePublicationFailureReconciliation } from './exclusive-operation.mjs';
14
+ import { notifyPublicationTerminal } from './publication-terminal.mjs';
14
15
 
15
16
  const stages = ['source_ready', 'ci', 'build', 'verify', 'publish'];
16
17
  export function publicationFailureProofDigest(fact, baselineReadback) {
@@ -97,10 +98,20 @@ export function validatePublicationFailureRecord(record, command, delivery, base
97
98
  assert.ok(record.baselineReadback, 'Settled recovery requires its production baseline proof');
98
99
  }
99
100
 
100
- export async function reconcilePublicationFailure(producer, item, run) {
101
+ export async function reconcilePublicationFailure(
102
+ producer,
103
+ item,
104
+ run,
105
+ { terminalRetry = false } = {}
106
+ ) {
101
107
  assert.equal(item.ack.state, 'accepted');
102
108
  assert.equal(item.command.action, 'publish');
103
- assert.equal(run.status, 'blocked');
109
+ assert.ok(
110
+ run.status === 'blocked' ||
111
+ (terminalRetry &&
112
+ run.status === 'failed' &&
113
+ item.publicationFailureReconciliation?.state === 'settled')
114
+ );
104
115
  assert.ok(!item.artifactIdentity);
105
116
  for (const id of ['deploy', 'production_acceptance']) {
106
117
  const row = run.stages.find((stage) => stage.id === id);
@@ -137,7 +148,9 @@ export async function reconcilePublicationFailure(producer, item, run) {
137
148
  fact: null,
138
149
  baselineReadback: null,
139
150
  };
151
+ if (terminalRetry) item.publicationFailureReconciliation.state = 'pending';
140
152
  assert.equal(item.publicationFailureReconciliation.state, 'pending');
153
+ run.status = 'blocked';
141
154
  run.recoveryState = 'unverified';
142
155
  run.completedAt = null;
143
156
  await bounded(producer.save());
@@ -267,4 +280,5 @@ export async function reconcilePublicationFailure(producer, item, run) {
267
280
  if (guard && !settled && !releasing && typeof guard.release === 'function') void cleanup(guard);
268
281
  lease.release({ resolved: settled });
269
282
  }
283
+ if (settled) await notifyPublicationTerminal(producer, item, run);
270
284
  }
@@ -2,11 +2,10 @@ import assert from 'node:assert/strict';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { createServer } from 'node:http';
4
4
  import { configurationHook } from './configuration-hook.mjs';
5
+ import { verifyPublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
5
6
 
6
7
  export function producerServer(producer, token, publication = {}) {
7
- assert.ok(
8
- typeof token === 'string' && token.length >= 43 && !/\s/.test(token),
9
- );
8
+ assert.ok(typeof token === 'string' && token.length >= 43 && !/\s/.test(token));
10
9
  const expected = Buffer.from(`Bearer ${token}`);
11
10
  const server = createServer(async (req, res) => {
12
11
  const reply = (code, body) => {
@@ -17,31 +16,29 @@ export function producerServer(producer, token, publication = {}) {
17
16
  res.end(JSON.stringify(body));
18
17
  };
19
18
  const supplied = Buffer.from(req.headers.authorization ?? '');
20
- if (
21
- supplied.length !== expected.length ||
22
- !timingSafeEqual(supplied, expected)
23
- )
19
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
24
20
  return reply(401, { error: 'Unauthorized' });
25
21
  try {
26
22
  const url = new URL(req.url, 'http://localhost');
27
23
  if (await configurationHook(req, url, producer, publication, reply)) return;
28
- if (
29
- req.method === 'GET' &&
30
- url.pathname === '/v1/release-control/snapshot'
31
- ) {
32
- assert.equal(
33
- url.searchParams.get('installationKey'),
34
- producer.installationKey,
35
- );
36
- return reply(
37
- 200,
38
- await producer.snapshot(url.searchParams.get('after') ?? '0'),
39
- );
24
+ const terminal = url.pathname.match(
25
+ /^\/v1\/release-control\/publications\/([a-f\d-]{36})\/terminal$/i
26
+ );
27
+ if (req.method === 'GET' && terminal && !url.search) {
28
+ const receipt = publication.readTerminalReceipt
29
+ ? await publication.readTerminalReceipt(terminal[1])
30
+ : producer.journal.read().commands[terminal[1]]?.deliveryTerminalReceipt;
31
+ if (!receipt) return reply(404, { error: 'Terminal receipt unavailable' });
32
+ const verified = verifyPublicationTerminalReceipt(receipt, token);
33
+ assert.equal(verified.proof.bindings.commandId, terminal[1]);
34
+ assert.equal(verified.proof.bindings.installationKey, producer.installationKey);
35
+ return reply(200, verified);
36
+ }
37
+ if (req.method === 'GET' && url.pathname === '/v1/release-control/snapshot') {
38
+ assert.equal(url.searchParams.get('installationKey'), producer.installationKey);
39
+ return reply(200, await producer.snapshot(url.searchParams.get('after') ?? '0'));
40
40
  }
41
- if (
42
- req.method === 'POST' &&
43
- url.pathname === '/v1/release-control/commands'
44
- ) {
41
+ if (req.method === 'POST' && url.pathname === '/v1/release-control/commands') {
45
42
  let bytes = 0;
46
43
  const chunks = [];
47
44
  for await (const chunk of req) {
@@ -51,37 +48,21 @@ export function producerServer(producer, token, publication = {}) {
51
48
  }
52
49
  return reply(
53
50
  200,
54
- await producer.command(
55
- JSON.parse(Buffer.concat(chunks).toString('utf8')),
56
- ),
51
+ await producer.command(JSON.parse(Buffer.concat(chunks).toString('utf8')))
57
52
  );
58
53
  }
59
54
  const publicationMatch = url.pathname.match(
60
- /^\/v1\/release-control\/publications\/([a-f\d-]{36})(\/report)?$/i,
55
+ /^\/v1\/release-control\/publications\/([a-f\d-]{36})(\/report)?$/i
61
56
  );
62
57
  if (publicationMatch && !url.search) {
63
- if (
64
- req.method === 'GET' &&
65
- !publicationMatch[2] &&
66
- publication.claimPublication
67
- ) {
58
+ if (req.method === 'GET' && !publicationMatch[2] && publication.claimPublication) {
68
59
  const rawRun = req.headers[publication.workflowRunHeader ?? 'x-myrobotica-workflow-run'];
69
60
  assert.ok(typeof rawRun === 'string' && /^[1-9]\d*$/.test(rawRun));
70
61
  const workflowRunId = Number(rawRun);
71
62
  assert.ok(Number.isSafeInteger(workflowRunId));
72
- return reply(
73
- 200,
74
- await publication.claimPublication(
75
- publicationMatch[1],
76
- workflowRunId,
77
- ),
78
- );
63
+ return reply(200, await publication.claimPublication(publicationMatch[1], workflowRunId));
79
64
  }
80
- if (
81
- req.method === 'POST' &&
82
- publicationMatch[2] &&
83
- publication.reportPublication
84
- ) {
65
+ if (req.method === 'POST' && publicationMatch[2] && publication.reportPublication) {
85
66
  let bytes = 0;
86
67
  const chunks = [];
87
68
  for await (const chunk of req) {
@@ -93,17 +74,14 @@ export function producerServer(producer, token, publication = {}) {
93
74
  200,
94
75
  await publication.reportPublication(
95
76
  publicationMatch[1],
96
- JSON.parse(Buffer.concat(chunks).toString('utf8')),
97
- ),
77
+ JSON.parse(Buffer.concat(chunks).toString('utf8'))
78
+ )
98
79
  );
99
80
  }
100
81
  }
101
- const match = url.pathname.match(
102
- /^\/v1\/release-control\/commands\/([a-f\d-]{36})$/i,
103
- );
82
+ const match = url.pathname.match(/^\/v1\/release-control\/commands\/([a-f\d-]{36})$/i);
104
83
  if (req.method === 'GET' && match) {
105
- if (!producer.acknowledgement(match[1]))
106
- return reply(404, { error: 'Unknown command' });
84
+ if (!producer.acknowledgement(match[1])) return reply(404, { error: 'Unknown command' });
107
85
  const ack = await producer.reconcile(match[1]);
108
86
  return reply(ack ? 200 : 404, ack ?? { error: 'Unknown command' });
109
87
  }