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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -2
  3. package/index.js +1836 -0
  4. package/package.json +70 -4
  5. package/skills/codebase-design/SKILL.md +114 -0
  6. package/skills/design-contract/SKILL.md +27 -0
  7. package/skills/design-critique/SKILL.md +19 -0
  8. package/skills/design-system/SKILL.md +17 -0
  9. package/skills/design-system/references/image-analysis.md +14 -0
  10. package/skills/design-system/references/tokens.md +32 -0
  11. package/skills/design-system/references/ui-rules.md +14 -0
  12. package/skills/diagnosing-bugs/SKILL.md +138 -0
  13. package/skills/frontend-design/SKILL.md +36 -0
  14. package/skills/handoff/SKILL.md +16 -0
  15. package/skills/improve-codebase-architecture/SKILL.md +71 -0
  16. package/skills/linter-negative/SKILL.md +74 -0
  17. package/skills/no-slop-ui/SKILL.md +26 -0
  18. package/skills/prototype/SKILL.md +26 -0
  19. package/skills/registry.json +44087 -0
  20. package/skills/tastemaker/SKILL.md +8 -0
  21. package/skills/tastemaker/references/tastemaker.md +14 -0
  22. package/skills/tdd/SKILL.md +38 -0
  23. package/skills/visual-review/SKILL.md +103 -0
  24. package/skills/visual-review/references/review-gates.md +42 -0
  25. package/skills/visual-review/references/surface-archetypes.md +37 -0
  26. package/skills/wizard/SKILL.md +44 -0
  27. package/skills/writing-for-agents/SKILL.md +81 -0
  28. package/src/cognition/cognition-store.js +251 -0
  29. package/src/cognition/cognitive-state.js +126 -0
  30. package/src/cognition/hypothesis-manager.js +113 -0
  31. package/src/cognition/observation-engine.js +112 -0
  32. package/src/cognition/observation-provenance.js +268 -0
  33. package/src/context/active-context-boundary.js +68 -0
  34. package/src/context/assembly.js +494 -0
  35. package/src/context/assessment-rules.js +55 -0
  36. package/src/context/capsule-staleness.js +267 -0
  37. package/src/context/capsules.js +2 -0
  38. package/src/context/compare-array.js +98 -0
  39. package/src/context/compare-object.js +89 -0
  40. package/src/context/compare-values.js +42 -0
  41. package/src/context/context-assembly-contract.js +71 -0
  42. package/src/context/context-budget-manager.js +232 -0
  43. package/src/context/context-builder.js +49 -0
  44. package/src/context/context-cache.js +55 -0
  45. package/src/context/context-capture.js +243 -0
  46. package/src/context/context-classification.js +199 -0
  47. package/src/context/context-compaction.js +179 -0
  48. package/src/context/context-decision-audit.js +267 -0
  49. package/src/context/context-demotion.js +173 -0
  50. package/src/context/context-dictionary.js +106 -0
  51. package/src/context/context-drift-detection.js +225 -0
  52. package/src/context/context-evaluation.js +374 -0
  53. package/src/context/context-event-ingress.js +100 -0
  54. package/src/context/context-event-normalization.js +224 -0
  55. package/src/context/context-feedback-loop.js +216 -0
  56. package/src/context/context-freshness.js +21 -0
  57. package/src/context/context-graph-builder.js +71 -0
  58. package/src/context/context-graph.js +451 -0
  59. package/src/context/context-history-relevance.js +321 -0
  60. package/src/context/context-interception.js +145 -0
  61. package/src/context/context-invalidation-events.js +154 -0
  62. package/src/context/context-lifecycle.js +170 -0
  63. package/src/context/context-loop-prevention.js +347 -0
  64. package/src/context/context-manager.js +340 -0
  65. package/src/context/context-memory-layers.js +265 -0
  66. package/src/context/context-minimality.js +166 -0
  67. package/src/context/context-normalization.js +130 -0
  68. package/src/context/context-optimization-metrics.js +434 -0
  69. package/src/context/context-output-policy.js +135 -0
  70. package/src/context/context-pack-authority.js +157 -0
  71. package/src/context/context-promotion.js +172 -0
  72. package/src/context/context-query-contract.js +101 -0
  73. package/src/context/context-ranking.js +164 -0
  74. package/src/context/context-rate-distortion.js +261 -0
  75. package/src/context/context-recovery.js +209 -0
  76. package/src/context/context-retrieval-integration.js +60 -0
  77. package/src/context/context-retrieval.js +93 -0
  78. package/src/context/context-router.js +457 -0
  79. package/src/context/context-routing-evaluation.js +331 -0
  80. package/src/context/context-routing-observability.js +296 -0
  81. package/src/context/context-routing-shadow.js +237 -0
  82. package/src/context/context-scope.js +181 -0
  83. package/src/context/context-snapshot.js +226 -0
  84. package/src/context/context-source-registry.js +95 -0
  85. package/src/context/context-sufficiency-gate.js +120 -0
  86. package/src/context/context-sufficiency-oracle.js +167 -0
  87. package/src/context/context-telemetry.js +176 -0
  88. package/src/context/context.js +633 -0
  89. package/src/context/dependency-closure.js +246 -0
  90. package/src/context/page-fault-tracker.js +275 -0
  91. package/src/context/prompt-engine.js +74 -0
  92. package/src/context/runtime-context-graph.js +260 -0
  93. package/src/context/selection.js +2 -0
  94. package/src/context/session.js +2 -0
  95. package/src/context/sufficiency-contract.js +300 -0
  96. package/src/context/tokenizer.js +17 -0
  97. package/src/context-benchmark-router.mjs +125 -0
  98. package/src/evidence/evidence-engine.js +161 -0
  99. package/src/evidence/evidence-factory.js +3 -0
  100. package/src/evidence/evidence-freshness.js +463 -0
  101. package/src/evidence/evidence-gap.js +76 -0
  102. package/src/evidence/evidence-lineage.js +525 -0
  103. package/src/evidence/evidence.js +136 -0
  104. package/src/evidence/freshness-validation.js +187 -0
  105. package/src/evidence/lineage.js +4 -0
  106. package/src/execution/action-evaluation.js +179 -0
  107. package/src/execution/circuit-breaker.js +70 -0
  108. package/src/execution/claim-interception.js +63 -0
  109. package/src/execution/execution-engine.js +258 -0
  110. package/src/execution/execution-intent.js +69 -0
  111. package/src/execution/execution-state.js +89 -0
  112. package/src/execution/formatting.js +60 -0
  113. package/src/execution/l1-sanitize.js +16 -0
  114. package/src/execution/l2-resource-guards.js +19 -0
  115. package/src/execution/l3-invariant-checker.js +5 -0
  116. package/src/execution/l4-containment.js +6 -0
  117. package/src/execution/runtime-guards.js +106 -0
  118. package/src/execution/strategy-identity.js +162 -0
  119. package/src/execution/tool-interceptor.js +7 -0
  120. package/src/execution/tool-result-classifier.js +63 -0
  121. package/src/integration/canary-deploy.js +143 -0
  122. package/src/integration/collector.js +59 -0
  123. package/src/integration/error-recovery.js +6 -0
  124. package/src/integration/event-bus.js +66 -0
  125. package/src/integration/health-checker.js +57 -0
  126. package/src/integration/logger.js +39 -0
  127. package/src/integration/message-handler.js +611 -0
  128. package/src/integration/opencode-db.js +153 -0
  129. package/src/integration/orchestration.js +419 -0
  130. package/src/integration/part-provenance.js +65 -0
  131. package/src/integration/rate-limiter.js +55 -0
  132. package/src/integration/release-gate.js +336 -0
  133. package/src/integration/rollback-manager.js +164 -0
  134. package/src/integration/router-adapter.js +269 -0
  135. package/src/integration/telemetry.js +45 -0
  136. package/src/integration/update-checker.js +10 -0
  137. package/src/persistence/event-sourcing.js +75 -0
  138. package/src/persistence/memory.js +501 -0
  139. package/src/persistence/migration-runner.js +16 -0
  140. package/src/persistence/persistence-manager.js +39 -0
  141. package/src/persistence/transaction-log.js +157 -0
  142. package/src/policy/action-capabilities.js +116 -0
  143. package/src/policy/action-policy.js +4 -0
  144. package/src/policy/assessment-engine.js +104 -0
  145. package/src/policy/assumption-tracking.js +66 -0
  146. package/src/policy/budget-enforcer.js +7 -0
  147. package/src/policy/config-validator.js +102 -0
  148. package/src/policy/execution-assessment.js +142 -0
  149. package/src/policy/failure-classification.js +5 -0
  150. package/src/policy/feature-flags.js +65 -0
  151. package/src/policy/governance-enforcement.js +118 -0
  152. package/src/policy/locale.js +15 -0
  153. package/src/policy/multi-signer.js +151 -0
  154. package/src/policy/policy-state-machine.js +97 -0
  155. package/src/policy/ponytail-constraint.js +38 -0
  156. package/src/policy/project-inspector.js +93 -0
  157. package/src/policy/query-builder.js +14 -0
  158. package/src/policy/request-classifier.js +79 -0
  159. package/src/policy/risk-engine.js +266 -0
  160. package/src/policy/scope-enforcement.js +140 -0
  161. package/src/policy/secret-scanner.js +68 -0
  162. package/src/policy/skill-routing.js +104 -0
  163. package/src/policy/strategy-capabilities.js +211 -0
  164. package/src/policy/strategy.js +6 -0
  165. package/src/policy/uncertainty.js +117 -0
  166. package/src/shared/container.js +44 -0
  167. package/src/shared/decision-types.js +24 -0
  168. package/src/shared/simple-test.js +160 -0
  169. package/src/shared/wam-events.js +52 -0
  170. package/src/skills/engine.js +1705 -0
  171. package/src/skills/skill-registry.js +19 -0
  172. package/src/skills/skill-routing.js +340 -0
  173. package/src/state/lifecycle-manager.js +113 -0
  174. package/src/state/operational-state.js +198 -0
  175. package/src/state/projections.js +47 -0
  176. package/src/state/replay-engine.js +5 -0
  177. package/src/state/requirement-ownership.js +20 -0
  178. package/src/state/requirement-state.js +152 -0
  179. package/src/state/run-event-identity.js +172 -0
  180. package/src/state/state-engine.js +19 -0
  181. package/src/state/state-machine.js +119 -0
  182. package/src/state/state-schema.js +318 -0
  183. package/src/state/state-store.js +335 -0
  184. package/src/state/state-transition.js +84 -0
  185. package/src/state/task-dependencies.js +287 -0
  186. package/src/state/task-execution.js +217 -0
  187. package/src/state/task-lifecycle.js +59 -0
  188. package/src/state/task-runs.js +361 -0
  189. package/src/state/task-state-file.js +86 -0
  190. package/src/state/task-state.js +41 -0
  191. package/src/state/task-status.js +54 -0
  192. package/src/state/task-store.js +48 -0
  193. package/src/state/wam-state.js +148 -0
  194. package/src/verification/completion-gate-simple.js +8 -0
  195. package/src/verification/completion-gate.js +183 -0
  196. package/src/verification/contract.js +9 -0
  197. package/src/verification/contradiction-resolution.js +255 -0
  198. package/src/verification/diagnostic-engine.js +659 -0
  199. package/src/verification/false-completion-prevention.js +100 -0
  200. package/src/verification/hook-validator.js +6 -0
  201. package/src/verification/requirements.js +6 -0
  202. package/src/verification/verification-context.js +162 -0
  203. package/src/verification/verification-lifecycle.js +66 -0
  204. package/src/verification/verification-model.js +89 -0
  205. package/src/verification/verification-persistence.js +129 -0
  206. package/src/verification/verification-policy.js +127 -0
  207. 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 };