opencode-wait-a-minute 0.0.0-stage → 1.1.0-rc.8
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/LICENSE +21 -0
- package/README.md +318 -2
- package/index.js +1836 -0
- package/package.json +70 -4
- package/skills/codebase-design/SKILL.md +114 -0
- package/skills/design-contract/SKILL.md +27 -0
- package/skills/design-critique/SKILL.md +19 -0
- package/skills/design-system/SKILL.md +17 -0
- package/skills/design-system/references/image-analysis.md +14 -0
- package/skills/design-system/references/tokens.md +32 -0
- package/skills/design-system/references/ui-rules.md +14 -0
- package/skills/diagnosing-bugs/SKILL.md +138 -0
- package/skills/frontend-design/SKILL.md +36 -0
- package/skills/handoff/SKILL.md +16 -0
- package/skills/improve-codebase-architecture/SKILL.md +71 -0
- package/skills/linter-negative/SKILL.md +74 -0
- package/skills/no-slop-ui/SKILL.md +26 -0
- package/skills/prototype/SKILL.md +26 -0
- package/skills/registry.json +44087 -0
- package/skills/tastemaker/SKILL.md +8 -0
- package/skills/tastemaker/references/tastemaker.md +14 -0
- package/skills/tdd/SKILL.md +38 -0
- package/skills/visual-review/SKILL.md +103 -0
- package/skills/visual-review/references/review-gates.md +42 -0
- package/skills/visual-review/references/surface-archetypes.md +37 -0
- package/skills/wizard/SKILL.md +44 -0
- package/skills/writing-for-agents/SKILL.md +81 -0
- package/src/cognition/cognition-store.js +251 -0
- package/src/cognition/cognitive-state.js +126 -0
- package/src/cognition/hypothesis-manager.js +113 -0
- package/src/cognition/observation-engine.js +112 -0
- package/src/cognition/observation-provenance.js +268 -0
- package/src/context/active-context-boundary.js +68 -0
- package/src/context/assembly.js +494 -0
- package/src/context/assessment-rules.js +55 -0
- package/src/context/capsule-staleness.js +267 -0
- package/src/context/capsules.js +2 -0
- package/src/context/compare-array.js +98 -0
- package/src/context/compare-object.js +89 -0
- package/src/context/compare-values.js +42 -0
- package/src/context/context-assembly-contract.js +71 -0
- package/src/context/context-budget-manager.js +232 -0
- package/src/context/context-builder.js +49 -0
- package/src/context/context-cache.js +55 -0
- package/src/context/context-capture.js +243 -0
- package/src/context/context-classification.js +199 -0
- package/src/context/context-compaction.js +179 -0
- package/src/context/context-decision-audit.js +267 -0
- package/src/context/context-demotion.js +173 -0
- package/src/context/context-dictionary.js +106 -0
- package/src/context/context-drift-detection.js +225 -0
- package/src/context/context-evaluation.js +374 -0
- package/src/context/context-event-ingress.js +100 -0
- package/src/context/context-event-normalization.js +224 -0
- package/src/context/context-feedback-loop.js +216 -0
- package/src/context/context-freshness.js +21 -0
- package/src/context/context-graph-builder.js +71 -0
- package/src/context/context-graph.js +451 -0
- package/src/context/context-history-relevance.js +321 -0
- package/src/context/context-interception.js +145 -0
- package/src/context/context-invalidation-events.js +154 -0
- package/src/context/context-lifecycle.js +170 -0
- package/src/context/context-loop-prevention.js +347 -0
- package/src/context/context-manager.js +340 -0
- package/src/context/context-memory-layers.js +265 -0
- package/src/context/context-minimality.js +166 -0
- package/src/context/context-normalization.js +130 -0
- package/src/context/context-optimization-metrics.js +434 -0
- package/src/context/context-output-policy.js +135 -0
- package/src/context/context-pack-authority.js +157 -0
- package/src/context/context-promotion.js +172 -0
- package/src/context/context-query-contract.js +101 -0
- package/src/context/context-ranking.js +164 -0
- package/src/context/context-rate-distortion.js +261 -0
- package/src/context/context-recovery.js +209 -0
- package/src/context/context-retrieval-integration.js +60 -0
- package/src/context/context-retrieval.js +93 -0
- package/src/context/context-router.js +457 -0
- package/src/context/context-routing-evaluation.js +331 -0
- package/src/context/context-routing-observability.js +296 -0
- package/src/context/context-routing-shadow.js +237 -0
- package/src/context/context-scope.js +181 -0
- package/src/context/context-snapshot.js +226 -0
- package/src/context/context-source-registry.js +95 -0
- package/src/context/context-sufficiency-gate.js +120 -0
- package/src/context/context-sufficiency-oracle.js +167 -0
- package/src/context/context-telemetry.js +176 -0
- package/src/context/context.js +633 -0
- package/src/context/dependency-closure.js +246 -0
- package/src/context/page-fault-tracker.js +275 -0
- package/src/context/prompt-engine.js +74 -0
- package/src/context/runtime-context-graph.js +260 -0
- package/src/context/selection.js +2 -0
- package/src/context/session.js +2 -0
- package/src/context/sufficiency-contract.js +300 -0
- package/src/context/tokenizer.js +17 -0
- package/src/context-benchmark-router.mjs +125 -0
- package/src/evidence/evidence-engine.js +161 -0
- package/src/evidence/evidence-factory.js +3 -0
- package/src/evidence/evidence-freshness.js +463 -0
- package/src/evidence/evidence-gap.js +76 -0
- package/src/evidence/evidence-lineage.js +525 -0
- package/src/evidence/evidence.js +136 -0
- package/src/evidence/freshness-validation.js +187 -0
- package/src/evidence/lineage.js +4 -0
- package/src/execution/action-evaluation.js +179 -0
- package/src/execution/circuit-breaker.js +70 -0
- package/src/execution/claim-interception.js +63 -0
- package/src/execution/execution-engine.js +258 -0
- package/src/execution/execution-intent.js +69 -0
- package/src/execution/execution-state.js +89 -0
- package/src/execution/formatting.js +60 -0
- package/src/execution/l1-sanitize.js +16 -0
- package/src/execution/l2-resource-guards.js +19 -0
- package/src/execution/l3-invariant-checker.js +5 -0
- package/src/execution/l4-containment.js +6 -0
- package/src/execution/runtime-guards.js +106 -0
- package/src/execution/strategy-identity.js +162 -0
- package/src/execution/tool-interceptor.js +7 -0
- package/src/execution/tool-result-classifier.js +63 -0
- package/src/integration/canary-deploy.js +143 -0
- package/src/integration/collector.js +59 -0
- package/src/integration/error-recovery.js +6 -0
- package/src/integration/event-bus.js +66 -0
- package/src/integration/health-checker.js +57 -0
- package/src/integration/logger.js +39 -0
- package/src/integration/message-handler.js +611 -0
- package/src/integration/opencode-db.js +153 -0
- package/src/integration/orchestration.js +419 -0
- package/src/integration/part-provenance.js +65 -0
- package/src/integration/rate-limiter.js +55 -0
- package/src/integration/release-gate.js +336 -0
- package/src/integration/rollback-manager.js +164 -0
- package/src/integration/router-adapter.js +269 -0
- package/src/integration/telemetry.js +45 -0
- package/src/integration/update-checker.js +10 -0
- package/src/persistence/event-sourcing.js +75 -0
- package/src/persistence/memory.js +501 -0
- package/src/persistence/migration-runner.js +16 -0
- package/src/persistence/persistence-manager.js +39 -0
- package/src/persistence/transaction-log.js +157 -0
- package/src/policy/action-capabilities.js +116 -0
- package/src/policy/action-policy.js +4 -0
- package/src/policy/assessment-engine.js +104 -0
- package/src/policy/assumption-tracking.js +66 -0
- package/src/policy/budget-enforcer.js +7 -0
- package/src/policy/config-validator.js +102 -0
- package/src/policy/execution-assessment.js +142 -0
- package/src/policy/failure-classification.js +5 -0
- package/src/policy/feature-flags.js +65 -0
- package/src/policy/governance-enforcement.js +118 -0
- package/src/policy/locale.js +15 -0
- package/src/policy/multi-signer.js +151 -0
- package/src/policy/policy-state-machine.js +97 -0
- package/src/policy/ponytail-constraint.js +38 -0
- package/src/policy/project-inspector.js +93 -0
- package/src/policy/query-builder.js +14 -0
- package/src/policy/request-classifier.js +79 -0
- package/src/policy/risk-engine.js +266 -0
- package/src/policy/scope-enforcement.js +140 -0
- package/src/policy/secret-scanner.js +68 -0
- package/src/policy/skill-routing.js +104 -0
- package/src/policy/strategy-capabilities.js +211 -0
- package/src/policy/strategy.js +6 -0
- package/src/policy/uncertainty.js +117 -0
- package/src/shared/container.js +44 -0
- package/src/shared/decision-types.js +24 -0
- package/src/shared/simple-test.js +160 -0
- package/src/shared/wam-events.js +52 -0
- package/src/skills/engine.js +1705 -0
- package/src/skills/skill-registry.js +19 -0
- package/src/skills/skill-routing.js +340 -0
- package/src/state/lifecycle-manager.js +113 -0
- package/src/state/operational-state.js +198 -0
- package/src/state/projections.js +47 -0
- package/src/state/replay-engine.js +5 -0
- package/src/state/requirement-ownership.js +20 -0
- package/src/state/requirement-state.js +152 -0
- package/src/state/run-event-identity.js +172 -0
- package/src/state/state-engine.js +19 -0
- package/src/state/state-machine.js +119 -0
- package/src/state/state-schema.js +318 -0
- package/src/state/state-store.js +335 -0
- package/src/state/state-transition.js +84 -0
- package/src/state/task-dependencies.js +287 -0
- package/src/state/task-execution.js +217 -0
- package/src/state/task-lifecycle.js +59 -0
- package/src/state/task-runs.js +361 -0
- package/src/state/task-state-file.js +86 -0
- package/src/state/task-state.js +41 -0
- package/src/state/task-status.js +54 -0
- package/src/state/task-store.js +48 -0
- package/src/state/wam-state.js +148 -0
- package/src/verification/completion-gate-simple.js +8 -0
- package/src/verification/completion-gate.js +183 -0
- package/src/verification/contract.js +9 -0
- package/src/verification/contradiction-resolution.js +255 -0
- package/src/verification/diagnostic-engine.js +659 -0
- package/src/verification/false-completion-prevention.js +100 -0
- package/src/verification/hook-validator.js +6 -0
- package/src/verification/requirements.js +6 -0
- package/src/verification/verification-context.js +162 -0
- package/src/verification/verification-lifecycle.js +66 -0
- package/src/verification/verification-model.js +89 -0
- package/src/verification/verification-persistence.js +129 -0
- package/src/verification/verification-policy.js +127 -0
- package/src/verification/verification.js +415 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// state-schema.js
|
|
2
|
+
// JSON Schema validation for durable state documents with corruption detection
|
|
3
|
+
// (hash + schema validation). No external dependencies — uses Node's built-in.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Schema registry
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal JSON Schema subset validator. Supports:
|
|
13
|
+
* - type: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null"
|
|
14
|
+
* - properties (with required[])
|
|
15
|
+
* - items
|
|
16
|
+
* - enum, const, pattern (string regex), minLength, maxLength
|
|
17
|
+
* - minimum, maximum
|
|
18
|
+
* - additionalProperties: false
|
|
19
|
+
* - oneOf, anyOf (shallow)
|
|
20
|
+
*
|
|
21
|
+
* Returns { valid: boolean, errors: [{ path, message }] }
|
|
22
|
+
*/
|
|
23
|
+
export function validateSchema(schema, value) {
|
|
24
|
+
const errors = [];
|
|
25
|
+
walk(schema, value, "", errors);
|
|
26
|
+
return { valid: errors.length === 0, errors };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function walk(schema, value, path, errors) {
|
|
30
|
+
if (schema === true) return; // any value
|
|
31
|
+
if (schema === false) {
|
|
32
|
+
errors.push({ path, message: "schema disallows this value" });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof schema !== "object" || schema === null) return;
|
|
36
|
+
|
|
37
|
+
// oneOf / anyOf (shallow — first match wins, otherwise collect all)
|
|
38
|
+
if (Array.isArray(schema.oneOf)) {
|
|
39
|
+
const matched = schema.oneOf.some((sub) => validateSchema(sub, value).valid);
|
|
40
|
+
if (!matched) errors.push({ path, message: "does not match any oneOf branch" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(schema.anyOf)) {
|
|
44
|
+
const matched = schema.anyOf.some((sub) => validateSchema(sub, value).valid);
|
|
45
|
+
if (!matched) errors.push({ path, message: "does not match any anyOf branch" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// const / enum
|
|
50
|
+
if (Object.prototype.hasOwnProperty.call(schema, "const")) {
|
|
51
|
+
if (!deepEqual(schema.const, value))
|
|
52
|
+
errors.push({ path, message: `expected const ${JSON.stringify(schema.const)}` });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(schema.enum)) {
|
|
56
|
+
if (!schema.enum.some((v) => deepEqual(v, value)))
|
|
57
|
+
errors.push({ path, message: `expected one of ${JSON.stringify(schema.enum)}` });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// type
|
|
62
|
+
if (schema.type) {
|
|
63
|
+
const actual = jsonType(value);
|
|
64
|
+
const expected = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
65
|
+
if (!expected.includes(actual)) {
|
|
66
|
+
errors.push({
|
|
67
|
+
path,
|
|
68
|
+
message: `expected type ${expected.join("|")}, got ${actual}`,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// string constraints
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength)
|
|
77
|
+
errors.push({ path, message: `string shorter than minLength ${schema.minLength}` });
|
|
78
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength)
|
|
79
|
+
errors.push({ path, message: `string longer than maxLength ${schema.maxLength}` });
|
|
80
|
+
if (schema.pattern) {
|
|
81
|
+
try {
|
|
82
|
+
if (!new RegExp(schema.pattern).test(value))
|
|
83
|
+
errors.push({ path, message: `does not match pattern ${schema.pattern}` });
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore bad regex */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// numeric constraints
|
|
91
|
+
if (typeof value === "number") {
|
|
92
|
+
if (typeof schema.minimum === "number" && value < schema.minimum)
|
|
93
|
+
errors.push({ path, message: `value below minimum ${schema.minimum}` });
|
|
94
|
+
if (typeof schema.maximum === "number" && value > schema.maximum)
|
|
95
|
+
errors.push({ path, message: `value above maximum ${schema.maximum}` });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// object
|
|
99
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
100
|
+
if (Array.isArray(schema.required)) {
|
|
101
|
+
for (const key of schema.required) {
|
|
102
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
103
|
+
errors.push({ path: joinPath(path, key), message: "missing required field" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (schema.properties) {
|
|
107
|
+
for (const [k, sub] of Object.entries(schema.properties)) {
|
|
108
|
+
if (Object.prototype.hasOwnProperty.call(value, k))
|
|
109
|
+
walk(sub, value[k], joinPath(path, k), errors);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (schema.additionalProperties === false && schema.properties) {
|
|
113
|
+
for (const k of Object.keys(value)) {
|
|
114
|
+
if (!Object.prototype.hasOwnProperty.call(schema.properties, k))
|
|
115
|
+
errors.push({ path: joinPath(path, k), message: "additional property not allowed" });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// array
|
|
121
|
+
if (Array.isArray(value) && schema.items) {
|
|
122
|
+
value.forEach((item, i) => walk(schema.items, item, `${path}[${i}]`, errors));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function joinPath(base, key) {
|
|
127
|
+
if (!base) return String(key);
|
|
128
|
+
if (/^\d+$/.test(String(key))) return `${base}[${key}]`;
|
|
129
|
+
return `${base}.${key}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function jsonType(v) {
|
|
133
|
+
if (v === null) return "null";
|
|
134
|
+
if (Array.isArray(v)) return "array";
|
|
135
|
+
if (typeof v === "number" && Number.isInteger(v)) return "integer";
|
|
136
|
+
return typeof v;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function deepEqual(a, b) {
|
|
140
|
+
if (a === b) return true;
|
|
141
|
+
if (a === null || b === null) return false;
|
|
142
|
+
if (typeof a !== typeof b) return false;
|
|
143
|
+
if (typeof a !== "object") return false;
|
|
144
|
+
if (Array.isArray(a)) {
|
|
145
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
146
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(b)) return false;
|
|
149
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
150
|
+
if (ka.length !== kb.length) return false;
|
|
151
|
+
return ka.every((k) => deepEqual(a[k], b[k]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Schemas for the five state surfaces defined in design.md
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const commonEnvelope = {
|
|
159
|
+
type: "object",
|
|
160
|
+
required: ["stateVersion", "lastModifiedAt"],
|
|
161
|
+
properties: {
|
|
162
|
+
stateVersion: { type: "integer", minimum: 1 },
|
|
163
|
+
lastModifiedAt: { type: "string", minLength: 1 },
|
|
164
|
+
},
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const SCHEMAS = {
|
|
169
|
+
assessment: {
|
|
170
|
+
...commonEnvelope,
|
|
171
|
+
properties: {
|
|
172
|
+
...commonEnvelope.properties,
|
|
173
|
+
taskId: { type: "string", minLength: 1 },
|
|
174
|
+
summary: { type: "string" },
|
|
175
|
+
result: { enum: ["PASS", "FAIL", "WARN", "INCONCLUSIVE"] },
|
|
176
|
+
findings: {
|
|
177
|
+
type: "array",
|
|
178
|
+
items: {
|
|
179
|
+
type: "object",
|
|
180
|
+
required: ["path", "result"],
|
|
181
|
+
properties: {
|
|
182
|
+
path: { type: "string" },
|
|
183
|
+
result: { enum: ["PASS", "FAIL", "WARN", "INCONCLUSIVE"] },
|
|
184
|
+
reason: { type: "string" },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
required: ["stateVersion", "lastModifiedAt", "taskId", "result"],
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
plan: {
|
|
193
|
+
...commonEnvelope,
|
|
194
|
+
properties: {
|
|
195
|
+
...commonEnvelope.properties,
|
|
196
|
+
planId: { type: "string", minLength: 1 },
|
|
197
|
+
taskId: { type: "string", minLength: 1 },
|
|
198
|
+
steps: {
|
|
199
|
+
type: "array",
|
|
200
|
+
items: {
|
|
201
|
+
type: "object",
|
|
202
|
+
required: ["id", "status"],
|
|
203
|
+
properties: {
|
|
204
|
+
id: { type: "string", minLength: 1 },
|
|
205
|
+
status: { enum: ["pending", "running", "done", "failed", "skipped"] },
|
|
206
|
+
description: { type: "string" },
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: ["stateVersion", "lastModifiedAt", "planId", "taskId", "steps"],
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
evidence: {
|
|
215
|
+
...commonEnvelope,
|
|
216
|
+
properties: {
|
|
217
|
+
...commonEnvelope.properties,
|
|
218
|
+
taskId: { type: "string", minLength: 1 },
|
|
219
|
+
stepId: { type: "string", minLength: 1 },
|
|
220
|
+
kind: { enum: ["file", "log", "metric", "snapshot"] },
|
|
221
|
+
contentHash: { type: "string", minLength: 1 },
|
|
222
|
+
},
|
|
223
|
+
required: ["stateVersion", "lastModifiedAt", "taskId", "stepId", "kind", "contentHash"],
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
completion: {
|
|
227
|
+
...commonEnvelope,
|
|
228
|
+
properties: {
|
|
229
|
+
...commonEnvelope.properties,
|
|
230
|
+
taskId: { type: "string", minLength: 1 },
|
|
231
|
+
status: { enum: ["complete", "incomplete", "blocked"] },
|
|
232
|
+
reportPath: { type: "string" },
|
|
233
|
+
},
|
|
234
|
+
required: ["stateVersion", "lastModifiedAt", "taskId", "status"],
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
drift: {
|
|
238
|
+
type: "object",
|
|
239
|
+
required: ["taskId", "lastModifiedAt", "entries"],
|
|
240
|
+
properties: {
|
|
241
|
+
taskId: { type: "string", minLength: 1 },
|
|
242
|
+
lastModifiedAt: { type: "string", minLength: 1 },
|
|
243
|
+
entries: {
|
|
244
|
+
type: "array",
|
|
245
|
+
items: {
|
|
246
|
+
type: "object",
|
|
247
|
+
required: ["ts", "kind"],
|
|
248
|
+
properties: {
|
|
249
|
+
ts: { type: "string", minLength: 1 },
|
|
250
|
+
kind: { enum: ["deviation", "correction", "warning"] },
|
|
251
|
+
note: { type: "string" },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
additionalProperties: false,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Hash + corruption detection
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Canonical JSON: stable key ordering so hashing is deterministic.
|
|
266
|
+
*/
|
|
267
|
+
export function canonicalJSON(value) {
|
|
268
|
+
return JSON.stringify(sortKeys(value));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sortKeys(v) {
|
|
272
|
+
if (v === null || typeof v !== "object") return v;
|
|
273
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
274
|
+
const out = {};
|
|
275
|
+
for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]);
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compute a SHA-256 hash of a state document payload (excluding the `_hash` itself).
|
|
281
|
+
*/
|
|
282
|
+
export function computeHash(payload) {
|
|
283
|
+
const { _hash, ...rest } = payload;
|
|
284
|
+
return createHash("sha256").update(canonicalJSON(rest)).digest("hex");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Wrap a payload with a `_hash` field that commits the canonical-JSON hash.
|
|
289
|
+
*/
|
|
290
|
+
export function seal(payload) {
|
|
291
|
+
const hash = computeHash(payload);
|
|
292
|
+
return { ...payload, _hash: hash };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Verify a sealed payload. Returns { valid, reason? }.
|
|
297
|
+
*/
|
|
298
|
+
export function verify(payload) {
|
|
299
|
+
if (!payload || typeof payload !== "object" || !("_hash" in payload))
|
|
300
|
+
return { valid: false, reason: "missing _hash field" };
|
|
301
|
+
const expected = payload._hash;
|
|
302
|
+
const actual = computeHash(payload);
|
|
303
|
+
if (expected !== actual) return { valid: false, reason: "hash mismatch (corruption detected)" };
|
|
304
|
+
return { valid: true };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Validate + verify a sealed payload. Returns { valid, errors, reason? }.
|
|
309
|
+
*/
|
|
310
|
+
export function validateSealed(schema, payload) {
|
|
311
|
+
const v = verify(payload);
|
|
312
|
+
if (!v.valid) return { valid: false, errors: [], reason: v.reason };
|
|
313
|
+
// _hash is metadata, strip it for schema check
|
|
314
|
+
const { _hash, ...data } = payload;
|
|
315
|
+
return { ...validateSchema(schema, data), reason: undefined };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const STATE_TYPES = Object.keys(SCHEMAS);
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// state-store.js
|
|
2
|
+
// Atomic writes (write-to-temp + rename), crash recovery (replay pending
|
|
3
|
+
// journal), and GC (max journal entries, max snapshots).
|
|
4
|
+
|
|
5
|
+
import { mkdir, rename, rm, stat, readFile, writeFile, readdir } from "node:fs/promises";
|
|
6
|
+
import { dirname, join, basename } from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
SCHEMAS,
|
|
10
|
+
STATE_TYPES,
|
|
11
|
+
seal,
|
|
12
|
+
validateSchema,
|
|
13
|
+
validateSealed,
|
|
14
|
+
} from "./state-schema.js";
|
|
15
|
+
import { TransactionLog, replay } from "../persistence/transaction-log.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
async function ensureDir(path) {
|
|
22
|
+
await mkdir(path, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Atomic write: write to a temp file in the same directory (so rename is on
|
|
27
|
+
* the same filesystem), fsync, then rename over the destination.
|
|
28
|
+
*
|
|
29
|
+
* Survives crashes mid-write: either the temp file exists (no effect on
|
|
30
|
+
* target) or the rename happened (target is fully written). Never torn.
|
|
31
|
+
*/
|
|
32
|
+
export async function atomicWrite(path, payload) {
|
|
33
|
+
await ensureDir(dirname(path));
|
|
34
|
+
const tmp = path + ".tmp." + randomBytes(6).toString("hex");
|
|
35
|
+
const data = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
|
36
|
+
await writeFile(tmp, data);
|
|
37
|
+
await rename(tmp, path);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// StateStore
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* StateStore — durable state with atomic writes, schema validation, and a
|
|
46
|
+
* crash-safe journal for replay.
|
|
47
|
+
*
|
|
48
|
+
* Layout:
|
|
49
|
+
* <root>/state/<type>/<id>.json — sealed state documents
|
|
50
|
+
* <root>/journal.log — append-only transaction log
|
|
51
|
+
* <root>/state-index.json — lightweight index
|
|
52
|
+
* <root>/quarantine/<type>/<id>.json — files that failed validation
|
|
53
|
+
* <root>/snapshots/<ts>.json — periodic snapshots (for GC)
|
|
54
|
+
*
|
|
55
|
+
* @param {string} root base directory for state
|
|
56
|
+
* @param {object} [opts]
|
|
57
|
+
* @param {number} [opts.maxJournalEntries=1000] GC threshold for journal size
|
|
58
|
+
* @param {number} [opts.maxSnapshots=5] GC threshold for snapshot count
|
|
59
|
+
* @param {number} [opts.retentionMs] ms after which a state entry
|
|
60
|
+
* is eligible for GC
|
|
61
|
+
*/
|
|
62
|
+
export class StateStore {
|
|
63
|
+
constructor(root, opts = {}) {
|
|
64
|
+
this.root = root;
|
|
65
|
+
this.maxJournalEntries = opts.maxJournalEntries ?? 1000;
|
|
66
|
+
this.maxSnapshots = opts.maxSnapshots ?? 5;
|
|
67
|
+
this.retentionMs = opts.retentionMs ?? null;
|
|
68
|
+
this.journal = new TransactionLog(join(root, "journal.log"));
|
|
69
|
+
this.indexPath = join(root, "state-index.json");
|
|
70
|
+
this._ready = this._init();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async _init() {
|
|
74
|
+
await ensureDir(this.root);
|
|
75
|
+
for (const t of STATE_TYPES) await ensureDir(join(this.root, "state", t));
|
|
76
|
+
await ensureDir(join(this.root, "quarantine"));
|
|
77
|
+
await ensureDir(join(this.root, "snapshots"));
|
|
78
|
+
// Reset index if missing/corrupt
|
|
79
|
+
try {
|
|
80
|
+
await this._loadIndex();
|
|
81
|
+
} catch {
|
|
82
|
+
await atomicWrite(this.indexPath, { entries: {} });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _loadIndex() {
|
|
87
|
+
const raw = await readFile(this.indexPath, "utf8");
|
|
88
|
+
const idx = JSON.parse(raw);
|
|
89
|
+
if (!idx || typeof idx !== "object" || typeof idx.entries !== "object") {
|
|
90
|
+
throw new Error("index invalid");
|
|
91
|
+
}
|
|
92
|
+
this._index = idx;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async _saveIndex() {
|
|
96
|
+
await atomicWrite(this.indexPath, this._index ?? { entries: {} });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_pathFor(type, id) {
|
|
100
|
+
return join(this.root, "state", type, `${id}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_quarantinePathFor(type, id) {
|
|
104
|
+
return join(this.root, "quarantine", type, `${id}.json`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write a state document. Validates against the schema, seals with hash,
|
|
109
|
+
* writes atomically, then appends a journal entry.
|
|
110
|
+
*
|
|
111
|
+
* Returns { ok: true, entry } on success, { ok: false, errors } on failure.
|
|
112
|
+
*/
|
|
113
|
+
async write(type, id, payload) {
|
|
114
|
+
await this._ready;
|
|
115
|
+
if (!STATE_TYPES.includes(type)) {
|
|
116
|
+
return { ok: false, errors: [{ path: "", message: `unknown state type: ${type}` }] };
|
|
117
|
+
}
|
|
118
|
+
const doc = {
|
|
119
|
+
...payload,
|
|
120
|
+
stateVersion: (payload.stateVersion ?? 0) + 1,
|
|
121
|
+
lastModifiedAt: payload.lastModifiedAt ?? new Date().toISOString(),
|
|
122
|
+
};
|
|
123
|
+
const result = validateSchema(SCHEMAS[type], doc);
|
|
124
|
+
if (!result.valid) return { ok: false, errors: result.errors };
|
|
125
|
+
|
|
126
|
+
const sealed = seal(doc);
|
|
127
|
+
|
|
128
|
+
const path = this._pathFor(type, id);
|
|
129
|
+
await atomicWrite(path, sealed);
|
|
130
|
+
|
|
131
|
+
const journalEntry = await this.journal.append({
|
|
132
|
+
type,
|
|
133
|
+
taskId: id,
|
|
134
|
+
op: "set",
|
|
135
|
+
data: sealed,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Update index
|
|
139
|
+
this._index = this._index ?? { entries: {} };
|
|
140
|
+
this._index.entries[`${type}:${id}`] = {
|
|
141
|
+
type,
|
|
142
|
+
id,
|
|
143
|
+
stateVersion: sealed.stateVersion,
|
|
144
|
+
lastModifiedAt: sealed.lastModifiedAt,
|
|
145
|
+
journalSeq: journalEntry.seq,
|
|
146
|
+
};
|
|
147
|
+
await this._saveIndex();
|
|
148
|
+
|
|
149
|
+
await this._maybeGC();
|
|
150
|
+
return { ok: true, entry: journalEntry };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read a state document. Validates schema + hash. If validation fails,
|
|
155
|
+
* quarantines the file and returns { ok: false, reason }.
|
|
156
|
+
*/
|
|
157
|
+
async read(type, id) {
|
|
158
|
+
await this._ready;
|
|
159
|
+
const path = this._pathFor(type, id);
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
raw = await readFile(path, "utf8");
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (e.code === "ENOENT") return { ok: false, reason: "not_found" };
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(raw);
|
|
170
|
+
} catch {
|
|
171
|
+
await this._quarantine(type, id, raw);
|
|
172
|
+
return { ok: false, reason: "json_parse_error" };
|
|
173
|
+
}
|
|
174
|
+
const v = validateSealed(SCHEMAS[type], parsed);
|
|
175
|
+
if (!v.valid) {
|
|
176
|
+
await this._quarantine(type, id, raw);
|
|
177
|
+
return { ok: false, reason: v.reason ?? "schema_invalid", errors: v.errors };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, data: parsed };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async _quarantine(type, id, raw) {
|
|
183
|
+
const qp = this._quarantinePathFor(type, id);
|
|
184
|
+
await ensureDir(dirname(qp));
|
|
185
|
+
await atomicWrite(qp, raw ?? "");
|
|
186
|
+
try {
|
|
187
|
+
await rm(this._pathFor(type, id));
|
|
188
|
+
} catch {
|
|
189
|
+
/* ignore */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async list(type) {
|
|
194
|
+
await this._ready;
|
|
195
|
+
const dir = join(this.root, "state", type);
|
|
196
|
+
let files;
|
|
197
|
+
try {
|
|
198
|
+
files = await readdir(dir);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (e.code === "ENOENT") return [];
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
return files.filter((f) => f.endsWith(".json")).map((f) => basename(f, ".json"));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Recover after a crash. Replays the journal, rehydrating the in-memory
|
|
208
|
+
* state map. Any journal entries that lack a corresponding state file are
|
|
209
|
+
* re-applied from the journal data.
|
|
210
|
+
*
|
|
211
|
+
* Returns { rehydrated, orphans, corrupted }.
|
|
212
|
+
*/
|
|
213
|
+
async recover() {
|
|
214
|
+
await this._ready;
|
|
215
|
+
const entries = await this.journal.readAll();
|
|
216
|
+
const { applied, skipped, finalState } = replay(entries);
|
|
217
|
+
|
|
218
|
+
// For each applied entry, ensure the state file exists (write if missing).
|
|
219
|
+
let rehydrated = 0;
|
|
220
|
+
let orphans = 0;
|
|
221
|
+
for (const { key } of applied) {
|
|
222
|
+
const [type, id] = key.split(":");
|
|
223
|
+
const data = finalState[key];
|
|
224
|
+
const path = this._pathFor(type, id);
|
|
225
|
+
let exists = true;
|
|
226
|
+
try {
|
|
227
|
+
await stat(path);
|
|
228
|
+
} catch {
|
|
229
|
+
exists = false;
|
|
230
|
+
}
|
|
231
|
+
if (!exists) {
|
|
232
|
+
await atomicWrite(path, data);
|
|
233
|
+
rehydrated++;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Skipped entries with _corrupt are quarantined into a sidecar file so
|
|
237
|
+
// the recovery operator can inspect them later.
|
|
238
|
+
const corrupted = skipped.filter((s) => s && s._corrupt);
|
|
239
|
+
if (corrupted.length) {
|
|
240
|
+
const sidecar = join(this.root, "quarantine", "journal-corrupt.json");
|
|
241
|
+
await ensureDir(dirname(sidecar));
|
|
242
|
+
await atomicWrite(sidecar, JSON.stringify(corrupted, null, 2));
|
|
243
|
+
orphans = corrupted.length;
|
|
244
|
+
}
|
|
245
|
+
return { rehydrated, orphans, corrupted };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Garbage collection.
|
|
250
|
+
* - If journal entries > maxJournalEntries: snapshot the live state, then
|
|
251
|
+
* truncate the journal.
|
|
252
|
+
* - If snapshots > maxSnapshots: prune the oldest.
|
|
253
|
+
* - If retentionMs is set: delete state entries whose lastModifiedAt is
|
|
254
|
+
* older than (now - retentionMs).
|
|
255
|
+
*/
|
|
256
|
+
async gc() {
|
|
257
|
+
await this._ready;
|
|
258
|
+
const journalSize = await this.journal.size();
|
|
259
|
+
|
|
260
|
+
// Journal compaction: if oversized, take a snapshot then truncate.
|
|
261
|
+
if (journalSize > this.maxJournalEntries) {
|
|
262
|
+
await this._snapshot();
|
|
263
|
+
await this.journal.truncate();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Snapshot pruning: keep only the newest maxSnapshots.
|
|
267
|
+
const snapDir = join(this.root, "snapshots");
|
|
268
|
+
let snaps = [];
|
|
269
|
+
try {
|
|
270
|
+
snaps = await readdir(snapDir);
|
|
271
|
+
} catch {
|
|
272
|
+
/* ignore */
|
|
273
|
+
}
|
|
274
|
+
snaps = snaps.filter((f) => f.endsWith(".json")).sort();
|
|
275
|
+
while (snaps.length > this.maxSnapshots) {
|
|
276
|
+
const oldest = snaps.shift();
|
|
277
|
+
await rm(join(snapDir, oldest), { force: true });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Retention-based eviction.
|
|
281
|
+
let evicted = 0;
|
|
282
|
+
if (this.retentionMs != null) {
|
|
283
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
284
|
+
for (const type of STATE_TYPES) {
|
|
285
|
+
const dir = join(this.root, "state", type);
|
|
286
|
+
let files = [];
|
|
287
|
+
try {
|
|
288
|
+
files = await readdir(dir);
|
|
289
|
+
} catch {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
for (const f of files) {
|
|
293
|
+
if (!f.endsWith(".json")) continue;
|
|
294
|
+
const full = join(dir, f);
|
|
295
|
+
try {
|
|
296
|
+
const raw = await readFile(full, "utf8");
|
|
297
|
+
const data = JSON.parse(raw);
|
|
298
|
+
const ts = Date.parse(data?.lastModifiedAt ?? "");
|
|
299
|
+
if (!Number.isFinite(ts)) continue;
|
|
300
|
+
if (ts < cutoff) {
|
|
301
|
+
await rm(full);
|
|
302
|
+
evicted++;
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
/* skip unreadable */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { journalCompacted: journalSize > this.maxJournalEntries, evicted };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async _maybeGC() {
|
|
314
|
+
// Cheap heuristic: only check journal size periodically.
|
|
315
|
+
if ((this._gcCounter = (this._gcCounter ?? 0) + 1) % 50 === 0) {
|
|
316
|
+
await this.gc();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async _snapshot() {
|
|
321
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
322
|
+
const snapPath = join(this.root, "snapshots", `${stamp}.json`);
|
|
323
|
+
const entries = {};
|
|
324
|
+
for (const type of STATE_TYPES) {
|
|
325
|
+
const ids = await this.list(type);
|
|
326
|
+
for (const id of ids) {
|
|
327
|
+
const r = await this.read(type, id);
|
|
328
|
+
if (r.ok) entries[`${type}:${id}`] = r.data;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
await atomicWrite(snapPath, { takenAt: new Date().toISOString(), entries });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export { TransactionLog, replay };
|