mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-31 01:41:55 +00:00
Add gated delivery lifecycle
This commit is contained in:
1 parent
73a4251054
commit
5a06debfef
13 files changed
+671
-3
No files matched your search
@@ -48,6 +48,9 @@ and the first Claude reference-fleet path:
|
||||
- a native integration queue with durable authorization and fast-forward main protection;
|
||||
- domain-owned convergence plans that defer during checks, measurements, pins, dirty work, and Git operations;
|
||||
- a local bare canonical Git option with honest one-disk/local-remote grading;
|
||||
- a durable delivery state machine that keeps implemented, verified,
|
||||
integrated, release-ready, released, deployed, and live-verified distinct,
|
||||
with per-state authority and provider-capability gates;
|
||||
- mutation audit events tied to project and Fleet identity.
|
||||
- isolated candidate version stores with explicit compatibility declarations;
|
||||
- acceptance-gated atomic activation, crash reconciliation, and exact rollback.
|
||||
@@ -268,6 +271,31 @@ checks, backlog, worktrees, and policy remain operational. Detach works during
|
||||
that outage and restores the prior local canonical configuration; it does not
|
||||
delete a user-owned remote.
|
||||
|
||||
Delivery is separate from integration. A clean domain tip starts as
|
||||
implemented; exact-commit checks gate verification, and a landed integration
|
||||
record gates integration. Release, deployment, and live verification require
|
||||
owner authority, explicit approval, and successful adapter receipts. Fresh
|
||||
installs configure no delivery providers, so TORCH cannot publish or deploy by
|
||||
accident:
|
||||
|
||||
```bash
|
||||
torch delivery create --area backend --label "API change" \
|
||||
--evidence "task:TASK-42" --json
|
||||
torch delivery plan --delivery <id> --to verified --by backend --json
|
||||
torch delivery transition --delivery <id> --to verified --by backend \
|
||||
--evidence "check:<receipt-id>" --json
|
||||
torch delivery configure --release-provider <adapter> \
|
||||
--deployment-provider <adapter> --dry-run --json
|
||||
torch delivery configure --release-provider <adapter> \
|
||||
--deployment-provider <adapter> --yes --json
|
||||
torch delivery transition --delivery <id> --to released --by owner \
|
||||
--evidence "artifact:<reference>" --yes --json
|
||||
```
|
||||
|
||||
Adapter names configure policy; a trusted installed adapter must also declare
|
||||
and implement the requested capability. The stock CLI ships with no release or
|
||||
deployment provider, so configuration alone cannot trigger an external action.
|
||||
|
||||
TORCH source checkouts can stage a self-host candidate without changing the
|
||||
active version. Candidate build runs the complete local acceptance, lint, and
|
||||
syntax gates before writing its receipt:
|
||||
|
||||
@@ -1217,6 +1217,18 @@ configuration MUST state who may perform each transition. Secrets are resolved
|
||||
at execution time from approved external stores and never copied into tracked
|
||||
Fleet state.
|
||||
|
||||
Implementation checkpoint (2026-09-27): TORCH now persists this complete state
|
||||
machine in the local control plane. Creation requires a clean, exact source
|
||||
worktree tip and evidence; verification requires the configured exact-commit
|
||||
check receipts; integration requires a landed request for that commit; and
|
||||
release, deployment, and live verification require separately configured
|
||||
provider capabilities, state-specific authority, evidence, successful adapter
|
||||
receipts, and explicit owner approval. Fresh installations configure both
|
||||
provider slots as `none`, so no external release or deployment can occur by
|
||||
default. The CLI and read-only Fleet observation surface are implemented and
|
||||
scenario-tested with a virtual adapter; no live release or deployment was
|
||||
performed.
|
||||
|
||||
## 25. Security and trust boundaries
|
||||
|
||||
TORCH MUST:
|
||||
|
||||
+4
-2
@@ -15,6 +15,7 @@
|
||||
"./console": "./src/console/server.mjs",
|
||||
"./design/brief": "./src/design/brief.mjs",
|
||||
"./design/architect": "./src/design/architect.mjs",
|
||||
"./delivery": "./src/delivery/service.mjs",
|
||||
"./evolution": "./src/evolution/service.mjs",
|
||||
"./forge": "./src/forge/service.mjs",
|
||||
"./control-plane": "./src/control-plane/service.mjs",
|
||||
@@ -66,6 +67,7 @@
|
||||
"src/convergence/",
|
||||
"src/control-plane/",
|
||||
"src/design/",
|
||||
"src/delivery/",
|
||||
"src/evolution/",
|
||||
"src/forge/",
|
||||
"src/kernel/",
|
||||
@@ -90,8 +92,8 @@
|
||||
"torch": "node bin/torch.mjs",
|
||||
"check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs && node --check src/console/server.mjs && node --check site/app.js && node --check site/console.js",
|
||||
"site:serve": "node bin/torch.mjs console serve",
|
||||
"format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix",
|
||||
"lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet"
|
||||
"format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/delivery src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix",
|
||||
"lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/delivery src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "^2.1.0",
|
||||
|
||||
@@ -134,6 +134,10 @@ test_integrity_note:
|
||||
given: a converged domain commit with every required exact-SHA receipt
|
||||
when: the domain requests integration and the Session Manager authorizes and lands it
|
||||
then: main fast-forwards only while source, target, receipts, cleanliness, and authority remain valid
|
||||
- id: SCN-delivery-lifecycle
|
||||
given: a clean implemented domain commit, exact check receipt, landed integration, and a virtual provider adapter
|
||||
when: TORCH advances it through verified, integrated, release-ready, released, deployed, and live-verified
|
||||
then: states cannot be skipped, authority remains separate, high-impact transitions need explicit approval, adapters declare capabilities, receipts persist, and CLI plus observation expose the same state
|
||||
- id: SCN-safe-convergence
|
||||
given: a managed domain worktree behind canonical main
|
||||
when: a check, measurement, pin, dirty state, or Git operation could make convergence unsafe
|
||||
@@ -253,6 +257,7 @@ test_integrity_note:
|
||||
- immutable check receipt commit and result
|
||||
- resource holder, FIFO position, expiry, and cancellation state
|
||||
- canonical main SHA before and after authorized landing
|
||||
- durable delivery state, authority blockers, adapter invocation order, and provider receipts
|
||||
- Git remote configuration and recoverability grade
|
||||
determinism_controls:
|
||||
- isolated temporary Git repositories
|
||||
@@ -295,6 +300,8 @@ test_integrity_note:
|
||||
- passing receipts detached from the commit actually measured
|
||||
- unfair or automatically stolen resource leases
|
||||
- integration based on branch labels, stale checks, or stale main
|
||||
- integration authority being treated as release or deployment authority
|
||||
- release, deployment, or live claims without explicit adapter receipts
|
||||
- a same-disk bare repository mislabeled as off-machine recovery
|
||||
- a merely passing command being mislabeled as complete candidate acceptance
|
||||
- candidate staging silently replacing stable or accepted code changing before activation
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
|
||||
export const DELIVERY_OPERATIONS = Object.freeze(['release', 'deploy', 'verifyLive']);
|
||||
|
||||
export function validateDeliveryAdapter(adapter) {
|
||||
if (!adapter || typeof adapter.name !== 'string' || !adapter.capabilities) {
|
||||
throw new TorchError('Delivery adapter is missing its name or capability declaration', {
|
||||
code: 'INVALID_DELIVERY_ADAPTER',
|
||||
});
|
||||
}
|
||||
for (const operation of DELIVERY_OPERATIONS) {
|
||||
if (!(operation in adapter.capabilities) || typeof adapter[operation] !== 'function') {
|
||||
throw new TorchError(`Delivery adapter ${adapter.name} does not declare and implement ${operation}`, {
|
||||
code: 'INVALID_DELIVERY_ADAPTER', details: { operation },
|
||||
});
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function unsupportedDeliveryCapability(adapterName, operation) {
|
||||
throw new TorchError(`${adapterName} delivery adapter does not support ${operation}`, {
|
||||
code: 'DELIVERY_CAPABILITY_UNSUPPORTED', details: { adapter: adapterName, operation },
|
||||
});
|
||||
}
|
||||
+64
@@ -38,6 +38,9 @@ import { ConvergenceService } from './convergence/service.mjs';
|
||||
import {
|
||||
attachForge, detachForge, forgeStatus, planForgeAttach, planForgeDetach,
|
||||
} from './forge/service.mjs';
|
||||
import {
|
||||
configureDelivery, DeliveryService, planDeliveryConfiguration,
|
||||
} from './delivery/service.mjs';
|
||||
|
||||
const HELP = `TORCH — portable agent fleet
|
||||
|
||||
@@ -103,6 +106,12 @@ Usage:
|
||||
torch forge attach --remote <name> [--provider <name>] --yes [--json]
|
||||
torch forge status [--json]
|
||||
torch forge detach [--dry-run] --yes [--json]
|
||||
torch delivery list [--state <state>] [--json]
|
||||
torch delivery get --delivery <id> [--json]
|
||||
torch delivery create --area <id> [--commit <sha>] --label <text> --evidence <text,...> [--json]
|
||||
torch delivery plan --delivery <id> --to <state> --by <id|owner> [--json]
|
||||
torch delivery transition --delivery <id> --to <state> --by <id|owner> --evidence <text,...> [--yes] [--json]
|
||||
torch delivery configure [--release-provider <name>] [--deployment-provider <name>] [--dry-run] --yes [--json]
|
||||
torch candidate plan --source <path> [--version <version>] [--json]
|
||||
torch candidate build --source <path> [--version <version>] --yes [--json]
|
||||
torch candidate status [--json]
|
||||
@@ -239,6 +248,7 @@ function runCandidateAcceptance(candidateRoot, spawn) {
|
||||
['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']],
|
||||
['cli-lifecycle-surface', ['SCN-cli-lifecycle-surface']],
|
||||
['forge-migration', ['SCN-forge-migration']],
|
||||
['delivery-lifecycle', ['SCN-delivery-lifecycle']],
|
||||
['review-install-roster', ['SCN-cli-domain-review']],
|
||||
['branches-worktrees', ['SCN-worktree-bootstrap']],
|
||||
['runtime-identities', ['SCN-mixed-runtime']],
|
||||
@@ -1078,6 +1088,60 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
}
|
||||
throw new TorchError(`Unknown forge operation: ${operation}`, { code: 'UNKNOWN_COMMAND' });
|
||||
}
|
||||
if (command === 'delivery') {
|
||||
const operation = argv[1] ?? 'list';
|
||||
if (operation === 'configure') {
|
||||
const input = {
|
||||
repositoryRoot: repository.root,
|
||||
releaseProvider: optionValue(argv, '--release-provider') ?? 'none',
|
||||
deploymentProvider: optionValue(argv, '--deployment-provider') ?? 'none',
|
||||
};
|
||||
if (argv.includes('--dry-run')) {
|
||||
const plan = planDeliveryConfiguration(input);
|
||||
print(plan, { json });
|
||||
return plan.canProceed ? 0 : 1;
|
||||
}
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Delivery adapter configuration changes tracked project policy. Review --dry-run, then use --yes.', {
|
||||
code: 'APPROVAL_REQUIRED',
|
||||
});
|
||||
}
|
||||
print(configureDelivery(input), { json });
|
||||
return 0;
|
||||
}
|
||||
const control = openControlPlane({ repositoryRoot: repository.root, env });
|
||||
try {
|
||||
const resources = new ResourceService({ repositoryRoot: repository.root, controlPlane: control });
|
||||
const checks = new CheckService({
|
||||
repositoryRoot: repository.root, controlPlane: control, resourceService: resources,
|
||||
});
|
||||
const delivery = new DeliveryService({
|
||||
repositoryRoot: repository.root, controlPlane: control, checkService: checks,
|
||||
});
|
||||
const deliveryId = optionValue(argv, '--delivery');
|
||||
if (operation === 'list') print({ deliveries: delivery.list({ state: optionValue(argv, '--state') }) }, { json });
|
||||
else if (operation === 'get') print(delivery.get(deliveryId), { json });
|
||||
else if (operation === 'create') print(delivery.create({
|
||||
sourceArea: optionValue(argv, '--area'), commit: optionValue(argv, '--commit') ?? 'HEAD',
|
||||
label: optionValue(argv, '--label'), evidence: commaList(optionValue(argv, '--evidence')),
|
||||
}), { json });
|
||||
else if (operation === 'plan') {
|
||||
const plan = delivery.planTransition({
|
||||
deliveryId, targetState: optionValue(argv, '--to'), actor: optionValue(argv, '--by'),
|
||||
});
|
||||
print(plan, { json });
|
||||
return plan.canProceed ? 0 : 1;
|
||||
} else if (operation === 'transition') {
|
||||
print(delivery.transition({
|
||||
deliveryId, targetState: optionValue(argv, '--to'), actor: optionValue(argv, '--by'),
|
||||
evidence: commaList(optionValue(argv, '--evidence')), approved: argv.includes('--yes'),
|
||||
}), { json });
|
||||
} else throw new TorchError(`Unknown delivery operation: ${operation}`, { code: 'UNKNOWN_COMMAND' });
|
||||
return 0;
|
||||
} finally {
|
||||
control.close();
|
||||
}
|
||||
}
|
||||
if (command === 'uninstall') {
|
||||
const result = uninstallProject({
|
||||
repository,
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { loadProjectConfig } from '../kernel/config.mjs';
|
||||
import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs';
|
||||
import { fileHash } from '../kernel/files.mjs';
|
||||
import { validateDeliveryAdapter } from '../adapters/delivery.mjs';
|
||||
|
||||
export const DELIVERY_STATES = Object.freeze([
|
||||
'implemented', 'verified', 'integrated', 'release-ready', 'released', 'deployed', 'live-verified',
|
||||
]);
|
||||
|
||||
const HIGH_IMPACT_STATES = new Set(['released', 'deployed', 'live-verified']);
|
||||
|
||||
function text(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TorchError(`${name} must be a non-empty string`, {
|
||||
code: 'DELIVERY_INPUT_INVALID', details: { field: name },
|
||||
});
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function textList(value, name) {
|
||||
if (!Array.isArray(value) || value.length === 0
|
||||
|| value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
||||
throw new TorchError(`${name} must be a non-empty array of strings`, {
|
||||
code: 'DELIVERY_INPUT_INVALID', details: { field: name },
|
||||
});
|
||||
}
|
||||
return [...new Set(value.map((entry) => entry.trim()))];
|
||||
}
|
||||
|
||||
function git(root, args) {
|
||||
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
||||
if (result.status !== 0) throw new TorchError('Delivery Git inspection failed', {
|
||||
code: 'GIT_COMMAND_FAILED', details: result.stderr?.trim() || null,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function initialize(database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
source_area TEXT NOT NULL,
|
||||
commit_sha TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
adapter_receipts_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS delivery_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
delivery_id TEXT NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
|
||||
from_state TEXT,
|
||||
to_state TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
adapter_receipt_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS deliveries_state_updated ON deliveries(state, updated_at, id);
|
||||
`);
|
||||
}
|
||||
|
||||
function hasTable(database, name) {
|
||||
return Boolean(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
}
|
||||
|
||||
function rowToDelivery(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id, projectId: row.project_id, label: row.label, sourceArea: row.source_area,
|
||||
commit: row.commit_sha, state: row.state, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
evidence: JSON.parse(row.evidence_json), adapterReceipts: JSON.parse(row.adapter_receipts_json),
|
||||
};
|
||||
}
|
||||
|
||||
function adapterRequirement(config, targetState) {
|
||||
if (targetState === 'released') {
|
||||
return { slot: 'release', operation: 'release', provider: config.delivery.adapters.release.provider };
|
||||
}
|
||||
if (targetState === 'deployed') {
|
||||
return { slot: 'deployment', operation: 'deploy', provider: config.delivery.adapters.deployment.provider };
|
||||
}
|
||||
if (targetState === 'live-verified') {
|
||||
return { slot: 'deployment', operation: 'verifyLive', provider: config.delivery.adapters.deployment.provider };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configurationPaths(repositoryRoot) {
|
||||
return { config: join(repositoryRoot, '.torch', 'torch.yaml') };
|
||||
}
|
||||
|
||||
function ownedConfiguration(repositoryRoot, manifest) {
|
||||
const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml');
|
||||
return Boolean(record && fileHash(configurationPaths(repositoryRoot).config) === record.sha256);
|
||||
}
|
||||
|
||||
export function planDeliveryConfiguration({
|
||||
repositoryRoot, releaseProvider = 'none', deploymentProvider = 'none',
|
||||
} = {}) {
|
||||
const release = text(releaseProvider, 'releaseProvider');
|
||||
const deployment = text(deploymentProvider, 'deploymentProvider');
|
||||
const config = loadProjectConfig(repositoryRoot);
|
||||
const manifest = readInstallManifest(repositoryRoot);
|
||||
const blockers = [];
|
||||
if (!ownedConfiguration(repositoryRoot, manifest)) blockers.push({ code: 'DELIVERY_CONFIGURATION_MODIFIED' });
|
||||
return {
|
||||
action: 'configure-delivery-adapters',
|
||||
current: {
|
||||
release: config.delivery.adapters.release.provider,
|
||||
deployment: config.delivery.adapters.deployment.provider,
|
||||
},
|
||||
requested: { release, deployment },
|
||||
blockers, canProceed: blockers.length === 0, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function configureDelivery(input = {}) {
|
||||
const plan = planDeliveryConfiguration(input);
|
||||
if (!plan.canProceed) throw new TorchError('Delivery adapter configuration is blocked', {
|
||||
code: 'DELIVERY_CONFIGURATION_BLOCKED', details: plan.blockers,
|
||||
});
|
||||
const config = loadProjectConfig(input.repositoryRoot);
|
||||
const manifest = readInstallManifest(input.repositoryRoot);
|
||||
config.delivery.adapters.release.provider = plan.requested.release;
|
||||
config.delivery.adapters.deployment.provider = plan.requested.deployment;
|
||||
const path = configurationPaths(input.repositoryRoot).config;
|
||||
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
||||
writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, {
|
||||
encoding: 'utf8', mode: 0o600, flag: 'wx',
|
||||
});
|
||||
renameSync(temporary, path);
|
||||
const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml');
|
||||
record.sha256 = fileHash(path);
|
||||
writeInstallManifest(input.repositoryRoot, manifest);
|
||||
return { ...plan, configuredAt: new Date().toISOString(), mutationPerformed: true };
|
||||
}
|
||||
|
||||
export class DeliveryService {
|
||||
constructor({
|
||||
repositoryRoot, controlPlane, checkService = null, adapters = new Map(),
|
||||
clock = () => new Date(), idFactory = randomUUID,
|
||||
} = {}) {
|
||||
if (!controlPlane) throw new TorchError('Delivery lifecycle requires the TORCH control plane', { code: 'CONTROL_PLANE_REQUIRED' });
|
||||
this.repositoryRoot = repositoryRoot;
|
||||
this.controlPlane = controlPlane;
|
||||
this.checkService = checkService;
|
||||
this.adapters = adapters instanceof Map ? adapters : new Map(Object.entries(adapters));
|
||||
this.clock = clock;
|
||||
this.idFactory = idFactory;
|
||||
this.database = controlPlane.database;
|
||||
this.config = loadProjectConfig(repositoryRoot);
|
||||
this.manifest = readInstallManifest(repositoryRoot);
|
||||
this.worktrees = new Map((this.manifest.external ?? [])
|
||||
.filter((entry) => entry.type === 'worktree').map((entry) => [entry.area, entry]));
|
||||
initialize(this.database);
|
||||
}
|
||||
|
||||
list({ state } = {}) {
|
||||
if (state && !DELIVERY_STATES.includes(state)) {
|
||||
throw new TorchError(`Unknown delivery state: ${state}`, { code: 'DELIVERY_STATE_INVALID' });
|
||||
}
|
||||
const rows = state
|
||||
? this.database.prepare('SELECT * FROM deliveries WHERE state = ? ORDER BY updated_at, id').all(state)
|
||||
: this.database.prepare('SELECT * FROM deliveries ORDER BY updated_at, id').all();
|
||||
return rows.map(rowToDelivery);
|
||||
}
|
||||
|
||||
get(deliveryId) {
|
||||
const id = text(deliveryId, 'deliveryId');
|
||||
const delivery = rowToDelivery(this.database.prepare('SELECT * FROM deliveries WHERE id = ?').get(id));
|
||||
if (!delivery) throw new TorchError(`Unknown delivery: ${id}`, { code: 'DELIVERY_NOT_FOUND' });
|
||||
return delivery;
|
||||
}
|
||||
|
||||
create({ sourceArea, commit = 'HEAD', label, evidence } = {}) {
|
||||
const area = this.controlPlane.assertIdentity(sourceArea);
|
||||
if (area === 'session-manager') {
|
||||
throw new TorchError('A delivery source must be a development domain', { code: 'DELIVERY_SOURCE_INVALID' });
|
||||
}
|
||||
const worktree = this.worktrees.get(area);
|
||||
if (!worktree) throw new TorchError(`No managed worktree for ${area}`, { code: 'WORKTREE_MISSING' });
|
||||
const resolvedCommit = git(worktree.path, ['rev-parse', text(commit, 'commit')]);
|
||||
const head = git(worktree.path, ['rev-parse', 'HEAD']);
|
||||
const dirty = git(worktree.path, ['status', '--porcelain']).split('\n').filter(Boolean);
|
||||
if (resolvedCommit !== head || dirty.length) {
|
||||
throw new TorchError('Delivery implementation must name the clean source worktree HEAD', {
|
||||
code: 'DELIVERY_SOURCE_UNSAFE', details: { resolvedCommit, head, dirty },
|
||||
});
|
||||
}
|
||||
const now = this.clock().toISOString();
|
||||
const record = {
|
||||
id: this.idFactory(), projectId: this.controlPlane.projectId, label: text(label, 'label'),
|
||||
sourceArea: area, commit: resolvedCommit, state: 'implemented', createdAt: now, updatedAt: now,
|
||||
evidence: textList(evidence, 'evidence'), adapterReceipts: [],
|
||||
};
|
||||
this.database.prepare(`INSERT INTO deliveries (
|
||||
id, project_id, label, source_area, commit_sha, state, created_at, updated_at,
|
||||
evidence_json, adapter_receipts_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(record.id, record.projectId, record.label, record.sourceArea, record.commit,
|
||||
record.state, now, now, JSON.stringify(record.evidence), '[]');
|
||||
this.database.prepare(`INSERT INTO delivery_events (
|
||||
id, delivery_id, from_state, to_state, actor, created_at, evidence_json, adapter_receipt_json
|
||||
) VALUES (?, ?, NULL, 'implemented', ?, ?, ?, NULL)`)
|
||||
.run(this.idFactory(), record.id, area, now, JSON.stringify(record.evidence));
|
||||
this.controlPlane.audit({
|
||||
actorId: area, operation: 'delivery.create', entityType: 'delivery', entityId: record.id,
|
||||
details: { commit: record.commit, state: record.state },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
planTransition({ deliveryId, targetState, actor } = {}) {
|
||||
const delivery = this.get(deliveryId);
|
||||
const target = text(targetState, 'targetState');
|
||||
const currentIndex = DELIVERY_STATES.indexOf(delivery.state);
|
||||
if (DELIVERY_STATES[currentIndex + 1] !== target) {
|
||||
throw new TorchError(`Delivery must advance from ${delivery.state} to ${DELIVERY_STATES[currentIndex + 1] ?? '<terminal>'}`, {
|
||||
code: 'DELIVERY_TRANSITION_INVALID', details: { current: delivery.state, target },
|
||||
});
|
||||
}
|
||||
const principal = text(actor, 'actor');
|
||||
if (principal !== 'owner') this.controlPlane.assertIdentity(principal);
|
||||
const authorityKey = target.replaceAll('-', '_');
|
||||
const configuredAuthority = this.config.delivery.authority[authorityKey] ?? [];
|
||||
const authorizedActors = configuredAuthority.map((entry) => entry === '$source' ? delivery.sourceArea : entry);
|
||||
const blockers = [];
|
||||
if (!authorizedActors.includes(principal)) blockers.push({ code: 'DELIVERY_AUTHORITY_REQUIRED', actor: principal, allowed: authorizedActors });
|
||||
if (target === 'verified') {
|
||||
const requiredChecks = this.config.integration.required_checks;
|
||||
if (requiredChecks.length && !this.checkService?.exactPasses({ commit: delivery.commit, requiredChecks })) {
|
||||
blockers.push({ code: 'DELIVERY_CHECKS_REQUIRED', commit: delivery.commit, checks: requiredChecks });
|
||||
}
|
||||
}
|
||||
if (target === 'integrated') {
|
||||
const landed = hasTable(this.database, 'integration_requests')
|
||||
? this.database.prepare(`SELECT id FROM integration_requests
|
||||
WHERE source_commit = ? AND state = 'landed' LIMIT 1`).get(delivery.commit)
|
||||
: null;
|
||||
if (!landed) blockers.push({ code: 'DELIVERY_INTEGRATION_REQUIRED', commit: delivery.commit });
|
||||
}
|
||||
const requirement = adapterRequirement(this.config, target);
|
||||
if (requirement) {
|
||||
if (requirement.provider === 'none') blockers.push({
|
||||
code: 'DELIVERY_ADAPTER_NOT_CONFIGURED', slot: requirement.slot, operation: requirement.operation,
|
||||
});
|
||||
else {
|
||||
const adapter = this.adapters.get(requirement.provider) ?? null;
|
||||
if (!adapter) blockers.push({ code: 'DELIVERY_ADAPTER_MISSING', provider: requirement.provider });
|
||||
else {
|
||||
validateDeliveryAdapter(adapter);
|
||||
if (adapter.capabilities[requirement.operation] !== true) blockers.push({
|
||||
code: 'DELIVERY_CAPABILITY_UNSUPPORTED', provider: requirement.provider, operation: requirement.operation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
action: 'delivery-transition', deliveryId: delivery.id, from: delivery.state, to: target,
|
||||
actor: principal, authority: authorizedActors, adapter: requirement ? {
|
||||
slot: requirement.slot, provider: requirement.provider, operation: requirement.operation,
|
||||
} : null,
|
||||
requiresApproval: HIGH_IMPACT_STATES.has(target), blockers,
|
||||
canProceed: blockers.length === 0, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
transition({ deliveryId, targetState, actor, evidence, approved = false } = {}) {
|
||||
const plan = this.planTransition({ deliveryId, targetState, actor });
|
||||
if (!plan.canProceed) throw new TorchError('Delivery transition is blocked', {
|
||||
code: 'DELIVERY_TRANSITION_BLOCKED', details: plan.blockers,
|
||||
});
|
||||
if (plan.requiresApproval && !approved) throw new TorchError('High-impact delivery transition requires explicit owner approval', {
|
||||
code: 'APPROVAL_REQUIRED', details: { state: plan.to },
|
||||
});
|
||||
const evidenceList = textList(evidence, 'evidence');
|
||||
const delivery = this.get(deliveryId);
|
||||
let receipt = null;
|
||||
if (plan.adapter) {
|
||||
const adapter = this.adapters.get(plan.adapter.provider);
|
||||
receipt = adapter[plan.adapter.operation]({ delivery, evidence: evidenceList });
|
||||
if (!receipt || receipt.status !== 'succeeded') throw new TorchError('Delivery adapter did not return a successful receipt', {
|
||||
code: 'DELIVERY_ADAPTER_FAILED', details: { adapter: plan.adapter, receipt },
|
||||
});
|
||||
}
|
||||
const now = this.clock().toISOString();
|
||||
const receipts = [...delivery.adapterReceipts, ...(receipt ? [{ ...receipt, operation: plan.adapter.operation }] : [])];
|
||||
const allEvidence = [...new Set([...delivery.evidence, ...evidenceList])];
|
||||
const update = this.database.prepare(`UPDATE deliveries SET state = ?, updated_at = ?, evidence_json = ?,
|
||||
adapter_receipts_json = ? WHERE id = ? AND state = ?`)
|
||||
.run(plan.to, now, JSON.stringify(allEvidence), JSON.stringify(receipts), delivery.id, delivery.state);
|
||||
if (update.changes !== 1) throw new TorchError('Delivery state changed concurrently', {
|
||||
code: 'DELIVERY_STATE_CONFLICT', details: { deliveryId: delivery.id, expected: delivery.state },
|
||||
});
|
||||
this.database.prepare(`INSERT INTO delivery_events (
|
||||
id, delivery_id, from_state, to_state, actor, created_at, evidence_json, adapter_receipt_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(this.idFactory(), delivery.id, delivery.state, plan.to, plan.actor, now,
|
||||
JSON.stringify(evidenceList), receipt ? JSON.stringify(receipt) : null);
|
||||
this.controlPlane.audit({
|
||||
actorId: plan.actor === 'owner' ? 'session-manager' : plan.actor,
|
||||
operation: `delivery.${plan.to}`, entityType: 'delivery', entityId: delivery.id,
|
||||
details: { commit: delivery.commit, approved, adapter: plan.adapter },
|
||||
});
|
||||
return this.get(delivery.id);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,21 @@ export const projectConfigSchema = z.object({
|
||||
required_checks: textList,
|
||||
landing_authority: textList.min(1),
|
||||
}).strict(),
|
||||
delivery: z.object({
|
||||
authority: z.object({
|
||||
implemented: textList.min(1),
|
||||
verified: textList.min(1),
|
||||
integrated: textList.min(1),
|
||||
release_ready: textList.min(1),
|
||||
released: textList.min(1),
|
||||
deployed: textList.min(1),
|
||||
live_verified: textList.min(1),
|
||||
}).strict(),
|
||||
adapters: z.object({
|
||||
release: z.object({ provider: text }).passthrough(),
|
||||
deployment: z.object({ provider: text }).passthrough(),
|
||||
}).strict(),
|
||||
}).strict(),
|
||||
session_manager: z.object({
|
||||
id: z.literal('session-manager'),
|
||||
runtime: text,
|
||||
|
||||
@@ -79,6 +79,21 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
required_checks: (proposal.checks ?? []).map((check) => check.id),
|
||||
landing_authority: ['session-manager'],
|
||||
},
|
||||
delivery: {
|
||||
authority: {
|
||||
implemented: ['$source'],
|
||||
verified: ['$source', 'session-manager'],
|
||||
integrated: ['session-manager'],
|
||||
release_ready: ['session-manager'],
|
||||
released: ['owner'],
|
||||
deployed: ['owner'],
|
||||
live_verified: ['owner'],
|
||||
},
|
||||
adapters: {
|
||||
release: { provider: 'none' },
|
||||
deployment: { provider: 'none' },
|
||||
},
|
||||
},
|
||||
session_manager: {
|
||||
id: 'session-manager', runtime: proposal.session_manager?.runtime ?? 'claude', start_last: true,
|
||||
branch: proposal.session_manager?.branch ?? null,
|
||||
|
||||
@@ -79,6 +79,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
checks: { definitions: config.checks ?? [], receipts: [] },
|
||||
resources: { definitions: config.resources ?? [], holders: [], waiters: [] },
|
||||
integration: [],
|
||||
deliveries: [],
|
||||
fleetChanges: [],
|
||||
schedules: config.schedules ?? [],
|
||||
audit: [],
|
||||
@@ -122,7 +123,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
}
|
||||
}
|
||||
result.checks.receipts = rows(database, 'check_receipts', `
|
||||
SELECT id, check_id AS checkId, area_id AS areaId, commit_sha AS commit, result, finished_at AS finishedAt
|
||||
SELECT id, check_id AS checkId, area_id AS areaId, commit_sha AS "commit", result, finished_at AS finishedAt
|
||||
FROM check_receipts ORDER BY finished_at DESC LIMIT 40
|
||||
`);
|
||||
result.resources.holders = rows(database, 'resource_leases', `
|
||||
@@ -137,6 +138,10 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
SELECT id, source_area AS sourceArea, source_commit AS sourceCommit, target_branch AS targetBranch, state, reason, updated_at AS updatedAt
|
||||
FROM integration_requests ORDER BY updated_at DESC LIMIT 40
|
||||
`);
|
||||
result.deliveries = rows(database, 'deliveries', `
|
||||
SELECT id, label, source_area AS sourceArea, commit_sha AS "commit", state, updated_at AS updatedAt
|
||||
FROM deliveries ORDER BY updated_at DESC, id LIMIT 40
|
||||
`);
|
||||
result.fleetChanges = rows(database, 'fleet_changes', `
|
||||
SELECT id, change_type AS changeType, state, proposer,
|
||||
COALESCE(json_extract(domain_json, '$.id'), json_extract(proposal_json, '$.resultDomains[0].id')) AS domainId,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
|
||||
'fleet-boundary-evolution',
|
||||
'cli-lifecycle-surface',
|
||||
'forge-migration',
|
||||
'delivery-lifecycle',
|
||||
'review-install-roster',
|
||||
'branches-worktrees',
|
||||
'runtime-identities',
|
||||
|
||||
@@ -39,6 +39,7 @@ function releaseCandidate(root, version) {
|
||||
'SCN-fleet-boundary-evolution',
|
||||
'SCN-cli-lifecycle-surface',
|
||||
'SCN-forge-migration',
|
||||
'SCN-delivery-lifecycle',
|
||||
'SCN-durable-message', 'SCN-ownership-handoff', 'SCN-worktree-purge-safety',
|
||||
'SCN-backlog-lifecycle', 'SCN-native-integration', 'SCN-resource-fifo',
|
||||
'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge',
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { CheckService } from '../../src/checks/service.mjs';
|
||||
import { openControlPlane } from '../../src/control-plane/service.mjs';
|
||||
import {
|
||||
configureDelivery, DeliveryService, planDeliveryConfiguration,
|
||||
} from '../../src/delivery/service.mjs';
|
||||
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
|
||||
import { proposeDomains } from '../../src/kernel/domains.mjs';
|
||||
import { inspectRepository } from '../../src/kernel/git.mjs';
|
||||
import { installProject } from '../../src/kernel/install.mjs';
|
||||
import { createWorktrees } from '../../src/kernel/worktrees.mjs';
|
||||
import { IntegrationService } from '../../src/integration/service.mjs';
|
||||
import { observeProject } from '../../src/observability/snapshot.mjs';
|
||||
|
||||
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-delivery-'));
|
||||
execFileSync('git', ['init', '-b', 'main', root]);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
|
||||
writeFileSync(join(root, 'package.json'), `${JSON.stringify({
|
||||
name: 'delivery-fixture', scripts: { 'verify:pass': 'node -e "process.exit(0)"' },
|
||||
}, null, 2)}\n`);
|
||||
writeFileSync(join(root, 'app.js'), 'export const value = 1;\n');
|
||||
execFileSync('git', ['-C', root, 'add', '.']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
|
||||
let repository = inspectRepository(root);
|
||||
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
|
||||
proposal.checks = proposal.checks.filter((check) => check.id === 'verify-pass');
|
||||
proposal.review = {
|
||||
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
|
||||
};
|
||||
const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) };
|
||||
installProject({ repository, proposal, env, projectId: 'delivery-fixture' });
|
||||
execFileSync('git', ['-C', root, 'add', '.torch']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']);
|
||||
repository = inspectRepository(root);
|
||||
createWorktrees({
|
||||
repository, parentOverride: join(tmpdir(), `${basename(root)}-worktrees`),
|
||||
});
|
||||
execFileSync('git', ['-C', root, 'add', '.torch/install-manifest.json']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'record managed worktrees']);
|
||||
const manifest = JSON.parse(readFileSync(join(root, '.torch', 'install-manifest.json'), 'utf8'));
|
||||
const worker = proposal.domains[0].id;
|
||||
const worktree = manifest.external.find((entry) => entry.type === 'worktree' && entry.area === worker);
|
||||
execFileSync('git', ['-C', worktree.path, 'merge', 'main']);
|
||||
writeFileSync(join(worktree.path, 'app.js'), 'export const value = 2;\n');
|
||||
execFileSync('git', ['-C', worktree.path, 'add', 'app.js']);
|
||||
execFileSync('git', ['-C', worktree.path, 'commit', '-m', 'delivery change']);
|
||||
return { root, env, worker, worktree };
|
||||
}
|
||||
|
||||
test('SCN-delivery-lifecycle: delivery authority, evidence, integration, and adapters are distinct gates', () => {
|
||||
const context = fixture();
|
||||
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
|
||||
let serial = 0;
|
||||
const idFactory = () => `delivery-id-${++serial}`;
|
||||
const checks = new CheckService({ repositoryRoot: context.root, controlPlane: control, idFactory });
|
||||
let service = new DeliveryService({
|
||||
repositoryRoot: context.root, controlPlane: control, checkService: checks, idFactory,
|
||||
});
|
||||
let delivery = service.create({
|
||||
sourceArea: context.worker, label: 'Portable release boundary', evidence: ['implementation commit'],
|
||||
});
|
||||
assert.equal(delivery.state, 'implemented');
|
||||
assert.throws(
|
||||
() => service.planTransition({ deliveryId: delivery.id, targetState: 'integrated', actor: 'session-manager' }),
|
||||
(error) => error.code === 'DELIVERY_TRANSITION_INVALID',
|
||||
);
|
||||
assert.equal(service.planTransition({
|
||||
deliveryId: delivery.id, targetState: 'verified', actor: context.worker,
|
||||
}).blockers.some((blocker) => blocker.code === 'DELIVERY_CHECKS_REQUIRED'), true);
|
||||
|
||||
const receipt = checks.run({ checkId: 'verify-pass', areaId: context.worker });
|
||||
delivery = service.transition({
|
||||
deliveryId: delivery.id, targetState: 'verified', actor: context.worker,
|
||||
evidence: [`check:${receipt.id}`],
|
||||
});
|
||||
assert.equal(delivery.state, 'verified');
|
||||
assert.equal(service.planTransition({
|
||||
deliveryId: delivery.id, targetState: 'integrated', actor: 'session-manager',
|
||||
}).blockers.some((blocker) => blocker.code === 'DELIVERY_INTEGRATION_REQUIRED'), true);
|
||||
|
||||
const integration = new IntegrationService({
|
||||
repositoryRoot: context.root, controlPlane: control, checkService: checks, idFactory,
|
||||
});
|
||||
const request = integration.request({ areaId: context.worker, commit: receipt.commit });
|
||||
integration.authorize({ requestId: request.id, actorId: 'session-manager' });
|
||||
const landed = integration.land({ requestId: request.id, actorId: 'session-manager' });
|
||||
delivery = service.transition({
|
||||
deliveryId: delivery.id, targetState: 'integrated', actor: 'session-manager',
|
||||
evidence: [`integration:${landed.id}`],
|
||||
});
|
||||
delivery = service.transition({
|
||||
deliveryId: delivery.id, targetState: 'release-ready', actor: 'session-manager',
|
||||
evidence: ['release checklist complete'],
|
||||
});
|
||||
assert.equal(delivery.state, 'release-ready');
|
||||
assert.equal(service.planTransition({
|
||||
deliveryId: delivery.id, targetState: 'released', actor: 'owner',
|
||||
}).blockers[0].code, 'DELIVERY_ADAPTER_NOT_CONFIGURED');
|
||||
|
||||
const configurationPlan = planDeliveryConfiguration({
|
||||
repositoryRoot: context.root, releaseProvider: 'fixture', deploymentProvider: 'fixture',
|
||||
});
|
||||
assert.equal(configurationPlan.canProceed, true);
|
||||
assert.equal(configurationPlan.mutationPerformed, false);
|
||||
configureDelivery({
|
||||
repositoryRoot: context.root, releaseProvider: 'fixture', deploymentProvider: 'fixture',
|
||||
});
|
||||
const invocations = [];
|
||||
const adapter = {
|
||||
name: 'fixture', capabilities: { release: true, deploy: true, verifyLive: true },
|
||||
release: ({ delivery: item }) => {
|
||||
invocations.push('release'); return { status: 'succeeded', reference: `release:${item.commit}` };
|
||||
},
|
||||
deploy: ({ delivery: item }) => {
|
||||
invocations.push('deploy'); return { status: 'succeeded', reference: `deploy:${item.commit}` };
|
||||
},
|
||||
verifyLive: ({ delivery: item }) => {
|
||||
invocations.push('verifyLive'); return { status: 'succeeded', reference: `live:${item.commit}` };
|
||||
},
|
||||
};
|
||||
service = new DeliveryService({
|
||||
repositoryRoot: context.root, controlPlane: control, checkService: checks,
|
||||
adapters: new Map([['fixture', adapter]]), idFactory,
|
||||
});
|
||||
assert.equal(service.planTransition({
|
||||
deliveryId: delivery.id, targetState: 'released', actor: 'session-manager',
|
||||
}).blockers[0].code, 'DELIVERY_AUTHORITY_REQUIRED');
|
||||
assert.throws(
|
||||
() => service.transition({
|
||||
deliveryId: delivery.id, targetState: 'released', actor: 'owner', evidence: ['release candidate'],
|
||||
}),
|
||||
(error) => error.code === 'APPROVAL_REQUIRED',
|
||||
);
|
||||
for (const [state, evidence] of [
|
||||
['released', 'published artifact'], ['deployed', 'deployment receipt'], ['live-verified', 'live probe'],
|
||||
]) {
|
||||
delivery = service.transition({
|
||||
deliveryId: delivery.id, targetState: state, actor: 'owner', evidence: [evidence], approved: true,
|
||||
});
|
||||
}
|
||||
assert.equal(delivery.state, 'live-verified');
|
||||
assert.deepEqual(invocations, ['release', 'deploy', 'verifyLive']);
|
||||
assert.equal(delivery.adapterReceipts.length, 3);
|
||||
assert.equal(observeProject({ repositoryRoot: context.root, env: context.env }).deliveries[0].state, 'live-verified');
|
||||
control.close();
|
||||
|
||||
const reopened = openControlPlane({ repositoryRoot: context.root, env: context.env });
|
||||
const durable = new DeliveryService({
|
||||
repositoryRoot: context.root, controlPlane: reopened, adapters: new Map([['fixture', adapter]]),
|
||||
}).get(delivery.id);
|
||||
assert.equal(durable.state, 'live-verified');
|
||||
assert.equal(durable.adapterReceipts.length, 3);
|
||||
reopened.close();
|
||||
|
||||
const cliCreate = spawnSync(process.execPath, [
|
||||
CLI, 'delivery', 'create', '--area', context.worker, '--label', 'CLI delivery',
|
||||
'--evidence', 'clean source tip', '--json',
|
||||
], { cwd: context.root, env: context.env, encoding: 'utf8' });
|
||||
assert.equal(cliCreate.status, 0, cliCreate.stderr || cliCreate.stdout);
|
||||
const cliDelivery = JSON.parse(cliCreate.stdout);
|
||||
assert.equal(cliDelivery.state, 'implemented');
|
||||
const cliList = spawnSync(process.execPath, [CLI, 'delivery', 'list', '--json'], {
|
||||
cwd: context.root, env: context.env, encoding: 'utf8',
|
||||
});
|
||||
assert.equal(cliList.status, 0, cliList.stderr || cliList.stdout);
|
||||
assert.equal(JSON.parse(cliList.stdout).deliveries.some((item) => item.id === cliDelivery.id), true);
|
||||
});
|
||||
Reference in new issue
Block a user