@wappy_ai/core 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/dist/clock.d.ts +8 -0
- package/dist/clock.js +17 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/interfaces.d.ts +92 -0
- package/dist/interfaces.js +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +6 -0
- package/dist/registry.d.ts +26 -0
- package/dist/registry.js +40 -0
- package/dist/schemas.d.ts +285 -0
- package/dist/schemas.js +149 -0
- package/dist/semver.d.ts +1 -0
- package/dist/semver.js +34 -0
- package/dist/setup-manifest.d.ts +20 -0
- package/dist/setup-manifest.js +19 -0
- package/dist/state/drift.d.ts +9 -0
- package/dist/state/drift.js +13 -0
- package/dist/state/errors.d.ts +14 -0
- package/dist/state/errors.js +26 -0
- package/dist/state/index.d.ts +11 -0
- package/dist/state/index.js +11 -0
- package/dist/state/io.d.ts +11 -0
- package/dist/state/io.js +54 -0
- package/dist/state/load.d.ts +23 -0
- package/dist/state/load.js +23 -0
- package/dist/state/lock.d.ts +19 -0
- package/dist/state/lock.js +85 -0
- package/dist/state/manifest.d.ts +18 -0
- package/dist/state/manifest.js +25 -0
- package/dist/state/migrations.d.ts +11 -0
- package/dist/state/migrations.js +28 -0
- package/dist/state/reset.d.ts +15 -0
- package/dist/state/reset.js +19 -0
- package/dist/state/schema.d.ts +73 -0
- package/dist/state/schema.js +56 -0
- package/dist/state/step-runner.d.ts +24 -0
- package/dist/state/step-runner.js +42 -0
- package/dist/state/version-skew.d.ts +9 -0
- package/dist/state/version-skew.js +7 -0
- package/dist/tracer.d.ts +16 -0
- package/dist/tracer.js +13 -0
- package/package.json +44 -0
package/dist/semver.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal semver-range check: supports "*", exact "x.y.z", "~x.y.z", "^x.y.z".
|
|
3
|
+
* Core stays dependency-light (zod + nothing else, CLAUDE.md) — swap for the
|
|
4
|
+
* `semver` package if a plugin ever needs a richer range grammar.
|
|
5
|
+
*/
|
|
6
|
+
function parse(version) {
|
|
7
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
8
|
+
if (!m)
|
|
9
|
+
throw new Error(`semver: invalid version "${version}"`);
|
|
10
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
11
|
+
}
|
|
12
|
+
export function satisfiesRange(version, range) {
|
|
13
|
+
const r = range.trim();
|
|
14
|
+
if (r === "*" || r === "")
|
|
15
|
+
return true;
|
|
16
|
+
const [vMaj, vMin, vPat] = parse(version);
|
|
17
|
+
if (r.startsWith("^")) {
|
|
18
|
+
const [rMaj, rMin, rPat] = parse(r.slice(1));
|
|
19
|
+
if (vMaj !== rMaj)
|
|
20
|
+
return false;
|
|
21
|
+
if (rMaj > 0)
|
|
22
|
+
return vMin > rMin || (vMin === rMin && vPat >= rPat);
|
|
23
|
+
// 0.x.y: caret only allows patch bumps within the same minor (npm semantics).
|
|
24
|
+
if (rMin > 0)
|
|
25
|
+
return vMin === rMin && vPat >= rPat;
|
|
26
|
+
return vMin === 0 && vPat === rPat;
|
|
27
|
+
}
|
|
28
|
+
if (r.startsWith("~")) {
|
|
29
|
+
const [rMaj, rMin, rPat] = parse(r.slice(1));
|
|
30
|
+
return vMaj === rMaj && vMin === rMin && vPat >= rPat;
|
|
31
|
+
}
|
|
32
|
+
const [rMaj, rMin, rPat] = parse(r);
|
|
33
|
+
return vMaj === rMaj && vMin === rMin && vPat === rPat;
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** One idempotent install step contributed by a part (ledger persistence is M2). */
|
|
2
|
+
export interface SetupStep {
|
|
3
|
+
id: string;
|
|
4
|
+
description: string;
|
|
5
|
+
envKeys?: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Declared by each part/plugin; core aggregates these into the state ledger (§5). */
|
|
8
|
+
export interface SetupManifest {
|
|
9
|
+
part: string;
|
|
10
|
+
steps: SetupStep[];
|
|
11
|
+
}
|
|
12
|
+
export interface AggregatedStep extends SetupStep {
|
|
13
|
+
part: string;
|
|
14
|
+
}
|
|
15
|
+
export interface AggregatedSetup {
|
|
16
|
+
steps: AggregatedStep[];
|
|
17
|
+
envKeys: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Flattens manifests into one step list (attributed to their part) + the deduped env key set. */
|
|
20
|
+
export declare function aggregateSetupManifests(manifests: SetupManifest[]): AggregatedSetup;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Flattens manifests into one step list (attributed to their part) + the deduped env key set. */
|
|
2
|
+
export function aggregateSetupManifests(manifests) {
|
|
3
|
+
const steps = [];
|
|
4
|
+
const envKeys = new Set();
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
for (const manifest of manifests) {
|
|
7
|
+
for (const step of manifest.steps) {
|
|
8
|
+
const key = `${manifest.part}:${step.id}`;
|
|
9
|
+
if (seen.has(key)) {
|
|
10
|
+
throw new Error(`aggregateSetupManifests: duplicate step "${step.id}" in part "${manifest.part}"`);
|
|
11
|
+
}
|
|
12
|
+
seen.add(key);
|
|
13
|
+
steps.push({ ...step, part: manifest.part });
|
|
14
|
+
for (const envKey of step.envKeys ?? [])
|
|
15
|
+
envKeys.add(envKey);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { steps, envKeys: [...envKeys] };
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { State } from "./schema.js";
|
|
2
|
+
export type DriftStatus = "clean" | "modified" | "missing";
|
|
3
|
+
export interface DriftEntry {
|
|
4
|
+
path: string;
|
|
5
|
+
part: string;
|
|
6
|
+
status: DriftStatus;
|
|
7
|
+
}
|
|
8
|
+
/** Compares each tracked generated file's stored hash against its current content on disk (doctor, SPEC §5). */
|
|
9
|
+
export declare function detectDrift(state: State, projectRoot: string): DriftEntry[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** Compares each tracked generated file's stored hash against its current content on disk (doctor, SPEC §5). */
|
|
5
|
+
export function detectDrift(state, projectRoot) {
|
|
6
|
+
return state.generatedFiles.map((f) => {
|
|
7
|
+
const abs = join(projectRoot, f.path);
|
|
8
|
+
if (!existsSync(abs))
|
|
9
|
+
return { path: f.path, part: f.part, status: "missing" };
|
|
10
|
+
const actual = createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
11
|
+
return { path: f.path, part: f.part, status: actual === f.sha256 ? "clean" : "modified" };
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The state file exists but can't be trusted (unreadable, malformed JSON, or fails schema after migration). */
|
|
2
|
+
export declare class StateCorruptError extends Error {
|
|
3
|
+
readonly cause?: unknown | undefined;
|
|
4
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
5
|
+
}
|
|
6
|
+
/** The state file's schemaVersion is newer than this core build understands. Never silently downgrade. */
|
|
7
|
+
export declare class StateVersionTooNewError extends Error {
|
|
8
|
+
readonly foundVersion: number;
|
|
9
|
+
readonly supportedVersion: number;
|
|
10
|
+
constructor(foundVersion: number, supportedVersion: number);
|
|
11
|
+
}
|
|
12
|
+
export declare class LockTimeoutError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The state file exists but can't be trusted (unreadable, malformed JSON, or fails schema after migration). */
|
|
2
|
+
export class StateCorruptError extends Error {
|
|
3
|
+
cause;
|
|
4
|
+
constructor(message, cause) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.cause = cause;
|
|
7
|
+
this.name = "StateCorruptError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** The state file's schemaVersion is newer than this core build understands. Never silently downgrade. */
|
|
11
|
+
export class StateVersionTooNewError extends Error {
|
|
12
|
+
foundVersion;
|
|
13
|
+
supportedVersion;
|
|
14
|
+
constructor(foundVersion, supportedVersion) {
|
|
15
|
+
super(`state file schemaVersion ${foundVersion} is newer than this @wappy_ai/core build supports (${supportedVersion}); upgrade @wappy_ai/core (and the CLI) before continuing`);
|
|
16
|
+
this.foundVersion = foundVersion;
|
|
17
|
+
this.supportedVersion = supportedVersion;
|
|
18
|
+
this.name = "StateVersionTooNewError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class LockTimeoutError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "LockTimeoutError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./errors.js";
|
|
2
|
+
export * from "./schema.js";
|
|
3
|
+
export * from "./io.js";
|
|
4
|
+
export * from "./lock.js";
|
|
5
|
+
export * from "./migrations.js";
|
|
6
|
+
export * from "./load.js";
|
|
7
|
+
export * from "./step-runner.js";
|
|
8
|
+
export * from "./manifest.js";
|
|
9
|
+
export * from "./drift.js";
|
|
10
|
+
export * from "./reset.js";
|
|
11
|
+
export * from "./version-skew.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./errors.js";
|
|
2
|
+
export * from "./schema.js";
|
|
3
|
+
export * from "./io.js";
|
|
4
|
+
export * from "./lock.js";
|
|
5
|
+
export * from "./migrations.js";
|
|
6
|
+
export * from "./load.js";
|
|
7
|
+
export * from "./step-runner.js";
|
|
8
|
+
export * from "./manifest.js";
|
|
9
|
+
export * from "./drift.js";
|
|
10
|
+
export * from "./reset.js";
|
|
11
|
+
export * from "./version-skew.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type State } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Write temp file (same directory, so rename is on one filesystem) + fsync + rename over the
|
|
4
|
+
* target. rename(2) is atomic: a reader of `path` always sees either the old content or the new
|
|
5
|
+
* content in full, never a partial write — even if the process dies mid-write, only the orphaned
|
|
6
|
+
* temp file is affected, not `path`. State is parsed through StateSchema first, so anything not
|
|
7
|
+
* in the schema (e.g. an accidentally-attached env *value*) is stripped before it ever reaches disk.
|
|
8
|
+
*/
|
|
9
|
+
export declare function writeStateAtomic(path: string, state: State): void;
|
|
10
|
+
/** Raw parsed JSON (any schema version) — null if no state file exists yet. Throws StateCorruptError if unreadable/not-JSON. */
|
|
11
|
+
export declare function readRawState(path: string): unknown;
|
package/dist/state/io.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, basename, join } from "node:path";
|
|
3
|
+
import { StateSchema } from "./schema.js";
|
|
4
|
+
import { StateCorruptError } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Write temp file (same directory, so rename is on one filesystem) + fsync + rename over the
|
|
7
|
+
* target. rename(2) is atomic: a reader of `path` always sees either the old content or the new
|
|
8
|
+
* content in full, never a partial write — even if the process dies mid-write, only the orphaned
|
|
9
|
+
* temp file is affected, not `path`. State is parsed through StateSchema first, so anything not
|
|
10
|
+
* in the schema (e.g. an accidentally-attached env *value*) is stripped before it ever reaches disk.
|
|
11
|
+
*/
|
|
12
|
+
export function writeStateAtomic(path, state) {
|
|
13
|
+
const clean = StateSchema.parse(state);
|
|
14
|
+
const dir = dirname(path);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
const tmp = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
17
|
+
const fd = openSync(tmp, "w");
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(fd, JSON.stringify(clean, null, 2));
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
renameSync(tmp, path);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
try {
|
|
29
|
+
unlinkSync(tmp);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* best-effort cleanup */
|
|
33
|
+
}
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Raw parsed JSON (any schema version) — null if no state file exists yet. Throws StateCorruptError if unreadable/not-JSON. */
|
|
38
|
+
export function readRawState(path) {
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return null;
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = readFileSync(path, "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw new StateCorruptError(`cannot read state file at ${path}: ${e.message}`, e);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
throw new StateCorruptError(`state file at ${path} is not valid JSON`, e);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { State } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* No interactive I/O here (MILESTONES.md B: core never prompts) — a not-ok result is the "offer",
|
|
4
|
+
* for a CLI/caller to present and act on (e.g. call reset()). Anything that goes wrong reading or
|
|
5
|
+
* migrating the file becomes `corrupt` except a too-new schemaVersion, which gets its own distinct
|
|
6
|
+
* result (the fix is "upgrade", not "reset").
|
|
7
|
+
*/
|
|
8
|
+
export type LoadResult = {
|
|
9
|
+
ok: true;
|
|
10
|
+
state: State | null;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
corrupt: true;
|
|
14
|
+
path: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
tooNew: true;
|
|
19
|
+
path: string;
|
|
20
|
+
foundVersion: number;
|
|
21
|
+
supportedVersion: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function loadState(path: string): LoadResult;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readRawState } from "./io.js";
|
|
2
|
+
import { migrate } from "./migrations.js";
|
|
3
|
+
import { StateVersionTooNewError } from "./errors.js";
|
|
4
|
+
export function loadState(path) {
|
|
5
|
+
let raw;
|
|
6
|
+
try {
|
|
7
|
+
raw = readRawState(path);
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
return { ok: false, corrupt: true, path, reason: e.message };
|
|
11
|
+
}
|
|
12
|
+
if (raw === null)
|
|
13
|
+
return { ok: true, state: null };
|
|
14
|
+
try {
|
|
15
|
+
return { ok: true, state: migrate(raw) };
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e instanceof StateVersionTooNewError) {
|
|
19
|
+
return { ok: false, tooNew: true, path, foundVersion: e.foundVersion, supportedVersion: e.supportedVersion };
|
|
20
|
+
}
|
|
21
|
+
return { ok: false, corrupt: true, path, reason: e.message };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Clock } from "../clock.js";
|
|
2
|
+
export interface LockOptions {
|
|
3
|
+
/** A lock older than this whose owning pid is dead is stale and can be taken over. Default 30s. */
|
|
4
|
+
staleMs?: number;
|
|
5
|
+
/** How long to wait for a live lock to free up before giving up. Default 5s. */
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
pollMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** true = acquired. false = someone else holds it (EEXIST). */
|
|
10
|
+
export declare function tryAcquireLock(path: string, clock?: Pick<Clock, "now">): boolean;
|
|
11
|
+
export declare function releaseLock(path: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* A lock is stale once it's older than staleMs AND its owning pid is no longer alive. This is
|
|
14
|
+
* also the crash-recovery path: nothing runs on SIGKILL, so a `finally`/exit-hook can never
|
|
15
|
+
* release the lock file for us — staleness detection on the NEXT acquire attempt is the only
|
|
16
|
+
* reliable way a killed holder's lock gets reclaimed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isLockStale(path: string, staleMs: number, clock?: Pick<Clock, "now">): boolean;
|
|
19
|
+
export declare function withLock<T>(path: string, fn: () => Promise<T> | T, opts?: LockOptions, clock?: Clock): Promise<T>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { systemClock } from "../clock.js";
|
|
3
|
+
import { LockTimeoutError } from "./errors.js";
|
|
4
|
+
/** true = acquired. false = someone else holds it (EEXIST). */
|
|
5
|
+
export function tryAcquireLock(path, clock = systemClock) {
|
|
6
|
+
try {
|
|
7
|
+
const fd = openSync(path, "wx");
|
|
8
|
+
try {
|
|
9
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: clock.now() }));
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
closeSync(fd);
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
if (e.code === "EEXIST")
|
|
18
|
+
return false;
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function releaseLock(path) {
|
|
23
|
+
try {
|
|
24
|
+
unlinkSync(path);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (e.code !== "ENOENT")
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readLockContents(path) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null; // missing, or corrupt — both treated as stale by the caller
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isProcessAlive(pid) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
// ESRCH = no such process (dead). EPERM = exists but owned by someone else (alive).
|
|
46
|
+
return e.code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A lock is stale once it's older than staleMs AND its owning pid is no longer alive. This is
|
|
51
|
+
* also the crash-recovery path: nothing runs on SIGKILL, so a `finally`/exit-hook can never
|
|
52
|
+
* release the lock file for us — staleness detection on the NEXT acquire attempt is the only
|
|
53
|
+
* reliable way a killed holder's lock gets reclaimed.
|
|
54
|
+
*/
|
|
55
|
+
export function isLockStale(path, staleMs, clock = systemClock) {
|
|
56
|
+
const contents = readLockContents(path);
|
|
57
|
+
if (!contents)
|
|
58
|
+
return true;
|
|
59
|
+
if (clock.now() - contents.acquiredAt < staleMs)
|
|
60
|
+
return false;
|
|
61
|
+
return !isProcessAlive(contents.pid);
|
|
62
|
+
}
|
|
63
|
+
export async function withLock(path, fn, opts = {}, clock = systemClock) {
|
|
64
|
+
const staleMs = opts.staleMs ?? 30_000;
|
|
65
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
66
|
+
const pollMs = opts.pollMs ?? 20;
|
|
67
|
+
const deadline = clock.now() + timeoutMs;
|
|
68
|
+
for (;;) {
|
|
69
|
+
if (tryAcquireLock(path, clock))
|
|
70
|
+
break;
|
|
71
|
+
if (isLockStale(path, staleMs, clock)) {
|
|
72
|
+
releaseLock(path);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (clock.now() >= deadline)
|
|
76
|
+
throw new LockTimeoutError(`timed out waiting for lock at ${path}`);
|
|
77
|
+
await clock.sleep(pollMs);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return await fn();
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
releaseLock(path);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SetupManifest } from "../setup-manifest.js";
|
|
2
|
+
import { type Clock } from "../clock.js";
|
|
3
|
+
import { type State, type StatePart, type StateStep } from "./schema.js";
|
|
4
|
+
export interface AppliedManifests {
|
|
5
|
+
state: State;
|
|
6
|
+
/** Steps that were in state but are no longer contributed by any registered part — reported, not silently dropped. */
|
|
7
|
+
removedSteps: Pick<StateStep, "id" | "part">[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Reconciles the ledger against the currently-registered parts' SetupManifests: existing step/env
|
|
11
|
+
* statuses are preserved, newly-declared ones start `pending`/unfilled, and steps no longer
|
|
12
|
+
* contributed by anyone are pulled out of the active list (SPEC §5 "unknown/removed steps").
|
|
13
|
+
*
|
|
14
|
+
* Step ids are only unique WITHIN a part (aggregateSetupManifests dedupes on `part:id`, so two
|
|
15
|
+
* different parts may both declare a step called e.g. "creds") — every lookup here is keyed by
|
|
16
|
+
* the `part:id` pair via stepKey(), never by `id` alone.
|
|
17
|
+
*/
|
|
18
|
+
export declare function applyManifests(state: State, parts: StatePart[], manifests: SetupManifest[], clock?: Pick<Clock, "now">): AppliedManifests;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { aggregateSetupManifests } from "../setup-manifest.js";
|
|
2
|
+
import { systemClock } from "../clock.js";
|
|
3
|
+
import { stepKey } from "./schema.js";
|
|
4
|
+
/**
|
|
5
|
+
* Reconciles the ledger against the currently-registered parts' SetupManifests: existing step/env
|
|
6
|
+
* statuses are preserved, newly-declared ones start `pending`/unfilled, and steps no longer
|
|
7
|
+
* contributed by anyone are pulled out of the active list (SPEC §5 "unknown/removed steps").
|
|
8
|
+
*
|
|
9
|
+
* Step ids are only unique WITHIN a part (aggregateSetupManifests dedupes on `part:id`, so two
|
|
10
|
+
* different parts may both declare a step called e.g. "creds") — every lookup here is keyed by
|
|
11
|
+
* the `part:id` pair via stepKey(), never by `id` alone.
|
|
12
|
+
*/
|
|
13
|
+
export function applyManifests(state, parts, manifests, clock = systemClock) {
|
|
14
|
+
const aggregated = aggregateSetupManifests(manifests);
|
|
15
|
+
const activeKeys = new Set(aggregated.steps.map((s) => stepKey(s)));
|
|
16
|
+
const existingStepByKey = new Map(state.steps.map((s) => [stepKey(s), s]));
|
|
17
|
+
const steps = aggregated.steps.map((s) => existingStepByKey.get(stepKey(s)) ?? { id: s.id, part: s.part, status: "pending" });
|
|
18
|
+
const removedSteps = state.steps.filter((s) => !activeKeys.has(stepKey(s))).map((s) => ({ id: s.id, part: s.part }));
|
|
19
|
+
const existingEnvByName = new Map(state.envKeys.map((e) => [e.name, e]));
|
|
20
|
+
const envKeys = aggregated.envKeys.map((name) => existingEnvByName.get(name) ?? { name, required: true, filled: false });
|
|
21
|
+
return {
|
|
22
|
+
state: { ...state, parts: [...parts], steps, envKeys, updatedAt: clock.now() },
|
|
23
|
+
removedSteps,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type State } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validates and returns a typed State. Never downgrades: a schemaVersion newer than this build
|
|
4
|
+
* supports is refused (StateVersionTooNewError), not silently reinterpreted.
|
|
5
|
+
*
|
|
6
|
+
* v1 is the only schema version so far, so there is nothing to migrate FROM yet. When v2 lands,
|
|
7
|
+
* this becomes a chain: walk `raw` through one pure transform per version (1->2, 2->3, ...) up to
|
|
8
|
+
* STATE_SCHEMA_VERSION before the final StateSchema.safeParse below — add fixtures/state/v2.json
|
|
9
|
+
* alongside it so migrations.m2.test.ts's fixture loop covers the new step forever.
|
|
10
|
+
*/
|
|
11
|
+
export declare function migrate(raw: unknown): State;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { STATE_SCHEMA_VERSION, StateSchema } from "./schema.js";
|
|
2
|
+
import { StateCorruptError, StateVersionTooNewError } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Validates and returns a typed State. Never downgrades: a schemaVersion newer than this build
|
|
5
|
+
* supports is refused (StateVersionTooNewError), not silently reinterpreted.
|
|
6
|
+
*
|
|
7
|
+
* v1 is the only schema version so far, so there is nothing to migrate FROM yet. When v2 lands,
|
|
8
|
+
* this becomes a chain: walk `raw` through one pure transform per version (1->2, 2->3, ...) up to
|
|
9
|
+
* STATE_SCHEMA_VERSION before the final StateSchema.safeParse below — add fixtures/state/v2.json
|
|
10
|
+
* alongside it so migrations.m2.test.ts's fixture loop covers the new step forever.
|
|
11
|
+
*/
|
|
12
|
+
export function migrate(raw) {
|
|
13
|
+
if (typeof raw !== "object" || raw === null || !("schemaVersion" in raw)) {
|
|
14
|
+
throw new StateCorruptError("state file is missing schemaVersion");
|
|
15
|
+
}
|
|
16
|
+
const version = raw.schemaVersion;
|
|
17
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
|
18
|
+
throw new StateCorruptError(`state file has an invalid schemaVersion: ${JSON.stringify(version)}`);
|
|
19
|
+
}
|
|
20
|
+
if (version > STATE_SCHEMA_VERSION) {
|
|
21
|
+
throw new StateVersionTooNewError(version, STATE_SCHEMA_VERSION);
|
|
22
|
+
}
|
|
23
|
+
const parsed = StateSchema.safeParse(raw);
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
throw new StateCorruptError(`state file failed validation: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
|
|
26
|
+
}
|
|
27
|
+
return parsed.data;
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Clock } from "../clock.js";
|
|
2
|
+
import { type State } from "./schema.js";
|
|
3
|
+
export type ResetScope = {
|
|
4
|
+
kind: "all";
|
|
5
|
+
} | {
|
|
6
|
+
kind: "plugin";
|
|
7
|
+
name: string;
|
|
8
|
+
};
|
|
9
|
+
export interface ResetResult {
|
|
10
|
+
state: State;
|
|
11
|
+
/** Paths the caller (fs-touching layer, not core) should actually delete. */
|
|
12
|
+
filesToRemove: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Never touches the filesystem itself — returns the paths to remove and the resulting state; the caller applies both. */
|
|
15
|
+
export declare function reset(state: State, scope: ResetScope, clock?: Pick<Clock, "now">): ResetResult;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { systemClock } from "../clock.js";
|
|
2
|
+
import { createEmptyState } from "./schema.js";
|
|
3
|
+
/** Never touches the filesystem itself — returns the paths to remove and the resulting state; the caller applies both. */
|
|
4
|
+
export function reset(state, scope, clock = systemClock) {
|
|
5
|
+
if (scope.kind === "all") {
|
|
6
|
+
return { state: createEmptyState(state.runId, clock), filesToRemove: state.generatedFiles.map((f) => f.path) };
|
|
7
|
+
}
|
|
8
|
+
const { name } = scope;
|
|
9
|
+
return {
|
|
10
|
+
state: {
|
|
11
|
+
...state,
|
|
12
|
+
parts: state.parts.filter((p) => p.name !== name),
|
|
13
|
+
steps: state.steps.filter((s) => s.part !== name),
|
|
14
|
+
generatedFiles: state.generatedFiles.filter((f) => f.part !== name),
|
|
15
|
+
updatedAt: clock.now(),
|
|
16
|
+
},
|
|
17
|
+
filesToRemove: state.generatedFiles.filter((f) => f.part === name).map((f) => f.path),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Clock } from "../clock.js";
|
|
3
|
+
/** .wappy/state.json schema v1 (SPEC §5). Bump this and add a migration (migrations.ts) to change the shape. */
|
|
4
|
+
export declare const STATE_SCHEMA_VERSION = 1;
|
|
5
|
+
export declare const StepStatusSchema: z.ZodEnum<{
|
|
6
|
+
failed: "failed";
|
|
7
|
+
pending: "pending";
|
|
8
|
+
done: "done";
|
|
9
|
+
}>;
|
|
10
|
+
export declare const StateStepSchema: z.ZodObject<{
|
|
11
|
+
id: z.ZodString;
|
|
12
|
+
part: z.ZodString;
|
|
13
|
+
status: z.ZodEnum<{
|
|
14
|
+
failed: "failed";
|
|
15
|
+
pending: "pending";
|
|
16
|
+
done: "done";
|
|
17
|
+
}>;
|
|
18
|
+
error: z.ZodOptional<z.ZodString>;
|
|
19
|
+
}, z.core.$strip>;
|
|
20
|
+
export type StateStep = z.infer<typeof StateStepSchema>;
|
|
21
|
+
export declare const StatePartSchema: z.ZodObject<{
|
|
22
|
+
name: z.ZodString;
|
|
23
|
+
version: z.ZodString;
|
|
24
|
+
}, z.core.$strip>;
|
|
25
|
+
export type StatePart = z.infer<typeof StatePartSchema>;
|
|
26
|
+
/** Names + booleans only — an env key's actual value is never written to state (SPEC §5, §11). */
|
|
27
|
+
export declare const EnvKeyStateSchema: z.ZodObject<{
|
|
28
|
+
name: z.ZodString;
|
|
29
|
+
required: z.ZodBoolean;
|
|
30
|
+
filled: z.ZodBoolean;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
export type EnvKeyState = z.infer<typeof EnvKeyStateSchema>;
|
|
33
|
+
export declare const GeneratedFileHashSchema: z.ZodObject<{
|
|
34
|
+
path: z.ZodString;
|
|
35
|
+
part: z.ZodString;
|
|
36
|
+
sha256: z.ZodString;
|
|
37
|
+
}, z.core.$strip>;
|
|
38
|
+
export type GeneratedFileHash = z.infer<typeof GeneratedFileHashSchema>;
|
|
39
|
+
export declare const StateSchema: z.ZodObject<{
|
|
40
|
+
schemaVersion: z.ZodLiteral<1>;
|
|
41
|
+
runId: z.ZodString;
|
|
42
|
+
parts: z.ZodArray<z.ZodObject<{
|
|
43
|
+
name: z.ZodString;
|
|
44
|
+
version: z.ZodString;
|
|
45
|
+
}, z.core.$strip>>;
|
|
46
|
+
steps: z.ZodArray<z.ZodObject<{
|
|
47
|
+
id: z.ZodString;
|
|
48
|
+
part: z.ZodString;
|
|
49
|
+
status: z.ZodEnum<{
|
|
50
|
+
failed: "failed";
|
|
51
|
+
pending: "pending";
|
|
52
|
+
done: "done";
|
|
53
|
+
}>;
|
|
54
|
+
error: z.ZodOptional<z.ZodString>;
|
|
55
|
+
}, z.core.$strip>>;
|
|
56
|
+
envKeys: z.ZodArray<z.ZodObject<{
|
|
57
|
+
name: z.ZodString;
|
|
58
|
+
required: z.ZodBoolean;
|
|
59
|
+
filled: z.ZodBoolean;
|
|
60
|
+
}, z.core.$strip>>;
|
|
61
|
+
lastStep: z.ZodNullable<z.ZodString>;
|
|
62
|
+
generatedFiles: z.ZodArray<z.ZodObject<{
|
|
63
|
+
path: z.ZodString;
|
|
64
|
+
part: z.ZodString;
|
|
65
|
+
sha256: z.ZodString;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
createdAt: z.ZodNumber;
|
|
68
|
+
updatedAt: z.ZodNumber;
|
|
69
|
+
}, z.core.$strip>;
|
|
70
|
+
export type State = z.infer<typeof StateSchema>;
|
|
71
|
+
/** Steps are only unique WITHIN a part (aggregateSetupManifests dedupes on `part:id`) — always key lookups by both. */
|
|
72
|
+
export declare function stepKey(step: Pick<StateStep, "part" | "id">): string;
|
|
73
|
+
export declare function createEmptyState(runId: string, clock?: Pick<Clock, "now">): State;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { systemClock } from "../clock.js";
|
|
3
|
+
/** .wappy/state.json schema v1 (SPEC §5). Bump this and add a migration (migrations.ts) to change the shape. */
|
|
4
|
+
export const STATE_SCHEMA_VERSION = 1;
|
|
5
|
+
export const StepStatusSchema = z.enum(["pending", "done", "failed"]);
|
|
6
|
+
export const StateStepSchema = z.object({
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
/** Which part contributed this step (SetupManifest.part) — scopes plugin-only reset. */
|
|
9
|
+
part: z.string().min(1),
|
|
10
|
+
status: StepStatusSchema,
|
|
11
|
+
error: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
export const StatePartSchema = z.object({
|
|
14
|
+
name: z.string().min(1),
|
|
15
|
+
version: z.string().min(1),
|
|
16
|
+
});
|
|
17
|
+
/** Names + booleans only — an env key's actual value is never written to state (SPEC §5, §11). */
|
|
18
|
+
export const EnvKeyStateSchema = z.object({
|
|
19
|
+
name: z.string().min(1),
|
|
20
|
+
required: z.boolean(),
|
|
21
|
+
filled: z.boolean(),
|
|
22
|
+
});
|
|
23
|
+
export const GeneratedFileHashSchema = z.object({
|
|
24
|
+
path: z.string().min(1),
|
|
25
|
+
part: z.string().min(1),
|
|
26
|
+
sha256: z.string().min(1),
|
|
27
|
+
});
|
|
28
|
+
export const StateSchema = z.object({
|
|
29
|
+
schemaVersion: z.literal(STATE_SCHEMA_VERSION),
|
|
30
|
+
runId: z.string().min(1),
|
|
31
|
+
parts: z.array(StatePartSchema),
|
|
32
|
+
steps: z.array(StateStepSchema),
|
|
33
|
+
envKeys: z.array(EnvKeyStateSchema),
|
|
34
|
+
lastStep: z.string().nullable(),
|
|
35
|
+
generatedFiles: z.array(GeneratedFileHashSchema),
|
|
36
|
+
createdAt: z.number(),
|
|
37
|
+
updatedAt: z.number(),
|
|
38
|
+
});
|
|
39
|
+
/** Steps are only unique WITHIN a part (aggregateSetupManifests dedupes on `part:id`) — always key lookups by both. */
|
|
40
|
+
export function stepKey(step) {
|
|
41
|
+
return `${step.part}:${step.id}`;
|
|
42
|
+
}
|
|
43
|
+
export function createEmptyState(runId, clock = systemClock) {
|
|
44
|
+
const now = clock.now();
|
|
45
|
+
return {
|
|
46
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
47
|
+
runId,
|
|
48
|
+
parts: [],
|
|
49
|
+
steps: [],
|
|
50
|
+
envKeys: [],
|
|
51
|
+
lastStep: null,
|
|
52
|
+
generatedFiles: [],
|
|
53
|
+
createdAt: now,
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
};
|
|
56
|
+
}
|