@casecore/release-agent 0.1.2 → 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
@@ -131,6 +131,61 @@ Alvori — `release-control-source.json`. Для старого формата A
131
131
  переход этапа, время, событие и сохранение выполняются общей реализацией;
132
132
  адаптер передаёт фактическое начало и завершение своей операции.
133
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
+
134
189
  ## Сборка и выпуск
135
190
 
136
191
  ```sh
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "package": "@casecore/release-agent",
4
- "version": "0.1.2",
5
- "sourceCommit": "f51fa3e14522b88887645d3f83cc2dcfaf478d32",
4
+ "version": "0.1.3",
5
+ "sourceCommit": "a9736762537078698065c74bbb0febb675b980c7",
6
6
  "sourceVerified": true,
7
7
  "sourceDirty": false,
8
8
  "buildKind": "release",
9
- "contentDigest": "sha256:8b324e7f25b018f0b79a9deded8aada1b885646a807578a2697bd47b7c8fb5a0",
9
+ "contentDigest": "sha256:21d9013bdd65fefe213eee463eaa9dc13bb2ee1647f392e2c6c90ca5953d2886",
10
10
  "files": {
11
11
  "src/configuration-hook.mjs": "sha256:1171bf982cbf7cb0092f63b633ef0b0a0947d8ed1106f88c9175c8add33d81b1",
12
12
  "src/exclusive-operation.mjs": "sha256:5a1d0b7d3aa41b901132e239586d66d6c7aa2c16682a7db033a7aab91b1d781a",
@@ -27,15 +27,16 @@
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
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.2",
3
+ "version": "0.1.3",
4
4
  "description": "Общий движок поставки CaseCore с адаптерами продуктов",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {