@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/brief/cli.d.ts +30 -0
- package/dist/brief/cli.d.ts.map +1 -0
- package/dist/brief/cli.js +85 -0
- package/dist/brief/collect.d.ts +17 -0
- package/dist/brief/collect.d.ts.map +1 -0
- package/dist/brief/collect.js +302 -0
- package/dist/brief/config.d.ts +40 -0
- package/dist/brief/config.d.ts.map +1 -0
- package/dist/brief/config.js +85 -0
- package/dist/brief/constants.d.ts +10 -0
- package/dist/brief/constants.d.ts.map +1 -0
- package/dist/brief/constants.js +9 -0
- package/dist/brief/docs.d.ts +53 -0
- package/dist/brief/docs.d.ts.map +1 -0
- package/dist/brief/docs.js +229 -0
- package/dist/brief/freshness.d.ts +104 -0
- package/dist/brief/freshness.d.ts.map +1 -0
- package/dist/brief/freshness.js +263 -0
- package/dist/brief/git-signals.d.ts +17 -0
- package/dist/brief/git-signals.d.ts.map +1 -0
- package/dist/brief/git-signals.js +101 -0
- package/dist/brief/index.d.ts +11 -0
- package/dist/brief/index.d.ts.map +1 -0
- package/dist/brief/index.js +5 -0
- package/dist/brief/reconcile.d.ts +75 -0
- package/dist/brief/reconcile.d.ts.map +1 -0
- package/dist/brief/reconcile.js +259 -0
- package/dist/brief/render.d.ts +60 -0
- package/dist/brief/render.d.ts.map +1 -0
- package/dist/brief/render.js +407 -0
- package/dist/brief/sanitize.d.ts +38 -0
- package/dist/brief/sanitize.d.ts.map +1 -0
- package/dist/brief/sanitize.js +48 -0
- package/dist/brief/selectors.d.ts +36 -0
- package/dist/brief/selectors.d.ts.map +1 -0
- package/dist/brief/selectors.js +75 -0
- package/dist/brief/story.d.ts +18 -0
- package/dist/brief/story.d.ts.map +1 -0
- package/dist/brief/story.js +59 -0
- package/dist/brief/types.d.ts +110 -0
- package/dist/brief/types.d.ts.map +1 -0
- package/dist/brief/types.js +11 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +166 -0
- package/dist/cli/init-args.d.ts +16 -0
- package/dist/cli/init-args.d.ts.map +1 -0
- package/dist/cli/init-args.js +24 -0
- package/dist/cli/ui-args.d.ts +23 -0
- package/dist/cli/ui-args.d.ts.map +1 -0
- package/dist/cli/ui-args.js +66 -0
- package/dist/cli/ui.d.ts +40 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +180 -0
- package/dist/coordinatorium/checkpoint.d.ts +44 -0
- package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
- package/dist/coordinatorium/checkpoint.js +124 -0
- package/dist/coordinatorium/cli.d.ts +39 -0
- package/dist/coordinatorium/cli.d.ts.map +1 -0
- package/dist/coordinatorium/cli.js +203 -0
- package/dist/coordinatorium/errors.d.ts +112 -0
- package/dist/coordinatorium/errors.d.ts.map +1 -0
- package/dist/coordinatorium/errors.js +145 -0
- package/dist/coordinatorium/index.d.ts +11 -0
- package/dist/coordinatorium/index.d.ts.map +1 -0
- package/dist/coordinatorium/index.js +5 -0
- package/dist/coordinatorium/lock.d.ts +187 -0
- package/dist/coordinatorium/lock.d.ts.map +1 -0
- package/dist/coordinatorium/lock.js +659 -0
- package/dist/coordinatorium/mission.d.ts +32 -0
- package/dist/coordinatorium/mission.d.ts.map +1 -0
- package/dist/coordinatorium/mission.js +87 -0
- package/dist/coordinatorium/state-keeper.d.ts +115 -0
- package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
- package/dist/coordinatorium/state-keeper.js +396 -0
- package/dist/coordinatorium/types.d.ts +69 -0
- package/dist/coordinatorium/types.d.ts.map +1 -0
- package/dist/coordinatorium/types.js +27 -0
- package/dist/durable/assess.d.ts +105 -0
- package/dist/durable/assess.d.ts.map +1 -0
- package/dist/durable/assess.js +239 -0
- package/dist/durable/canonical.d.ts +25 -0
- package/dist/durable/canonical.d.ts.map +1 -0
- package/dist/durable/canonical.js +78 -0
- package/dist/durable/cli.d.ts +37 -0
- package/dist/durable/cli.d.ts.map +1 -0
- package/dist/durable/cli.js +141 -0
- package/dist/durable/errors.d.ts +188 -0
- package/dist/durable/errors.d.ts.map +1 -0
- package/dist/durable/errors.js +317 -0
- package/dist/durable/fs-store.d.ts +233 -0
- package/dist/durable/fs-store.d.ts.map +1 -0
- package/dist/durable/fs-store.js +1393 -0
- package/dist/durable/index.d.ts +20 -0
- package/dist/durable/index.d.ts.map +1 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/log.d.ts +33 -0
- package/dist/durable/log.d.ts.map +1 -0
- package/dist/durable/log.js +157 -0
- package/dist/durable/memory-store.d.ts +51 -0
- package/dist/durable/memory-store.d.ts.map +1 -0
- package/dist/durable/memory-store.js +302 -0
- package/dist/durable/projection.d.ts +35 -0
- package/dist/durable/projection.d.ts.map +1 -0
- package/dist/durable/projection.js +328 -0
- package/dist/durable/prune-check.d.ts +17 -0
- package/dist/durable/prune-check.d.ts.map +1 -0
- package/dist/durable/prune-check.js +45 -0
- package/dist/durable/register.d.ts +100 -0
- package/dist/durable/register.d.ts.map +1 -0
- package/dist/durable/register.js +532 -0
- package/dist/durable/retention.d.ts +49 -0
- package/dist/durable/retention.d.ts.map +1 -0
- package/dist/durable/retention.js +59 -0
- package/dist/durable/runtime.d.ts +497 -0
- package/dist/durable/runtime.d.ts.map +1 -0
- package/dist/durable/runtime.js +1271 -0
- package/dist/durable/seams.d.ts +40 -0
- package/dist/durable/seams.d.ts.map +1 -0
- package/dist/durable/seams.js +21 -0
- package/dist/durable/testing.d.ts +11 -0
- package/dist/durable/testing.d.ts.map +1 -0
- package/dist/durable/testing.js +25 -0
- package/dist/durable/types.d.ts +442 -0
- package/dist/durable/types.d.ts.map +1 -0
- package/dist/durable/types.js +75 -0
- package/dist/graph/builder.d.ts +11 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/builder.js +153 -0
- package/dist/graph/identity.d.ts +37 -0
- package/dist/graph/identity.d.ts.map +1 -0
- package/dist/graph/identity.js +46 -0
- package/dist/graph/index.d.ts +7 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +3 -0
- package/dist/graph/resolver.d.ts +41 -0
- package/dist/graph/resolver.d.ts.map +1 -0
- package/dist/graph/resolver.js +36 -0
- package/dist/graph/types.d.ts +42 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +2 -0
- package/dist/http/server.d.ts +76 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +195 -0
- package/dist/http/ui-assets.d.ts +22 -0
- package/dist/http/ui-assets.d.ts.map +1 -0
- package/dist/http/ui-assets.js +100 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/init/index.d.ts +12 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +90 -0
- package/dist/init/prompts.d.ts +3 -0
- package/dist/init/prompts.d.ts.map +1 -0
- package/dist/init/prompts.js +47 -0
- package/dist/init/scaffold.d.ts +25 -0
- package/dist/init/scaffold.d.ts.map +1 -0
- package/dist/init/scaffold.js +204 -0
- package/dist/init/types.d.ts +14 -0
- package/dist/init/types.d.ts.map +1 -0
- package/dist/init/types.js +1 -0
- package/dist/readmodel/dashboard.d.ts +40 -0
- package/dist/readmodel/dashboard.d.ts.map +1 -0
- package/dist/readmodel/dashboard.js +236 -0
- package/dist/readmodel/index.d.ts +3 -0
- package/dist/readmodel/index.d.ts.map +1 -0
- package/dist/readmodel/index.js +1 -0
- package/dist/readmodel/types.d.ts +104 -0
- package/dist/readmodel/types.d.ts.map +1 -0
- package/dist/readmodel/types.js +15 -0
- package/dist/root/index.d.ts +179 -0
- package/dist/root/index.d.ts.map +1 -0
- package/dist/root/index.js +418 -0
- package/dist/root/safe-write.d.ts +268 -0
- package/dist/root/safe-write.d.ts.map +1 -0
- package/dist/root/safe-write.js +966 -0
- package/dist/root/write-artifacts.d.ts +20 -0
- package/dist/root/write-artifacts.d.ts.map +1 -0
- package/dist/root/write-artifacts.js +42 -0
- package/dist/scriptorium/compile.d.ts +4 -0
- package/dist/scriptorium/compile.d.ts.map +1 -0
- package/dist/scriptorium/compile.js +185 -0
- package/dist/scriptorium/frontmatter.d.ts +13 -0
- package/dist/scriptorium/frontmatter.d.ts.map +1 -0
- package/dist/scriptorium/frontmatter.js +90 -0
- package/dist/scriptorium/index.d.ts +3 -0
- package/dist/scriptorium/index.d.ts.map +1 -0
- package/dist/scriptorium/index.js +1 -0
- package/dist/scriptorium/markdown.d.ts +31 -0
- package/dist/scriptorium/markdown.d.ts.map +1 -0
- package/dist/scriptorium/markdown.js +88 -0
- package/dist/scriptorium/types.d.ts +78 -0
- package/dist/scriptorium/types.d.ts.map +1 -0
- package/dist/scriptorium/types.js +1 -0
- package/dist/types/enforcement.d.ts +11 -0
- package/dist/types/enforcement.d.ts.map +1 -0
- package/dist/types/enforcement.js +1 -0
- package/dist/types/integrity.d.ts +33 -0
- package/dist/types/integrity.d.ts.map +1 -0
- package/dist/types/integrity.js +1 -0
- package/dist/types/project-definition.d.ts +30 -0
- package/dist/types/project-definition.d.ts.map +1 -0
- package/dist/types/project-definition.js +1 -0
- package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
- package/dist/ui/assets/index-C4NhMaq7.js +28 -0
- package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
- package/dist/ui/index.html +13 -0
- package/dist/validate/index.d.ts +6 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +214 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +1 -0
- package/package.json +67 -4
- package/templates/.gitkeep +0 -0
- package/templates/default-instance/instruction/.cursorrules +53 -0
- package/templates/default-instance/instruction/.windsurfrules +53 -0
- package/templates/default-instance/instruction/CLAUDE.md +53 -0
- package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
- package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
- package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
- package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
- package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
- package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
- package/templates/default-instance/monolith/context/maps/routing-map.md +147 -0
- package/templates/default-instance/monolith/context/maps/semantic-map.md +73 -0
- package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
- package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
- package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
- package/templates/default-instance/monolith/governance/README.md +29 -0
- package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
- package/templates/default-instance/monolith/learning/INDEX.md +48 -0
- package/templates/default-instance/monolith/learning/injection-map.md +49 -0
- package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
- package/templates/default-instance/monolith/memory/errors/README.md +26 -0
- package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
- package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
- package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
- package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
- package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
- package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
- package/templates/default-instance/monolith/memory/working/active.md +41 -0
- package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
- package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
- package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
- package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
- package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
- package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
- package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
- package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
- package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
- package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
- package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
- package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
- package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
- package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
- package/templates/default-instance/monolith/skills/INDEX.md +28 -0
- package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
- package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
- package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
- package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
- package/templates/default-instance/monolith/state.json +11 -0
- package/templates/default-instance/monolith/tests/INDEX.md +57 -0
- package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
- package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
- package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
- package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
- package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
- package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
- package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
- package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
- package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
- package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
- package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
- package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
- package/templates/default-instance/projectroot/AGENTS.md +107 -0
- package/templates/default-instance/projectroot/monolith.config.json +32 -0
- package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persisted prune decision register: record formats and the pure derivation of the prune state.
|
|
3
|
+
*
|
|
4
|
+
* Design A (docs/architecture/DURABLE-PRUNE-PROTOCOL-DESIGN-004.md, sections 5, 19, 20, 32, 37, 41; ADR-019).
|
|
5
|
+
* This module does no I/O of its own. `derivePruneState` is a function of the names in the task directory and of what
|
|
6
|
+
* `RegisterIO` returns for them, so its decisions can be tested with injected partial listings and I/O errors
|
|
7
|
+
* without a filesystem. It never writes, never deletes and never repairs.
|
|
8
|
+
*
|
|
9
|
+
* Every verdict is fail-closed: `corrupt`, `unreadable`, `missing` and `legacy` never grant an irreversible action,
|
|
10
|
+
* and a missing name is never read as permission to delete anything (section 37, "absence of information is not
|
|
11
|
+
* permission").
|
|
12
|
+
*/
|
|
13
|
+
import { canonicalize, sha256Hex } from './canonical.js';
|
|
14
|
+
import { MAX_PRUNE_ATTEMPTS } from './types.js';
|
|
15
|
+
export { MAX_PRUNE_ATTEMPTS };
|
|
16
|
+
/** Re-checks of the register by a writer that has published a file (Design A, section 24.2). */
|
|
17
|
+
export const MAX_WRITE_RESOLVE = 3;
|
|
18
|
+
/** A register record is tiny; anything larger is refused before it is read. */
|
|
19
|
+
export const MAX_REGISTER_BYTES = 64 * 1024;
|
|
20
|
+
export const REGISTER_FORMAT_VERSION = 2;
|
|
21
|
+
/**
|
|
22
|
+
* Staging files of the write primitive: `<name>.tmp.<pid>.<hex16>`. The ONE definition: the filesystem store uses it
|
|
23
|
+
* for its own temp names too (Design A, section 20.2, N1), so the register can never mistake a staging file for a record.
|
|
24
|
+
*/
|
|
25
|
+
export const STAGING_RE = /^(.+)\.tmp\.\d+\.[0-9a-f]{16}$/;
|
|
26
|
+
const PREPARE_RE = /^prune-([1-9][0-9]{0,8})\.prepare\.json$/;
|
|
27
|
+
const DECISION_RE = /^prune-([1-9][0-9]{0,8})\.decision\.json$/;
|
|
28
|
+
export const TOMBSTONE_FILE = 'tombstone.json';
|
|
29
|
+
export const PROTOCOL_MARKER_FILE = 'durable-protocol.json';
|
|
30
|
+
export const prepareFile = (n) => `prune-${n}.prepare.json`;
|
|
31
|
+
export const decisionFile = (n) => `prune-${n}.decision.json`;
|
|
32
|
+
const DECISION_BY = ['pruner', 'child', 'creator', 'claim', 'append', 'compact', 'pin'];
|
|
33
|
+
const TASK_STATUSES = ['active', 'completed', 'failed', 'cancelled'];
|
|
34
|
+
const withoutCheck = (r) => {
|
|
35
|
+
const { check: _check, ...rest } = r;
|
|
36
|
+
return rest;
|
|
37
|
+
};
|
|
38
|
+
/** SHA-256 of the canonical serialization of every other field: a guard against accidental damage, not against an adversary. */
|
|
39
|
+
export function checkOf(record) {
|
|
40
|
+
return sha256Hex(canonicalize(withoutCheck(record)));
|
|
41
|
+
}
|
|
42
|
+
export function encodePrepare(attempt, tombstone) {
|
|
43
|
+
const body = { v: REGISTER_FORMAT_VERSION, attempt, tombstone };
|
|
44
|
+
return `${canonicalize({ ...body, check: checkOf(body) })}\n`;
|
|
45
|
+
}
|
|
46
|
+
export function encodeDecision(attempt, outcome, tombstone, by) {
|
|
47
|
+
const body = outcome === 'commit'
|
|
48
|
+
? { v: REGISTER_FORMAT_VERSION, attempt, outcome, tombstone: tombstone }
|
|
49
|
+
: { v: REGISTER_FORMAT_VERSION, attempt, outcome, ...(by ? { by } : {}) };
|
|
50
|
+
return `${canonicalize({ ...body, check: checkOf(body) })}\n`;
|
|
51
|
+
}
|
|
52
|
+
/** The `tombstone.json` file published from a commit. Same content, any reader of the v1 format understands it. */
|
|
53
|
+
export function encodeTombstone(tombstone) {
|
|
54
|
+
return `${canonicalize(tombstone)}\n`;
|
|
55
|
+
}
|
|
56
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
57
|
+
const TOMBSTONE_KEYS = ['v', 'taskId', 'kind', 'finalStatus', 'createdAt', 'closedAt', 'prunedAt', 'headSeq', 'headHash', 'outcomeDigest'];
|
|
58
|
+
/** Closed schema of a v1 tombstone. `null` when it is anything else. */
|
|
59
|
+
export function asTombstone(v, taskId) {
|
|
60
|
+
if (!isObj(v))
|
|
61
|
+
return null;
|
|
62
|
+
const keys = Object.keys(v);
|
|
63
|
+
if (keys.length !== TOMBSTONE_KEYS.length || !TOMBSTONE_KEYS.every((k) => keys.includes(k)))
|
|
64
|
+
return null;
|
|
65
|
+
const str = (k) => typeof v[k] === 'string';
|
|
66
|
+
if (v.v !== 1 || v.taskId !== taskId)
|
|
67
|
+
return null;
|
|
68
|
+
if (!['kind', 'createdAt', 'closedAt', 'prunedAt', 'headHash', 'outcomeDigest'].every(str))
|
|
69
|
+
return null;
|
|
70
|
+
if (typeof v.finalStatus !== 'string' || !TASK_STATUSES.includes(v.finalStatus))
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof v.headSeq !== 'number' || !Number.isInteger(v.headSeq) || v.headSeq < 0)
|
|
73
|
+
return null;
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
/** Same tombstone, whatever the key order. */
|
|
77
|
+
export const sameTombstone = (a, b) => canonicalize(a) === canonicalize(b);
|
|
78
|
+
function parseCanonical(text) {
|
|
79
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(body);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
// Written canonically by us. Re-serializing and comparing refuses duplicate keys, a BOM, odd whitespace and any other
|
|
88
|
+
// spelling the strict reader did not produce itself.
|
|
89
|
+
try {
|
|
90
|
+
return canonicalize(parsed) === body ? parsed : undefined;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function parsePrepare(text, n, taskId) {
|
|
97
|
+
const o = parseCanonical(text);
|
|
98
|
+
if (!isObj(o))
|
|
99
|
+
return { k: 'bad', reason: 'not-canonical-json' };
|
|
100
|
+
const keys = Object.keys(o).sort().join(',');
|
|
101
|
+
if (keys !== 'attempt,check,tombstone,v')
|
|
102
|
+
return { k: 'bad', reason: 'schema' };
|
|
103
|
+
if (o.v !== REGISTER_FORMAT_VERSION)
|
|
104
|
+
return { k: 'bad', reason: 'version' };
|
|
105
|
+
if (o.attempt !== n)
|
|
106
|
+
return { k: 'bad', reason: 'attempt' };
|
|
107
|
+
const tomb = asTombstone(o.tombstone, taskId);
|
|
108
|
+
if (!tomb)
|
|
109
|
+
return { k: 'bad', reason: 'tombstone' };
|
|
110
|
+
if (o.check !== checkOf(o))
|
|
111
|
+
return { k: 'bad', reason: 'check' };
|
|
112
|
+
return { k: 'ok', v: o };
|
|
113
|
+
}
|
|
114
|
+
export function parseDecision(text, n, taskId) {
|
|
115
|
+
const o = parseCanonical(text);
|
|
116
|
+
if (!isObj(o))
|
|
117
|
+
return { k: 'bad', reason: 'not-canonical-json' };
|
|
118
|
+
if (o.v !== REGISTER_FORMAT_VERSION)
|
|
119
|
+
return { k: 'bad', reason: 'version' };
|
|
120
|
+
if (o.attempt !== n)
|
|
121
|
+
return { k: 'bad', reason: 'attempt' };
|
|
122
|
+
const keys = Object.keys(o);
|
|
123
|
+
const allowed = o.outcome === 'abort'
|
|
124
|
+
? ['v', 'attempt', 'outcome', 'by', 'check']
|
|
125
|
+
: o.outcome === 'commit'
|
|
126
|
+
? ['v', 'attempt', 'outcome', 'tombstone', 'migrated', 'legacySha256', 'check']
|
|
127
|
+
: null;
|
|
128
|
+
if (!allowed)
|
|
129
|
+
return { k: 'bad', reason: 'outcome' };
|
|
130
|
+
if (keys.some((k) => !allowed.includes(k)) || !['v', 'attempt', 'outcome', 'check'].every((k) => keys.includes(k)))
|
|
131
|
+
return { k: 'bad', reason: 'schema' };
|
|
132
|
+
if (o.outcome === 'abort') {
|
|
133
|
+
if ('by' in o && !DECISION_BY.includes(o.by))
|
|
134
|
+
return { k: 'bad', reason: 'by' };
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
if (!asTombstone(o.tombstone, taskId))
|
|
138
|
+
return { k: 'bad', reason: 'tombstone' };
|
|
139
|
+
const hasM = 'migrated' in o;
|
|
140
|
+
const hasS = 'legacySha256' in o;
|
|
141
|
+
if (hasM !== hasS || (hasM && (o.migrated !== true || typeof o.legacySha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.legacySha256)))) {
|
|
142
|
+
return { k: 'bad', reason: 'migration' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (o.check !== checkOf(o))
|
|
146
|
+
return { k: 'bad', reason: 'check' };
|
|
147
|
+
return { k: 'ok', v: o };
|
|
148
|
+
}
|
|
149
|
+
/** `tombstone.json` is the v1 format and is written by older code with its own key order: parse leniently, validate strictly. */
|
|
150
|
+
export function parseTombstoneFile(text, taskId) {
|
|
151
|
+
let o;
|
|
152
|
+
try {
|
|
153
|
+
o = JSON.parse(text);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { k: 'bad', reason: 'not-json' };
|
|
157
|
+
}
|
|
158
|
+
const t = asTombstone(o, taskId);
|
|
159
|
+
return t ? { k: 'ok', v: t } : { k: 'bad', reason: 'schema' };
|
|
160
|
+
}
|
|
161
|
+
class Unstable extends Error {
|
|
162
|
+
}
|
|
163
|
+
function classify(names) {
|
|
164
|
+
const c = { prep: new Set(), dec: new Set(), tomb: false, unknown: [] };
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
if (STAGING_RE.test(name))
|
|
167
|
+
continue; // N1: a staging file is never interpreted, whatever its base name
|
|
168
|
+
let m = PREPARE_RE.exec(name);
|
|
169
|
+
if (m) {
|
|
170
|
+
c.prep.add(Number(m[1]));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
m = DECISION_RE.exec(name);
|
|
174
|
+
if (m) {
|
|
175
|
+
c.dec.add(Number(m[1]));
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (name === TOMBSTONE_FILE) {
|
|
179
|
+
c.tomb = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (name.startsWith('prune-'))
|
|
183
|
+
c.unknown.push(name); // N5
|
|
184
|
+
}
|
|
185
|
+
return c;
|
|
186
|
+
}
|
|
187
|
+
const maxOf = (...sets) => {
|
|
188
|
+
let m = 0;
|
|
189
|
+
for (const s of sets)
|
|
190
|
+
for (const n of s)
|
|
191
|
+
if (n > m)
|
|
192
|
+
m = n;
|
|
193
|
+
return m;
|
|
194
|
+
};
|
|
195
|
+
const corrupt = (reason, file, attempt, basis = 'present') => ({
|
|
196
|
+
kind: 'corrupt',
|
|
197
|
+
reason,
|
|
198
|
+
basis,
|
|
199
|
+
...(file ? { file } : {}),
|
|
200
|
+
...(attempt !== undefined ? { attempt } : {}),
|
|
201
|
+
});
|
|
202
|
+
const unreadable = (reason) => ({ kind: 'unreadable', reason });
|
|
203
|
+
class Known {
|
|
204
|
+
io;
|
|
205
|
+
prep;
|
|
206
|
+
dec;
|
|
207
|
+
tomb;
|
|
208
|
+
unknown;
|
|
209
|
+
cache = new Map();
|
|
210
|
+
constructor(c, io) {
|
|
211
|
+
this.io = io;
|
|
212
|
+
this.prep = new Set(c.prep);
|
|
213
|
+
this.dec = new Set(c.dec);
|
|
214
|
+
this.tomb = c.tomb;
|
|
215
|
+
this.unknown = c.unknown;
|
|
216
|
+
}
|
|
217
|
+
/** A name that was listed must still be there: a name that vanished is not damage and not permission (it is "unstable"). */
|
|
218
|
+
seen(key, read) {
|
|
219
|
+
let o = this.cache.get(key);
|
|
220
|
+
if (!o) {
|
|
221
|
+
o = read();
|
|
222
|
+
this.cache.set(key, o);
|
|
223
|
+
}
|
|
224
|
+
if (o.k === 'absent')
|
|
225
|
+
throw new Unstable();
|
|
226
|
+
return o;
|
|
227
|
+
}
|
|
228
|
+
readPrepare(n) {
|
|
229
|
+
return this.seen(`p${n}`, () => this.io.prepare(n));
|
|
230
|
+
}
|
|
231
|
+
readDecision(n) {
|
|
232
|
+
return this.seen(`d${n}`, () => this.io.decision(n));
|
|
233
|
+
}
|
|
234
|
+
readTombstone() {
|
|
235
|
+
return this.seen('t', () => this.io.tombstone());
|
|
236
|
+
}
|
|
237
|
+
/** Look a name up that was NOT in the listing (A8: a lookup sees every name published before it started). */
|
|
238
|
+
lookup(kind, n) {
|
|
239
|
+
const key = `${kind}${n}`;
|
|
240
|
+
let o = this.cache.get(key);
|
|
241
|
+
if (!o) {
|
|
242
|
+
o = kind === 'p' ? this.io.prepare(n) : this.io.decision(n);
|
|
243
|
+
if (o.k !== 'absent')
|
|
244
|
+
this.cache.set(key, o);
|
|
245
|
+
}
|
|
246
|
+
return o;
|
|
247
|
+
}
|
|
248
|
+
add(kind, n) {
|
|
249
|
+
(kind === 'p' ? this.prep : this.dec).add(n);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const ioName = (kind, n) => (kind === 't' ? TOMBSTONE_FILE : kind === 'p' ? prepareFile(n) : decisionFile(n));
|
|
253
|
+
function foldCommit(K, k, commit) {
|
|
254
|
+
// (b1) A record with an index above the commit cannot exist in a correct run, whatever it contains. Independent of any I/O error.
|
|
255
|
+
for (const n of [...K.prep, ...K.dec])
|
|
256
|
+
if (n > k)
|
|
257
|
+
return { t: 'final', state: corrupt('history', undefined, n) };
|
|
258
|
+
let prepFinding = null;
|
|
259
|
+
let io = null;
|
|
260
|
+
const absent = [];
|
|
261
|
+
// (a) audit: prepare.k and tombstone.json (when present) must agree with the commit.
|
|
262
|
+
if (K.prep.has(k)) {
|
|
263
|
+
const p = K.readPrepare(k);
|
|
264
|
+
if (p.k === 'ok') {
|
|
265
|
+
if (!sameTombstone(p.v.tombstone, commit.tombstone))
|
|
266
|
+
prepFinding = 'commit-audit';
|
|
267
|
+
}
|
|
268
|
+
else if (p.k === 'bad')
|
|
269
|
+
prepFinding = 'commit-audit';
|
|
270
|
+
else if (p.k === 'ioerr')
|
|
271
|
+
io = io ?? `io:${ioName('p', k)}`;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
absent.push({ reason: 'commit-audit', file: prepareFile(k) });
|
|
275
|
+
}
|
|
276
|
+
if (K.tomb) {
|
|
277
|
+
const t = K.readTombstone();
|
|
278
|
+
if (t.k === 'ok') {
|
|
279
|
+
if (!sameTombstone(t.v, commit.tombstone))
|
|
280
|
+
prepFinding = prepFinding ?? 'commit-audit';
|
|
281
|
+
}
|
|
282
|
+
else if (t.k === 'bad')
|
|
283
|
+
prepFinding = prepFinding ?? 'commit-audit';
|
|
284
|
+
else if (t.k === 'ioerr')
|
|
285
|
+
io = io ?? `io:${TOMBSTONE_FILE}`;
|
|
286
|
+
}
|
|
287
|
+
// A finding that rests on a present, successfully read file stands whatever happens to the other inputs.
|
|
288
|
+
if (prepFinding)
|
|
289
|
+
return { t: 'final', state: corrupt(prepFinding, undefined, k) };
|
|
290
|
+
// Otherwise an input whose value is unknown means the audit cannot be done: unreadable, not corrupt, not committed.
|
|
291
|
+
if (io)
|
|
292
|
+
return { t: 'final', state: unreadable(io) };
|
|
293
|
+
// (b2) every name below k must be there. Their CONTENT is not read: damage below k does not undo a verified commit.
|
|
294
|
+
for (let j = 1; j < k; j++) {
|
|
295
|
+
if (!K.prep.has(j))
|
|
296
|
+
absent.push({ reason: 'history', file: prepareFile(j) });
|
|
297
|
+
else if (!K.dec.has(j))
|
|
298
|
+
absent.push({ reason: 'history', file: decisionFile(j) });
|
|
299
|
+
}
|
|
300
|
+
if (absent.length > 0)
|
|
301
|
+
return { t: 'absent', reason: absent[0].reason, file: absent[0].file, attempt: k };
|
|
302
|
+
const anomalies = K.unknown.map((n) => `UNKNOWN_PRUNE:${n}`);
|
|
303
|
+
return {
|
|
304
|
+
t: 'final',
|
|
305
|
+
state: {
|
|
306
|
+
kind: 'committed',
|
|
307
|
+
attempt: k,
|
|
308
|
+
tombstone: commit.tombstone,
|
|
309
|
+
durability: 'NOT_GUARANTEED',
|
|
310
|
+
...(commit.migrated ? { migrated: true } : {}),
|
|
311
|
+
...(anomalies.length ? { anomalies } : {}),
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function foldNoCommit(K, decs, ioDecisions) {
|
|
316
|
+
// IO-G1: any decision whose value is unknown may be the commit. No finding of steps 4-5 is established.
|
|
317
|
+
if (ioDecisions.length > 0)
|
|
318
|
+
return { t: 'final', state: unreadable(`io:${ioDecisions[0]}`) };
|
|
319
|
+
const m = maxOf(K.prep);
|
|
320
|
+
const prepObs = new Map();
|
|
321
|
+
const ioOther = [];
|
|
322
|
+
let bad = null;
|
|
323
|
+
let badFile;
|
|
324
|
+
const markBad = (reason, file) => {
|
|
325
|
+
// fixed order of reasons: register > unknown-artifact > gap
|
|
326
|
+
const rank = (r) => (r === 'register' ? 3 : r === 'unknown-artifact' ? 2 : r ? 1 : 0);
|
|
327
|
+
if (rank(reason) > rank(bad)) {
|
|
328
|
+
bad = reason;
|
|
329
|
+
badFile = file;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
for (const n of [...K.prep].sort((a, b) => a - b)) {
|
|
333
|
+
const p = K.readPrepare(n);
|
|
334
|
+
prepObs.set(n, p);
|
|
335
|
+
if (p.k === 'bad')
|
|
336
|
+
markBad('register', prepareFile(n));
|
|
337
|
+
else if (p.k === 'ioerr')
|
|
338
|
+
ioOther.push(prepareFile(n));
|
|
339
|
+
}
|
|
340
|
+
for (const [n, d] of decs)
|
|
341
|
+
if (d.k === 'bad')
|
|
342
|
+
markBad('register', decisionFile(n));
|
|
343
|
+
for (const u of K.unknown)
|
|
344
|
+
markBad('unknown-artifact', u);
|
|
345
|
+
let tombObs = null;
|
|
346
|
+
if (K.tomb) {
|
|
347
|
+
tombObs = K.readTombstone();
|
|
348
|
+
if (tombObs.k === 'bad')
|
|
349
|
+
markBad('register', TOMBSTONE_FILE);
|
|
350
|
+
else if (tombObs.k === 'ioerr')
|
|
351
|
+
ioOther.push(TOMBSTONE_FILE);
|
|
352
|
+
}
|
|
353
|
+
if (bad)
|
|
354
|
+
return { t: 'final', state: corrupt(bad, badFile) };
|
|
355
|
+
// Structure by NAME: absence findings (class A-bar).
|
|
356
|
+
const gaps = [];
|
|
357
|
+
for (let j = 1; j <= m; j++)
|
|
358
|
+
if (!K.prep.has(j))
|
|
359
|
+
gaps.push({ reason: 'gap', file: prepareFile(j), attempt: j });
|
|
360
|
+
for (const j of K.dec)
|
|
361
|
+
if (!K.prep.has(j))
|
|
362
|
+
gaps.push({ reason: 'gap', file: decisionFile(j), attempt: j });
|
|
363
|
+
for (let j = 1; j < m; j++)
|
|
364
|
+
if (K.prep.has(j) && !K.dec.has(j))
|
|
365
|
+
gaps.push({ reason: 'gap', file: decisionFile(j), attempt: j });
|
|
366
|
+
if (K.tomb && m > 0)
|
|
367
|
+
gaps.push({ reason: 'tombstone-without-commit', file: TOMBSTONE_FILE });
|
|
368
|
+
const legacyCandidate = K.tomb && m === 0 && gaps.length === 0;
|
|
369
|
+
if (K.tomb && m === 0 && gaps.length > 0)
|
|
370
|
+
gaps.push({ reason: 'tombstone-without-commit', file: TOMBSTONE_FILE });
|
|
371
|
+
if ((gaps.length > 0 || legacyCandidate) && ioOther.length > 0)
|
|
372
|
+
return { t: 'final', state: unreadable(`io:${ioOther[0]}`) }; // IO-G2
|
|
373
|
+
if (gaps.length > 0)
|
|
374
|
+
return { t: 'absent', reason: gaps[0].reason, file: gaps[0].file, ...(gaps[0].attempt !== undefined ? { attempt: gaps[0].attempt } : {}) };
|
|
375
|
+
if (legacyCandidate)
|
|
376
|
+
return { t: 'legacy' };
|
|
377
|
+
if (ioOther.length > 0)
|
|
378
|
+
return { t: 'final', state: unreadable(`io:${ioOther[0]}`) };
|
|
379
|
+
if (m === 0)
|
|
380
|
+
return { t: 'final', state: { kind: 'live', attempt: 0 } };
|
|
381
|
+
const last = decs.get(m);
|
|
382
|
+
if (last && last.k === 'ok')
|
|
383
|
+
return { t: 'final', state: { kind: 'live', attempt: m } };
|
|
384
|
+
const prep = prepObs.get(m);
|
|
385
|
+
return { t: 'final', state: { kind: 'preparing', attempt: m, tombstone: prep.v.tombstone } };
|
|
386
|
+
}
|
|
387
|
+
function fold(K) {
|
|
388
|
+
const decs = new Map();
|
|
389
|
+
const commits = [];
|
|
390
|
+
const ioDecisions = [];
|
|
391
|
+
for (const n of [...K.dec].sort((a, b) => a - b)) {
|
|
392
|
+
const d = K.readDecision(n);
|
|
393
|
+
decs.set(n, d);
|
|
394
|
+
if (d.k === 'ok' && d.v.outcome === 'commit')
|
|
395
|
+
commits.push(n);
|
|
396
|
+
else if (d.k === 'ioerr')
|
|
397
|
+
ioDecisions.push(decisionFile(n));
|
|
398
|
+
}
|
|
399
|
+
if (commits.length >= 2)
|
|
400
|
+
return { t: 'final', state: corrupt('multiple-commit', decisionFile(commits[1]), commits[1]) };
|
|
401
|
+
if (commits.length === 1) {
|
|
402
|
+
const k = commits[0];
|
|
403
|
+
return foldCommit(K, k, decs.get(k).v);
|
|
404
|
+
}
|
|
405
|
+
return foldNoCommit(K, decs, ioDecisions);
|
|
406
|
+
}
|
|
407
|
+
/** Names that the protocol says must exist because of `x` (Req(x), section 32.3). */
|
|
408
|
+
function expected(K, commitAttempt) {
|
|
409
|
+
const out = new Set();
|
|
410
|
+
for (const j of K.prep)
|
|
411
|
+
for (let i = 1; i < j; i++) {
|
|
412
|
+
out.add(`p${i}`);
|
|
413
|
+
out.add(`d${i}`);
|
|
414
|
+
}
|
|
415
|
+
for (const j of K.dec) {
|
|
416
|
+
for (let i = 1; i <= j; i++)
|
|
417
|
+
out.add(`p${i}`);
|
|
418
|
+
for (let i = 1; i < j; i++)
|
|
419
|
+
out.add(`d${i}`);
|
|
420
|
+
}
|
|
421
|
+
if (K.tomb && commitAttempt !== null)
|
|
422
|
+
for (let i = 1; i <= commitAttempt; i++) {
|
|
423
|
+
out.add(`p${i}`);
|
|
424
|
+
out.add(`d${i}`);
|
|
425
|
+
}
|
|
426
|
+
for (const j of K.prep)
|
|
427
|
+
out.delete(`p${j}`);
|
|
428
|
+
for (const j of K.dec)
|
|
429
|
+
out.delete(`d${j}`);
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The state of a task's register.
|
|
434
|
+
*
|
|
435
|
+
* `names` is ONE directory listing of `tasks/<id>/` (not an atomic snapshot: a name created during the listing may be
|
|
436
|
+
* missing from it). `io` reads individual files. A verdict that rests only on names that were NOT in the listing is
|
|
437
|
+
* not given without looking those names up (section 32.3); if the lookup finds the names the verdict is recomputed, if
|
|
438
|
+
* every one is confirmed absent it stands with `basis: 'lookup'`, and if anything cannot be confirmed the answer is
|
|
439
|
+
* `unreadable` — never a guess.
|
|
440
|
+
*/
|
|
441
|
+
export function derivePruneState(names, io, maxAttempts = MAX_PRUNE_ATTEMPTS) {
|
|
442
|
+
const c = classify(names);
|
|
443
|
+
// Fast path: nothing in the directory belongs to the register (staging files do not count).
|
|
444
|
+
if (c.prep.size === 0 && c.dec.size === 0 && !c.tomb && c.unknown.length === 0)
|
|
445
|
+
return { kind: 'live', attempt: 0 };
|
|
446
|
+
const K = new Known(c, io);
|
|
447
|
+
try {
|
|
448
|
+
let v = fold(K);
|
|
449
|
+
for (let round = 0; round < 2 && v.t !== 'final'; round++) {
|
|
450
|
+
const commitAttempt = (() => {
|
|
451
|
+
for (const n of K.dec) {
|
|
452
|
+
const d = K.lookup('d', n);
|
|
453
|
+
if (d.k === 'ok' && d.v.outcome === 'commit')
|
|
454
|
+
return n;
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
})();
|
|
458
|
+
const want = expected(K, commitAttempt);
|
|
459
|
+
const ruleT = K.tomb && commitAttempt === null;
|
|
460
|
+
if (want.size === 0 && !ruleT)
|
|
461
|
+
return unreadable('register-observation-unstable');
|
|
462
|
+
if (want.size > 2 * maxAttempts)
|
|
463
|
+
return unreadable('register-observation-unstable');
|
|
464
|
+
const found = [];
|
|
465
|
+
const look = (kind, n) => {
|
|
466
|
+
const o = K.lookup(kind, n);
|
|
467
|
+
return o.k === 'absent' ? 'absent' : o.k === 'ioerr' ? 'ioerr' : 'found';
|
|
468
|
+
};
|
|
469
|
+
for (const e of want) {
|
|
470
|
+
const kind = e[0];
|
|
471
|
+
const n = Number(e.slice(1));
|
|
472
|
+
const r = look(kind, n);
|
|
473
|
+
if (r === 'ioerr')
|
|
474
|
+
return unreadable(`io-lookup:${ioName(kind, n)}`);
|
|
475
|
+
if (r === 'found')
|
|
476
|
+
found.push([kind, n]);
|
|
477
|
+
}
|
|
478
|
+
if (ruleT) {
|
|
479
|
+
// Rule T (and 004F, R4E-03: also when there is no register at all): the tombstone was published after a commit
|
|
480
|
+
// of an attempt the listing did not show. Look for the decisions up to M, then walk the tail until the first
|
|
481
|
+
// prepare that is absent.
|
|
482
|
+
const M = maxOf(K.prep, K.dec);
|
|
483
|
+
for (let j = 1; j <= M; j++) {
|
|
484
|
+
if (K.dec.has(j))
|
|
485
|
+
continue;
|
|
486
|
+
const r = look('d', j);
|
|
487
|
+
if (r === 'ioerr')
|
|
488
|
+
return unreadable(`io-lookup:${decisionFile(j)}`);
|
|
489
|
+
if (r === 'found' && !found.some((f) => f[0] === 'd' && f[1] === j))
|
|
490
|
+
found.push(['d', j]);
|
|
491
|
+
}
|
|
492
|
+
for (let j = M + 1; j <= maxAttempts; j++) {
|
|
493
|
+
if (K.prep.has(j)) {
|
|
494
|
+
// already known: only its decision can be new
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const r = look('p', j);
|
|
498
|
+
if (r === 'ioerr')
|
|
499
|
+
return unreadable(`io-lookup:${prepareFile(j)}`);
|
|
500
|
+
if (r === 'absent')
|
|
501
|
+
break;
|
|
502
|
+
found.push(['p', j]);
|
|
503
|
+
}
|
|
504
|
+
const rd = K.dec.has(j) ? 'known' : look('d', j);
|
|
505
|
+
if (rd === 'ioerr')
|
|
506
|
+
return unreadable(`io-lookup:${decisionFile(j)}`);
|
|
507
|
+
if (rd === 'found')
|
|
508
|
+
found.push(['d', j]);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (found.length === 0) {
|
|
512
|
+
// Everything the protocol says must exist is confirmed absent: the finding is real.
|
|
513
|
+
if (v.t === 'legacy') {
|
|
514
|
+
const t = K.readTombstone();
|
|
515
|
+
return { kind: 'legacy', basis: 'lookup', ...(t.k === 'ok' ? { tombstone: t.v } : {}) };
|
|
516
|
+
}
|
|
517
|
+
return corrupt(v.reason, v.file, v.attempt, 'lookup');
|
|
518
|
+
}
|
|
519
|
+
for (const [kind, n] of found)
|
|
520
|
+
K.add(kind, n);
|
|
521
|
+
v = fold(K);
|
|
522
|
+
}
|
|
523
|
+
if (v.t === 'final')
|
|
524
|
+
return v.state;
|
|
525
|
+
return unreadable('register-observation-unstable');
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
if (e instanceof Unstable)
|
|
529
|
+
return unreadable('register-observation-unstable');
|
|
530
|
+
throw e;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention planning — a pure function. Deciding is separated from doing so a
|
|
3
|
+
* plan can be reviewed (dry run) before anything is removed.
|
|
4
|
+
*
|
|
5
|
+
* Rules, in order of precedence:
|
|
6
|
+
* 1. An active task is never pruned. If its log is long, it may be compacted
|
|
7
|
+
* (events packed into verified segments; nothing is lost).
|
|
8
|
+
* 2. A terminal task is kept while it is younger than `terminalRetentionMs`.
|
|
9
|
+
* 3. A terminal task that an active task still points at (`parentTaskId` or
|
|
10
|
+
* `dependsOn`) is kept: the active task's continuation may need its
|
|
11
|
+
* result and history.
|
|
12
|
+
* 4. Otherwise it is pruned. Pruning leaves a tombstone (id, final status,
|
|
13
|
+
* hash of the head and of the outcome) so a late duplicate delivery to a
|
|
14
|
+
* pruned task is recognised instead of resurrecting it.
|
|
15
|
+
*/
|
|
16
|
+
import type { TaskState } from './types.js';
|
|
17
|
+
export interface RetentionPolicy {
|
|
18
|
+
/** How long a finished task stays fully readable. */
|
|
19
|
+
terminalRetentionMs: number;
|
|
20
|
+
/** Compact an active task once it holds more than this many events. Default 1000. */
|
|
21
|
+
compactAfterEvents?: number;
|
|
22
|
+
/** Events at the head that always stay loose (never packed). Default 100. */
|
|
23
|
+
keepTailEvents?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Do not create a segment for fewer events than this. Without it a task sitting just above
|
|
26
|
+
* the threshold would get one tiny segment per appended event. Default 100.
|
|
27
|
+
*/
|
|
28
|
+
minCompactBatch?: number;
|
|
29
|
+
}
|
|
30
|
+
export type RetentionAction = 'prune' | 'compact' | 'keep';
|
|
31
|
+
export interface RetentionDecision {
|
|
32
|
+
taskId: string;
|
|
33
|
+
action: RetentionAction;
|
|
34
|
+
reason: string;
|
|
35
|
+
/** For `compact`: pack events up to and including this seq. */
|
|
36
|
+
throughSeq?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare const DEFAULT_COMPACT_AFTER_EVENTS = 1000;
|
|
39
|
+
export declare const DEFAULT_KEEP_TAIL_EVENTS = 100;
|
|
40
|
+
export declare const DEFAULT_MIN_COMPACT_BATCH = 100;
|
|
41
|
+
/** Work left over by an interrupted run; finishing it is always safe because both operations are marker-first. */
|
|
42
|
+
export interface InterruptedWork {
|
|
43
|
+
/** Tasks whose loose events are still on disk although a segment covers them. */
|
|
44
|
+
compactions?: ReadonlySet<string>;
|
|
45
|
+
/** Pruned tasks (tombstone present) whose data files are still on disk. */
|
|
46
|
+
prunes?: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
export declare function planRetention(states: readonly TaskState[], compactedThrough: ReadonlyMap<string, number>, policy: RetentionPolicy, now: Date, interrupted?: InterruptedWork): RetentionDecision[];
|
|
49
|
+
//# sourceMappingURL=retention.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/durable/retention.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,qFAAqF;IACrF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6EAA6E;IAC7E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AAE3D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,4BAA4B,OAAO,CAAC;AACjD,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAE7C,kHAAkH;AAClH,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AAED,wBAAgB,aAAa,CAC3B,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,gBAAgB,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7C,MAAM,EAAE,eAAe,EACvB,GAAG,EAAE,IAAI,EACT,WAAW,GAAE,eAAoB,GAChC,iBAAiB,EAAE,CAyCrB"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention planning — a pure function. Deciding is separated from doing so a
|
|
3
|
+
* plan can be reviewed (dry run) before anything is removed.
|
|
4
|
+
*
|
|
5
|
+
* Rules, in order of precedence:
|
|
6
|
+
* 1. An active task is never pruned. If its log is long, it may be compacted
|
|
7
|
+
* (events packed into verified segments; nothing is lost).
|
|
8
|
+
* 2. A terminal task is kept while it is younger than `terminalRetentionMs`.
|
|
9
|
+
* 3. A terminal task that an active task still points at (`parentTaskId` or
|
|
10
|
+
* `dependsOn`) is kept: the active task's continuation may need its
|
|
11
|
+
* result and history.
|
|
12
|
+
* 4. Otherwise it is pruned. Pruning leaves a tombstone (id, final status,
|
|
13
|
+
* hash of the head and of the outcome) so a late duplicate delivery to a
|
|
14
|
+
* pruned task is recognised instead of resurrecting it.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_COMPACT_AFTER_EVENTS = 1000;
|
|
17
|
+
export const DEFAULT_KEEP_TAIL_EVENTS = 100;
|
|
18
|
+
export const DEFAULT_MIN_COMPACT_BATCH = 100;
|
|
19
|
+
export function planRetention(states, compactedThrough, policy, now, interrupted = {}) {
|
|
20
|
+
const referencedBy = new Map();
|
|
21
|
+
for (const s of states) {
|
|
22
|
+
if (s.status !== 'active')
|
|
23
|
+
continue;
|
|
24
|
+
for (const ref of [...(s.parentTaskId ? [s.parentTaskId] : []), ...s.dependsOn]) {
|
|
25
|
+
referencedBy.set(ref, [...(referencedBy.get(ref) ?? []), s.taskId]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const compactAfter = policy.compactAfterEvents ?? DEFAULT_COMPACT_AFTER_EVENTS;
|
|
29
|
+
const keepTail = policy.keepTailEvents ?? DEFAULT_KEEP_TAIL_EVENTS;
|
|
30
|
+
const minBatch = policy.minCompactBatch ?? DEFAULT_MIN_COMPACT_BATCH;
|
|
31
|
+
// Finishing an interrupted compaction only removes copies a segment already covers: no policy threshold applies.
|
|
32
|
+
const finishCompaction = (s) => ({
|
|
33
|
+
taskId: s.taskId, action: 'compact', reason: 'finish interrupted compaction', throughSeq: compactedThrough.get(s.taskId) ?? 0,
|
|
34
|
+
});
|
|
35
|
+
const decisions = [...states]
|
|
36
|
+
.sort((a, b) => (a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0))
|
|
37
|
+
.map((s) => {
|
|
38
|
+
if (s.status === 'active') {
|
|
39
|
+
const through = s.headSeq - keepTail;
|
|
40
|
+
if (s.eventCount > compactAfter && through - (compactedThrough.get(s.taskId) ?? 0) >= minBatch) {
|
|
41
|
+
return { taskId: s.taskId, action: 'compact', reason: `${s.eventCount} events (> ${compactAfter})`, throughSeq: through };
|
|
42
|
+
}
|
|
43
|
+
return interrupted.compactions?.has(s.taskId) ? finishCompaction(s) : { taskId: s.taskId, action: 'keep', reason: 'active' };
|
|
44
|
+
}
|
|
45
|
+
const closedAt = Date.parse(s.closedAt);
|
|
46
|
+
if (now.getTime() - closedAt < policy.terminalRetentionMs) {
|
|
47
|
+
return interrupted.compactions?.has(s.taskId) ? finishCompaction(s) : { taskId: s.taskId, action: 'keep', reason: 'within retention period' };
|
|
48
|
+
}
|
|
49
|
+
const holders = referencedBy.get(s.taskId);
|
|
50
|
+
if (holders && holders.length > 0) {
|
|
51
|
+
return { taskId: s.taskId, action: 'keep', reason: `referenced by active task(s): ${holders.sort().join(', ')}` };
|
|
52
|
+
}
|
|
53
|
+
return { taskId: s.taskId, action: 'prune', reason: `${s.status} and past retention` };
|
|
54
|
+
});
|
|
55
|
+
for (const taskId of interrupted.prunes ?? []) {
|
|
56
|
+
decisions.push({ taskId, action: 'prune', reason: 'finish interrupted prune (tombstone present, data still on disk)' });
|
|
57
|
+
}
|
|
58
|
+
return decisions.sort((a, b) => (a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0));
|
|
59
|
+
}
|