asylumwarden 0.0.0 → 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 +10 -5
- package/adapters/opencode/index.js +282 -0
- package/bin/asylumwarden.mjs +5 -0
- package/package.json +14 -1
- package/schemas/config.schema.json +93 -0
- package/schemas/ledger.schema.json +21 -0
- package/schemas/manifest.schema.json +49 -0
- package/schemas/receipt.schema.json +102 -0
- package/schemas/registry.schema.json +43 -0
- package/src/cli/main.mjs +342 -0
- package/src/config/bootstrap.mjs +36 -0
- package/src/config/load.mjs +531 -0
- package/src/config/schemas.mjs +58 -0
- package/src/context.mjs +50 -0
- package/src/index.mjs +12 -0
- package/src/kernel/canon.mjs +59 -0
- package/src/kernel/errors.mjs +80 -0
- package/src/kernel/fsutil.mjs +147 -0
- package/src/kernel/glob.mjs +70 -0
- package/src/kernel/lock.mjs +104 -0
- package/src/kernel/schemalite.mjs +153 -0
- package/src/kernel/state.mjs +56 -0
- package/src/lease/lease.mjs +551 -0
- package/src/ledger/anchor.mjs +395 -0
- package/src/ledger/chain.mjs +468 -0
- package/src/manifest/manifest.mjs +199 -0
- package/src/manifest/plan.mjs +142 -0
- package/src/receipt/receipt.mjs +555 -0
- package/src/views/views.mjs +148 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { fail } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
// Canonical JSON — CONTRACT §1. Recursive stable stringify: keys sorted by
|
|
6
|
+
// UTF-16 code unit, arrays order-preserving, no insignificant whitespace.
|
|
7
|
+
export function canon(value) {
|
|
8
|
+
return stableStringify(value, new Set());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function stableStringify(value, seen) {
|
|
12
|
+
if (value === null) return "null";
|
|
13
|
+
const type = typeof value;
|
|
14
|
+
if (type === "string" || type === "boolean") return JSON.stringify(value);
|
|
15
|
+
if (type === "number") {
|
|
16
|
+
if (!Number.isFinite(value)) fail("INTERNAL", "canon: non-finite number");
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
}
|
|
19
|
+
if (type !== "object") fail("INTERNAL", `canon: unsupported type ${type}`);
|
|
20
|
+
if (seen.has(value)) fail("INTERNAL", "canon: cyclic structure");
|
|
21
|
+
seen.add(value);
|
|
22
|
+
try {
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return `[${value.map((item) => stableStringify(item, seen)).join(",")}]`;
|
|
25
|
+
}
|
|
26
|
+
const keys = Object.keys(value).sort();
|
|
27
|
+
const parts = keys.map(
|
|
28
|
+
(key) => `${JSON.stringify(key)}:${stableStringify(value[key], seen)}`,
|
|
29
|
+
);
|
|
30
|
+
return `{${parts.join(",")}}`;
|
|
31
|
+
} finally {
|
|
32
|
+
seen.delete(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sha256Hex(bytesOrString) {
|
|
37
|
+
const hash = createHash("sha256");
|
|
38
|
+
hash.update(bytesOrString);
|
|
39
|
+
return hash.digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sha256Of(value) {
|
|
43
|
+
return sha256Hex(canon(value));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const GENESIS_PREV = "0".repeat(64);
|
|
47
|
+
|
|
48
|
+
// Streaming file digest. Callers must have already checked the entry is a
|
|
49
|
+
// regular file (not a symlink / directory) when containment matters.
|
|
50
|
+
export async function sha256File(path) {
|
|
51
|
+
const hash = createHash("sha256");
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
const stream = createReadStream(path);
|
|
54
|
+
stream.on("error", reject);
|
|
55
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
56
|
+
stream.on("end", resolve);
|
|
57
|
+
});
|
|
58
|
+
return hash.digest("hex");
|
|
59
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Error taxonomy — single source: docs/CONTRACT.md §8.3.
|
|
2
|
+
export const EXIT_OK = 0;
|
|
3
|
+
export const EXIT_BUSINESS = 1;
|
|
4
|
+
export const EXIT_FAIL_CLOSED = 2;
|
|
5
|
+
|
|
6
|
+
const BUSINESS_CODES = new Set([
|
|
7
|
+
"USAGE",
|
|
8
|
+
"UNKNOWN-COMMAND",
|
|
9
|
+
"LEASE-HELD",
|
|
10
|
+
"LEASE-EXPIRED",
|
|
11
|
+
"LEASE-MAX-HOLD",
|
|
12
|
+
"LEASE-BUSY",
|
|
13
|
+
"STEAL-DENIED",
|
|
14
|
+
"EPOCH-MISMATCH",
|
|
15
|
+
"ACTOR-MISMATCH",
|
|
16
|
+
"CELL-STATE",
|
|
17
|
+
"RESTART-REQUIRED",
|
|
18
|
+
"RECEIPT-COMMANDS",
|
|
19
|
+
"RECEIPT-HASH",
|
|
20
|
+
"READ-OUTSIDE",
|
|
21
|
+
"WAVE-EXISTS",
|
|
22
|
+
"WAVE-MISSING",
|
|
23
|
+
"CELL-MISSING",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const FAIL_CLOSED_CODES = new Set([
|
|
27
|
+
"CONFIG-MISSING",
|
|
28
|
+
"CONFIG-INVALID",
|
|
29
|
+
"CONFIG-DRIFT",
|
|
30
|
+
"REGISTRY-MISSING",
|
|
31
|
+
"REGISTRY-INVALID",
|
|
32
|
+
"ROOTS-OVERLAP",
|
|
33
|
+
"ROOT-MISSING",
|
|
34
|
+
"FORBIDDEN-ROOT",
|
|
35
|
+
"GLOB-INVALID",
|
|
36
|
+
"UNKNOWN-KIND",
|
|
37
|
+
"MANIFEST-INVALID",
|
|
38
|
+
"DEP-UNKNOWN",
|
|
39
|
+
"DEP-CYCLE",
|
|
40
|
+
"DEP-DUPLICATE",
|
|
41
|
+
"PATH-ESCAPE",
|
|
42
|
+
"FORBIDDEN-SURFACE",
|
|
43
|
+
"SCHEMA-VIOLATION",
|
|
44
|
+
"STATE-CORRUPT",
|
|
45
|
+
"LEDGER-BROKEN",
|
|
46
|
+
"LEDGER-TORN",
|
|
47
|
+
"LEDGER-BUSY",
|
|
48
|
+
"ANCHOR-FAIL",
|
|
49
|
+
"ANCHOR-STALE",
|
|
50
|
+
"INTERNAL",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export function exitClassFor(code) {
|
|
54
|
+
if (BUSINESS_CODES.has(code)) return EXIT_BUSINESS;
|
|
55
|
+
// Unknown codes can never widen the success path; fail closed.
|
|
56
|
+
if (FAIL_CLOSED_CODES.has(code)) return EXIT_FAIL_CLOSED;
|
|
57
|
+
return EXIT_FAIL_CLOSED;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isKnownCode(code) {
|
|
61
|
+
return BUSINESS_CODES.has(code) || FAIL_CLOSED_CODES.has(code);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class WardenError extends Error {
|
|
65
|
+
constructor(code, message, violations = []) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "WardenError";
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.violations = violations;
|
|
70
|
+
this.exitClass = exitClassFor(code);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function violation(field, rule, detail) {
|
|
75
|
+
return { field, rule, detail };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fail(code, message, violations = []) {
|
|
79
|
+
throw new WardenError(code, message, violations);
|
|
80
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { fail, violation } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
export const CAP_CONFIG = 1_048_576; // 1 MiB — config / registry / manifest / receipt
|
|
9
|
+
export const CAP_LINE = 262_144; // 256 KiB — one ledger line
|
|
10
|
+
export const CAP_STRING = 4096;
|
|
11
|
+
|
|
12
|
+
export function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function expandHome(raw) {
|
|
17
|
+
if (typeof raw !== "string") fail("INTERNAL", "expandHome: non-string path");
|
|
18
|
+
if (raw === "~") return os.homedir();
|
|
19
|
+
if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
24
|
+
|
|
25
|
+
function guardKeys(node, where) {
|
|
26
|
+
if (node === null || typeof node !== "object") return;
|
|
27
|
+
for (const key of Object.getOwnPropertyNames(node)) {
|
|
28
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
29
|
+
fail("SCHEMA-VIOLATION", `forbidden key ${key} at ${where}`, [
|
|
30
|
+
violation(where, "forbidden-key", key),
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const key of Object.getOwnPropertyNames(node)) {
|
|
35
|
+
guardKeys(node[key], `${where}.${key}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Read + parse with size cap, null prototype, and forbidden-key rejection.
|
|
40
|
+
// codeOnBad selects the code family: state files are STATE-CORRUPT,
|
|
41
|
+
// user-supplied JSON is SCHEMA-VIOLATION.
|
|
42
|
+
export async function readJsonCapped(filePath, cap = CAP_CONFIG, codeOnBad = "STATE-CORRUPT") {
|
|
43
|
+
let handle;
|
|
44
|
+
try {
|
|
45
|
+
handle = await fs.open(filePath, "r");
|
|
46
|
+
const stat = await handle.stat();
|
|
47
|
+
if (!stat.isFile()) {
|
|
48
|
+
fail(codeOnBad, `not a regular file: ${filePath}`, [violation(filePath, "not-file", "")]);
|
|
49
|
+
}
|
|
50
|
+
if (stat.size > cap) {
|
|
51
|
+
fail(codeOnBad, `file exceeds ${cap} byte cap: ${filePath}`, [
|
|
52
|
+
violation(filePath, "size-cap", String(stat.size)),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
const text = await handle.readFile({ encoding: "utf8" });
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(text);
|
|
59
|
+
} catch {
|
|
60
|
+
fail(codeOnBad, `unparseable JSON: ${filePath}`, [violation(filePath, "json-parse", "")]);
|
|
61
|
+
}
|
|
62
|
+
guardKeys(parsed, path.basename(filePath));
|
|
63
|
+
return { value: deepNullProto(parsed), size: stat.size };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error && error.code === "ENOENT") throw error;
|
|
66
|
+
if (error && error.name === "WardenError") throw error;
|
|
67
|
+
fail(codeOnBad, `cannot read ${filePath}: ${error.message}`, [
|
|
68
|
+
violation(filePath, "read-error", error.message),
|
|
69
|
+
]);
|
|
70
|
+
} finally {
|
|
71
|
+
await handle?.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function deepNullProto(node) {
|
|
76
|
+
if (node === null || typeof node !== "object") return node;
|
|
77
|
+
// Arrays keep Array.prototype: JSON arrays cannot carry __proto__ via
|
|
78
|
+
// index keys, and consumers legitimately call .map/.filter/.every.
|
|
79
|
+
if (Array.isArray(node)) {
|
|
80
|
+
for (let i = 0; i < node.length; i += 1) node[i] = deepNullProto(node[i]);
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
for (const key of Object.getOwnPropertyNames(node)) {
|
|
84
|
+
node[key] = deepNullProto(node[key]);
|
|
85
|
+
}
|
|
86
|
+
Object.setPrototypeOf(node, null);
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Atomic JSON write: tmp (same dir) → full write → fsync → rename → re-read.
|
|
91
|
+
export async function atomicWriteJson(filePath, value) {
|
|
92
|
+
const dir = path.dirname(filePath);
|
|
93
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
const tmp = path.join(
|
|
95
|
+
dir,
|
|
96
|
+
`.${path.basename(filePath)}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`,
|
|
97
|
+
);
|
|
98
|
+
const payload = `${JSON.stringify(value, null, 2)}\n`;
|
|
99
|
+
let handle;
|
|
100
|
+
try {
|
|
101
|
+
handle = await fs.open(tmp, "w", 0o600);
|
|
102
|
+
await handle.writeFile(payload, "utf8");
|
|
103
|
+
await handle.sync();
|
|
104
|
+
} finally {
|
|
105
|
+
await handle?.close();
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await fs.rename(tmp, filePath);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await fs.rm(tmp, { force: true });
|
|
111
|
+
fail("INTERNAL", `atomic rename failed for ${filePath}: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
// Read-back verification per write protocol.
|
|
114
|
+
const back = await fs.readFile(filePath, "utf8");
|
|
115
|
+
if (back !== payload) {
|
|
116
|
+
fail("INTERNAL", `atomic write read-back mismatch: ${filePath}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function ensureDir(dirPath, mode = 0o700) {
|
|
121
|
+
await fs.mkdir(dirPath, { recursive: true, mode });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function fileExists(filePath) {
|
|
125
|
+
try {
|
|
126
|
+
await fs.stat(filePath);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function pidAlive(pid, host) {
|
|
134
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
135
|
+
if (host !== os.hostname()) {
|
|
136
|
+
// Cannot probe foreign hosts: assume alive; expiry is time-gated.
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pid, 0);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return error.code === "EPERM";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { existsSync };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Glob matching for forbidden patterns — CONTRACT §1.
|
|
2
|
+
// `**` matches zero or more path segments; `*` one segment interior; `?` one
|
|
3
|
+
// char. A trailing `/**` also matches the directory itself, so directory
|
|
4
|
+
// patterns cover their contents conservatively. No char classes, no braces.
|
|
5
|
+
|
|
6
|
+
import { fail } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
|
|
10
|
+
export function compileGlob(pattern) {
|
|
11
|
+
const hit = cache.get(pattern);
|
|
12
|
+
if (hit) return hit;
|
|
13
|
+
if (typeof pattern !== "string" || !pattern.startsWith("/")) {
|
|
14
|
+
fail("GLOB-INVALID", `glob pattern must be absolute: ${String(pattern)}`);
|
|
15
|
+
}
|
|
16
|
+
const raw = pattern.split("/");
|
|
17
|
+
const segs = [];
|
|
18
|
+
for (const seg of raw) {
|
|
19
|
+
if (seg === "**" && segs.at(-1) === "**") continue; // collapse **/**
|
|
20
|
+
if (seg === "" && segs.length > 0) continue; // collapse //
|
|
21
|
+
segs.push(seg);
|
|
22
|
+
}
|
|
23
|
+
let re = "";
|
|
24
|
+
let needSep = false;
|
|
25
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
26
|
+
const seg = segs[i];
|
|
27
|
+
if (seg === "") {
|
|
28
|
+
re += "/";
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (seg === "**") {
|
|
32
|
+
if (i === segs.length - 1) {
|
|
33
|
+
re += re.endsWith("/") ? ".*" : "(?:/.*)?";
|
|
34
|
+
needSep = false;
|
|
35
|
+
} else {
|
|
36
|
+
if (needSep) re += "/";
|
|
37
|
+
re += "(?:[^/]+/)*";
|
|
38
|
+
needSep = false;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (needSep) re += "/";
|
|
43
|
+
re += segmentToRe(seg);
|
|
44
|
+
needSep = true;
|
|
45
|
+
}
|
|
46
|
+
const compiled = new RegExp(`^${re}$`);
|
|
47
|
+
cache.set(pattern, compiled);
|
|
48
|
+
return compiled;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function segmentToRe(seg) {
|
|
52
|
+
let out = "";
|
|
53
|
+
for (const ch of seg) {
|
|
54
|
+
if (ch === "*") out += "[^/]*";
|
|
55
|
+
else if (ch === "?") out += "[^/]";
|
|
56
|
+
else out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function matchGlob(pattern, absPath) {
|
|
62
|
+
return compileGlob(pattern).test(absPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function matchAny(patterns, absPath) {
|
|
66
|
+
for (const pattern of patterns) {
|
|
67
|
+
if (matchGlob(pattern, absPath)) return pattern;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Generic exclusive lock for short critical sections (per-cell lease
|
|
2
|
+
// mutation). Same posture as the ledger lock (CONTRACT §5.3): O_EXCL create,
|
|
3
|
+
// forensic body, stale recovery for dead holders, fail-closed busy.
|
|
4
|
+
// Not for long holds — callers run milliseconds of file work inside.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
import { fail, violation } from "./errors.mjs";
|
|
10
|
+
import { ensureDir, nowIso, pidAlive } from "./fsutil.mjs";
|
|
11
|
+
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function withExclusiveLock(
|
|
17
|
+
lockPath,
|
|
18
|
+
{
|
|
19
|
+
staleAfterMs = 30_000,
|
|
20
|
+
busyAfterMs = 60_000,
|
|
21
|
+
deadlineMs = 5_000,
|
|
22
|
+
pollMs = 10,
|
|
23
|
+
busyCode = "LEDGER-BUSY",
|
|
24
|
+
busyMessage = "critical section busy",
|
|
25
|
+
} = {},
|
|
26
|
+
fn,
|
|
27
|
+
) {
|
|
28
|
+
await ensureDir(lockPath.slice(0, lockPath.lastIndexOf("/")) || "/");
|
|
29
|
+
const deadline = Date.now() + deadlineMs;
|
|
30
|
+
let acquired = false;
|
|
31
|
+
while (!acquired) {
|
|
32
|
+
let handle;
|
|
33
|
+
try {
|
|
34
|
+
handle = await fs.open(lockPath, "wx", 0o600);
|
|
35
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname(), ts: nowIso() }));
|
|
36
|
+
acquired = true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error?.code !== "EEXIST") {
|
|
39
|
+
fail("INTERNAL", `lock create failed: ${lockPath}: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
const status = await inspect(lockPath);
|
|
42
|
+
if (status === "stale") {
|
|
43
|
+
await fs.rm(lockPath, { force: true });
|
|
44
|
+
continue; // one retry loop through create
|
|
45
|
+
}
|
|
46
|
+
if (status === "busy") {
|
|
47
|
+
fail(busyCode, `${busyMessage}: ${lockPath}`, [violation(lockPath, "lock-busy", "holder alive")]);
|
|
48
|
+
}
|
|
49
|
+
if (Date.now() > deadline) {
|
|
50
|
+
fail(busyCode, `${busyMessage}: ${lockPath} (wait deadline exceeded)`, [
|
|
51
|
+
violation(lockPath, "lock-timeout", String(deadlineMs)),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
await sleep(pollMs);
|
|
55
|
+
} finally {
|
|
56
|
+
await handle?.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return await fn();
|
|
61
|
+
} finally {
|
|
62
|
+
await fs.rm(lockPath, { force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// gone|stale|busy|contended
|
|
67
|
+
async function inspect(lockPath) {
|
|
68
|
+
let text;
|
|
69
|
+
let mtimeMs;
|
|
70
|
+
try {
|
|
71
|
+
;[text, mtimeMs] = await Promise.all([
|
|
72
|
+
fs.readFile(lockPath, "utf8"),
|
|
73
|
+
fs.stat(lockPath).then((s) => s.mtimeMs),
|
|
74
|
+
]);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code === "ENOENT") return "contended"; // released mid-inspect, retry
|
|
77
|
+
return "busy";
|
|
78
|
+
}
|
|
79
|
+
const trimmed = text.trim();
|
|
80
|
+
if (trimmed === "") {
|
|
81
|
+
// create-vs-write window: stale only far beyond the busy window
|
|
82
|
+
return Date.now() - mtimeMs > 60_000 ? "stale" : "contended";
|
|
83
|
+
}
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
body = JSON.parse(trimmed);
|
|
87
|
+
} catch {
|
|
88
|
+
return Date.now() - mtimeMs > 60_000 ? "stale" : "busy";
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
!body ||
|
|
92
|
+
typeof body !== "object" ||
|
|
93
|
+
!Number.isInteger(body.pid) ||
|
|
94
|
+
typeof body.ts !== "string"
|
|
95
|
+
) {
|
|
96
|
+
return Date.now() - mtimeMs > 60_000 ? "stale" : "busy";
|
|
97
|
+
}
|
|
98
|
+
if (pidAlive(body.pid, body.host)) {
|
|
99
|
+
// A live holder older than the busy window is wedged: fail closed.
|
|
100
|
+
return Date.now() - Date.parse(body.ts) > 60_000 ? "busy" : "contended";
|
|
101
|
+
}
|
|
102
|
+
// Dead holder: stale beyond the stale window; recent death still drains.
|
|
103
|
+
return Date.now() - Date.parse(body.ts) > 30_000 ? "stale" : "contended";
|
|
104
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// schemalite — the zero-dependency JSON Schema subset used by core.
|
|
2
|
+
// SUPPORTED keywords ONLY; a schema using anything else fails the build with
|
|
3
|
+
// INTERNAL (drift guard). Semantics follow draft 2020-12 for the subset.
|
|
4
|
+
|
|
5
|
+
import { fail, violation } from "./errors.mjs";
|
|
6
|
+
import { canon } from "./canon.mjs";
|
|
7
|
+
|
|
8
|
+
const SUPPORTED = new Set([
|
|
9
|
+
"$schema",
|
|
10
|
+
"$id",
|
|
11
|
+
"title",
|
|
12
|
+
"description",
|
|
13
|
+
"type",
|
|
14
|
+
"const",
|
|
15
|
+
"enum",
|
|
16
|
+
"required",
|
|
17
|
+
"properties",
|
|
18
|
+
"additionalProperties",
|
|
19
|
+
"items",
|
|
20
|
+
"pattern",
|
|
21
|
+
"format",
|
|
22
|
+
"minimum",
|
|
23
|
+
"maxItems",
|
|
24
|
+
"minItems",
|
|
25
|
+
"maxProperties",
|
|
26
|
+
"minProperties",
|
|
27
|
+
"maxLength",
|
|
28
|
+
"minLength",
|
|
29
|
+
"allOf",
|
|
30
|
+
"anyOf",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const DATE_TIME_RE =
|
|
34
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
35
|
+
|
|
36
|
+
function typeOf(value) {
|
|
37
|
+
if (value === null) return "null";
|
|
38
|
+
if (Array.isArray(value)) return "array";
|
|
39
|
+
if (Number.isInteger(value)) return "integer";
|
|
40
|
+
return typeof value; // string | number | boolean | object
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function typeMatches(value, type) {
|
|
44
|
+
const actual = typeOf(value);
|
|
45
|
+
if (type === actual) return true;
|
|
46
|
+
if (type === "number" && actual === "integer") return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function push(violations, pathLabel, rule, detail) {
|
|
51
|
+
violations.push(violation(pathLabel, rule, detail));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateSchema(schema, value, pathLabel = "$") {
|
|
55
|
+
const violations = [];
|
|
56
|
+
check(schema, value, pathLabel, violations);
|
|
57
|
+
return violations;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function check(schema, value, pathLabel, violations) {
|
|
61
|
+
for (const keyword of Object.getOwnPropertyNames(schema)) {
|
|
62
|
+
if (!SUPPORTED.has(keyword)) {
|
|
63
|
+
fail("INTERNAL", `schemalite: unsupported keyword "${keyword}" in schema`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const { type, const: konst, enum: enumList, required, properties, additionalProperties,
|
|
67
|
+
items, pattern, format, minimum, minItems, maxItems, minProperties, maxProperties,
|
|
68
|
+
maxLength, minLength, allOf, anyOf } = schema;
|
|
69
|
+
|
|
70
|
+
if (type !== undefined) {
|
|
71
|
+
const types = Array.isArray(type) ? type : [type];
|
|
72
|
+
if (!types.some((t) => typeMatches(value, t))) {
|
|
73
|
+
push(violations, pathLabel, "type", `expected ${types.join("|")}, got ${typeOf(value)}`);
|
|
74
|
+
return; // further keyword checks would cascade
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (konst !== undefined && canon(value) !== canon(konst)) {
|
|
78
|
+
push(violations, pathLabel, "const", `expected ${JSON.stringify(konst)}`);
|
|
79
|
+
}
|
|
80
|
+
if (enumList !== undefined && !enumList.some((alt) => canon(value) === canon(alt))) {
|
|
81
|
+
push(violations, pathLabel, "enum", `got ${JSON.stringify(value)}`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
if (minLength !== undefined && value.length < minLength) {
|
|
85
|
+
push(violations, pathLabel, "minLength", String(value.length));
|
|
86
|
+
}
|
|
87
|
+
if (maxLength !== undefined && value.length > maxLength) {
|
|
88
|
+
push(violations, pathLabel, "maxLength", String(value.length));
|
|
89
|
+
}
|
|
90
|
+
if (pattern !== undefined && !new RegExp(pattern).test(value)) {
|
|
91
|
+
push(violations, pathLabel, "pattern", `got ${JSON.stringify(value)}`);
|
|
92
|
+
}
|
|
93
|
+
if (format === "date-time" && !DATE_TIME_RE.test(value)) {
|
|
94
|
+
push(violations, pathLabel, "format", `not RFC3339 date-time: ${value}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === "number" && minimum !== undefined && value < minimum) {
|
|
98
|
+
push(violations, pathLabel, "minimum", `got ${value}, min ${minimum}`);
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
if (minItems !== undefined && value.length < minItems) {
|
|
102
|
+
push(violations, pathLabel, "minItems", String(value.length));
|
|
103
|
+
}
|
|
104
|
+
if (maxItems !== undefined && value.length > maxItems) {
|
|
105
|
+
push(violations, pathLabel, "maxItems", String(value.length));
|
|
106
|
+
}
|
|
107
|
+
if (items !== undefined) {
|
|
108
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
109
|
+
check(items, value[i], `${pathLabel}[${i}]`, violations);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (typeOf(value) === "object") {
|
|
114
|
+
const own = Object.getOwnPropertyNames(value);
|
|
115
|
+
if (minProperties !== undefined && own.length < minProperties) {
|
|
116
|
+
push(violations, pathLabel, "minProperties", String(own.length));
|
|
117
|
+
}
|
|
118
|
+
if (maxProperties !== undefined && own.length > maxProperties) {
|
|
119
|
+
push(violations, pathLabel, "maxProperties", String(own.length));
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(required)) {
|
|
122
|
+
for (const key of required) {
|
|
123
|
+
if (!Object.hasOwn(value, key)) {
|
|
124
|
+
push(violations, pathLabel, "required", `missing ${key}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const key of own) {
|
|
129
|
+
const childLabel = `${pathLabel}.${key}`;
|
|
130
|
+
if (properties !== undefined && Object.hasOwn(properties, key)) {
|
|
131
|
+
check(properties[key], value[key], childLabel, violations);
|
|
132
|
+
} else if (additionalProperties === false) {
|
|
133
|
+
push(violations, pathLabel, "additionalProperties", `unknown key ${key}`);
|
|
134
|
+
} else if (additionalProperties !== undefined && typeof additionalProperties === "object") {
|
|
135
|
+
check(additionalProperties, value[key], childLabel, violations);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(allOf)) {
|
|
140
|
+
for (const sub of allOf) check(sub, value, pathLabel, violations);
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(anyOf)) {
|
|
143
|
+
const branches = anyOf.map((sub) => {
|
|
144
|
+
const local = [];
|
|
145
|
+
check(sub, value, pathLabel, local);
|
|
146
|
+
return local;
|
|
147
|
+
});
|
|
148
|
+
if (branches.every((local) => local.length > 0)) {
|
|
149
|
+
push(violations, pathLabel, "anyOf", "no variant matched");
|
|
150
|
+
for (const local of branches) violations.push(...local.map((v) => ({ ...v, rule: `anyOf:${v.rule}` })));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// State layout + state file accessors — CONTRACT §4. The layout is a
|
|
2
|
+
// contract value; every consumer builds paths through layout().
|
|
3
|
+
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { atomicWriteJson, CAP_CONFIG, readJsonCapped } from "./fsutil.mjs";
|
|
7
|
+
|
|
8
|
+
export const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
|
+
export const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
10
|
+
|
|
11
|
+
export function layout(stateHome) {
|
|
12
|
+
const waves = path.join(stateHome, "waves");
|
|
13
|
+
const receipts = path.join(stateHome, "receipts");
|
|
14
|
+
const locks = path.join(stateHome, "locks");
|
|
15
|
+
return {
|
|
16
|
+
stateHome,
|
|
17
|
+
ledger: path.join(stateHome, "ledger.jsonl"),
|
|
18
|
+
anchorState: path.join(stateHome, "anchor-state.json"),
|
|
19
|
+
locks,
|
|
20
|
+
ledgerLock: path.join(locks, "ledger.lock"),
|
|
21
|
+
waves,
|
|
22
|
+
receipts,
|
|
23
|
+
waveDir: (wave) => path.join(waves, wave),
|
|
24
|
+
metaFile: (wave) => path.join(waves, wave, "meta.json"),
|
|
25
|
+
cellsDir: (wave) => path.join(waves, wave, "cells"),
|
|
26
|
+
cellFile: (wave, id) => path.join(waves, wave, "cells", `${id}.json`),
|
|
27
|
+
leasesDir: (wave) => path.join(waves, wave, "leases"),
|
|
28
|
+
leaseDir: (wave, id) => path.join(waves, wave, "leases", id),
|
|
29
|
+
leaseLock: (wave, id) => path.join(waves, wave, "leases", id, "claim.lock"),
|
|
30
|
+
ownerFile: (wave, id) => path.join(waves, wave, "leases", id, "owner.json"),
|
|
31
|
+
leaseHistory: (wave, id) => path.join(waves, wave, "leases", id, "history.jsonl"),
|
|
32
|
+
receiptFile: (wave, id) => path.join(receipts, wave, `${id}.json`),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readStateJson(filePath) {
|
|
37
|
+
const { value } = await readJsonCapped(filePath, CAP_CONFIG, "STATE-CORRUPT");
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function writeStateJson(filePath, value) {
|
|
42
|
+
await atomicWriteJson(filePath, value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function removeStateFile(filePath) {
|
|
46
|
+
const fs = await import("node:fs/promises");
|
|
47
|
+
await fs.rm(filePath, { force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readCell(sl, wave, id) {
|
|
51
|
+
return readStateJson(sl.cellFile(wave, id));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function writeCell(sl, wave, id, cell) {
|
|
55
|
+
await writeStateJson(sl.cellFile(wave, id), cell);
|
|
56
|
+
}
|