@casecore/release-agent 0.1.1 → 0.1.3

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
@@ -61,19 +61,42 @@ UUID исходной попытки доставки и UUID команды Cas
61
61
  `reconcile(commandId)` вызывает подключённый
62
62
  `provider.reconcilePublicationFailure(command, { signal, delivery, recoveryDeadlineAt })`.
63
63
  Адаптер возвращает `null` при неопределённом результате либо удерживаемый guard:
64
- `{ fact, assertOwned(), release({ resolved, proofDigest }) }`.
64
+ `{ fact, baselineReadback, assertOwned(), release({ resolved, proofDigest }) }`.
65
65
  `fact` содержит ровно `schema: 1`, `kind: 'failed_before_installation'`,
66
66
  `commandId`, `installationKey`, `expectedSource`, исходный `delivery`, свежий
67
67
  `observedAt`, `stage: source_ready | ci | build | verify | publish`,
68
68
  `authenticated: true`, `executionStopped: true`, `pendingTransaction: false`,
69
69
  `publicationResolved: true`, `installationNotStarted: true`.
70
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
+
71
80
  Адаптер подтверждает фактическую остановку исполнителя и его дочерних/удалённых
72
81
  операций, исходную идентичность и отсутствие незавершённой установки через
73
82
  существующие журналы и механизмы владения продукта. Он удерживает исключение
74
83
  конкурирующих действий до сохранения pending-доказательства и подтверждения
75
84
  его durable-завершения в `release({ resolved: true, proofDigest })`.
76
- `proofDigest` имеет вид `sha256:<hex>` и вычисляется движком из `fact`.
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
+ владение и свежесть перед сохранением завершения.
77
100
  Ошибка/потеря ACK не разрешает следующий выпуск. Guard и его callback работают
78
101
  внутри доверенного адаптера; HTTP-запрос не может подставить этот результат.
79
102
 
@@ -82,6 +105,8 @@ UUID исходной попытки доставки и UUID команды Cas
82
105
  этапы. Артефакт и успешная установка не подставляются. Сохранённый pending
83
106
  запрещает команды, продолжение и резервирование после перезапуска; восстановление
84
107
  проходит через тот же `reconcile` и новую фактическую проверку владения.
108
+ Повторная проверка может обновить время наблюдения, но не исходный baseline.
109
+ Старое завершённое восстановление без readback не превращается в доказанный результат.
85
110
  Его отдельный предел — 30 секунд; срок публикации не продлевается.
86
111
  Обычный `reserveExclusiveOperation` становится доступен после завершения
87
112
  восстановления, когда остальные операции установки также разрешены.
@@ -106,6 +131,61 @@ Alvori — `release-control-source.json`. Для старого формата A
106
131
  переход этапа, время, событие и сохранение выполняются общей реализацией;
107
132
  адаптер передаёт фактическое начало и завершение своей операции.
108
133
 
134
+ Для команды публикации, которая точно не была принята исполнителем, SDK 0.1.3
135
+ предоставляет `store.reconcileNonAdmission(command, trustedHeldContext)`.
136
+ Ответ GET 404, истёкший срок или пустой журнал сами по себе не разрешают этот
137
+ переход. Контекст передаёт точную установку и политику источника, `scope`
138
+ (`tenantId`, `workspaceId`, `parentRunId`, `operationGeneration`,
139
+ `credentialRevision`, `streamId`, `baselineIdentity`), `recoveryDeadlineAt`
140
+ в миллисекундах UTC и доверенный `acquireGuard(command, context)`.
141
+ Срок восстановления ограничен 30 секундами и не меняет исходные T0 и deadline.
142
+
143
+ Адаптер удерживает общую блокировку приёма, worker, конфигурации и публикации;
144
+ подтверждает остановку всех исходных обработчиков и операций; исключает поздний
145
+ приём; проверяет отсутствие команды в том же защищённом журнале и получает
146
+ свежее независимое подтверждение прежней установки. Он также удерживает
147
+ каноническое владение CaseCore для той же команды, parent run, outbox и scope.
148
+ `acquireGuard` возвращает `null` при неопределённости либо
149
+ `{ fact, baselineReadback, assertOwned(), release({ resolved, proofDigest }) }`.
150
+ Точный формат `NonAdmissionFact` и контекста описан в декларациях SDK.
151
+ Readback использует прежние пять полей `ProductionReadback`, совпадает с
152
+ `scope.baselineIdentity` и подтверждает успешный smoke после исходного T0,
153
+ не старше 300 секунд. Служебные сведения о native владении передаются отдельно.
154
+
155
+ Движок сохраняет запись `commands[id]` с двумя полями: `command` и
156
+ `admissionRejection`. Последняя содержит `schema: 1`, `state: pending | rejected`,
157
+ точные `fact`, `baselineReadback`, `proofDigest`, стандартный rejected ACK
158
+ и `completedAt`. Запись не содержит accepted timestamp, этапов, очереди,
159
+ артефакта или выполненного запуска. `progress` исключает её из списка запусков.
160
+ Общая функция `nonAdmissionProofDigest(command, fact, baselineReadback)` доступна
161
+ из `@casecore/release-agent` и `@casecore/release-agent/source-store`;
162
+ адаптер использует её для того же native/central ACK, без копии алгоритма хеша.
163
+
164
+ Сначала сохраняется защищённый pending-запрет позднего приёма, затем проверяются
165
+ владение и свежесть и вызывается `release({ resolved: true, proofDigest })`.
166
+ Он подтверждает фактический native ACK и завершение центральной транзакции
167
+ владения; отрицательный ACK центральной команды применяется отдельно штатным
168
+ путём после окончательного сохранения SDK. Внешняя native блокировка остаётся
169
+ удержанной до возврата метода. После ACK движок повторно проверяет срок и
170
+ свежесть, сохраняет `rejected` и возвращает стандартное подтверждение отказа.
171
+ Повторные `accept` и `reconcile` возвращают тот же отказ для точной команды;
172
+ коллизия UUID с другими входами запрещена.
173
+
174
+ Потеря ACK, ошибка сохранения или утрата владения оставляют pending и исходную
175
+ неопределённость. `reconcile` для pending возвращает `null`; повторное восстановление
176
+ требует нового фактически удерживаемого guard с прежней идентичностью команды,
177
+ parent, установки, credential revision, stream и baseline. Generation новой
178
+ временной native/central блокировки может отличаться; это не продлевает старую
179
+ блокировку или исходные часы. Прежние точные доказательства сохраняются в
180
+ частном `admissionRejection.priorProofs` (не более 100), новый digest связывает
181
+ текущее доказательство. Уже завершённый отказ и его ACK не переписываются.
182
+ Pending запрещает новый приём; `save` через другие API того же процесса также
183
+ запрещён во время восстановления. Адаптер учитывает этот дискриминатор при
184
+ выборе worker-задач и при изменении конфигурации. Native fence и проверка
185
+ неопределённого состояния после перезапуска остаются обязательны, в том числе
186
+ когда запись pending физически не удалось сохранить. HTTP не принимает
187
+ доверенный callback или эти доказательства из пользовательского JSON.
188
+
109
189
  ## Сборка и выпуск
110
190
 
111
191
  ```sh
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';
@@ -1,7 +1,10 @@
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 { validatePublicationFailureRecord } from './producer-publication-failure.mjs';
4
+ import {
5
+ validatePublicationFailureRecord,
6
+ validatePublicationFailureReadback,
7
+ } from './producer-publication-failure.mjs';
5
8
 
6
9
  /** Validate persisted state before any provider command can run after restart. */
7
10
  export function validProducerState(state, installationKey, sourcePolicy) {
@@ -30,7 +33,8 @@ export function validProducerState(state, installationKey, sourcePolicy) {
30
33
  validatePublicationFailureRecord(
31
34
  item.publicationFailureReconciliation,
32
35
  item.command,
33
- item.deliveryExecution
36
+ item.deliveryExecution,
37
+ item.baseline
34
38
  );
35
39
  }
36
40
  validateProducerSnapshot(
@@ -58,7 +62,21 @@ export function validProducerState(state, installationKey, sourcePolicy) {
58
62
  if (reconciliation.state === 'settled') {
59
63
  assert.equal(run.status, 'failed');
60
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
+ );
61
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
+ }
62
80
  }
63
81
  }
64
82
  return true;
@@ -1,10 +1,22 @@
1
1
  import assert from 'node:assert/strict';
2
- import { canonical, hash, keys, timestamp } from './protocol-internals.mjs';
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';
3
11
  import { validateOriginalDelivery, blocker } from './producer-support.mjs';
4
12
  import { verifyPublicationDelivery } from './producer-delivery.mjs';
5
13
  import { reservePublicationFailureReconciliation } from './exclusive-operation.mjs';
6
14
 
7
15
  const stages = ['source_ready', 'ci', 'build', 'verify', 'publish'];
16
+ export function publicationFailureProofDigest(fact, baselineReadback) {
17
+ return `sha256:${hash(canonical({ fact, baselineReadback }))}`;
18
+ }
19
+
8
20
  export function validatePublicationFailureFact(fact, command, delivery) {
9
21
  keys(fact, [
10
22
  'schema',
@@ -44,11 +56,45 @@ export function validatePublicationFailureFact(fact, command, delivery) {
44
56
  return fact;
45
57
  }
46
58
 
47
- export function validatePublicationFailureRecord(record, command, delivery) {
48
- keys(record, ['state', 'fact']);
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']);
49
89
  assert.ok(['pending', 'settled'].includes(record.state));
50
90
  assert.ok(record.state === 'pending' || record.fact !== null);
51
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');
52
98
  }
53
99
 
54
100
  export async function reconcilePublicationFailure(producer, item, run) {
@@ -86,8 +132,14 @@ export async function reconcilePublicationFailure(producer, item, run) {
86
132
  .then(() => owned.release({ resolved: false, proofDigest: null }))
87
133
  .catch(() => {});
88
134
  try {
89
- item.publicationFailureReconciliation ??= { state: 'pending', fact: null };
135
+ item.publicationFailureReconciliation ??= {
136
+ state: 'pending',
137
+ fact: null,
138
+ baselineReadback: null,
139
+ };
90
140
  assert.equal(item.publicationFailureReconciliation.state, 'pending');
141
+ run.recoveryState = 'unverified';
142
+ run.completedAt = null;
91
143
  await bounded(producer.save());
92
144
  const arriving = Promise.resolve().then(() =>
93
145
  producer.provider.reconcilePublicationFailure(structuredClone(item.command), {
@@ -113,8 +165,26 @@ export async function reconcilePublicationFailure(producer, item, run) {
113
165
  const fact = structuredClone(
114
166
  validatePublicationFailureFact(guard.fact, item.command, item.deliveryExecution)
115
167
  );
116
- const age = producer.now().getTime() - Date.parse(fact.observedAt);
117
- assert.ok(age >= -30000 && age <= 30000, 'Fresh publication failure authority required');
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();
118
188
  const row = run.stages.find((stage) => stage.id === fact.stage);
119
189
  assert.ok(
120
190
  row && !['passed', 'reused'].includes(row.status),
@@ -132,18 +202,25 @@ export async function reconcilePublicationFailure(producer, item, run) {
132
202
  await bounded(guard.assertOwned());
133
203
  lease.assertOwned();
134
204
  controller.signal.throwIfAborted();
205
+ assertFresh();
135
206
  item.publicationFailureReconciliation.fact = fact;
207
+ item.publicationFailureReconciliation.baselineReadback = baselineReadback;
136
208
  item.deliveryExecution = structuredClone(fact.delivery);
209
+ run.readback = structuredClone(baselineReadback);
137
210
  await bounded(producer.save());
138
- const proofDigest = `sha256:${hash(canonical(fact))}`;
211
+ const proofDigest = publicationFailureProofDigest(fact, baselineReadback);
139
212
  await bounded(guard.assertOwned());
213
+ lease.assertOwned();
214
+ controller.signal.throwIfAborted();
215
+ assertFresh();
140
216
  releasing = true;
141
217
  await bounded(guard.release({ resolved: true, proofDigest }));
142
218
  controller.signal.throwIfAborted();
143
219
  lease.assertOwned();
220
+ const completedAt = assertFresh().toISOString();
144
221
  run.status = 'failed';
145
222
  run.recoveryState = 'verified';
146
- run.completedAt = producer.now().toISOString();
223
+ run.completedAt = completedAt;
147
224
  run.blockers = [
148
225
  blocker(
149
226
  'FAILED_BEFORE_INSTALLATION',
@@ -166,6 +243,8 @@ export async function reconcilePublicationFailure(producer, item, run) {
166
243
  item.publicationFailureReconciliation ??= { state: 'pending', fact: null };
167
244
  item.publicationFailureReconciliation.state = 'pending';
168
245
  run.status = 'blocked';
246
+ run.recoveryState = 'unverified';
247
+ run.completedAt = null;
169
248
  run.blockers = [
170
249
  blocker(
171
250
  'RECONCILIATION_REQUIRED',
@@ -98,9 +98,14 @@ export interface PublicationFailureFact {
98
98
  }
99
99
  export interface PublicationFailureGuard {
100
100
  fact: PublicationFailureFact;
101
+ baselineReadback: ProductionReadback;
101
102
  assertOwned(): Promise<void>;
102
103
  release(options: { resolved: boolean; proofDigest: string | null }): Promise<void>;
103
104
  }
105
+ export function publicationFailureProofDigest(
106
+ fact: PublicationFailureFact,
107
+ baselineReadback: ProductionReadback
108
+ ): string;
104
109
  export class ReleaseControlProducer {
105
110
  constructor(options: {
106
111
  installationKey: string;
package/dist/producer.mjs CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { reconcilePublicationFailure } from './producer-publication-failure.mjs';
6
+ export { publicationFailureProofDigest } from './producer-publication-failure.mjs';
6
7
  export {
7
8
  sameIdentity,
8
9
  validateExpectedSource,
@@ -1,41 +1,42 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "package": "@casecore/release-agent",
4
- "version": "0.1.1",
5
- "sourceCommit": "afb036322db3a5ff3bbe4d3d2d975837ebf469b1",
4
+ "version": "0.1.3",
5
+ "sourceCommit": "a9736762537078698065c74bbb0febb675b980c7",
6
6
  "sourceVerified": true,
7
7
  "sourceDirty": false,
8
8
  "buildKind": "release",
9
- "contentDigest": "sha256:c5f5749d35f85bf2ae7878983ecfd0ef77ee065e1526bf29d54680ddf1b9cc70",
9
+ "contentDigest": "sha256:21d9013bdd65fefe213eee463eaa9dc13bb2ee1647f392e2c6c90ca5953d2886",
10
10
  "files": {
11
11
  "src/configuration-hook.mjs": "sha256:1171bf982cbf7cb0092f63b633ef0b0a0947d8ed1106f88c9175c8add33d81b1",
12
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
17
  "src/producer-admission.mjs": "sha256:975c7dac25d4c8f94423e5a4db2bd99c556afd6492b26fbda58e841f1df8fc3c",
18
18
  "src/producer-delivery.mjs": "sha256:b20d9ae20dee90bbbe739245a84c9f4b91b1ab958b5e2995ff8705da24ba3988",
19
19
  "src/producer-execution.mjs": "sha256:6d9fbca493e4386c92c4d853a66ece3677ec7ab644d6da15b68dd744f30186ef",
20
- "src/producer-journal.mjs": "sha256:4799a2224ff1004d68892b807b222da708f8f51e0554b01f54d5c877a8ef84bd",
21
- "src/producer-publication-failure.mjs": "sha256:513e71276c7ce4088c95cb3605917afc4709cbb7222ac746958923d7f18eac70",
20
+ "src/producer-journal.mjs": "sha256:0934057702fb611b9a8d9dde58cb2c2024f436a49a4e5f6ab02f8884a320cea7",
21
+ "src/producer-publication-failure.mjs": "sha256:726b2b85880143b70219473896b894f64b25b1c495f77488f1e73ce950b839f6",
22
22
  "src/producer-publication.mjs": "sha256:c09fa07b37507093b71bbaff3af9df8ec2331c0cba24a4b936d247e9b35257f7",
23
23
  "src/producer-server.mjs": "sha256:def28e10a17eb80fb04ba69b09050731bb005949f949a7f1fb46dae267323434",
24
24
  "src/producer-state.mjs": "sha256:c3fcdace4c6579be092d39803cfb308e4bf88060b3f75bb7fc8b91284609b4ce",
25
25
  "src/producer-support.mjs": "sha256:0a10f6fa50cd8e729ba432640d0845004488ead2bcc1c9383f02548037f83d66",
26
- "src/producer.mjs": "sha256:7dd4e6118531c25c6d5547d769263858e556731c76af87fba4c21c8ed98b3994",
26
+ "src/producer.mjs": "sha256:6d0b875a9901a14edf5b93c771356f5352e924ff7977c13b027343c9bdca42f4",
27
27
  "src/protocol-internals.mjs": "sha256:d08a0c72079d091792c5327adafac5ffc38ea20138a6c44b88879e7157a890b3",
28
28
  "src/protocol.mjs": "sha256:1fe135a2e60922b517886a392c2b0c2250b773abda8b1a1fcd9af86df2be6dae",
29
29
  "src/snapshot-validator.mjs": "sha256:1279aa19e0932776bc1998935f70ca6fae65391f96b60e2d65bcab3b2cb46036",
30
- "src/source-admission.mjs": "sha256:da083d984b1c0eb018ec464057ce320108a79e1aed3231ab22e8110c32a48363",
31
- "src/source-store.mjs": "sha256:5473094f0b75cf73673762530ea1b8db286106c58942bc5670395b0c9fe19a77",
30
+ "src/source-admission.mjs": "sha256:25e0e3cc163895c3f6b5057162538c4c313bd37e67956c63b2624df52517b437",
31
+ "src/source-non-admission.mjs": "sha256:a2308518c8337b662946c5778fe30a5a35a6e7f0b8ed6130eeca93d8ea959ded",
32
+ "src/source-store.mjs": "sha256:b58a833518086a3d64fe163c323c90b6d6f4cd0793982891146c0a5f9d7241b9",
32
33
  "src/stage-journal.mjs": "sha256:96321418e4f8b48aba3dd0a4269a8f345d6763acb81f3b1e330ba3baf06a213e",
33
34
  "types/index.d.ts": "sha256:92ef7b5600f05d52dcb646fc3e4d2b4944ead52ff1c9ee23bb9f4659bebf05a6",
34
35
  "types/journal.d.ts": "sha256:79beb61af50b00400588a87426a7f5b38bf38d4b8224284dc6e0631378aa244e",
35
36
  "types/pipeline.d.ts": "sha256:c9135e25fb852377c32a16ce781405393c5857886e3f77754b715747202c70a9",
36
- "types/producer.d.ts": "sha256:16ec1e81964a982c9653cdfe70eb3aa22ea8dd7200c2949a8e6731686df7a7bb",
37
+ "types/producer.d.ts": "sha256:980aad137365f6c131b6bfabfab411a73c4a2b2fb35a167152ef8ed446ec1ade",
37
38
  "types/protocol.d.ts": "sha256:02c06986c81bfc4214169f62da8e0566704b71a5add58434dfca92c673fad4ce",
38
- "types/source-store.d.ts": "sha256:de044c2bca9826b6c20caefb00d22a4c78183a869b8835fb8f15935989e2372a",
39
+ "types/source-store.d.ts": "sha256:ce2086dd682e8f57bc5e1581b411ec9fe1cf1cfba8dccafe38c22d102c2b9904",
39
40
  "types/types.d.ts": "sha256:cdc6664854bffc371a3da45f29bf1b31f14fd61b66e8f8bae8309dd836f6de6e"
40
41
  }
41
42
  }
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { createHash } from 'node:crypto';
3
+ import { admissionRejectionAck } from './source-non-admission.mjs';
3
4
 
4
5
  const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
5
6
  const identity = (value) =>
@@ -138,6 +139,7 @@ export function admitArtifactCommand(command, state, options, helpers) {
138
139
  const reject = (code, message) =>
139
140
  ack(command.commandId, 'rejected', sourceBlocker(code, message));
140
141
  const duplicate = state.commands[command.commandId];
142
+ if (duplicate?.admissionRejection) return admissionRejectionAck(duplicate, command);
141
143
  if (duplicate)
142
144
  return isDeepStrictEqual(duplicate.command, command)
143
145
  ? ack(command.commandId, 'reconciled')
@@ -185,10 +187,11 @@ export function admitArtifactCommand(command, state, options, helpers) {
185
187
  if (
186
188
  Object.values(state.commands).some(
187
189
  (record) =>
188
- record !== previous &&
189
- (['queued', 'running', 'unverified'].includes(record.status) ||
190
- (['failed', 'blocked'].includes(record.status) &&
191
- !['verified', 'not_required'].includes(record.recoveryState)))
190
+ record.admissionRejection?.state === 'pending' ||
191
+ (record !== previous &&
192
+ (['queued', 'running', 'unverified'].includes(record.status) ||
193
+ (['failed', 'blocked'].includes(record.status) &&
194
+ !['verified', 'not_required'].includes(record.recoveryState))))
192
195
  )
193
196
  )
194
197
  return reject('release_in_progress', 'Another canonical execution is not yet reconciled.');
@@ -317,6 +320,7 @@ export function sourceProgress(state, cursor, now, { sourceCandidate, sourceTime
317
320
  candidates.set(candidate.id, candidate);
318
321
  };
319
322
  for (const record of Object.values(state.commands)) {
323
+ if (record.admissionRejection) continue;
320
324
  const sourceCommand = record.sourceCommand ?? record.command;
321
325
  const candidate = sourceCandidate(
322
326
  sourceCommand.expectedSource,
@@ -0,0 +1,430 @@
1
+ import assert from 'node:assert/strict';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import {
4
+ canonical,
5
+ hash,
6
+ keys,
7
+ completeIdentity,
8
+ validateReadback,
9
+ timestamp,
10
+ } from './protocol-internals.mjs';
11
+ import { acceptedProduction, sameIdentity } from './protocol.mjs';
12
+ import { validPublishCommand } from './source-store.mjs';
13
+
14
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
15
+ const scopeFields = [
16
+ 'tenantId',
17
+ 'workspaceId',
18
+ 'parentRunId',
19
+ 'operationGeneration',
20
+ 'credentialRevision',
21
+ 'streamId',
22
+ 'baselineIdentity',
23
+ ];
24
+ const operations = new Map();
25
+ const reason = {
26
+ code: 'command_not_admitted',
27
+ message: 'Команда не была принята исполнителем; отсутствие выполнения подтверждено',
28
+ retryable: false,
29
+ };
30
+ const acknowledgement = (command) => ({
31
+ protocolVersion: 1,
32
+ commandId: command.commandId,
33
+ runId: command.commandId,
34
+ state: 'rejected',
35
+ reason: structuredClone(reason),
36
+ });
37
+
38
+ export function nonAdmissionProofDigest(command, fact, baselineReadback) {
39
+ return `sha256:${hash(canonical({ command, fact, baselineReadback }))}`;
40
+ }
41
+ function validateScope(scope) {
42
+ keys(scope, scopeFields);
43
+ for (const key of ['tenantId', 'workspaceId', 'parentRunId', 'operationGeneration'])
44
+ assert.match(scope[key], uuid);
45
+ assert.ok(Number.isSafeInteger(scope.credentialRevision) && scope.credentialRevision > 0);
46
+ assert.ok(
47
+ typeof scope.streamId === 'string' && scope.streamId.length > 0 && scope.streamId.length <= 200
48
+ );
49
+ assert.ok(
50
+ completeIdentity(scope.baselineIdentity),
51
+ 'Complete retained baseline identity required'
52
+ );
53
+ }
54
+ function validateFact(fact, command, scope) {
55
+ keys(fact, [
56
+ 'schema',
57
+ 'kind',
58
+ 'commandId',
59
+ 'installationKey',
60
+ 'expectedSource',
61
+ 'executionProvider',
62
+ 'requestedAt',
63
+ 'deadlineAt',
64
+ ...scopeFields,
65
+ 'observedAt',
66
+ 'authenticated',
67
+ 'admissionStopped',
68
+ 'journalAbsenceVerified',
69
+ 'executionNotStarted',
70
+ 'installationNotStarted',
71
+ 'pendingTransaction',
72
+ 'baselineUnchanged',
73
+ ]);
74
+ assert.equal(fact.schema, 1);
75
+ assert.equal(fact.kind, 'command_not_admitted');
76
+ for (const key of [
77
+ 'commandId',
78
+ 'installationKey',
79
+ 'executionProvider',
80
+ 'requestedAt',
81
+ 'deadlineAt',
82
+ ])
83
+ assert.equal(fact[key], command[key]);
84
+ assert.deepEqual(fact.expectedSource, command.expectedSource);
85
+ const actualScope = Object.fromEntries(scopeFields.map((key) => [key, fact[key]]));
86
+ validateScope(actualScope);
87
+ if (scope) assert.deepEqual(actualScope, scope, 'Non-admission ownership scope changed');
88
+ timestamp(fact.observedAt);
89
+ for (const key of [
90
+ 'authenticated',
91
+ 'admissionStopped',
92
+ 'journalAbsenceVerified',
93
+ 'executionNotStarted',
94
+ 'installationNotStarted',
95
+ 'baselineUnchanged',
96
+ ])
97
+ assert.equal(fact[key], true);
98
+ assert.equal(fact.pendingTransaction, false);
99
+ }
100
+ function validateBaseline(readback, command, fact, now) {
101
+ validateReadback(readback);
102
+ assert.equal(readback.installationKey, command.installationKey);
103
+ assert.ok(
104
+ sameIdentity(readback.identity, fact.baselineIdentity),
105
+ 'Non-admission baseline changed'
106
+ );
107
+ assert.equal(readback.source, 'independent-production');
108
+ assert.equal(readback.functionalSmoke.status, 'passed');
109
+ assert.equal(readback.functionalSmoke.sourceCommit, fact.baselineIdentity.sourceCommit);
110
+ const observations = [readback.observedAt, readback.functionalSmoke.checkedAt];
111
+ assert.ok(
112
+ observations.every((value) => Date.parse(value) >= Date.parse(command.requestedAt)),
113
+ 'Baseline proof precedes the original request'
114
+ );
115
+ if (now !== undefined) {
116
+ assert.ok(
117
+ acceptedProduction(
118
+ readback,
119
+ { ...command, expectedIdentity: fact.baselineIdentity },
120
+ new Date(now)
121
+ ),
122
+ 'Independent baseline acceptance required'
123
+ );
124
+ assert.ok(
125
+ observations.every((value) => now - Date.parse(value) <= 300000),
126
+ 'Fresh independent baseline required'
127
+ );
128
+ const age = now - Date.parse(fact.observedAt);
129
+ assert.ok(age >= -30000 && age <= 30000, 'Fresh non-admission authority required');
130
+ }
131
+ }
132
+ function immutableFact(fact) {
133
+ const { observedAt: _time, operationGeneration: _generation, ...identity } = fact;
134
+ return identity;
135
+ }
136
+ export function validNonAdmissionRecord(record) {
137
+ try {
138
+ keys(record, ['command', 'admissionRejection']);
139
+ const command = record.command;
140
+ assert.ok(
141
+ validPublishCommand(
142
+ command,
143
+ command.installationKey,
144
+ command.expectedSource?.repository,
145
+ command.expectedSource?.ref
146
+ )
147
+ );
148
+ const proof = record.admissionRejection;
149
+ keys(
150
+ proof,
151
+ [
152
+ 'schema',
153
+ 'state',
154
+ 'fact',
155
+ 'baselineReadback',
156
+ 'proofDigest',
157
+ 'acknowledgement',
158
+ 'completedAt',
159
+ ],
160
+ ['priorProofs']
161
+ );
162
+ assert.equal(proof.schema, 1);
163
+ assert.ok(['pending', 'rejected'].includes(proof.state));
164
+ validateFact(proof.fact, command);
165
+ validateBaseline(proof.baselineReadback, command, proof.fact);
166
+ assert.equal(
167
+ proof.proofDigest,
168
+ nonAdmissionProofDigest(command, proof.fact, proof.baselineReadback)
169
+ );
170
+ assert.deepEqual(proof.acknowledgement, acknowledgement(command));
171
+ if (proof.priorProofs !== undefined) {
172
+ assert.ok(Array.isArray(proof.priorProofs) && proof.priorProofs.length <= 100);
173
+ const digests = new Set([proof.proofDigest]);
174
+ for (const prior of proof.priorProofs) {
175
+ keys(prior, ['fact', 'baselineReadback', 'proofDigest']);
176
+ validateFact(prior.fact, command);
177
+ validateBaseline(prior.baselineReadback, command, prior.fact);
178
+ assert.deepEqual(
179
+ immutableFact(prior.fact),
180
+ immutableFact(proof.fact),
181
+ 'Retained non-admission scope changed'
182
+ );
183
+ assert.equal(
184
+ prior.proofDigest,
185
+ nonAdmissionProofDigest(command, prior.fact, prior.baselineReadback)
186
+ );
187
+ assert.ok(!digests.has(prior.proofDigest), 'Duplicate non-admission attempt proof');
188
+ digests.add(prior.proofDigest);
189
+ }
190
+ }
191
+ if (proof.state === 'pending') assert.equal(proof.completedAt, null);
192
+ else {
193
+ timestamp(proof.completedAt);
194
+ validateBaseline(proof.baselineReadback, command, proof.fact, Date.parse(proof.completedAt));
195
+ }
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+ export function nonAdmissionBusy(path) {
202
+ return operations.has(path);
203
+ }
204
+ export function admissionRejectionAck(record, command) {
205
+ assert.ok(validNonAdmissionRecord(record), 'Invalid non-admission receipt');
206
+ if (command)
207
+ assert.deepEqual(record.command, command, 'Command ID is bound to different immutable inputs');
208
+ assert.equal(record.admissionRejection.state, 'rejected', 'NON_ADMISSION_RECONCILIATION_PENDING');
209
+ return structuredClone(record.admissionRejection.acknowledgement);
210
+ }
211
+
212
+ /** The caller supplies actual native and central exclusion; journal absence alone has no authority. */
213
+ export async function reconcileNonAdmission(journal, command, context) {
214
+ command = structuredClone(command);
215
+ assert.ok(
216
+ validPublishCommand(
217
+ command,
218
+ context.installationKey,
219
+ context.repository,
220
+ context.ref ?? 'main'
221
+ ),
222
+ 'Invalid scoped source command'
223
+ );
224
+ validateScope(context.scope);
225
+ assert.equal(
226
+ typeof context.acquireGuard,
227
+ 'function',
228
+ 'Trusted non-admission guard callback required'
229
+ );
230
+ const clock = context.now ?? Date.now;
231
+ const deadline = context.recoveryDeadlineAt;
232
+ const prior = operations.get(journal.path);
233
+ assert.ok(
234
+ !prior || (prior.state === 'held' && prior.commandId === command.commandId),
235
+ 'NON_ADMISSION_OPERATION_BUSY'
236
+ );
237
+ const operation = { commandId: command.commandId, state: 'active' };
238
+ operations.set(journal.path, operation);
239
+ let guard,
240
+ timer,
241
+ releasing = false,
242
+ settled = false,
243
+ retain = prior !== undefined;
244
+ const controller = new AbortController();
245
+ const signal = context.signal
246
+ ? AbortSignal.any([context.signal, controller.signal])
247
+ : controller.signal;
248
+ const alive = () => {
249
+ signal.throwIfAborted();
250
+ assert.ok(
251
+ Number.isSafeInteger(deadline) && deadline > clock() && deadline <= clock() + 30000,
252
+ 'Non-admission recovery deadline expired'
253
+ );
254
+ };
255
+ const timeout = new Promise((_, reject) => {
256
+ timer = setTimeout(
257
+ () => {
258
+ controller.abort();
259
+ reject(Error('Non-admission recovery timed out'));
260
+ },
261
+ Math.max(1, Math.min(30000, deadline - clock()))
262
+ );
263
+ });
264
+ const bounded = (promise) => Promise.race([promise, timeout]);
265
+ const cleanup = (owned) =>
266
+ Promise.resolve()
267
+ .then(() => owned.release({ resolved: false, proofDigest: null }))
268
+ .catch(() => {});
269
+ let pending;
270
+ try {
271
+ alive();
272
+ const existing = journal.read().commands[command.commandId];
273
+ if (existing) {
274
+ assert.deepEqual(
275
+ existing.command,
276
+ command,
277
+ 'Command ID is bound to different immutable inputs'
278
+ );
279
+ assert.ok(
280
+ existing.admissionRejection,
281
+ 'Command already admitted; use its existing recovery boundary'
282
+ );
283
+ retain = true;
284
+ validateFact(existing.admissionRejection.fact, command, {
285
+ ...context.scope,
286
+ operationGeneration: existing.admissionRejection.fact.operationGeneration,
287
+ });
288
+ if (existing.admissionRejection.state === 'rejected') {
289
+ settled = true;
290
+ return admissionRejectionAck(existing, command);
291
+ }
292
+ }
293
+ const arriving = Promise.resolve().then(() =>
294
+ context.acquireGuard(structuredClone(command), {
295
+ signal,
296
+ recoveryDeadlineAt: deadline,
297
+ scope: structuredClone(context.scope),
298
+ })
299
+ );
300
+ arriving.then(
301
+ (late) => {
302
+ if (signal.aborted && late && typeof late.release === 'function') void cleanup(late);
303
+ },
304
+ () => {}
305
+ );
306
+ guard = await bounded(arriving);
307
+ if (guard === null) return null;
308
+ retain = true;
309
+ assert.ok(
310
+ guard && typeof guard.assertOwned === 'function' && typeof guard.release === 'function',
311
+ 'Held non-admission guard required'
312
+ );
313
+ const fact = structuredClone(guard.fact),
314
+ baselineReadback = structuredClone(guard.baselineReadback);
315
+ validateFact(fact, command, context.scope);
316
+ const fresh = () => {
317
+ alive();
318
+ const now = clock();
319
+ assert.ok(now < deadline, 'Non-admission recovery deadline expired');
320
+ validateBaseline(baselineReadback, command, fact, now);
321
+ return now;
322
+ };
323
+ await bounded(guard.assertOwned());
324
+ fresh();
325
+ const state = journal.read();
326
+ const previous = state.commands[command.commandId];
327
+ if (previous) {
328
+ assert.deepEqual(
329
+ previous.command,
330
+ command,
331
+ 'Command ID is bound to different immutable inputs'
332
+ );
333
+ assert.ok(previous.admissionRejection?.state === 'pending', 'Non-admission receipt changed');
334
+ assert.deepEqual(
335
+ immutableFact(fact),
336
+ immutableFact(previous.admissionRejection.fact),
337
+ 'Non-admission identity changed'
338
+ );
339
+ }
340
+ assert.ok(
341
+ Object.entries(state.commands).every(
342
+ ([id, record]) =>
343
+ id === command.commandId ||
344
+ (record.admissionRejection
345
+ ? record.admissionRejection.state === 'rejected'
346
+ : !['queued', 'running', 'unverified'].includes(record.status) &&
347
+ (!['failed', 'blocked'].includes(record.status) ||
348
+ ['verified', 'not_required'].includes(record.recoveryState)))
349
+ ),
350
+ 'Other source work requires reconciliation'
351
+ );
352
+ assert.ok(
353
+ previous || Object.keys(state.commands).length < 100,
354
+ 'Source journal retention requires reconciliation'
355
+ );
356
+ pending = {
357
+ command,
358
+ admissionRejection: {
359
+ schema: 1,
360
+ state: 'pending',
361
+ fact,
362
+ baselineReadback,
363
+ proofDigest: nonAdmissionProofDigest(command, fact, baselineReadback),
364
+ acknowledgement: acknowledgement(command),
365
+ completedAt: null,
366
+ },
367
+ };
368
+ if (previous) {
369
+ const retained = previous.admissionRejection;
370
+ const history = structuredClone(retained.priorProofs ?? []);
371
+ if (retained.proofDigest !== pending.admissionRejection.proofDigest) {
372
+ assert.ok(history.length < 100, 'Non-admission attempt retention requires reconciliation');
373
+ history.push({
374
+ fact: structuredClone(retained.fact),
375
+ baselineReadback: structuredClone(retained.baselineReadback),
376
+ proofDigest: retained.proofDigest,
377
+ });
378
+ }
379
+ pending.admissionRejection.priorProofs = history;
380
+ }
381
+ state.commands[command.commandId] = pending;
382
+ journal.save(state);
383
+ await bounded(guard.assertOwned());
384
+ fresh();
385
+ releasing = true;
386
+ await bounded(
387
+ guard.release({ resolved: true, proofDigest: pending.admissionRejection.proofDigest })
388
+ );
389
+ fresh();
390
+ const latest = journal.read();
391
+ assert.deepEqual(
392
+ latest.commands[command.commandId],
393
+ pending,
394
+ 'Protected non-admission receipt changed'
395
+ );
396
+ const completed = structuredClone(pending);
397
+ completed.admissionRejection.state = 'rejected';
398
+ completed.admissionRejection.completedAt = new Date(fresh()).toISOString();
399
+ latest.commands[command.commandId] = completed;
400
+ journal.save(latest);
401
+ fresh();
402
+ settled = true;
403
+ return admissionRejectionAck(completed, command);
404
+ } catch (error) {
405
+ if (pending) {
406
+ try {
407
+ const latest = journal.read(),
408
+ selected = latest.commands[command.commandId];
409
+ if (
410
+ isDeepStrictEqual(selected, pending) ||
411
+ (selected?.admissionRejection?.state === 'rejected' &&
412
+ isDeepStrictEqual(selected.command, command) &&
413
+ selected.admissionRejection.proofDigest === pending.admissionRejection.proofDigest)
414
+ ) {
415
+ latest.commands[command.commandId] = pending;
416
+ journal.save(latest);
417
+ }
418
+ } catch {
419
+ /* Retain in-process ownership; native startup must also refuse uncertain recovery. */
420
+ }
421
+ }
422
+ throw error;
423
+ } finally {
424
+ controller.abort();
425
+ clearTimeout(timer);
426
+ if (guard && !settled && !releasing && typeof guard.release === 'function') void cleanup(guard);
427
+ if (settled || !retain) operations.delete(journal.path);
428
+ else operation.state = 'held';
429
+ }
430
+ }
@@ -5,7 +5,76 @@ import type {
5
5
  Blocker,
6
6
  StageId,
7
7
  ArtifactIdentity,
8
+ ProductionReadback,
8
9
  } from './types.js';
10
+ export interface NonAdmissionScope {
11
+ tenantId: string;
12
+ workspaceId: string;
13
+ parentRunId: string;
14
+ operationGeneration: string;
15
+ credentialRevision: number;
16
+ streamId: string;
17
+ baselineIdentity: ArtifactIdentity;
18
+ }
19
+ export interface NonAdmissionFact extends NonAdmissionScope {
20
+ schema: 1;
21
+ kind: 'command_not_admitted';
22
+ commandId: string;
23
+ installationKey: string;
24
+ expectedSource: ExpectedSource;
25
+ executionProvider: 'github' | 'local';
26
+ requestedAt: string;
27
+ deadlineAt: string;
28
+ observedAt: string;
29
+ authenticated: true;
30
+ admissionStopped: true;
31
+ journalAbsenceVerified: true;
32
+ executionNotStarted: true;
33
+ installationNotStarted: true;
34
+ pendingTransaction: false;
35
+ baselineUnchanged: true;
36
+ }
37
+ export interface NonAdmissionGuard {
38
+ fact: NonAdmissionFact;
39
+ baselineReadback: ProductionReadback;
40
+ assertOwned(): Promise<void>;
41
+ release(options: { resolved: boolean; proofDigest: string | null }): Promise<void>;
42
+ }
43
+ export interface NonAdmissionContext {
44
+ installationKey: string;
45
+ repository: string;
46
+ ref?: string;
47
+ scope: NonAdmissionScope;
48
+ signal?: AbortSignal;
49
+ recoveryDeadlineAt: number;
50
+ now?: () => number;
51
+ acquireGuard(
52
+ command: RunnerCommand,
53
+ context: { signal: AbortSignal; recoveryDeadlineAt: number; scope: NonAdmissionScope }
54
+ ): Promise<NonAdmissionGuard | null>;
55
+ }
56
+ export interface NonAdmissionRecord {
57
+ command: RunnerCommand;
58
+ admissionRejection: {
59
+ schema: 1;
60
+ state: 'pending' | 'rejected';
61
+ fact: NonAdmissionFact;
62
+ baselineReadback: ProductionReadback;
63
+ proofDigest: string;
64
+ acknowledgement: Acknowledgement;
65
+ completedAt: string | null;
66
+ priorProofs?: {
67
+ fact: NonAdmissionFact;
68
+ baselineReadback: ProductionReadback;
69
+ proofDigest: string;
70
+ }[];
71
+ };
72
+ }
73
+ export function nonAdmissionProofDigest(
74
+ command: RunnerCommand,
75
+ fact: NonAdmissionFact,
76
+ baselineReadback: ProductionReadback
77
+ ): string;
9
78
  export interface SourceState {
10
79
  schema: 1;
11
80
  cursor: number;
@@ -50,6 +119,10 @@ export function sourceStore(
50
119
  options: { installationKey: string; repository: string; ref?: string; now?: number }
51
120
  ): Acknowledgement;
52
121
  reconcile(commandId: string): Acknowledgement | null;
122
+ reconcileNonAdmission(
123
+ command: RunnerCommand,
124
+ context: NonAdmissionContext
125
+ ): Promise<Acknowledgement | null>;
53
126
  };
54
127
  export function progress(
55
128
  state: SourceState,
@@ -1,6 +1,13 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { createProtectedJsonStore } from './journal.mjs';
3
3
  import { admitArtifactCommand, sourceProgress } from './source-admission.mjs';
4
+ import {
5
+ validNonAdmissionRecord,
6
+ admissionRejectionAck,
7
+ nonAdmissionBusy,
8
+ reconcileNonAdmission,
9
+ } from './source-non-admission.mjs';
10
+ export { nonAdmissionProofDigest } from './source-non-admission.mjs';
4
11
 
5
12
  const fields = (value, keys) =>
6
13
  value &&
@@ -106,27 +113,29 @@ export function validSourceState(state) {
106
113
  record.command?.commandId === id &&
107
114
  record.command.protocolVersion === 1 &&
108
115
  ['publish', 'deploy', 'retry', 'rollback'].includes(record.command.action) &&
109
- [
110
- 'queued',
111
- 'running',
112
- 'failed',
113
- 'blocked',
114
- 'deployed',
115
- 'rolled_back',
116
- 'unverified',
117
- ].includes(record.status) &&
118
- sourceTimestamp(record.acceptedAt) &&
119
- (record.completedAt === null || sourceTimestamp(record.completedAt)) &&
120
- Array.isArray(record.stages) &&
121
- record.stages.length === sourceStageDefinitions.length &&
122
- sourceStageDefinitions.every(
123
- ([stageId]) => record.stages.filter((stage) => stage.id === stageId).length === 1
124
- ) &&
125
- Array.isArray(record.blockers) &&
126
- record.blockers.length <= 100 &&
127
- record.dispatches &&
128
- typeof record.dispatches === 'object' &&
129
- !Array.isArray(record.dispatches)
116
+ (Object.hasOwn(record, 'admissionRejection')
117
+ ? validNonAdmissionRecord(record)
118
+ : [
119
+ 'queued',
120
+ 'running',
121
+ 'failed',
122
+ 'blocked',
123
+ 'deployed',
124
+ 'rolled_back',
125
+ 'unverified',
126
+ ].includes(record.status) &&
127
+ sourceTimestamp(record.acceptedAt) &&
128
+ (record.completedAt === null || sourceTimestamp(record.completedAt)) &&
129
+ Array.isArray(record.stages) &&
130
+ record.stages.length === sourceStageDefinitions.length &&
131
+ sourceStageDefinitions.every(
132
+ ([stageId]) => record.stages.filter((stage) => stage.id === stageId).length === 1
133
+ ) &&
134
+ Array.isArray(record.blockers) &&
135
+ record.blockers.length <= 100 &&
136
+ record.dispatches &&
137
+ typeof record.dispatches === 'object' &&
138
+ !Array.isArray(record.dispatches))
130
139
  ) &&
131
140
  Array.isArray(state.events) &&
132
141
  state.events.length <= 1000
@@ -134,10 +143,21 @@ export function validSourceState(state) {
134
143
  }
135
144
 
136
145
  /** One host driver writes under its shared flock; atomic readers never infer missing success. */
137
- export function sourceStore(directory, { filename = 'source-publication.json', validateState = validSourceState } = {}) {
138
- const journal = createProtectedJsonStore(directory, { filename, validate: validateState, initial: blank });
146
+ export function sourceStore(
147
+ directory,
148
+ { filename = 'source-publication.json', validateState = validSourceState } = {}
149
+ ) {
150
+ const journal = createProtectedJsonStore(directory, {
151
+ filename,
152
+ validate: validateState,
153
+ initial: blank,
154
+ });
139
155
  directory = journal.directory;
140
- const { read, save } = journal;
156
+ const { read } = journal;
157
+ const save = (state) => {
158
+ if (nonAdmissionBusy(journal.path)) throw new Error('NON_ADMISSION_OPERATION_BUSY');
159
+ journal.save(state);
160
+ };
141
161
  const event = (
142
162
  state,
143
163
  record,
@@ -175,6 +195,13 @@ export function sourceStore(directory, { filename = 'source-publication.json', v
175
195
  save,
176
196
  event,
177
197
  accept(command, { installationKey, repository, ref = 'main', now = Date.now() }) {
198
+ const retained = read().commands[command?.commandId];
199
+ if (retained?.admissionRejection) {
200
+ if (!validPublishCommand(command, installationKey, repository, ref))
201
+ throw new Error('Invalid scoped source publication command.');
202
+ return admissionRejectionAck(retained, command);
203
+ }
204
+ if (nonAdmissionBusy(journal.path)) throw new Error('NON_ADMISSION_OPERATION_BUSY');
178
205
  if (command?.action !== 'publish')
179
206
  return admitArtifactCommand(
180
207
  command,
@@ -186,6 +213,7 @@ export function sourceStore(directory, { filename = 'source-publication.json', v
186
213
  throw new Error('Invalid scoped source publication command.');
187
214
  const state = read();
188
215
  const previous = state.commands[command.commandId];
216
+ if (previous?.admissionRejection) return admissionRejectionAck(previous, command);
189
217
  if (previous)
190
218
  return isDeepStrictEqual(previous.command, command)
191
219
  ? ack(command.commandId, 'reconciled')
@@ -207,6 +235,7 @@ export function sourceStore(directory, { filename = 'source-publication.json', v
207
235
  if (
208
236
  Object.values(state.commands).some(
209
237
  (record) =>
238
+ record.admissionRejection?.state === 'pending' ||
210
239
  ['queued', 'running', 'unverified'].includes(record.status) ||
211
240
  (['failed', 'blocked'].includes(record.status) &&
212
241
  record.recoveryState !== 'verified' &&
@@ -269,7 +298,15 @@ export function sourceStore(directory, { filename = 'source-publication.json', v
269
298
  },
270
299
  reconcile(commandId) {
271
300
  if (!uuid.test(commandId ?? '')) throw new Error('Invalid command identity.');
272
- return read().commands[commandId] ? ack(commandId, 'reconciled') : null;
301
+ const record = read().commands[commandId];
302
+ if (record?.admissionRejection)
303
+ return record.admissionRejection.state === 'rejected'
304
+ ? admissionRejectionAck(record)
305
+ : null;
306
+ return record ? ack(commandId, 'reconciled') : null;
307
+ },
308
+ reconcileNonAdmission(command, context) {
309
+ return reconcileNonAdmission(journal, command, context);
273
310
  },
274
311
  };
275
312
  }
@@ -304,11 +341,21 @@ export function sourceCandidate(expectedSource, at, blockers = []) {
304
341
  },
305
342
  };
306
343
  }
307
- export function progress(state, cursor = '0', now = Date.now(), { validateState = validSourceState } = {}) {
344
+ export function progress(
345
+ state,
346
+ cursor = '0',
347
+ now = Date.now(),
348
+ { validateState = validSourceState } = {}
349
+ ) {
308
350
  if (!validateState(state) || !/^\d+$/u.test(cursor) || BigInt(cursor) > BigInt(state.cursor))
309
351
  throw new Error('Invalid source journal cursor or state.');
310
352
  return sourceProgress(state, cursor, now, { sourceCandidate, sourceTimestamp });
311
353
  }
312
354
 
313
- export { completeArtifact, baselineCandidateId, admitArtifactCommand, sourceProgress } from './source-admission.mjs';
355
+ export {
356
+ completeArtifact,
357
+ baselineCandidateId,
358
+ admitArtifactCommand,
359
+ sourceProgress,
360
+ } from './source-admission.mjs';
314
361
  export { createStageJournal } from './stage-journal.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casecore/release-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Общий движок поставки CaseCore с адаптерами продуктов",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {