mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan N3 / spec §11.5.1 / §21.5.
@open-design/contracts ArtifactManifest gains the spec §11.5.1
provenance + downstream-distribution surface as additive optional
fields:
sourcePluginSnapshotId / sourcePluginId / sourcePluginVersion /
sourceTaskKind / sourceRunId / sourceProjectId / parentArtifactId
artifactKind / renderKind / handoffKind
exportTargets[] / deployTargets[]
Spec §11.5.1 invariants:
- sourcePluginSnapshotId NEVER changes after first write.
- exportTargets[] / deployTargets[] are append-only.
- handoffKind promotes monotonically along
design-only < implementation-plan < patch < deployable-app.
apps/daemon/src/plugins/atoms/handoff.ts ships the daemon-side
helper:
recordHandoff({ manifest, exportTarget?, deployTarget?,
handoffKind?, enforceMonotonicHandoff? })
→ { manifest, changed }
- Idempotent: a (surface, target) pair only ever lands once on
exportTargets[]; same for (provider, location) on deployTargets[].
- handoffKind defaults to monotonic; pass enforceMonotonicHandoff:
false on a rollback path.
isDeployableAppEligible({ manifest, buildPassing, testsPassing })
→ boolean
Spec §11.5.1 promotion rule for the deployable-app tier: requires
build.passing + tests.passing AND at least one exportTargets[]
entry on docker / cli surface. Centralises the rule so plugins
don't reimplement it.
packages/contracts/src/index.ts now uses .js extensions on every
re-export so the daemon's NodeNext moduleResolution picks up the
new types end-to-end.
Daemon tests: 1534 → 1543 (+9 cases on plugins-handoff: appends
exportTargets / deployTargets, idempotency, monotonic handoffKind
promotion, downgrade refusal vs. rollback escape, deployable-app
eligibility rule).
Co-authored-by: Tom Huang <1043269994@qq.com>
99 lines
3.9 KiB
TypeScript
99 lines
3.9 KiB
TypeScript
// Phase 7-8 entry slice / spec §11.5.1 — handoff atom helper.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { ArtifactManifest } from '@open-design/contracts';
|
|
import { isDeployableAppEligible, recordHandoff } from '../src/plugins/atoms/handoff.js';
|
|
|
|
const baseManifest = (extra: Partial<ArtifactManifest> = {}): ArtifactManifest => ({
|
|
version: 1,
|
|
kind: 'html',
|
|
title: 'Fixture',
|
|
entry: 'index.html',
|
|
renderer: 'html',
|
|
exports: [],
|
|
...extra,
|
|
});
|
|
|
|
describe('recordHandoff — append-only contracts', () => {
|
|
it('appends a new exportTargets entry', () => {
|
|
const out = recordHandoff({
|
|
manifest: baseManifest(),
|
|
exportTarget: { surface: 'cli', target: '/workspace/od/x.html', exportedAt: 1000 },
|
|
});
|
|
expect(out.changed).toContain('exportTargets');
|
|
expect(out.manifest.exportTargets).toEqual([
|
|
{ surface: 'cli', target: '/workspace/od/x.html', exportedAt: 1000 },
|
|
]);
|
|
});
|
|
|
|
it('is idempotent on identical (surface, target) pairs', () => {
|
|
const first = recordHandoff({
|
|
manifest: baseManifest(),
|
|
exportTarget: { surface: 'cli', target: '/p/a.html', exportedAt: 1 },
|
|
});
|
|
const second = recordHandoff({
|
|
manifest: first.manifest,
|
|
exportTarget: { surface: 'cli', target: '/p/a.html', exportedAt: 5 },
|
|
});
|
|
expect(second.changed).toEqual([]);
|
|
expect(second.manifest.exportTargets?.length).toBe(1);
|
|
});
|
|
|
|
it('appends deployTargets independently', () => {
|
|
const out = recordHandoff({
|
|
manifest: baseManifest(),
|
|
deployTarget: { provider: 'aws', location: 'arn:aws:ecs:...', deployedAt: 2 },
|
|
});
|
|
expect(out.changed).toEqual(['deployTargets']);
|
|
expect(out.manifest.deployTargets?.[0]?.provider).toBe('aws');
|
|
});
|
|
});
|
|
|
|
describe('recordHandoff — handoffKind monotonicity', () => {
|
|
it('records the first handoffKind unconditionally', () => {
|
|
const out = recordHandoff({
|
|
manifest: baseManifest(),
|
|
handoffKind: 'design-only',
|
|
});
|
|
expect(out.manifest.handoffKind).toBe('design-only');
|
|
expect(out.changed).toContain('handoffKind');
|
|
});
|
|
|
|
it('promotes handoffKind along the axis design-only → implementation-plan → patch → deployable-app', () => {
|
|
const a = recordHandoff({ manifest: baseManifest({ handoffKind: 'design-only' }), handoffKind: 'patch' });
|
|
expect(a.manifest.handoffKind).toBe('patch');
|
|
const b = recordHandoff({ manifest: a.manifest, handoffKind: 'deployable-app' });
|
|
expect(b.manifest.handoffKind).toBe('deployable-app');
|
|
});
|
|
|
|
it('refuses to downgrade when enforceMonotonicHandoff is on (default)', () => {
|
|
const a = recordHandoff({ manifest: baseManifest({ handoffKind: 'patch' }), handoffKind: 'design-only' });
|
|
expect(a.manifest.handoffKind).toBe('patch');
|
|
expect(a.changed).not.toContain('handoffKind');
|
|
});
|
|
|
|
it('allows downgrade when enforceMonotonicHandoff is false (rollback path)', () => {
|
|
const a = recordHandoff({
|
|
manifest: baseManifest({ handoffKind: 'deployable-app' }),
|
|
handoffKind: 'patch',
|
|
enforceMonotonicHandoff: false,
|
|
});
|
|
expect(a.manifest.handoffKind).toBe('patch');
|
|
});
|
|
});
|
|
|
|
describe('isDeployableAppEligible', () => {
|
|
it('requires both build + tests passing', () => {
|
|
const m = baseManifest({ exportTargets: [{ surface: 'docker', target: 'ghcr.io/od/x:1', exportedAt: 1 }] });
|
|
expect(isDeployableAppEligible({ manifest: m, buildPassing: true, testsPassing: true })).toBe(true);
|
|
expect(isDeployableAppEligible({ manifest: m, buildPassing: false, testsPassing: true })).toBe(false);
|
|
expect(isDeployableAppEligible({ manifest: m, buildPassing: true, testsPassing: false })).toBe(false);
|
|
});
|
|
|
|
it('requires at least one docker or cli exportTarget', () => {
|
|
const m = baseManifest({
|
|
exportTargets: [{ surface: 'figma', target: 'file/abc', exportedAt: 1 }],
|
|
});
|
|
expect(isDeployableAppEligible({ manifest: m, buildPassing: true, testsPassing: true })).toBe(false);
|
|
});
|
|
});
|