@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 +55 -0
- package/dist/provenance.json +7 -6
- 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
|
@@ -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
|
package/dist/provenance.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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",
|
|
@@ -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:
|
|
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
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';
|