@casecore/release-agent 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -49,6 +49,68 @@ import { ReleaseControlProducer } from '@casecore/release-agent/producer';
49
49
  `production_acceptance` подтверждается через независимый `readProduction`.
50
50
  Журнал сохраняет прежний формат MyRobotica и исходные идентификаторы команд.
51
51
 
52
+ UUID исходной попытки доставки и UUID команды CaseCore могут различаться.
53
+ `provider.verifyPublicationDelivery(command, delivery)` подтверждает их связь
54
+ по каноническому источнику продукта до `bindPublicationDelivery`: исходный commit,
55
+ репозиторий, ветка, выбранный исполнитель, доверенный статус, T0 и deadline
56
+ сохраняются. Для разных UUID обработчик обязан вернуть `true`; при подключённом
57
+ обработчике он проверяет и совпадающие UUID. При его отсутствии поддерживается
58
+ прежний контракт с совпадающими UUID.
59
+
60
+ После ошибки публикации до появления артефакта существующий
61
+ `reconcile(commandId)` вызывает подключённый
62
+ `provider.reconcilePublicationFailure(command, { signal, delivery, recoveryDeadlineAt })`.
63
+ Адаптер возвращает `null` при неопределённом результате либо удерживаемый guard:
64
+ `{ fact, baselineReadback, assertOwned(), release({ resolved, proofDigest }) }`.
65
+ `fact` содержит ровно `schema: 1`, `kind: 'failed_before_installation'`,
66
+ `commandId`, `installationKey`, `expectedSource`, исходный `delivery`, свежий
67
+ `observedAt`, `stage: source_ready | ci | build | verify | publish`,
68
+ `authenticated: true`, `executionStopped: true`, `pendingTransaction: false`,
69
+ `publicationResolved: true`, `installationNotStarted: true`.
70
+
71
+ `baselineReadback` использует существующий формат `ProductionReadback` и
72
+ подтверждает предыдущую рабочую установку под тем же удерживаемым guard.
73
+ Её точная идентичность обязана совпадать с baseline, сохранённым при принятии
74
+ исходной команды. Требуются правильная установка, `source: independent-production`,
75
+ успешный functional smoke с тем же source commit и ссылкой на доказательство.
76
+ `observedAt` и `functionalSmoke.checkedAt` не предшествуют исходному T0,
77
+ не находятся в будущем и отстают от завершения не более чем на 300 секунд.
78
+ Отсутствующий baseline, readback или несовпадение оставляют восстановление pending.
79
+
80
+ Адаптер подтверждает фактическую остановку исполнителя и его дочерних/удалённых
81
+ операций, исходную идентичность и отсутствие незавершённой установки через
82
+ существующие журналы и механизмы владения продукта. Он удерживает исключение
83
+ конкурирующих действий до сохранения pending-доказательства и подтверждения
84
+ его durable-завершения в `release({ resolved: true, proofDigest })`.
85
+ До ACK движок сохраняет точные `fact` и `baselineReadback` в защищённом pending-журнале,
86
+ а readback — в существующем `run.readback`; статус остаётся `blocked`,
87
+ `recoveryState: unverified`, `completedAt: null`.
88
+ Адаптер вычисляет ожидаемый ACK digest общей функцией
89
+ `publicationFailureProofDigest(fact, baselineReadback)` из `@casecore/release-agent`
90
+ или `@casecore/release-agent/producer`. Движок использует ту же функцию;
91
+ копировать реализацию хеша в адаптер не нужно. Функция вычисляет digest,
92
+ а доверенность доказательств и владение проверяются отдельно.
93
+ `proofDigest` связывает оба значения: `sha256:${hash(canonical({ fact, baselineReadback }))}`.
94
+ Здесь `canonical` сортирует ключи верхнего уровня объекта через `localeCompare`
95
+ и возвращает его JSON-строку; `hash` вычисляет SHA-256 от `JSON.stringify`
96
+ этой строки в UTF-8. Вложенные значения сохраняют точный порядок ключей
97
+ переданного адаптером доказательства. Адаптер подтверждает тот же digest.
98
+ Владение и свежесть повторно проверяются перед ACK, а после ACK — локальное
99
+ владение и свежесть перед сохранением завершения.
100
+ Ошибка/потеря ACK не разрешает следующий выпуск. Guard и его callback работают
101
+ внутри доверенного адаптера; HTTP-запрос не может подставить этот результат.
102
+
103
+ Только после подтверждённого завершения движок сохраняет `failed` и
104
+ `recoveryState: verified`, оставляя исходную команду, часы, диагностику и успешные
105
+ этапы. Артефакт и успешная установка не подставляются. Сохранённый pending
106
+ запрещает команды, продолжение и резервирование после перезапуска; восстановление
107
+ проходит через тот же `reconcile` и новую фактическую проверку владения.
108
+ Повторная проверка может обновить время наблюдения, но не исходный baseline.
109
+ Старое завершённое восстановление без readback не превращается в доказанный результат.
110
+ Его отдельный предел — 30 секунд; срок публикации не продлевается.
111
+ Обычный `reserveExclusiveOperation` становится доступен после завершения
112
+ восстановления, когда остальные операции установки также разрешены.
113
+
52
114
  Источник публикации ограничивается установкой. Для `main` передаётся точная
53
115
  политика, например `sourcePolicy: { repository: 'owner/product', ref: 'main' }`.
54
116
  Без политики сохраняется прежнее ограничение MyRobotica на ветки `codex/`.
@@ -3,13 +3,27 @@ import assert from 'node:assert/strict';
3
3
  // Generic process-local ownership only. Products restore pending ownership from
4
4
  // their own durable configuration state before exposing effect-bearing methods.
5
5
  const operations = new WeakMap();
6
- const mutableMethods = ['command', 'execute', 'resumeScheduled', 'resumePublication',
7
- 'reconcile', 'bindPublicationDelivery', 'bindPublicationArtifact', 'qualifyForwardRecovery'];
6
+ const mutableMethods = [
7
+ 'command',
8
+ 'execute',
9
+ 'resumeScheduled',
10
+ 'resumePublication',
11
+ 'reconcile',
12
+ 'bindPublicationDelivery',
13
+ 'bindPublicationArtifact',
14
+ 'qualifyForwardRecovery',
15
+ ];
8
16
  function metadata(producer) {
9
17
  const state = operations.get(producer);
10
18
  assert.ok(state, 'DELIVERY_OPERATION_BARRIER_REQUIRED');
11
19
  return state;
12
20
  }
21
+ function pendingFailures(producer, state) {
22
+ return [
23
+ ...Object.values(producer.state.commands),
24
+ ...(state.loaded ? [] : Object.values(producer.journal.read().commands)),
25
+ ].filter((item) => item.publicationFailureReconciliation?.state === 'pending');
26
+ }
13
27
  export function installOperationBarrier(producer) {
14
28
  assert.ok(!operations.has(producer), 'DELIVERY_OPERATION_BARRIER_ALREADY_INSTALLED');
15
29
  operations.set(producer, { activity: 0, lease: null, loaded: false });
@@ -19,10 +33,26 @@ export function installOperationBarrier(producer) {
19
33
  Object.defineProperty(producer, name, {
20
34
  value: async function (...args) {
21
35
  const state = metadata(this);
22
- assert.ok(!state.lease, 'DELIVERY_EXCLUSIVE_OPERATION_HELD');
36
+ assert.ok(
37
+ !state.lease ||
38
+ (name === 'reconcile' &&
39
+ state.lease.kind === 'publicationFailure' &&
40
+ state.lease.status === 'held' &&
41
+ state.lease.commandId === args[0]),
42
+ 'DELIVERY_EXCLUSIVE_OPERATION_HELD'
43
+ );
44
+ assert.ok(
45
+ pendingFailures(this, state).every(
46
+ (item) => name === 'reconcile' && item.command.commandId === args[0]
47
+ ),
48
+ 'DELIVERY_PUBLICATION_FAILURE_RECONCILIATION_PENDING'
49
+ );
23
50
  state.activity++;
24
- try { return await method.apply(this, args); }
25
- finally { state.activity--; }
51
+ try {
52
+ return await method.apply(this, args);
53
+ } finally {
54
+ state.activity--;
55
+ }
26
56
  },
27
57
  });
28
58
  }
@@ -33,34 +63,110 @@ export function markOperationStateLoaded(producer) {
33
63
  function installationBusy(producer, state) {
34
64
  // A restored configuration reservation precedes initialize. Inspect the
35
65
  // protected existing journal so an empty constructor cannot hide a live run.
36
- const runs = state.loaded ? producer.state.runs :
37
- [...producer.state.runs, ...producer.journal.read().runs];
38
- return state.activity > 0 || producer.admitting !== undefined ||
39
- producer.pending.size > 0 || producer.scheduling === true ||
40
- runs.some((run) =>
41
- ['queued', 'running'].includes(run.status) ||
42
- (run.status === 'blocked' && (run.recoveryState !== 'verified' ||
43
- run.blockers?.some((reason) => reason.code === 'RECONCILIATION_REQUIRED'))) ||
44
- ['failed', 'unverified'].includes(run.recoveryState));
66
+ const runs = state.loaded
67
+ ? producer.state.runs
68
+ : [...producer.state.runs, ...producer.journal.read().runs];
69
+ return (
70
+ pendingFailures(producer, state).length > 0 ||
71
+ state.activity > 0 ||
72
+ producer.admitting !== undefined ||
73
+ producer.pending.size > 0 ||
74
+ producer.scheduling === true ||
75
+ runs.some(
76
+ (run) =>
77
+ ['queued', 'running'].includes(run.status) ||
78
+ (run.status === 'blocked' &&
79
+ (run.recoveryState !== 'verified' ||
80
+ run.blockers?.some((reason) => reason.code === 'RECONCILIATION_REQUIRED'))) ||
81
+ ['failed', 'unverified'].includes(run.recoveryState)
82
+ )
83
+ );
84
+ }
85
+
86
+ // Only the existing reconcile entrypoint can recover its own pre-artifact run.
87
+ // General configuration reservations retain the unchanged busy-run refusal.
88
+ export function reservePublicationFailureReconciliation(producer, commandId) {
89
+ const state = metadata(producer);
90
+ assert.ok(
91
+ state.loaded &&
92
+ state.activity === 1 &&
93
+ producer.admitting === undefined &&
94
+ producer.pending.size === 0 &&
95
+ producer.scheduling !== true,
96
+ 'DELIVERY_INSTALLATION_BUSY'
97
+ );
98
+ assert.ok(
99
+ !state.lease ||
100
+ (state.lease.kind === 'publicationFailure' &&
101
+ state.lease.status === 'held' &&
102
+ state.lease.commandId === commandId),
103
+ 'DELIVERY_EXCLUSIVE_OPERATION_HELD'
104
+ );
105
+ assert.ok(
106
+ !producer.state.runs.some(
107
+ (run) =>
108
+ run.commandId !== commandId &&
109
+ (['queued', 'running'].includes(run.status) ||
110
+ (run.status === 'blocked' &&
111
+ (run.recoveryState !== 'verified' ||
112
+ run.blockers?.some((reason) => reason.code === 'RECONCILIATION_REQUIRED'))) ||
113
+ ['failed', 'unverified'].includes(run.recoveryState))
114
+ ),
115
+ 'DELIVERY_INSTALLATION_BUSY'
116
+ );
117
+ assert.ok(
118
+ !Object.values(producer.state.commands).some(
119
+ (item) =>
120
+ item.command.commandId !== commandId &&
121
+ item.publicationFailureReconciliation?.state === 'pending'
122
+ ),
123
+ 'DELIVERY_INSTALLATION_BUSY'
124
+ );
125
+ const generation = Symbol();
126
+ state.lease = { kind: 'publicationFailure', commandId, generation, status: 'active' };
127
+ const assertOwned = () =>
128
+ assert.ok(
129
+ state.lease?.generation === generation && state.lease.status === 'active',
130
+ 'DELIVERY_EXCLUSIVE_OPERATION_NOT_OWNED'
131
+ );
132
+ return {
133
+ assertOwned,
134
+ release({ resolved }) {
135
+ assertOwned();
136
+ if (resolved === true) state.lease = null;
137
+ else state.lease.status = 'held';
138
+ },
139
+ };
45
140
  }
46
141
  export function reserveExclusiveOperation(producer, { operationId } = {}) {
47
- assert.ok(typeof operationId === 'string' && operationId.trim().length > 0 && operationId.length <= 200,
48
- 'DELIVERY_OPERATION_ID_REQUIRED');
142
+ assert.ok(
143
+ typeof operationId === 'string' && operationId.trim().length > 0 && operationId.length <= 200,
144
+ 'DELIVERY_OPERATION_ID_REQUIRED'
145
+ );
49
146
  const state = metadata(producer);
50
- assert.ok(!state.lease || (state.lease.status === 'held' && state.lease.operationId === operationId),
51
- 'DELIVERY_EXCLUSIVE_OPERATION_HELD');
147
+ assert.ok(
148
+ !state.lease || (state.lease.status === 'held' && state.lease.operationId === operationId),
149
+ 'DELIVERY_EXCLUSIVE_OPERATION_HELD'
150
+ );
52
151
  assert.ok(!installationBusy(producer, state), 'DELIVERY_INSTALLATION_BUSY');
53
152
  const generation = Symbol();
54
153
  state.lease = { operationId, generation, status: 'active' };
55
- const assertOwned = () => assert.ok(state.lease?.generation === generation &&
56
- state.lease.status === 'active', 'DELIVERY_EXCLUSIVE_OPERATION_NOT_OWNED');
154
+ const assertOwned = () =>
155
+ assert.ok(
156
+ state.lease?.generation === generation && state.lease.status === 'active',
157
+ 'DELIVERY_EXCLUSIVE_OPERATION_NOT_OWNED'
158
+ );
57
159
  return {
58
160
  assertOwned,
59
161
  release(options = {}) {
60
162
  assertOwned();
61
- assert.ok(options && typeof options === 'object' && !Array.isArray(options) &&
62
- (options.resolved === undefined || typeof options.resolved === 'boolean'),
63
- 'DELIVERY_OPERATION_RESOLUTION_INVALID');
163
+ assert.ok(
164
+ options &&
165
+ typeof options === 'object' &&
166
+ !Array.isArray(options) &&
167
+ (options.resolved === undefined || typeof options.resolved === 'boolean'),
168
+ 'DELIVERY_OPERATION_RESOLUTION_INVALID'
169
+ );
64
170
  if (options?.resolved === true) state.lease = null;
65
171
  else state.lease.status = 'held';
66
172
  },
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './pipeline.mjs';
2
2
  export * from './source-store.mjs';
3
3
  export * from './producer.mjs';
4
+ export { publicationFailureProofDigest } from './producer-publication-failure.mjs';
4
5
  export { createProtectedJsonStore } from './journal.mjs';
@@ -15,14 +15,10 @@ export class ProducerAdmission extends ProducerState {
15
15
  assert.equal(
16
16
  existing.fingerprint,
17
17
  fingerprint,
18
- 'Command ID already belongs to different input',
18
+ 'Command ID already belongs to different input'
19
19
  );
20
20
  if (preparedRunId !== undefined)
21
- assert.equal(
22
- existing.ack.runId,
23
- preparedRunId,
24
- 'Prepared run ID changed',
25
- );
21
+ assert.equal(existing.ack.runId, preparedRunId, 'Prepared run ID changed');
26
22
  return {
27
23
  ...existing.ack,
28
24
  state: existing.ack.state === 'rejected' ? 'rejected' : 'reconciled',
@@ -36,9 +32,7 @@ export class ProducerAdmission extends ProducerState {
36
32
  const current = await this.snapshot('0', {
37
33
  deadlineAt: input.deadlineAt,
38
34
  });
39
- const candidate = current.candidates.find(
40
- (c) => c.id === input.candidateId,
41
- );
35
+ const candidate = current.candidates.find((c) => c.id === input.candidateId);
42
36
  const publication = input.action === 'publish';
43
37
  let reason = null;
44
38
  if (
@@ -48,28 +42,21 @@ export class ProducerAdmission extends ProducerState {
48
42
  candidate.state !== 'ready' ||
49
43
  !sameIdentity(candidate.identity, input.expectedIdentity))
50
44
  )
51
- reason = blocker(
52
- 'CANDIDATE_CHANGED',
53
- 'Кандидат изменился или ещё не готов',
54
- );
45
+ reason = blocker('CANDIDATE_CHANGED', 'Кандидат изменился или ещё не готов');
55
46
  if (
56
47
  publication &&
57
48
  (!candidate ||
58
49
  candidate.state !== 'source_ready' ||
59
50
  !candidate.expectedSource ||
60
- canonical(candidate.expectedSource) !==
61
- canonical(input.expectedSource) ||
62
- !Object.values(candidate.identity ?? {}).every(
63
- (item) => item === null,
64
- ) ||
51
+ canonical(candidate.expectedSource) !== canonical(input.expectedSource) ||
52
+ !Object.values(candidate.identity ?? {}).every((item) => item === null) ||
65
53
  Object.keys(candidate.identity ?? {})
66
54
  .sort()
67
- .join(',') !==
68
- 'configDigest,imageDigest,schemaVersion,sourceCommit,version')
55
+ .join(',') !== 'configDigest,imageDigest,schemaVersion,sourceCommit,version')
69
56
  )
70
57
  reason = blocker(
71
58
  'SOURCE_CANDIDATE_CHANGED',
72
- 'Исходный кандидат изменился или ещё не готов',
59
+ 'Исходный кандидат изменился или ещё не готов'
73
60
  );
74
61
  if (
75
62
  input.action !== 'rollback' &&
@@ -79,23 +66,15 @@ export class ProducerAdmission extends ProducerState {
79
66
  (publication
80
67
  ? input.expectedSource.sourceCommit
81
68
  : input.expectedIdentity.sourceCommit) ||
82
- candidate.changes.baseCommit !==
83
- current.installed.identity.sourceCommit)
69
+ candidate.changes.baseCommit !== current.installed.identity.sourceCommit)
84
70
  )
85
71
  reason = blocker(
86
72
  'SOURCE_COMPARISON_UNVERIFIED',
87
- 'Точный состав изменений относительно установленной версии не подтверждён',
73
+ 'Точный состав изменений относительно установленной версии не подтверждён'
88
74
  );
89
75
  if (Date.parse(input.requestedAt) > this.now().getTime() + 30000)
90
- reason = blocker(
91
- 'INVALID_REQUEST_TIME',
92
- 'Время исходного запроса находится в будущем',
93
- );
94
- if (
95
- !reason &&
96
- input.action !== 'rollback' &&
97
- this.provider.validateAdmission
98
- ) {
76
+ reason = blocker('INVALID_REQUEST_TIME', 'Время исходного запроса находится в будущем');
77
+ if (!reason && input.action !== 'rollback' && this.provider.validateAdmission) {
99
78
  try {
100
79
  await this.provider.validateAdmission(input, candidate, {
101
80
  now: this.now(),
@@ -103,18 +82,21 @@ export class ProducerAdmission extends ProducerState {
103
82
  } catch {
104
83
  reason = blocker(
105
84
  'DELIVERY_EXECUTION_INVALID',
106
- 'Исходный срок доставки не подтверждён или изменён',
85
+ 'Исходный срок доставки не подтверждён или изменён'
107
86
  );
108
87
  }
109
88
  }
110
89
  if (input.lane === 'scheduled' && !this.provider.announce)
111
90
  reason = blocker(
112
91
  'UNSUPPORTED_SCHEDULE',
113
- 'Транспорт уведомления об окне установки не подключён',
92
+ 'Транспорт уведомления об окне установки не подключён'
114
93
  );
115
94
  if (Date.parse(input.deadlineAt) <= this.now().getTime())
116
95
  reason = blocker('DEADLINE_EXCEEDED', 'Исходный срок выполнения истёк');
117
96
  if (
97
+ Object.values(this.state.commands).some(
98
+ (item) => item.publicationFailureReconciliation?.state === 'pending'
99
+ ) ||
118
100
  this.state.runs.some(
119
101
  (r) =>
120
102
  ['queued', 'running'].includes(r.status) ||
@@ -126,44 +108,34 @@ export class ProducerAdmission extends ProducerState {
126
108
  input.action === 'rollback' &&
127
109
  r.id === input.previousRunId &&
128
110
  ['failed', 'blocked'].includes(r.status)
129
- )),
111
+ ))
130
112
  )
131
113
  )
132
114
  reason = blocker(
133
115
  'INSTALLATION_BUSY',
134
116
  'Установка уже занята или требует восстановления',
135
- true,
117
+ true
136
118
  );
137
119
  if (!this.provider.actions?.includes(input.action))
138
- reason = blocker(
139
- 'UNSUPPORTED_ACTION',
140
- 'Операция не подключена к исполнительному агенту',
141
- );
120
+ reason = blocker('UNSUPPORTED_ACTION', 'Операция не подключена к исполнительному агенту');
142
121
  if (!['deploy', 'publish'].includes(input.action)) {
143
- const previous = this.state.runs.find(
144
- (r) => r.id === input.previousRunId,
145
- );
146
- const previousItem =
147
- previous && this.state.commands[previous.commandId];
122
+ const previous = this.state.runs.find((r) => r.id === input.previousRunId);
123
+ const previousItem = previous && this.state.commands[previous.commandId];
148
124
  const previousCommand = previousItem?.command;
149
125
  if (
150
126
  !previous ||
151
127
  !previousCommand ||
152
128
  (input.action === 'retry'
153
129
  ? previousCommand.candidateId !== input.candidateId
154
- : !sameIdentity(
155
- previousItem.baseline?.identity,
156
- input.expectedIdentity,
157
- )) ||
130
+ : !sameIdentity(previousItem.baseline?.identity, input.expectedIdentity)) ||
158
131
  (input.action === 'retry' &&
159
132
  (previousCommand.deadlineAt !== input.deadlineAt ||
160
133
  previousCommand.requestedAt !== input.requestedAt)) ||
161
- (input.action === 'rollback' &&
162
- !['failed', 'blocked'].includes(previous.status))
134
+ (input.action === 'rollback' && !['failed', 'blocked'].includes(previous.status))
163
135
  )
164
136
  reason = blocker(
165
137
  'INVALID_CONTINUATION',
166
- 'Продолжение должно сохранить исходный кандидат и срок',
138
+ 'Продолжение должно сохранить исходный кандидат и срок'
167
139
  );
168
140
  }
169
141
  const runId = preparedRunId ?? input.commandId;
@@ -178,12 +150,8 @@ export class ProducerAdmission extends ProducerState {
178
150
  fingerprint,
179
151
  command: structuredClone(input),
180
152
  baseline: structuredClone(current.installed),
181
- ...(publication && candidate
182
- ? { sourceCandidate: structuredClone(candidate) }
183
- : {}),
184
- ...(candidate?.deliveryExecution
185
- ? { deliveryExecution: candidate.deliveryExecution }
186
- : {}),
153
+ ...(publication && candidate ? { sourceCandidate: structuredClone(candidate) } : {}),
154
+ ...(candidate?.deliveryExecution ? { deliveryExecution: candidate.deliveryExecution } : {}),
187
155
  ack,
188
156
  };
189
157
  if (!reason) {
@@ -220,7 +188,7 @@ export class ProducerAdmission extends ProducerState {
220
188
  null,
221
189
  'command',
222
190
  reason ? reason.message : 'Команда принята',
223
- reason ? 'warning' : 'info',
191
+ reason ? 'warning' : 'info'
224
192
  );
225
193
  await this.save();
226
194
  if (!reason) {
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import { hash, canonical } from './protocol-internals.mjs';
3
+ import { validateOriginalDelivery } from './producer-support.mjs';
4
+
5
+ export async function verifyPublicationDelivery(
6
+ producer,
7
+ command,
8
+ delivery,
9
+ { fresh = true } = {}
10
+ ) {
11
+ assert.equal(command.action, 'publish');
12
+ validateOriginalDelivery(delivery, command.expectedSource.sourceCommit);
13
+ assert.equal(delivery.requestedAt, command.requestedAt);
14
+ assert.equal(delivery.deadlineAt, command.deadlineAt);
15
+ const item = producer.state.commands[command.commandId];
16
+ assert.equal(item?.ack.state, 'accepted', 'Publication command was not admitted');
17
+ assert.equal(item.fingerprint, hash(canonical(command)));
18
+ if (item.deliveryExecution)
19
+ assert.deepEqual(item.deliveryExecution, delivery, 'Original published delivery changed');
20
+ if (typeof producer.provider.verifyPublicationDelivery === 'function')
21
+ assert.equal(
22
+ await producer.provider.verifyPublicationDelivery(command, delivery),
23
+ true,
24
+ 'Original delivery correlation was not verified'
25
+ );
26
+ else
27
+ assert.equal(
28
+ delivery.id,
29
+ command.commandId,
30
+ 'Independent delivery requires a trusted correlation verifier'
31
+ );
32
+ if (fresh) assert.ok(Date.parse(command.deadlineAt) > producer.now().getTime());
33
+ return delivery;
34
+ }
@@ -1,15 +1,25 @@
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 {
5
+ validatePublicationFailureRecord,
6
+ validatePublicationFailureReadback,
7
+ } from './producer-publication-failure.mjs';
4
8
 
5
9
  /** Validate persisted state before any provider command can run after restart. */
6
10
  export function validProducerState(state, installationKey, sourcePolicy) {
7
11
  try {
8
- keys(state, ['schema', 'installationKey', 'cursor', 'commands', 'runs', 'events'], ['diagnostics']);
12
+ keys(
13
+ state,
14
+ ['schema', 'installationKey', 'cursor', 'commands', 'runs', 'events'],
15
+ ['diagnostics']
16
+ );
9
17
  assert.equal(state.schema, 1);
10
18
  assert.equal(state.installationKey, installationKey);
11
19
  assert.match(state.cursor, /^\d+$/);
12
- assert.ok(state.commands && typeof state.commands === 'object' && !Array.isArray(state.commands));
20
+ assert.ok(
21
+ state.commands && typeof state.commands === 'object' && !Array.isArray(state.commands)
22
+ );
13
23
  for (const [id, item] of Object.entries(state.commands)) {
14
24
  assert.match(id, uuid);
15
25
  assert.equal(item.command.commandId, id);
@@ -19,16 +29,58 @@ export function validProducerState(state, installationKey, sourcePolicy) {
19
29
  assert.equal(item.ack.protocolVersion, 1);
20
30
  assert.ok(['accepted', 'rejected'].includes(item.ack.state));
21
31
  assert.match(item.ack.runId, uuid);
32
+ if (item.publicationFailureReconciliation !== undefined)
33
+ validatePublicationFailureRecord(
34
+ item.publicationFailureReconciliation,
35
+ item.command,
36
+ item.deliveryExecution,
37
+ item.baseline
38
+ );
22
39
  }
23
- validateProducerSnapshot({
24
- protocolVersion: 1, installationKey, observedAt: new Date().toISOString(),
25
- connection: 'unverified', installed: null, candidates: [],
26
- runs: state.runs.slice(-100), events: state.events, nextCursor: state.cursor,
27
- }, installationKey, sourcePolicy);
40
+ validateProducerSnapshot(
41
+ {
42
+ protocolVersion: 1,
43
+ installationKey,
44
+ observedAt: new Date().toISOString(),
45
+ connection: 'unverified',
46
+ installed: null,
47
+ candidates: [],
48
+ runs: state.runs.slice(-100),
49
+ events: state.events,
50
+ nextCursor: state.cursor,
51
+ },
52
+ installationKey,
53
+ sourcePolicy
54
+ );
28
55
  for (const run of state.runs) {
29
56
  assert.ok(state.commands[run.commandId]);
30
57
  assert.equal(state.commands[run.commandId].ack.runId, run.id);
58
+ const reconciliation = state.commands[run.commandId].publicationFailureReconciliation;
59
+ if (reconciliation) {
60
+ assert.equal(state.commands[run.commandId].command.action, 'publish');
61
+ assert.ok(!state.commands[run.commandId].artifactIdentity);
62
+ if (reconciliation.state === 'settled') {
63
+ assert.equal(run.status, 'failed');
64
+ assert.equal(run.recoveryState, 'verified');
65
+ assert.ok(run.completedAt);
66
+ validatePublicationFailureReadback(
67
+ reconciliation.baselineReadback,
68
+ state.commands[run.commandId].command,
69
+ state.commands[run.commandId].baseline,
70
+ new Date(run.completedAt)
71
+ );
72
+ } else assert.equal(run.status, 'blocked');
73
+ if (reconciliation.baselineReadback != null) {
74
+ assert.deepEqual(run.readback, reconciliation.baselineReadback);
75
+ if (reconciliation.state === 'pending') {
76
+ assert.equal(run.recoveryState, 'unverified');
77
+ assert.equal(run.completedAt, null);
78
+ }
79
+ }
80
+ }
31
81
  }
32
82
  return true;
33
- } catch { return false; }
83
+ } catch {
84
+ return false;
85
+ }
34
86
  }
@@ -0,0 +1,270 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ canonical,
4
+ hash,
5
+ keys,
6
+ timestamp,
7
+ completeIdentity,
8
+ validateReadback,
9
+ } from './protocol-internals.mjs';
10
+ import { acceptedProduction, sameIdentity } from './protocol.mjs';
11
+ import { validateOriginalDelivery, blocker } from './producer-support.mjs';
12
+ import { verifyPublicationDelivery } from './producer-delivery.mjs';
13
+ import { reservePublicationFailureReconciliation } from './exclusive-operation.mjs';
14
+
15
+ const stages = ['source_ready', 'ci', 'build', 'verify', 'publish'];
16
+ export function publicationFailureProofDigest(fact, baselineReadback) {
17
+ return `sha256:${hash(canonical({ fact, baselineReadback }))}`;
18
+ }
19
+
20
+ export function validatePublicationFailureFact(fact, command, delivery) {
21
+ keys(fact, [
22
+ 'schema',
23
+ 'kind',
24
+ 'commandId',
25
+ 'installationKey',
26
+ 'expectedSource',
27
+ 'delivery',
28
+ 'observedAt',
29
+ 'stage',
30
+ 'authenticated',
31
+ 'executionStopped',
32
+ 'pendingTransaction',
33
+ 'publicationResolved',
34
+ 'installationNotStarted',
35
+ ]);
36
+ assert.equal(fact.schema, 1);
37
+ assert.equal(fact.kind, 'failed_before_installation');
38
+ assert.equal(fact.commandId, command.commandId);
39
+ assert.equal(fact.installationKey, command.installationKey);
40
+ keys(fact.expectedSource, ['repository', 'ref', 'sourceCommit']);
41
+ assert.equal(canonical(fact.expectedSource), canonical(command.expectedSource));
42
+ validateOriginalDelivery(fact.delivery, command.expectedSource.sourceCommit);
43
+ assert.equal(fact.delivery.requestedAt, command.requestedAt);
44
+ assert.equal(fact.delivery.deadlineAt, command.deadlineAt);
45
+ if (delivery) assert.deepEqual(fact.delivery, delivery);
46
+ timestamp(fact.observedAt);
47
+ assert.ok(stages.includes(fact.stage));
48
+ for (const field of [
49
+ 'authenticated',
50
+ 'executionStopped',
51
+ 'publicationResolved',
52
+ 'installationNotStarted',
53
+ ])
54
+ assert.equal(fact[field], true);
55
+ assert.equal(fact.pendingTransaction, false);
56
+ return fact;
57
+ }
58
+
59
+ export function validatePublicationFailureReadback(readback, command, baseline, now) {
60
+ assert.ok(completeIdentity(baseline?.identity), 'Retained production baseline required');
61
+ validateReadback(baseline);
62
+ assert.equal(baseline.installationKey, command.installationKey);
63
+ validateReadback(readback);
64
+ assert.ok(sameIdentity(readback.identity, baseline.identity), 'Production baseline changed');
65
+ assert.equal(readback.installationKey, command.installationKey);
66
+ assert.equal(readback.source, 'independent-production');
67
+ assert.equal(readback.functionalSmoke.status, 'passed');
68
+ assert.equal(readback.functionalSmoke.sourceCommit, baseline.identity.sourceCommit);
69
+ const observations = [readback.observedAt, readback.functionalSmoke.checkedAt];
70
+ assert.ok(
71
+ observations.every((value) => Date.parse(value) >= Date.parse(command.requestedAt)),
72
+ 'Baseline observations must preserve the original request boundary'
73
+ );
74
+ if (now) {
75
+ assert.ok(
76
+ acceptedProduction(readback, { ...command, expectedIdentity: baseline.identity }, now),
77
+ 'Independent production baseline acceptance required'
78
+ );
79
+ assert.ok(
80
+ observations.every((value) => now.getTime() - Date.parse(value) <= 300000),
81
+ 'Fresh production baseline readback required'
82
+ );
83
+ }
84
+ return readback;
85
+ }
86
+
87
+ export function validatePublicationFailureRecord(record, command, delivery, baseline) {
88
+ keys(record, ['state', 'fact'], ['baselineReadback']);
89
+ assert.ok(['pending', 'settled'].includes(record.state));
90
+ assert.ok(record.state === 'pending' || record.fact !== null);
91
+ if (record.fact !== null) validatePublicationFailureFact(record.fact, command, delivery);
92
+ if (record.baselineReadback != null) {
93
+ assert.ok(record.fact, 'Baseline proof requires its publication failure fact');
94
+ validatePublicationFailureReadback(record.baselineReadback, command, baseline);
95
+ }
96
+ if (record.state === 'settled')
97
+ assert.ok(record.baselineReadback, 'Settled recovery requires its production baseline proof');
98
+ }
99
+
100
+ export async function reconcilePublicationFailure(producer, item, run) {
101
+ assert.equal(item.ack.state, 'accepted');
102
+ assert.equal(item.command.action, 'publish');
103
+ assert.equal(run.status, 'blocked');
104
+ assert.ok(!item.artifactIdentity);
105
+ for (const id of ['deploy', 'production_acceptance']) {
106
+ const row = run.stages.find((stage) => stage.id === id);
107
+ assert.ok(
108
+ row && row.startedAt === null && row.status === 'pending',
109
+ 'Installation must reconcile through its existing production boundary'
110
+ );
111
+ }
112
+ const lease = reservePublicationFailureReconciliation(producer, item.command.commandId);
113
+ const controller = new AbortController();
114
+ const recoveryDeadlineAt = producer.now().getTime() + 30000;
115
+ let timer;
116
+ let guard;
117
+ let settled = false;
118
+ let releasing = false;
119
+ const timeout = new Promise((_, reject) => {
120
+ timer = setTimeout(() => {
121
+ controller.abort();
122
+ reject(
123
+ Object.assign(Error('Publication failure reconciliation timed out'), {
124
+ code: 'PUBLICATION_FAILURE_RECONCILIATION_TIMEOUT',
125
+ })
126
+ );
127
+ }, 30000);
128
+ });
129
+ const bounded = (operation) => Promise.race([operation, timeout]);
130
+ const cleanup = (owned) =>
131
+ Promise.resolve()
132
+ .then(() => owned.release({ resolved: false, proofDigest: null }))
133
+ .catch(() => {});
134
+ try {
135
+ item.publicationFailureReconciliation ??= {
136
+ state: 'pending',
137
+ fact: null,
138
+ baselineReadback: null,
139
+ };
140
+ assert.equal(item.publicationFailureReconciliation.state, 'pending');
141
+ run.recoveryState = 'unverified';
142
+ run.completedAt = null;
143
+ await bounded(producer.save());
144
+ const arriving = Promise.resolve().then(() =>
145
+ producer.provider.reconcilePublicationFailure(structuredClone(item.command), {
146
+ signal: controller.signal,
147
+ delivery: structuredClone(item.deliveryExecution ?? null),
148
+ recoveryDeadlineAt,
149
+ })
150
+ );
151
+ // A late native guard still owns its cleanup; timeout never makes it usable.
152
+ arriving.then(
153
+ (late) => {
154
+ if (controller.signal.aborted && late && typeof late.release === 'function')
155
+ void cleanup(late);
156
+ },
157
+ () => {}
158
+ );
159
+ guard = await bounded(arriving);
160
+ if (guard === null) return;
161
+ assert.ok(
162
+ guard && typeof guard.assertOwned === 'function' && typeof guard.release === 'function',
163
+ 'Owned publication failure guard required'
164
+ );
165
+ const fact = structuredClone(
166
+ validatePublicationFailureFact(guard.fact, item.command, item.deliveryExecution)
167
+ );
168
+ const baselineReadback = structuredClone(
169
+ validatePublicationFailureReadback(
170
+ guard.baselineReadback,
171
+ item.command,
172
+ item.baseline,
173
+ producer.now()
174
+ )
175
+ );
176
+ const assertFresh = () => {
177
+ const now = producer.now();
178
+ assert.ok(
179
+ now.getTime() <= recoveryDeadlineAt,
180
+ 'Publication failure recovery deadline expired'
181
+ );
182
+ const age = now.getTime() - Date.parse(fact.observedAt);
183
+ assert.ok(age >= -30000 && age <= 30000, 'Fresh publication failure authority required');
184
+ validatePublicationFailureReadback(baselineReadback, item.command, item.baseline, now);
185
+ return now;
186
+ };
187
+ assertFresh();
188
+ const row = run.stages.find((stage) => stage.id === fact.stage);
189
+ assert.ok(
190
+ row && !['passed', 'reused'].includes(row.status),
191
+ 'Failed stage conflicts with retained proof'
192
+ );
193
+ const previous = item.publicationFailureReconciliation.fact;
194
+ if (previous) {
195
+ const { observedAt: _oldTime, ...oldIdentity } = previous;
196
+ const { observedAt: _newTime, ...newIdentity } = fact;
197
+ assert.deepEqual(newIdentity, oldIdentity, 'Publication failure reconciliation changed');
198
+ }
199
+ await bounded(
200
+ verifyPublicationDelivery(producer, item.command, fact.delivery, { fresh: false })
201
+ );
202
+ await bounded(guard.assertOwned());
203
+ lease.assertOwned();
204
+ controller.signal.throwIfAborted();
205
+ assertFresh();
206
+ item.publicationFailureReconciliation.fact = fact;
207
+ item.publicationFailureReconciliation.baselineReadback = baselineReadback;
208
+ item.deliveryExecution = structuredClone(fact.delivery);
209
+ run.readback = structuredClone(baselineReadback);
210
+ await bounded(producer.save());
211
+ const proofDigest = publicationFailureProofDigest(fact, baselineReadback);
212
+ await bounded(guard.assertOwned());
213
+ lease.assertOwned();
214
+ controller.signal.throwIfAborted();
215
+ assertFresh();
216
+ releasing = true;
217
+ await bounded(guard.release({ resolved: true, proofDigest }));
218
+ controller.signal.throwIfAborted();
219
+ lease.assertOwned();
220
+ const completedAt = assertFresh().toISOString();
221
+ run.status = 'failed';
222
+ run.recoveryState = 'verified';
223
+ run.completedAt = completedAt;
224
+ run.blockers = [
225
+ blocker(
226
+ 'FAILED_BEFORE_INSTALLATION',
227
+ 'Выпуск завершился ошибкой до установки; выполнение остановлено'
228
+ ),
229
+ ];
230
+ if (row.status !== 'failed') {
231
+ row.status = 'failed';
232
+ row.failure = structuredClone(run.blockers[0]);
233
+ row.completedAt = run.completedAt;
234
+ if (row.startedAt)
235
+ row.elapsedMs = Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt));
236
+ }
237
+ item.publicationFailureReconciliation.state = 'settled';
238
+ producer.event(run.id, null, 'failure', run.blockers[0].message, 'error');
239
+ await bounded(producer.save());
240
+ settled = true;
241
+ } catch (error) {
242
+ // Even a completed native ACK cannot authorize admission before local save.
243
+ item.publicationFailureReconciliation ??= { state: 'pending', fact: null };
244
+ item.publicationFailureReconciliation.state = 'pending';
245
+ run.status = 'blocked';
246
+ run.recoveryState = 'unverified';
247
+ run.completedAt = null;
248
+ run.blockers = [
249
+ blocker(
250
+ 'RECONCILIATION_REQUIRED',
251
+ 'Подтверждение остановки выпуска требует восстановления',
252
+ true
253
+ ),
254
+ ];
255
+ producer.diagnostic(run.id, null, error);
256
+ // Persist pending with the same protected store even when a failed queued
257
+ // save poisoned the promise chain; this is recovery, never a force-clear.
258
+ try {
259
+ producer.journal.save(structuredClone(producer.state));
260
+ } catch {
261
+ /* Remain HELD; restart requires journal reconciliation. */
262
+ }
263
+ throw error;
264
+ } finally {
265
+ controller.abort();
266
+ clearTimeout(timer);
267
+ if (guard && !settled && !releasing && typeof guard.release === 'function') void cleanup(guard);
268
+ lease.release({ resolved: settled });
269
+ }
270
+ }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
- import { hash, canonical, completeIdentity } from './protocol-internals.mjs';
3
- import { validateOriginalDelivery } from './producer-support.mjs';
2
+ import { completeIdentity } from './protocol-internals.mjs';
3
+ import { verifyPublicationDelivery } from './producer-delivery.mjs';
4
4
  import { ProducerAdmission } from './producer-admission.mjs';
5
5
 
6
6
  export class ProducerPublication extends ProducerAdmission {
@@ -9,7 +9,7 @@ export class ProducerPublication extends ProducerAdmission {
9
9
  const item = this.state.commands[command.commandId];
10
10
  assert.ok(
11
11
  completeIdentity(item?.artifactIdentity),
12
- 'Published artifact identity has not been verified',
12
+ 'Published artifact identity has not been verified'
13
13
  );
14
14
  return {
15
15
  ...command,
@@ -17,53 +17,30 @@ export class ProducerPublication extends ProducerAdmission {
17
17
  };
18
18
  }
19
19
  async bindPublicationDelivery(command, delivery) {
20
- assert.equal(command.action, 'publish');
21
- validateOriginalDelivery(delivery, command.expectedSource.sourceCommit);
22
- assert.equal(delivery.id, command.commandId);
23
- assert.equal(delivery.requestedAt, command.requestedAt);
24
- assert.equal(delivery.deadlineAt, command.deadlineAt);
25
- assert.ok(Date.parse(command.deadlineAt) > this.now().getTime());
20
+ await verifyPublicationDelivery(this, command, delivery);
26
21
  const item = this.state.commands[command.commandId];
27
- assert.equal(item.fingerprint, hash(canonical(command)));
28
- if (item.deliveryExecution)
29
- assert.deepEqual(
30
- item.deliveryExecution,
31
- delivery,
32
- 'Original published delivery changed',
33
- );
34
22
  item.deliveryExecution = structuredClone(delivery);
35
23
  await this.save();
36
24
  return delivery;
37
25
  }
38
26
  async bindPublicationArtifact(command, identity, { delivery } = {}) {
39
27
  assert.equal(command.action, 'publish');
40
- assert.ok(
41
- completeIdentity(identity),
42
- 'Published artifact identity must be complete',
43
- );
28
+ assert.ok(completeIdentity(identity), 'Published artifact identity must be complete');
44
29
  await this.bindPublicationDelivery(command, delivery);
45
30
  assert.equal(
46
31
  typeof this.provider.verifyPublicationArtifact,
47
32
  'function',
48
- 'Signed publication lineage verifier is required',
33
+ 'Signed publication lineage verifier is required'
49
34
  );
50
35
  assert.equal(
51
- await this.provider.verifyPublicationArtifact(
52
- command,
53
- identity,
54
- delivery,
55
- ),
36
+ await this.provider.verifyPublicationArtifact(command, identity, delivery),
56
37
  true,
57
- 'Signed publication lineage was not verified',
38
+ 'Signed publication lineage was not verified'
58
39
  );
59
40
  assert.ok(Date.parse(command.deadlineAt) > this.now().getTime());
60
41
  const item = this.state.commands[command.commandId];
61
42
  if (item.artifactIdentity)
62
- assert.deepEqual(
63
- item.artifactIdentity,
64
- identity,
65
- 'Bound artifact identity changed',
66
- );
43
+ assert.deepEqual(item.artifactIdentity, identity, 'Bound artifact identity changed');
67
44
  item.artifactIdentity = structuredClone(identity);
68
45
  await this.save();
69
46
  return identity;
@@ -72,54 +49,47 @@ export class ProducerPublication extends ProducerAdmission {
72
49
  const item = this.state.commands[commandId];
73
50
  assert.ok(
74
51
  item?.ack.state !== 'rejected' && item?.command.action === 'publish',
75
- 'Source publication was not admitted',
52
+ 'Source publication was not admitted'
76
53
  );
77
54
  if ([...this.pending].length) return this.acknowledgement(commandId);
78
55
  assert.ok(
79
56
  Date.parse(item.command.deadlineAt) > this.now().getTime(),
80
- 'Original publication deadline exhausted',
57
+ 'Original publication deadline exhausted'
81
58
  );
82
59
  assert.equal(
83
60
  typeof this.provider.validatePublicationResume,
84
61
  'function',
85
- 'Source publication recovery is not connected',
62
+ 'Source publication recovery is not connected'
86
63
  );
87
64
  assert.equal(
88
65
  await this.provider.validatePublicationResume(item.command, item),
89
66
  true,
90
- 'Source publication recovery is not verified',
91
- );
92
- const run = this.state.runs.find(
93
- (record) => record.commandId === commandId,
67
+ 'Source publication recovery is not verified'
94
68
  );
69
+ const run = this.state.runs.find((record) => record.commandId === commandId);
95
70
  assert.ok(run && !['deployed', 'rolled_back'].includes(run.status));
96
71
  if (run.recoveryState !== 'not_required') {
97
72
  assert.ok(
98
73
  this.provider.remotePublication === true &&
99
74
  typeof this.provider.qualifyPublicationResume === 'function',
100
- 'Pending installation must reconcile before publication resumes',
75
+ 'Pending installation must reconcile before publication resumes'
101
76
  );
102
77
  if (!item.artifactIdentity) {
103
- const prepared = await this.provider.publicationResumeArtifact?.(
104
- item.command,
105
- item,
106
- );
78
+ const prepared = await this.provider.publicationResumeArtifact?.(item.command, item);
107
79
  if (!prepared) return this.acknowledgement(commandId);
108
80
  await this.bindPublicationArtifact(item.command, prepared.identity, {
109
81
  delivery: prepared.delivery,
110
82
  });
111
83
  }
112
84
  if (
113
- (await this.provider.qualifyPublicationResume(
114
- this.resolvedCommand(item.command),
115
- item,
116
- )) !== true
85
+ (await this.provider.qualifyPublicationResume(this.resolvedCommand(item.command), item)) !==
86
+ true
117
87
  )
118
88
  return this.acknowledgement(commandId);
119
89
  }
120
90
  assert.ok(
121
91
  Date.parse(item.command.deadlineAt) > this.now().getTime(),
122
- 'Original publication deadline exhausted',
92
+ 'Original publication deadline exhausted'
123
93
  );
124
94
  run.status = 'queued';
125
95
  run.blockers = [];
@@ -58,6 +58,11 @@ export interface ReleaseProvider {
58
58
  identity: ArtifactIdentity,
59
59
  delivery: OriginalDelivery
60
60
  ): Promise<boolean>;
61
+ verifyPublicationDelivery?(command: RunnerCommand, delivery: OriginalDelivery): Promise<boolean>;
62
+ reconcilePublicationFailure?(
63
+ command: RunnerCommand,
64
+ context: ProviderContext & { delivery: OriginalDelivery | null; recoveryDeadlineAt: number }
65
+ ): Promise<PublicationFailureGuard | null>;
61
66
  validatePublicationResume?(
62
67
  command: RunnerCommand,
63
68
  item: Record<string, unknown>
@@ -76,6 +81,31 @@ export interface ReleaseProvider {
76
81
  ): Promise<Record<string, unknown>>;
77
82
  productionFailure?(fact: ProductionReadback): Error | undefined;
78
83
  }
84
+ export interface PublicationFailureFact {
85
+ schema: 1;
86
+ kind: 'failed_before_installation';
87
+ commandId: string;
88
+ installationKey: string;
89
+ expectedSource: { repository: string; ref: string; sourceCommit: string };
90
+ delivery: OriginalDelivery;
91
+ observedAt: string;
92
+ stage: 'source_ready' | 'ci' | 'build' | 'verify' | 'publish';
93
+ authenticated: true;
94
+ executionStopped: true;
95
+ pendingTransaction: false;
96
+ publicationResolved: true;
97
+ installationNotStarted: true;
98
+ }
99
+ export interface PublicationFailureGuard {
100
+ fact: PublicationFailureFact;
101
+ baselineReadback: ProductionReadback;
102
+ assertOwned(): Promise<void>;
103
+ release(options: { resolved: boolean; proofDigest: string | null }): Promise<void>;
104
+ }
105
+ export function publicationFailureProofDigest(
106
+ fact: PublicationFailureFact,
107
+ baselineReadback: ProductionReadback
108
+ ): string;
79
109
  export class ReleaseControlProducer {
80
110
  constructor(options: {
81
111
  installationKey: string;
package/dist/producer.mjs CHANGED
@@ -2,7 +2,15 @@ import assert from 'node:assert/strict';
2
2
  import { sameIdentity, acceptedProduction } from './protocol.mjs';
3
3
  import { blocker, failureMessage, failedProduction } from './producer-support.mjs';
4
4
  import { ProducerExecution } from './producer-execution.mjs';
5
- export { sameIdentity, validateExpectedSource, validateRunnerCommand, validateProducerSnapshot, acceptedProduction } from './protocol.mjs';
5
+ import { reconcilePublicationFailure } from './producer-publication-failure.mjs';
6
+ export { publicationFailureProofDigest } from './producer-publication-failure.mjs';
7
+ export {
8
+ sameIdentity,
9
+ validateExpectedSource,
10
+ validateRunnerCommand,
11
+ validateProducerSnapshot,
12
+ acceptedProduction,
13
+ } from './protocol.mjs';
6
14
  export { producerServer } from './producer-server.mjs';
7
15
 
8
16
  export class ReleaseControlProducer extends ProducerExecution {
@@ -10,6 +18,17 @@ export class ReleaseControlProducer extends ProducerExecution {
10
18
  const item = this.state.commands[commandId];
11
19
  assert.ok(item, 'Unknown command');
12
20
  const run = this.state.runs.find((r) => r.commandId === commandId);
21
+ if (
22
+ item.command.action === 'publish' &&
23
+ run?.status === 'blocked' &&
24
+ !item.artifactIdentity &&
25
+ typeof this.provider.reconcilePublicationFailure === 'function'
26
+ ) {
27
+ await reconcilePublicationFailure(this, item, run);
28
+ return this.acknowledgement(commandId);
29
+ }
30
+ if (item.publicationFailureReconciliation?.state === 'pending')
31
+ return this.acknowledgement(commandId);
13
32
  if (
14
33
  item.command.action === 'publish' &&
15
34
  run?.status === 'blocked' &&
@@ -41,13 +60,10 @@ export class ReleaseControlProducer extends ProducerExecution {
41
60
  await this.qualifyForwardRecovery(run, item.command);
42
61
  if (run.status === 'failed') {
43
62
  for (const row of run.stages.filter((s) =>
44
- ['running', 'unverified'].includes(s.status),
63
+ ['running', 'unverified'].includes(s.status)
45
64
  )) {
46
65
  row.status = 'failed';
47
- row.failure = blocker(
48
- 'INTERRUPTED_PROOF',
49
- 'Подтверждение результата было прервано',
50
- );
66
+ row.failure = blocker('INTERRUPTED_PROOF', 'Подтверждение результата было прервано');
51
67
  }
52
68
  }
53
69
  await this.save();
@@ -60,7 +76,7 @@ export class ReleaseControlProducer extends ProducerExecution {
60
76
  acceptedProduction(
61
77
  fact,
62
78
  { ...resolved, expectedIdentity: item.baseline.identity },
63
- this.now(),
79
+ this.now()
64
80
  ) &&
65
81
  !sameIdentity(item.baseline.identity, resolved.expectedIdentity)
66
82
  ) {
@@ -71,16 +87,11 @@ export class ReleaseControlProducer extends ProducerExecution {
71
87
  blocker(
72
88
  'RECOVERY_VERIFIED',
73
89
  'Предыдущая установка восстановлена; исходная выкатка не завершена',
74
- true,
90
+ true
75
91
  ),
76
92
  ];
77
93
  run.completedAt = this.now().toISOString();
78
- this.event(
79
- run.id,
80
- null,
81
- 'readback',
82
- 'Подтверждено восстановление предыдущей установки',
83
- );
94
+ this.event(run.id, null, 'readback', 'Подтверждено восстановление предыдущей установки');
84
95
  await this.save();
85
96
  }
86
97
  if (
@@ -90,35 +101,28 @@ export class ReleaseControlProducer extends ProducerExecution {
90
101
  .every((s) => ['passed', 'reused'].includes(s.status))
91
102
  ) {
92
103
  for (const row of run.stages.filter((s) =>
93
- ['deploy', 'production_acceptance'].includes(s.id),
104
+ ['deploy', 'production_acceptance'].includes(s.id)
94
105
  )) {
95
106
  row.status = 'passed';
96
107
  row.completedAt = this.now().toISOString();
97
108
  row.failure = null;
98
109
  if (row.startedAt)
99
- row.elapsedMs = Math.max(
100
- 0,
101
- Date.parse(row.completedAt) - Date.parse(row.startedAt),
102
- );
110
+ row.elapsedMs = Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt));
103
111
  }
104
112
  run.recoveryState = 'verified';
105
113
  run.readback = fact;
106
- run.status =
107
- item.command.action === 'rollback' ? 'rolled_back' : 'deployed';
114
+ run.status = item.command.action === 'rollback' ? 'rolled_back' : 'deployed';
108
115
  run.blockers = [];
109
116
  run.completedAt = this.now().toISOString();
110
117
  this.event(
111
118
  run.id,
112
119
  null,
113
120
  'readback',
114
- 'Фактический результат восстановлен после перезапуска',
121
+ 'Фактический результат восстановлен после перезапуска'
115
122
  );
116
123
  await this.save();
117
124
  }
118
- if (
119
- run.status === 'blocked' &&
120
- failedProduction(fact, resolved, this.now())
121
- ) {
125
+ if (run.status === 'blocked' && failedProduction(fact, resolved, this.now())) {
122
126
  run.readback = fact;
123
127
  const cause = this.provider.productionFailure?.(fact);
124
128
  if (cause) this.diagnostic(run.id, 'production_acceptance', cause);
@@ -130,7 +134,7 @@ export class ReleaseControlProducer extends ProducerExecution {
130
134
  cause?.code || 'FUNCTIONAL_ACCEPTANCE_FAILED',
131
135
  cause
132
136
  ? failureMessage(cause)
133
- : 'Функциональная приёмка установленной версии не пройдена',
137
+ : 'Функциональная приёмка установленной версии не пройдена'
134
138
  );
135
139
  }
136
140
  await this.save();
@@ -1,27 +1,29 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "package": "@casecore/release-agent",
4
- "version": "0.1.0",
5
- "sourceCommit": "fed6c2a396507ac6a488b78d38383593031dfe4a",
4
+ "version": "0.1.2",
5
+ "sourceCommit": "f51fa3e14522b88887645d3f83cc2dcfaf478d32",
6
6
  "sourceVerified": true,
7
7
  "sourceDirty": false,
8
8
  "buildKind": "release",
9
- "contentDigest": "sha256:1ee3136876107e544ba846d3997fadf9c85ea109950f55a322d3df80eb3c1ea1",
9
+ "contentDigest": "sha256:8b324e7f25b018f0b79a9deded8aada1b885646a807578a2697bd47b7c8fb5a0",
10
10
  "files": {
11
11
  "src/configuration-hook.mjs": "sha256:1171bf982cbf7cb0092f63b633ef0b0a0947d8ed1106f88c9175c8add33d81b1",
12
- "src/exclusive-operation.mjs": "sha256:05f09115e6292c014f8881d125148983f0097f8b5fa7dbcf365dd8355e747657",
12
+ "src/exclusive-operation.mjs": "sha256:5a1d0b7d3aa41b901132e239586d66d6c7aa2c16682a7db033a7aab91b1d781a",
13
13
  "src/identity.mjs": "sha256:253021058fc79232d83f3ecba6f1023fd046f27aabdf9c129a103d3c9ccb07f5",
14
- "src/index.mjs": "sha256:38d3ebcffe8716f7146c18061d26a392d7784b97a79c59ed53ff839175de2f94",
14
+ "src/index.mjs": "sha256:76bda5e1dc376bb7033d8fdbddb0afa85c7c68bbe028aea078ffceaaa35ec75e",
15
15
  "src/journal.mjs": "sha256:35a2620d56084ec1aa164e09d77b8e77096413d9572f7b601da8ef7149b996bd",
16
16
  "src/pipeline.mjs": "sha256:7a2d39d235ce7160441d177200389c5f3eae1cc494ec36082a23e8d64a0abe3f",
17
- "src/producer-admission.mjs": "sha256:61030d58f3a1e66bb487cef60ab17efec364ddfc8c0c61ba7396f64f0ee55de0",
17
+ "src/producer-admission.mjs": "sha256:975c7dac25d4c8f94423e5a4db2bd99c556afd6492b26fbda58e841f1df8fc3c",
18
+ "src/producer-delivery.mjs": "sha256:b20d9ae20dee90bbbe739245a84c9f4b91b1ab958b5e2995ff8705da24ba3988",
18
19
  "src/producer-execution.mjs": "sha256:6d9fbca493e4386c92c4d853a66ece3677ec7ab644d6da15b68dd744f30186ef",
19
- "src/producer-journal.mjs": "sha256:58a31d919300c697dfc3630c856512df5c9c9c47cad4b29c66e4da545a9a0255",
20
- "src/producer-publication.mjs": "sha256:dac520f02205ac6c2b3f66a059f66d8cb61e7b0324e45f9264cf69a96042749b",
20
+ "src/producer-journal.mjs": "sha256:0934057702fb611b9a8d9dde58cb2c2024f436a49a4e5f6ab02f8884a320cea7",
21
+ "src/producer-publication-failure.mjs": "sha256:726b2b85880143b70219473896b894f64b25b1c495f77488f1e73ce950b839f6",
22
+ "src/producer-publication.mjs": "sha256:c09fa07b37507093b71bbaff3af9df8ec2331c0cba24a4b936d247e9b35257f7",
21
23
  "src/producer-server.mjs": "sha256:def28e10a17eb80fb04ba69b09050731bb005949f949a7f1fb46dae267323434",
22
24
  "src/producer-state.mjs": "sha256:c3fcdace4c6579be092d39803cfb308e4bf88060b3f75bb7fc8b91284609b4ce",
23
25
  "src/producer-support.mjs": "sha256:0a10f6fa50cd8e729ba432640d0845004488ead2bcc1c9383f02548037f83d66",
24
- "src/producer.mjs": "sha256:24cc6db310bcacf301c28982859d732f07d8c5f468e4b5f172d4eb19f6ca14b9",
26
+ "src/producer.mjs": "sha256:6d0b875a9901a14edf5b93c771356f5352e924ff7977c13b027343c9bdca42f4",
25
27
  "src/protocol-internals.mjs": "sha256:d08a0c72079d091792c5327adafac5ffc38ea20138a6c44b88879e7157a890b3",
26
28
  "src/protocol.mjs": "sha256:1fe135a2e60922b517886a392c2b0c2250b773abda8b1a1fcd9af86df2be6dae",
27
29
  "src/snapshot-validator.mjs": "sha256:1279aa19e0932776bc1998935f70ca6fae65391f96b60e2d65bcab3b2cb46036",
@@ -31,7 +33,7 @@
31
33
  "types/index.d.ts": "sha256:92ef7b5600f05d52dcb646fc3e4d2b4944ead52ff1c9ee23bb9f4659bebf05a6",
32
34
  "types/journal.d.ts": "sha256:79beb61af50b00400588a87426a7f5b38bf38d4b8224284dc6e0631378aa244e",
33
35
  "types/pipeline.d.ts": "sha256:c9135e25fb852377c32a16ce781405393c5857886e3f77754b715747202c70a9",
34
- "types/producer.d.ts": "sha256:df99fb3617710eb7bbb7a038df22f089e029f2b1fbd1d82f5ad862dadc3b43da",
36
+ "types/producer.d.ts": "sha256:980aad137365f6c131b6bfabfab411a73c4a2b2fb35a167152ef8ed446ec1ade",
35
37
  "types/protocol.d.ts": "sha256:02c06986c81bfc4214169f62da8e0566704b71a5add58434dfca92c673fad4ce",
36
38
  "types/source-store.d.ts": "sha256:de044c2bca9826b6c20caefb00d22a4c78183a869b8835fb8f15935989e2372a",
37
39
  "types/types.d.ts": "sha256:cdc6664854bffc371a3da45f29bf1b31f14fd61b66e8f8bae8309dd836f6de6e"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casecore/release-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Общий движок поставки CaseCore с адаптерами продуктов",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {