@casecore/release-agent 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,160 @@
1
- # Temporary Holding Version
1
+ # @casecore/release-agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Общий движок поставки CaseCore, Alvori и MyRobotica. Канонический исходный код
4
+ находится в репозитории CaseCore. Продукты подключают точную опубликованную версию;
5
+ отдельные редактируемые копии движка в репозиториях продуктов не поддерживаются.
6
+
7
+ Движок сохраняет исходную идентичность команды, ограничение времени, защищённый
8
+ журнал, блокировки, повторное выполнение и восстановление наблюдаемого результата.
9
+ Сборку, подпись, миграции, резервное копирование, активацию и независимую проверку
10
+ выполняет адаптер конкретного продукта. Установка уже принятой команды продолжается
11
+ по локальному журналу и не требует постоянной доступности API CaseCore.
12
+
13
+ ## Подключение
14
+
15
+ ```sh
16
+ pnpm add --save-exact @casecore/release-agent@0.1.0
17
+ ```
18
+
19
+ В проекте фиксируются точная версия и `pnpm-lock.yaml`. Поддерживается Node.js
20
+ 22.14 или новее и POSIX-система с приватными каталогами и файлами. Перед активацией
21
+ на сервере с Node.js 18 необходимо обновить среду исполнения до поддерживаемой
22
+ версии. Публикация пакета сама по себе не означает обновление серверов продуктов.
23
+
24
+ ```js
25
+ import { executePipeline, newExecution } from '@casecore/release-agent/pipeline';
26
+ import { sourceStore } from '@casecore/release-agent/source-store';
27
+ import { ReleaseControlProducer } from '@casecore/release-agent/producer';
28
+ ```
29
+
30
+ ## Контракт адаптеров
31
+
32
+ | Вход пакета | Назначение |
33
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `pipeline` | Семь последовательных этапов, повторное использование успешных этапов, остановка при неопределённом результате, восстановление блокировки |
35
+ | `source-store` | Приём команд публикации и установки, неизменяемые входы, источник кандидатов и прогресс |
36
+ | `producer` | Протокол исполнительного агента, адаптер `provider`, планирование, наблюдение и восстановление |
37
+ | `protocol` | Проверка команд, снимков, источника и независимого подтверждения установки |
38
+ | `journal` | Приватная атомарная запись с `fsync` файла и каталога, отказ при ссылках и повреждённых данных |
39
+
40
+ `executePipeline` получает команды и их аргументы от адаптера через `stages` и
41
+ вызывает предоставленный `run`. Каждый этап фиксирует `inputsDigest`.
42
+ `reconcileExecution` снимает блокировку только после подтверждения отсутствия
43
+ живого исполнителя и проверки фактического результата адаптером. Истёкший срок
44
+ не продлевается. Неопределённые действия не запускаются повторно автоматически.
45
+
46
+ `ReleaseControlProducer` получает `provider` с существующими операциями продукта:
47
+ `snapshot`, `execute`, `readProduction` и подключёнными операциями восстановления.
48
+ `provider.execute(command, context)` вызывает `context.stage` для реальных этапов;
49
+ `production_acceptance` подтверждается через независимый `readProduction`.
50
+ Журнал сохраняет прежний формат MyRobotica и исходные идентификаторы команд.
51
+
52
+ Источник публикации ограничивается установкой. Для `main` передаётся точная
53
+ политика, например `sourcePolicy: { repository: 'owner/product', ref: 'main' }`.
54
+ Без политики сохраняется прежнее ограничение MyRobotica на ветки `codex/`.
55
+ Регулярное выражение допустимо только для явно указанного репозитория и без
56
+ флагов `g`/`y`. Это не разрешает публикацию произвольного источника другой установки.
57
+
58
+ `sourceStore(directory, { filename, validateState })` сохраняет исторический путь
59
+ и проверку схемы журнала адаптера. CaseCore использует `source-publication.json`,
60
+ Alvori — `release-control-source.json`. Для старого формата Alvori адаптер передаёт
61
+ проверку допустимого состояния и ту же проверку в `progress(state, cursor, now,
62
+ { validateState })`. Проверки идентичности, коллизий и срока команды выполняет
63
+ движок независимо от проверки формата журнала.
64
+
65
+ Процессы записи `source-store` и `producer` запускаются под существующей общей
66
+ блокировкой установки; внутрипроцессная очередь не заменяет блокировку процессов.
67
+ Переход не допускает одновременную работу старого и нового исполнителей.
68
+ `createStageJournal({ store, state, record, now })` использует этот же журнал:
69
+ переход этапа, время, событие и сохранение выполняются общей реализацией;
70
+ адаптер передаёт фактическое начало и завершение своей операции.
71
+
72
+ ## Сборка и выпуск
73
+
74
+ ```sh
75
+ pnpm --dir packages/libs/release-agent run build
76
+ pnpm --dir packages/libs/release-agent pack
77
+ ```
78
+
79
+ Сборка использует только встроенные средства Node.js. `dist/provenance.json`
80
+ фиксирует версию, исходный commit и SHA-256 каждого исходного модуля и декларации.
81
+ Сборка вне Git требует явного `RELEASE_AGENT_SOURCE_COMMIT` с точным commit.
82
+ Разработческая сборка с изменёнными или непроверенными исходниками помечается
83
+ `buildKind: development`, `sourceVerified: false`. Для официальной сборки
84
+ задаётся `RELEASE_AGENT_REQUIRE_CLEAN_SOURCE=1`: грязная ревизия или другой
85
+ переданный commit блокируют выпуск. Сборка сверяет исходники с байтами commit.
86
+ Официальный выпуск выполняется из чистой ревизии через отдельный трек
87
+ публикации npm-пакетов CaseCore. Содержимое опубликованной версии не заменяется.
88
+
89
+ После изменения движка продукты обновляют точную зависимость и lockfile,
90
+ собирают собственный артефакт и применяют его через существующую процедуру
91
+ обновления исполнителя. Отдельная подпись, миграции и проверки сервера остаются
92
+ границей доставки каждого продукта.
93
+
94
+ ## Подключение конфигурации установки
95
+
96
+ В третьем аргументе `producerServer` адаптер может передать `configure(request)`
97
+ и `reconcileConfiguration(configurationId)`. Без этих обработчиков маршруты
98
+ недоступны. Используется тот же сервер и Bearer-аутентификация исполнителя.
99
+
100
+ `POST /v1/release-control/configuration` принимает ровно восемь полей:
101
+ `protocolVersion: 1`, UUID `configurationId`, положительный целочисленный
102
+ `configurationRevision`, `installationKey`, `executionProvider: github | local`,
103
+ `repository: owner/repo`, `ref: main`, `githubToken: string | null`.
104
+ Размер тела ограничен 16384 байтами; установка должна совпадать с исполнителем.
105
+ Регистр репозитория сохраняется. `githubToken: null` явно отзывает credential.
106
+
107
+ Обработчик возвращает семь полей идентичности без токена, `state: applied | rejected`
108
+ и `reason`: `null` для применённой конфигурации или `{code, message, retryable}`
109
+ для отказа. Если результат ещё не подтверждён, обработчик возвращает `undefined`:
110
+ сервер отвечает HTTP 503 с `{error: "UNKNOWN"}`. Успех не подставляется автоматически.
111
+
112
+ `GET /v1/release-control/configuration/:configurationId` вызывает восстановление
113
+ результата через адаптер. `null` означает отсутствие конфигурации (HTTP 404),
114
+ `undefined` — незавершённый или неопределённый результат (HTTP 503 `UNKNOWN`).
115
+ Готовый ответ должен совпадать с UUID и установкой и не содержать токен.
116
+
117
+ Адаптер продукта отвечает за идентичность и ревизии конфигурации, блокировки,
118
+ хранение credential, изменение окружения и фактическую активацию. Общий движок
119
+ не записывает токен, конфигурацию или её ответ в журнал релизов. Ошибки обработчика
120
+ и небезопасные ответы возвращаются без внутренних подробностей.
121
+
122
+ Перед фактическим изменением или восстановлением конфигурации адаптер вызывает
123
+ `producer.reserveExclusiveOperation({ operationId })`. Метод синхронно резервирует
124
+ исполнителя и возвращает `assertOwned()` и `release({ resolved })`. Пока есть
125
+ активный или неопределённый владелец, команды, откат, продолжение публикации,
126
+ плановая установка, привязка артефакта и восстановление релиза не выполняются.
127
+ Резервирование также запрещено во время уже начатой операции или неразрешённого
128
+ состояния установки. Чтение снимка и поиск отсутствующей конфигурации доступны.
129
+
130
+ `release({ resolved: true })` снимает резервирование после подтверждённого результата.
131
+ `false` или отсутствие `resolved` сохраняет удержание и лишает текущий handle прав.
132
+ Для проверки неопределённого результата тот же `operationId` получает новый handle;
133
+ другой ID и старые handles не могут снять или заменить удержание. Повторное
134
+ получение активного handle запрещено даже для того же ID.
135
+
136
+ Резервирование хранится только в памяти движка. После перезапуска адаптер сначала
137
+ восстанавливает его из своего защищённого журнала незавершённой конфигурации,
138
+ затем вызывает `initialize` и открывает сервер. `initialize`, снимок и поиск
139
+ ответа не снимают удержание и не разрешают продолжение эффектов. Сам HTTP-обработчик
140
+ не резервирует установку: callback делает это только перед фактической операцией.
141
+
142
+ ## Проверки
143
+
144
+ ```sh
145
+ pnpm --dir packages/libs/release-agent test
146
+ pnpm --dir packages/libs/release-agent run test:pack
147
+ ```
148
+
149
+ Проверки покрывают повторное выполнение, коллизии, исходный срок, неопределённый
150
+ результат, блокировки, повреждённый журнал и старые пути состояния. `test:pack`
151
+ выполняет `pnpm pack` и запускает потребителя в изолированном каталоге, используя
152
+ только распакованный пакет. Для повторного использования готового архива:
153
+
154
+ ```sh
155
+ RELEASE_AGENT_TARBALL=/absolute/path/casecore-release-agent-0.1.0.tgz \
156
+ pnpm --dir packages/libs/release-agent run test:pack
157
+ ```
158
+
159
+ Запуск проверок и сборок должен учитывать действующее разрешение владельца задачи;
160
+ наличие команд и тестов не является подтверждением их успешного выполнения.
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { keys, uuid, validateBlocker } from './protocol-internals.mjs';
3
+
4
+ const identityFields = ['protocolVersion', 'configurationId', 'configurationRevision',
5
+ 'installationKey', 'executionProvider', 'repository', 'ref'];
6
+ const pat = /(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)/;
7
+ function validateIdentity(value, installationKey) {
8
+ assert.equal(value.protocolVersion, 1);
9
+ assert.match(value.configurationId, uuid);
10
+ assert.ok(Number.isSafeInteger(value.configurationRevision) && value.configurationRevision > 0);
11
+ assert.equal(value.installationKey, installationKey);
12
+ assert.ok(['github', 'local'].includes(value.executionProvider));
13
+ assert.ok(typeof value.repository === 'string' && value.repository.length <= 200);
14
+ assert.match(value.repository, /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/);
15
+ assert.equal(value.ref, 'main');
16
+ }
17
+ function validateRequest(value, installationKey) {
18
+ keys(value, [...identityFields, 'githubToken']);
19
+ validateIdentity(value, installationKey);
20
+ assert.ok(value.githubToken === null || typeof value.githubToken === 'string');
21
+ }
22
+ function validateAcknowledgement(value, installationKey, expected, token) {
23
+ // Snapshot callback data before validation; inherited serializers and getters
24
+ // must not replace a checked acknowledgement when the server emits JSON.
25
+ value = structuredClone(value);
26
+ keys(value, [...identityFields, 'state', 'reason']);
27
+ validateIdentity(value, installationKey);
28
+ for (const field of Object.keys(expected)) assert.equal(value[field], expected[field]);
29
+ assert.ok(['applied', 'rejected'].includes(value.state));
30
+ if (value.state === 'applied') assert.equal(value.reason, null);
31
+ else {
32
+ validateBlocker(value.reason);
33
+ }
34
+ for (const text of [...Object.values(value), value.reason?.code, value.reason?.message]) {
35
+ if (typeof text !== 'string') continue;
36
+ assert.ok(!pat.test(text));
37
+ if (typeof token === 'string' && token.length) assert.ok(!text.includes(token));
38
+ }
39
+ return value;
40
+ }
41
+ function readBody(request) {
42
+ return new Promise((resolve, reject) => {
43
+ let bytes = 0;
44
+ const chunks = [];
45
+ const clean = () => {
46
+ request.off('data', data);
47
+ request.off('end', end);
48
+ request.off('error', error);
49
+ request.off('aborted', aborted);
50
+ };
51
+ const tooLarge = () => {
52
+ clean(); request.resume();
53
+ reject(Object.assign(Error('Configuration request rejected'), { code: 'CONFIGURATION_BODY_TOO_LARGE' }));
54
+ };
55
+ const data = (chunk) => {
56
+ bytes += chunk.length;
57
+ if (bytes > 16384) return tooLarge();
58
+ chunks.push(chunk);
59
+ };
60
+ const end = () => { clean(); resolve(Buffer.concat(chunks).toString('utf8')); };
61
+ const error = () => { clean(); reject(Error('Configuration request rejected')); };
62
+ const aborted = error;
63
+ request.on('data', data); request.on('end', end);
64
+ request.on('error', error); request.on('aborted', aborted);
65
+ if (Number(request.headers['content-length']) > 16384) tooLarge();
66
+ });
67
+ }
68
+
69
+ /** Optional transport only: credential persistence and activation stay in the product adapter. */
70
+ export async function configurationHook(request, url, producer, options, reply) {
71
+ if (request.method === 'POST' && url.pathname === '/v1/release-control/configuration' &&
72
+ typeof options.configure === 'function') {
73
+ assert.equal(url.search, '');
74
+ const command = JSON.parse(await readBody(request));
75
+ validateRequest(command, producer.installationKey);
76
+ const identity = Object.fromEntries(identityFields.map((field) => [field, command[field]]));
77
+ const token = command.githubToken;
78
+ const result = await options.configure(structuredClone(command));
79
+ reply(result === undefined ? 503 : 200, result === undefined ? { error: 'UNKNOWN' } :
80
+ validateAcknowledgement(result, producer.installationKey, identity, token));
81
+ return true;
82
+ }
83
+ const match = url.pathname.match(/^\/v1\/release-control\/configuration\/([^/]+)$/);
84
+ if (request.method === 'GET' && match && typeof options.reconcileConfiguration === 'function') {
85
+ assert.equal(url.search, '');
86
+ assert.match(match[1], uuid);
87
+ const result = await options.reconcileConfiguration(match[1]);
88
+ if (result === null) reply(404, { error: 'Unknown configuration' });
89
+ else if (result === undefined) reply(503, { error: 'UNKNOWN' });
90
+ else reply(200, validateAcknowledgement(result, producer.installationKey,
91
+ { configurationId: match[1] }));
92
+ return true;
93
+ }
94
+ return false;
95
+ }
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ // Generic process-local ownership only. Products restore pending ownership from
4
+ // their own durable configuration state before exposing effect-bearing methods.
5
+ const operations = new WeakMap();
6
+ const mutableMethods = ['command', 'execute', 'resumeScheduled', 'resumePublication',
7
+ 'reconcile', 'bindPublicationDelivery', 'bindPublicationArtifact', 'qualifyForwardRecovery'];
8
+ function metadata(producer) {
9
+ const state = operations.get(producer);
10
+ assert.ok(state, 'DELIVERY_OPERATION_BARRIER_REQUIRED');
11
+ return state;
12
+ }
13
+ export function installOperationBarrier(producer) {
14
+ assert.ok(!operations.has(producer), 'DELIVERY_OPERATION_BARRIER_ALREADY_INSTALLED');
15
+ operations.set(producer, { activity: 0, lease: null, loaded: false });
16
+ for (const name of mutableMethods) {
17
+ const method = producer[name];
18
+ assert.equal(typeof method, 'function');
19
+ Object.defineProperty(producer, name, {
20
+ value: async function (...args) {
21
+ const state = metadata(this);
22
+ assert.ok(!state.lease, 'DELIVERY_EXCLUSIVE_OPERATION_HELD');
23
+ state.activity++;
24
+ try { return await method.apply(this, args); }
25
+ finally { state.activity--; }
26
+ },
27
+ });
28
+ }
29
+ }
30
+ export function markOperationStateLoaded(producer) {
31
+ metadata(producer).loaded = true;
32
+ }
33
+ function installationBusy(producer, state) {
34
+ // A restored configuration reservation precedes initialize. Inspect the
35
+ // protected existing journal so an empty constructor cannot hide a live run.
36
+ const runs = state.loaded ? producer.state.runs :
37
+ [...producer.state.runs, ...producer.journal.read().runs];
38
+ return state.activity > 0 || producer.admitting !== undefined ||
39
+ producer.pending.size > 0 || producer.scheduling === true ||
40
+ runs.some((run) =>
41
+ ['queued', 'running'].includes(run.status) ||
42
+ (run.status === 'blocked' && (run.recoveryState !== 'verified' ||
43
+ run.blockers?.some((reason) => reason.code === 'RECONCILIATION_REQUIRED'))) ||
44
+ ['failed', 'unverified'].includes(run.recoveryState));
45
+ }
46
+ export function reserveExclusiveOperation(producer, { operationId } = {}) {
47
+ assert.ok(typeof operationId === 'string' && operationId.trim().length > 0 && operationId.length <= 200,
48
+ 'DELIVERY_OPERATION_ID_REQUIRED');
49
+ const state = metadata(producer);
50
+ assert.ok(!state.lease || (state.lease.status === 'held' && state.lease.operationId === operationId),
51
+ 'DELIVERY_EXCLUSIVE_OPERATION_HELD');
52
+ assert.ok(!installationBusy(producer, state), 'DELIVERY_INSTALLATION_BUSY');
53
+ const generation = Symbol();
54
+ state.lease = { operationId, generation, status: 'active' };
55
+ const assertOwned = () => assert.ok(state.lease?.generation === generation &&
56
+ state.lease.status === 'active', 'DELIVERY_EXCLUSIVE_OPERATION_NOT_OWNED');
57
+ return {
58
+ assertOwned,
59
+ release(options = {}) {
60
+ assertOwned();
61
+ assert.ok(options && typeof options === 'object' && !Array.isArray(options) &&
62
+ (options.resolved === undefined || typeof options.resolved === 'boolean'),
63
+ 'DELIVERY_OPERATION_RESOLUTION_INVALID');
64
+ if (options?.resolved === true) state.lease = null;
65
+ else state.lease.status = 'held';
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,12 @@
1
+ export function sourceTimestamp(value) {
2
+ if (
3
+ typeof value !== 'string' ||
4
+ !/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u.test(
5
+ value
6
+ ) ||
7
+ !Number.isFinite(Date.parse(value))
8
+ )
9
+ return false;
10
+ const date = value.slice(0, 10);
11
+ return new Date(Date.parse(`${date}T00:00:00Z`)).toISOString().slice(0, 10) === date;
12
+ }
@@ -0,0 +1,4 @@
1
+ export * from './pipeline.js';
2
+ export * from './source-store.js';
3
+ export * from './producer.js';
4
+ export { createProtectedJsonStore } from './journal.js';
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ export * from './pipeline.mjs';
2
+ export * from './source-store.mjs';
3
+ export * from './producer.mjs';
4
+ export { createProtectedJsonStore } from './journal.mjs';
@@ -0,0 +1,20 @@
1
+ export interface ProtectedJsonStore<T> {
2
+ directory: string;
3
+ path: string;
4
+ read(): T;
5
+ save(state: T): void;
6
+ }
7
+ export interface JsonStoreOptions<T> {
8
+ filename: string;
9
+ validate?: (state: T) => boolean;
10
+ initial?: () => T;
11
+ }
12
+ export function createProtectedJsonStore<T = unknown>(
13
+ directory: string,
14
+ options: JsonStoreOptions<T>
15
+ ): ProtectedJsonStore<T>;
16
+ export function atomicJson<T>(
17
+ path: string,
18
+ value: T,
19
+ options?: Omit<JsonStoreOptions<T>, 'filename'>
20
+ ): Promise<void>;
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync,
4
+ readFileSync, renameSync, unlinkSync, writeFileSync,
5
+ } from 'node:fs';
6
+ import { basename, dirname, isAbsolute, join, parse, resolve, sep } from 'node:path';
7
+
8
+ function directoryChain(directory) {
9
+ let current = parse(directory).root;
10
+ for (const segment of directory.slice(current.length).split(sep).filter(Boolean)) {
11
+ current = join(current, segment);
12
+ try {
13
+ const metadata = lstatSync(current);
14
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
15
+ throw new Error('Journal path must contain only real directories.');
16
+ } catch (error) {
17
+ if (error.code !== 'ENOENT') throw error;
18
+ mkdirSync(current, { mode: 0o700 });
19
+ }
20
+ }
21
+ }
22
+ function protectedFile(metadata, owner) {
23
+ return metadata.isFile() && !metadata.isSymbolicLink() && metadata.nlink === 1 &&
24
+ (metadata.mode & 0o077) === 0 && metadata.uid === owner;
25
+ }
26
+
27
+ /** Synchronous protected persistence for host drivers already serialized by flock.
28
+ * Validation is product-schema-specific, while private files and fsync are shared. */
29
+ export function createProtectedJsonStore(directory, {
30
+ filename, validate = () => true, initial = () => null,
31
+ } = {}) {
32
+ if (!isAbsolute(directory) || typeof filename !== 'string' ||
33
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}$/.test(filename) ||
34
+ typeof validate !== 'function' || typeof initial !== 'function')
35
+ throw new Error('An absolute protected journal directory and basename are required.');
36
+ if (typeof process.getuid !== 'function')
37
+ throw new Error('Protected host journals require a POSIX runtime.');
38
+ directory = resolve(directory);
39
+ const owner = process.getuid();
40
+ const verifyDirectory = () => {
41
+ directoryChain(directory);
42
+ const metadata = lstatSync(directory);
43
+ if ((metadata.mode & 0o077) !== 0 || metadata.uid !== owner)
44
+ throw new Error('Protected journal directory is required.');
45
+ };
46
+ verifyDirectory();
47
+ const path = join(directory, filename);
48
+ const read = () => {
49
+ verifyDirectory();
50
+ let descriptor;
51
+ try {
52
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
53
+ } catch (error) {
54
+ if (error.code === 'ENOENT') return initial();
55
+ throw error;
56
+ }
57
+ try {
58
+ if (!protectedFile(fstatSync(descriptor), owner))
59
+ throw new Error('Protected canonical journal is required.');
60
+ const state = JSON.parse(readFileSync(descriptor, 'utf8'));
61
+ if (!validate(state)) throw new Error('Journal requires reconciliation.');
62
+ return state;
63
+ } finally {
64
+ closeSync(descriptor);
65
+ }
66
+ };
67
+ const save = (state) => {
68
+ if (!validate(state)) throw new Error('Invalid canonical journal state.');
69
+ verifyDirectory();
70
+ try {
71
+ if (!protectedFile(lstatSync(path), owner))
72
+ throw new Error('Protected canonical journal is required.');
73
+ } catch (error) {
74
+ if (error.code !== 'ENOENT') throw error;
75
+ }
76
+ const temporary = `${path}.${randomUUID()}.tmp`;
77
+ let descriptor;
78
+ try {
79
+ descriptor = openSync(temporary,
80
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
81
+ 0o600);
82
+ writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}\n`);
83
+ fsyncSync(descriptor);
84
+ closeSync(descriptor);
85
+ descriptor = undefined;
86
+ renameSync(temporary, path);
87
+ const parent = openSync(directory,
88
+ constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
89
+ try { fsyncSync(parent); } finally { closeSync(parent); }
90
+ } finally {
91
+ if (descriptor !== undefined) closeSync(descriptor);
92
+ try { unlinkSync(temporary); } catch (error) {
93
+ if (error.code !== 'ENOENT') throw error;
94
+ }
95
+ }
96
+ };
97
+ return { directory, path, read, save };
98
+ }
99
+
100
+ /** Promise API retains producer/pipeline callers; the durable write completes before resolution. */
101
+ export async function atomicJson(path, value, options = {}) {
102
+ createProtectedJsonStore(dirname(path), { ...options, filename: basename(path) }).save(value);
103
+ }
@@ -0,0 +1,43 @@
1
+ import type { Execution, PipelineStage, PipelineState, StageRecord, StageId } from './types.js';
2
+ export type { Execution, PipelineStage, PipelineState, StageRecord, StageId } from './types.js';
3
+ export { atomicJson } from './journal.js';
4
+ export const DELIVERY_STAGES: StageId[];
5
+ export function newExecution(input: {
6
+ product: string;
7
+ installationKey: string;
8
+ sourceCommit: string;
9
+ now?: number;
10
+ }): Execution;
11
+ export interface ReconcileStageContext {
12
+ stage: PipelineStage;
13
+ execution: Execution;
14
+ previous: StageRecord;
15
+ remainingMs: number;
16
+ }
17
+ export interface PipelineOptions {
18
+ execution: Execution;
19
+ stages: PipelineStage[];
20
+ stateDir: string;
21
+ run(
22
+ command: string,
23
+ args: string[],
24
+ context: { execution: Execution; stageId: StageId; timeoutMs: number; inputsDigest: string }
25
+ ): Promise<{ exitCode: number }>;
26
+ reconcile?(context: ReconcileStageContext): Promise<'passed' | 'not_applied'>;
27
+ now?: () => number;
28
+ preflight?(context: { execution: Execution; remainingMs: number }): Promise<void>;
29
+ recover?(context: {
30
+ execution: Execution;
31
+ stage: PipelineStage;
32
+ state: PipelineState;
33
+ }): Promise<void>;
34
+ }
35
+ export function executePipeline(options: PipelineOptions): Promise<PipelineState>;
36
+ export function reconcileExecution(options: {
37
+ execution: Execution;
38
+ stages: PipelineStage[];
39
+ stateDir: string;
40
+ reconcile(context: ReconcileStageContext): Promise<'passed' | 'not_applied'>;
41
+ recover?(context: { execution: Execution; state: PipelineState }): Promise<void>;
42
+ isAlive?(pid: number): boolean;
43
+ }): Promise<PipelineState>;