@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 +82 -2
- package/dist/index.mjs +1 -0
- package/dist/producer-journal.mjs +20 -2
- package/dist/producer-publication-failure.mjs +87 -8
- package/dist/producer.d.ts +5 -0
- package/dist/producer.mjs +1 -0
- package/dist/provenance.json +12 -11
- package/dist/source-admission.mjs +8 -4
- package/dist/source-non-admission.mjs +430 -0
- package/dist/source-store.d.ts +73 -0
- package/dist/source-store.mjs +74 -27
- package/package.json +1 -1
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
|
-
|
|
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,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 {
|
|
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 {
|
|
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
|
|
48
|
-
|
|
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 ??= {
|
|
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
|
|
117
|
-
|
|
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 =
|
|
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 =
|
|
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',
|
package/dist/producer.d.ts
CHANGED
|
@@ -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,
|
package/dist/provenance.json
CHANGED
|
@@ -1,41 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"package": "@casecore/release-agent",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"sourceCommit": "
|
|
4
|
+
"version": "0.1.3",
|
|
5
|
+
"sourceCommit": "a9736762537078698065c74bbb0febb675b980c7",
|
|
6
6
|
"sourceVerified": true,
|
|
7
7
|
"sourceDirty": false,
|
|
8
8
|
"buildKind": "release",
|
|
9
|
-
"contentDigest": "sha256:
|
|
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:
|
|
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:
|
|
21
|
-
"src/producer-publication-failure.mjs": "sha256:
|
|
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:
|
|
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:
|
|
31
|
-
"src/source-
|
|
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:
|
|
37
|
+
"types/producer.d.ts": "sha256:980aad137365f6c131b6bfabfab411a73c4a2b2fb35a167152ef8ed446ec1ade",
|
|
37
38
|
"types/protocol.d.ts": "sha256:02c06986c81bfc4214169f62da8e0566704b71a5add58434dfca92c673fad4ce",
|
|
38
|
-
"types/source-store.d.ts": "sha256:
|
|
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
|
|
189
|
-
(
|
|
190
|
-
(['
|
|
191
|
-
|
|
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
|
+
}
|
package/dist/source-store.d.ts
CHANGED
|
@@ -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,
|
package/dist/source-store.mjs
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
355
|
+
export {
|
|
356
|
+
completeArtifact,
|
|
357
|
+
baselineCandidateId,
|
|
358
|
+
admitArtifactCommand,
|
|
359
|
+
sourceProgress,
|
|
360
|
+
} from './source-admission.mjs';
|
|
314
361
|
export { createStageJournal } from './stage-journal.mjs';
|