@smartmemory/compose 0.3.6-beta → 0.3.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/.claude/skills/compose/SKILL.md +42 -88
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +754 -347
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
- package/dist/assets/arc-DY26UIVo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
- package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
- package/dist/assets/graph-BNzKGvoy.js +1 -0
- package/dist/assets/graph-CI_1htl0.js +331 -0
- package/dist/assets/index-BEfrNBp8.js +123 -0
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BI8cXFPI.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/min-vPHfnXcC.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +39 -4
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +1624 -195
- package/lib/canon-guard.js +245 -0
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +291 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +50 -15
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +66 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -14
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/hooks-status.js +32 -3
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +166 -0
- package/lib/judgment/store/records.js +184 -25
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +375 -22
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2842 -262
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +250 -54
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/mcp-enforcement.js +21 -35
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +428 -153
- package/lib/review-normalize.js +4 -0
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +391 -31
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +124 -24
- package/server/compose-mcp.js +91 -790
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +35 -3
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/mobile-CaoXUwAr.js +0 -17
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/record-shape.js — record rules that belong to the seam, not to a provider.
|
|
3
|
+
*
|
|
4
|
+
* Two rules lived as private members of `LocalFluidProvider` while it was the
|
|
5
|
+
* only provider, and the moment a second one exists they are drift generators:
|
|
6
|
+
*
|
|
7
|
+
* 1. `normalizeRecord()` — the contract-default filling and defensive cloning
|
|
8
|
+
* that `_normalize` did (COMP-FOH C12).
|
|
9
|
+
* 2. `assertPatchable()` — the `UNPATCHABLE` refusal that `updateRecord` did
|
|
10
|
+
* (COMP-FOH C16).
|
|
11
|
+
*
|
|
12
|
+
* WHY THESE ARE SHARED AND NOT COPIED
|
|
13
|
+
* -----------------------------------
|
|
14
|
+
* Both encode a promise the *seam* makes, not a choice a provider gets to make.
|
|
15
|
+
* `getRecord` returning a record whose optional fields are filled to contract
|
|
16
|
+
* defaults is what lets a caller read a record written before a field existed;
|
|
17
|
+
* `updateRecord` refusing to patch `discussion` is what makes the deliberation
|
|
18
|
+
* trail evidence rather than a mutable blob. A provider that implements either
|
|
19
|
+
* one differently is not a slower or simpler provider — it is a provider with a
|
|
20
|
+
* different contract, which defeats the point of the swap.
|
|
21
|
+
*
|
|
22
|
+
* The second copy is the dangerous one, because it is the one nobody re-reads.
|
|
23
|
+
* `local-provider.js` enforces `UNPATCHABLE` and says why in a comment that a
|
|
24
|
+
* new provider's author would never see.
|
|
25
|
+
*
|
|
26
|
+
* Pure functions only — no I/O, no store, no provider reference — so both
|
|
27
|
+
* providers and the tests can call them directly.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { KIND } from './provider.js';
|
|
31
|
+
|
|
32
|
+
/** Handle prefix per record kind. Handles are quoted in docs and must be short
|
|
33
|
+
* and stable; the prefix set is fixed here so two providers allocate the same
|
|
34
|
+
* shape of handle for the same kind. */
|
|
35
|
+
export const HANDLE_PREFIX = Object.freeze({
|
|
36
|
+
[KIND.IDEA]: 'IDEA',
|
|
37
|
+
[KIND.POSITION]: 'POS',
|
|
38
|
+
[KIND.JOINT]: 'JOINT',
|
|
39
|
+
[KIND.DECISION]: 'DEC',
|
|
40
|
+
[KIND.THREAD]: 'THREAD',
|
|
41
|
+
[KIND.QUESTION]: 'Q',
|
|
42
|
+
[KIND.CLUSTER]: 'CLUS',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Handle grammar, with the suffix bounded to 9 digits.
|
|
47
|
+
*
|
|
48
|
+
* The bound is load-bearing, not cosmetic. Allocation reads the suffix as a
|
|
49
|
+
* Number; past the exact-integer range `max + 1 === max`, so importing a
|
|
50
|
+
* gigantic handle would make the allocator hand the same one out forever. The
|
|
51
|
+
* contract carries the same bound.
|
|
52
|
+
*/
|
|
53
|
+
export const HANDLE_RE = /^([A-Z][A-Z0-9]*)-([1-9][0-9]{0,8})$/;
|
|
54
|
+
|
|
55
|
+
/** Canonical fluid lifecycle states, per the contract's `status` enum. */
|
|
56
|
+
export const FLUID_STATUSES = Object.freeze(new Set(['new', 'discussing', 'promoted', 'killed']));
|
|
57
|
+
|
|
58
|
+
/** Link types the contract defines. Validated so a typo becomes an error at the
|
|
59
|
+
* write rather than a silently unqueryable edge. */
|
|
60
|
+
export const LINK_TYPES = Object.freeze(new Set([
|
|
61
|
+
'promoted_to', 'maps_to', 'informs', 'blocks',
|
|
62
|
+
'supports', 'contradicts', 'supersedes', 'duplicate_of',
|
|
63
|
+
]));
|
|
64
|
+
|
|
65
|
+
export function assertStatus(status) {
|
|
66
|
+
if (!FLUID_STATUSES.has(status)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`fluid: invalid status "${status}" (expected one of: ${[...FLUID_STATUSES].join(', ')})`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return status;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A caller-supplied handle must be well-formed AND belong to its kind — an
|
|
75
|
+
* `idea` carrying `DEC-4` would be invisible to idea handle allocation and
|
|
76
|
+
* would collide with a real decision later. */
|
|
77
|
+
export function assertHandle(handle, kind) {
|
|
78
|
+
const m = HANDLE_RE.exec(handle ?? '');
|
|
79
|
+
if (!m) {
|
|
80
|
+
throw new Error(`fluid: malformed handle "${handle}" (expected PREFIX-N, N >= 1)`);
|
|
81
|
+
}
|
|
82
|
+
const expected = HANDLE_PREFIX[kind];
|
|
83
|
+
if (m[1] !== expected) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`fluid: handle "${handle}" does not belong to kind "${kind}" (expected prefix ${expected})`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return handle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function assertLink(link) {
|
|
92
|
+
if (!link || !LINK_TYPES.has(link.type)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`fluid: invalid link type "${link?.type}" (expected one of: ${[...LINK_TYPES].join(', ')})`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (typeof link.target !== 'string' || !link.target) {
|
|
98
|
+
throw new Error('fluid: link requires a target');
|
|
99
|
+
}
|
|
100
|
+
return link;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which lifecycle fact an update represents.
|
|
105
|
+
*
|
|
106
|
+
* Status transitions win over field edits because they are the events other
|
|
107
|
+
* rungs read (promotion and kill are the two outcomes a conviction or
|
|
108
|
+
* calibration layer scores against). Checked in that order so a promote that
|
|
109
|
+
* also retitles is still recorded as a promotion.
|
|
110
|
+
*
|
|
111
|
+
* Shared because the event stream is the thing a later semantic layer learns
|
|
112
|
+
* from: two providers that classified the same edit differently would make that
|
|
113
|
+
* history unreadable across a swap.
|
|
114
|
+
*/
|
|
115
|
+
export function eventTypeForUpdate(before, after, patch) {
|
|
116
|
+
if (after.status !== before.status) {
|
|
117
|
+
if (after.status === 'killed') return 'killed';
|
|
118
|
+
if (after.status === 'promoted') return 'promoted';
|
|
119
|
+
}
|
|
120
|
+
if (patch.priority !== undefined && after.priority !== before.priority) return 'triaged';
|
|
121
|
+
if (after.discussion.length > before.discussion.length) return 'discussed';
|
|
122
|
+
return 'updated';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fields a caller may never patch through `updateRecord`.
|
|
127
|
+
*
|
|
128
|
+
* `handle`/`kind`/`id` are identity: `handle` is quoted in docs and commits, and
|
|
129
|
+
* `kind` additionally decides a record's storage type on providers that key
|
|
130
|
+
* behaviour off it — a provider whose backing store freezes the type at creation
|
|
131
|
+
* would diverge permanently from a record claiming a new one.
|
|
132
|
+
*
|
|
133
|
+
* `provenance` is write-time-stamped and never retrofitted. `discussion` is
|
|
134
|
+
* append-only evidence — a deliberation trail that can be replaced wholesale is
|
|
135
|
+
* not evidence, so it moves only through `appendDiscussion()`. Timestamps are
|
|
136
|
+
* provider-assigned.
|
|
137
|
+
*/
|
|
138
|
+
export const UNPATCHABLE = Object.freeze([
|
|
139
|
+
'handle', 'kind', 'provenance', 'discussion', 'id', 'created_at', 'updated_at',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Refuse a patch that touches an unpatchable field.
|
|
144
|
+
*
|
|
145
|
+
* Refuses rather than silently dropping. Silently ignoring `discussion: []`
|
|
146
|
+
* would look to the caller exactly like a successful erase of the deliberation
|
|
147
|
+
* trail, and looking successful is the dangerous half.
|
|
148
|
+
*
|
|
149
|
+
* Keys are tested with `!== undefined`, so an explicit `null` is a violation
|
|
150
|
+
* too: `{ provenance: null }` is an attempt to clear provenance, not an absent
|
|
151
|
+
* field.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} patch caller-supplied patch
|
|
154
|
+
* @param {string} providerName for the error message
|
|
155
|
+
* @throws {Error} naming every offending field at once, so a caller fixes one
|
|
156
|
+
* call rather than discovering the list one rejection at a time.
|
|
157
|
+
*/
|
|
158
|
+
export function assertPatchable(patch, providerName) {
|
|
159
|
+
const forbidden = UNPATCHABLE.filter((f) => patch?.[f] !== undefined);
|
|
160
|
+
if (forbidden.length) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`fluid: field(s) ${forbidden.join(', ')} cannot be changed through updateRecord ` +
|
|
163
|
+
`(identity and append-only evidence are not patchable; use appendDiscussion for discussion)` +
|
|
164
|
+
(providerName ? ` [${providerName}]` : '')
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Normalize a record on the way out.
|
|
171
|
+
*
|
|
172
|
+
* Every structured field is cloned. Returning stored objects by reference lets
|
|
173
|
+
* a caller mutate what the provider considers canonical with no write, no
|
|
174
|
+
* timestamp, no event and no save — a change that appeared to take effect and
|
|
175
|
+
* then silently vanished. Nothing shares a reference with the store.
|
|
176
|
+
*
|
|
177
|
+
* Absent optional fields are filled to their contract defaults rather than left
|
|
178
|
+
* `undefined`, so a record written before a field existed reads back the same
|
|
179
|
+
* shape as one written after it. This is also what makes a record recovered
|
|
180
|
+
* from a store that erases nulls and empty containers (COMP-FOH C14) read back
|
|
181
|
+
* intact, without a migration.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} record raw stored record
|
|
184
|
+
* @returns {object} a fresh object sharing no references with `record`
|
|
185
|
+
*/
|
|
186
|
+
export function normalizeRecord(record) {
|
|
187
|
+
return {
|
|
188
|
+
id: record.id,
|
|
189
|
+
handle: record.handle,
|
|
190
|
+
kind: record.kind,
|
|
191
|
+
title: record.title,
|
|
192
|
+
body: record.body ?? '',
|
|
193
|
+
status: record.status,
|
|
194
|
+
status_label: record.status_label ?? null,
|
|
195
|
+
priority: record.priority ?? null,
|
|
196
|
+
// The 2x2 matrix's two axes. Filled to null here rather than left absent for
|
|
197
|
+
// the reason stated above: every record written before COMP-PLAN-IDEA-UNIFY
|
|
198
|
+
// S3b-2 predates both fields, and a caller that has to distinguish "absent"
|
|
199
|
+
// from "unassigned" would be reading the migration's timeline out of the
|
|
200
|
+
// record shape.
|
|
201
|
+
effort: record.effort ?? null,
|
|
202
|
+
impact: record.impact ?? null,
|
|
203
|
+
cluster: record.cluster ?? null,
|
|
204
|
+
cluster_order: record.cluster_order ?? null,
|
|
205
|
+
tags: Array.isArray(record.tags) ? [...record.tags] : [],
|
|
206
|
+
source: record.source ?? null,
|
|
207
|
+
links: Array.isArray(record.links) ? record.links.map((l) => ({ ...l })) : [],
|
|
208
|
+
killed: record.killed ? { ...record.killed } : null,
|
|
209
|
+
discussion: Array.isArray(record.discussion) ? record.discussion.map((d) => ({ ...d })) : [],
|
|
210
|
+
provenance: { ...record.provenance },
|
|
211
|
+
created_at: record.created_at,
|
|
212
|
+
updated_at: record.updated_at,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/record-store.js — durable, git-tracked persistence for the floor
|
|
3
|
+
* provider.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (S3 entry-gate ruling, 2026-08-04)
|
|
6
|
+
* --------------------------------------------------
|
|
7
|
+
* S1 hosted records on vision-store items, in an additive `fluid_ext`
|
|
8
|
+
* namespace. That was the right call for a seam pilot and the wrong one for
|
|
9
|
+
* durability: `.compose/data/vision-state.json` is gitignored (`.gitignore:3`
|
|
10
|
+
* matches `data/`), so wiring the CLI, the API and the UI onto the provider
|
|
11
|
+
* would have moved idea canon from a tracked file to an untracked one on a
|
|
12
|
+
* single machine — and committed a GENERATED `ideabox.md` with no source of
|
|
13
|
+
* truth behind it on any other clone or in CI.
|
|
14
|
+
*
|
|
15
|
+
* The owner ruled: track the records, split them out of vision-state. That is
|
|
16
|
+
* not merely a path change. It inverts which side is canon:
|
|
17
|
+
*
|
|
18
|
+
* BEFORE vision item = canon, nothing tracked
|
|
19
|
+
* AFTER record file = canon, vision item = optional projection
|
|
20
|
+
*
|
|
21
|
+
* Vision-state could not simply be un-ignored: it is ~540KB of runtime state
|
|
22
|
+
* (gates, connections, 465 items) rewritten on every save, so tracking it would
|
|
23
|
+
* mean an unreadable diff and a merge conflict per parallel session. Records
|
|
24
|
+
* need the opposite properties, so they get their own files.
|
|
25
|
+
*
|
|
26
|
+
* LAYOUT — mirrors `docs/judgment/`, deliberately
|
|
27
|
+
* -----------------------------------------------
|
|
28
|
+
* The judgment layer already solved "tool-owned canon plus a generated markdown
|
|
29
|
+
* projection" and is the house convention; a third pattern here would be drift
|
|
30
|
+
* for its own sake.
|
|
31
|
+
*
|
|
32
|
+
* docs/judgment/records/joints/*.json ↔ <root>/records/<HANDLE>.json
|
|
33
|
+
* docs/judgment/records/ledger.jsonl ↔ <root>/events.jsonl
|
|
34
|
+
* docs/judgment/REGISTER.md (generated) ↔ docs/product/ideabox.md
|
|
35
|
+
*
|
|
36
|
+
* ONE FILE PER RECORD, not one array file. A whole-array rewrite makes every
|
|
37
|
+
* `ideabox add` a diff against all records and a merge conflict between two
|
|
38
|
+
* clones that each added an idea. Per-record files make an add a pure file
|
|
39
|
+
* creation. The handle is the filename because it is unique, never reused
|
|
40
|
+
* (retired handles stay retired — see the events log), and already constrained
|
|
41
|
+
* to a filesystem-safe grammar by the contract.
|
|
42
|
+
*
|
|
43
|
+
* NO IN-MEMORY SNAPSHOT. Every read hits the directory. This is what retires
|
|
44
|
+
* the S1 `_sync()` hazard rather than merely narrowing it: there is no cached
|
|
45
|
+
* state for a second writer to invalidate, so the CLI and a running server
|
|
46
|
+
* cannot erase each other's records by saving a stale view. Interleaving is
|
|
47
|
+
* still unserialized — see the known gap at the foot of this file.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
appendFileSync,
|
|
52
|
+
existsSync,
|
|
53
|
+
mkdirSync,
|
|
54
|
+
readFileSync,
|
|
55
|
+
readdirSync,
|
|
56
|
+
renameSync,
|
|
57
|
+
rmSync,
|
|
58
|
+
writeFileSync,
|
|
59
|
+
} from 'node:fs';
|
|
60
|
+
import { join, resolve } from 'node:path';
|
|
61
|
+
import { randomUUID } from 'node:crypto';
|
|
62
|
+
|
|
63
|
+
/** Tracked root, relative to the project. Under `docs/` on purpose: this is
|
|
64
|
+
* product canon that belongs in review and in git history, not runtime state.
|
|
65
|
+
* It sits beside its own projection (`docs/product/ideabox.md`). */
|
|
66
|
+
export const DEFAULT_RECORDS_ROOT = join('docs', 'product', 'fluid');
|
|
67
|
+
|
|
68
|
+
const RECORDS_DIR = 'records';
|
|
69
|
+
const EVENTS_FILE = 'events.jsonl';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Filenames are handles, so the grammar is enforced on the way to disk as well
|
|
73
|
+
* as at the seam. A handle is interpolated into a path; anything carrying a
|
|
74
|
+
* separator or a traversal segment must never reach `join()`. The seam already
|
|
75
|
+
* validates handles, but a store that writes wherever it is pointed is one
|
|
76
|
+
* caller away from writing outside its root.
|
|
77
|
+
*/
|
|
78
|
+
const HANDLE_RE = /^[A-Z][A-Z0-9]*-[1-9][0-9]{0,8}$/;
|
|
79
|
+
|
|
80
|
+
function assertHandleSafe(handle) {
|
|
81
|
+
if (typeof handle !== 'string' || !HANDLE_RE.test(handle)) {
|
|
82
|
+
throw new Error(`fluid: unsafe record handle "${handle}" — refusing to build a path from it`);
|
|
83
|
+
}
|
|
84
|
+
return handle;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scans RAW log text for handle tokens, independent of JSON parseability.
|
|
89
|
+
*
|
|
90
|
+
* Carried over from S1 unchanged, and for the same reason: the watermark must
|
|
91
|
+
* survive a corrupt or partially-written line. If the only tombstone for a
|
|
92
|
+
* retired handle were lost to a parse failure, that handle would be reissued,
|
|
93
|
+
* and handles are external citations — reuse silently repoints a citation in a
|
|
94
|
+
* doc or a commit at a different idea. Tolerant history reading and durable
|
|
95
|
+
* handle retirement are separate jobs, so they read the log differently.
|
|
96
|
+
*/
|
|
97
|
+
const HANDLE_TOKEN_RE = /"handle"\s*:\s*"([A-Z][A-Z0-9]*-[1-9][0-9]{0,8})"/g;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Durable per-record storage plus the append-only lifecycle log.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately dumb: no validation of record CONTENT (the provider validates
|
|
103
|
+
* against `contracts/fluid-record.schema.json` before handing anything over),
|
|
104
|
+
* no lifecycle rules, no handle allocation. It knows about paths, atomicity and
|
|
105
|
+
* durability, and nothing else.
|
|
106
|
+
*/
|
|
107
|
+
export class FluidRecordStore {
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} cwd project root
|
|
110
|
+
* @param {object} [config]
|
|
111
|
+
* @param {string} [config.recordsRoot] absolute or project-relative override
|
|
112
|
+
* (tests point this at a tmp dir)
|
|
113
|
+
*/
|
|
114
|
+
constructor(cwd, config = {}) {
|
|
115
|
+
this.cwd = cwd;
|
|
116
|
+
const root = config.recordsRoot ?? DEFAULT_RECORDS_ROOT;
|
|
117
|
+
// resolve(), not join(): an absolute override is documented and a plain
|
|
118
|
+
// join turns `/tmp/fluid` into `<cwd>/tmp/fluid`, silently writing records
|
|
119
|
+
// somewhere the caller never named.
|
|
120
|
+
this.root = resolve(cwd, root);
|
|
121
|
+
this.recordsDir = join(this.root, RECORDS_DIR);
|
|
122
|
+
this.eventsPath = join(this.root, EVENTS_FILE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_pathFor(handle) {
|
|
126
|
+
return join(this.recordsDir, `${assertHandleSafe(handle)}.json`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
// Records
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Write a record atomically.
|
|
135
|
+
*
|
|
136
|
+
* tmp + rename, matching `VisionStore._save()`. A half-written record file is
|
|
137
|
+
* worse here than in a single state file: it is not a corrupt blob you notice
|
|
138
|
+
* on load, it is one idea that silently fails to parse while its 19 siblings
|
|
139
|
+
* load fine. The rename makes the file appear whole or not at all.
|
|
140
|
+
*
|
|
141
|
+
* The tmp name carries a random suffix rather than a timestamp so two writers
|
|
142
|
+
* in the same millisecond cannot land on the same tmp path and interleave
|
|
143
|
+
* their bytes.
|
|
144
|
+
*/
|
|
145
|
+
write(record) {
|
|
146
|
+
const path = this._pathFor(record.handle);
|
|
147
|
+
mkdirSync(this.recordsDir, { recursive: true });
|
|
148
|
+
// Pretty-printed with a trailing newline: these files are reviewed as
|
|
149
|
+
// diffs. Key order is deterministic because the provider builds every
|
|
150
|
+
// record through one object literal and a re-read preserves that order —
|
|
151
|
+
// NOT via a sorted replacer array, which would also filter nested objects
|
|
152
|
+
// and silently drop `provenance` and every link field.
|
|
153
|
+
const data = JSON.stringify(record, null, 2) + '\n';
|
|
154
|
+
const tmp = `${path}.tmp.${randomUUID()}`;
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(tmp, data, 'utf8');
|
|
157
|
+
renameSync(tmp, path);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// A leftover tmp file is invisible to read() (it does not end in .json
|
|
160
|
+
// alone — see list()), but leaving litter in tracked canon would show up
|
|
161
|
+
// in `git status` and in review.
|
|
162
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
163
|
+
throw new Error(`fluid: failed to persist record ${record.handle} — ${err.message}`);
|
|
164
|
+
}
|
|
165
|
+
return record;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @returns {object|null} the record, or null when absent. */
|
|
169
|
+
read(handle) {
|
|
170
|
+
const path = this._pathFor(handle);
|
|
171
|
+
if (!existsSync(path)) return null;
|
|
172
|
+
let record;
|
|
173
|
+
try {
|
|
174
|
+
record = JSON.parse(readFileSync(path, 'utf8'));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// Loud, not null. Returning null for an unparseable file would make a
|
|
177
|
+
// corrupt record indistinguishable from a deleted one, and the caller's
|
|
178
|
+
// next move on "absent" is to allocate the handle again or report it
|
|
179
|
+
// missing — both of which quietly destroy the damaged record.
|
|
180
|
+
throw new Error(`fluid: record file for ${handle} is unreadable — ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
return this._assertIdentity(record, handle, path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The filename and the record's own `handle` must agree.
|
|
187
|
+
*
|
|
188
|
+
* These are git-tracked files a human can open, and every write path routes
|
|
189
|
+
* through `record.handle` to choose its destination. So a file whose name and
|
|
190
|
+
* contents disagree is not a cosmetic inconsistency: reading `IDEA-1.json`
|
|
191
|
+
* that contains `"handle": "IDEA-2"` and then saving it writes IDEA-2.json,
|
|
192
|
+
* destroying a DIFFERENT record while leaving the malformed one untouched.
|
|
193
|
+
*
|
|
194
|
+
* Cheap to check, and the check has to live here because this is the only
|
|
195
|
+
* layer that knows both the name asked for and the bytes on disk.
|
|
196
|
+
*/
|
|
197
|
+
_assertIdentity(record, handle, path) {
|
|
198
|
+
if (record?.handle !== handle) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`fluid: record file ${path} declares handle "${record?.handle}" but is named ` +
|
|
201
|
+
`"${handle}" — refusing to read it, because writing it back would overwrite ` +
|
|
202
|
+
`a different record`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return record;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every record on disk.
|
|
210
|
+
*
|
|
211
|
+
* Sorted by filename so the enumeration is deterministic regardless of
|
|
212
|
+
* `readdirSync` order, which is filesystem-dependent. The provider re-sorts
|
|
213
|
+
* for presentation; this only guarantees the input to that sort is stable, so
|
|
214
|
+
* a regenerated projection does not churn on an unrelated machine.
|
|
215
|
+
*
|
|
216
|
+
* ONE CORRUPT FILE FAILS THE WHOLE LIST, deliberately. Skipping it would be
|
|
217
|
+
* the friendlier-looking choice and the destructive one: this list is what
|
|
218
|
+
* regenerates `ideabox.md`, so a silently omitted record becomes an idea
|
|
219
|
+
* deleted from tracked markdown by a routine write. Refusing to list at all
|
|
220
|
+
* is recoverable; a projection that quietly drops an idea is not.
|
|
221
|
+
*/
|
|
222
|
+
list() {
|
|
223
|
+
if (!existsSync(this.recordsDir)) return [];
|
|
224
|
+
const names = readdirSync(this.recordsDir)
|
|
225
|
+
.filter((n) => n.endsWith('.json') && HANDLE_RE.test(n.slice(0, -'.json'.length)))
|
|
226
|
+
.sort();
|
|
227
|
+
return names.map((n) => {
|
|
228
|
+
const handle = n.slice(0, -'.json'.length);
|
|
229
|
+
// read() enforces filename↔handle identity, so a mismatched file fails
|
|
230
|
+
// the whole list rather than being projected under the wrong identity.
|
|
231
|
+
const record = this.read(handle);
|
|
232
|
+
if (!record) {
|
|
233
|
+
// Raced against a delete between readdir and read. Not an error; the
|
|
234
|
+
// record is genuinely gone.
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return record;
|
|
238
|
+
}).filter(Boolean);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Handles of live records only. Retired handles live in the log. */
|
|
242
|
+
liveHandles() {
|
|
243
|
+
if (!existsSync(this.recordsDir)) return new Set();
|
|
244
|
+
const out = new Set();
|
|
245
|
+
for (const n of readdirSync(this.recordsDir)) {
|
|
246
|
+
if (!n.endsWith('.json')) continue;
|
|
247
|
+
const handle = n.slice(0, -'.json'.length);
|
|
248
|
+
if (HANDLE_RE.test(handle)) out.add(handle);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @returns {boolean} whether a file was removed. */
|
|
254
|
+
remove(handle) {
|
|
255
|
+
const path = this._pathFor(handle);
|
|
256
|
+
if (!existsSync(path)) return false;
|
|
257
|
+
rmSync(path);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// -------------------------------------------------------------------------
|
|
262
|
+
// Events — append-only
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
_rawLog() {
|
|
266
|
+
return existsSync(this.eventsPath) ? readFileSync(this.eventsPath, 'utf8') : '';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Append one event.
|
|
271
|
+
*
|
|
272
|
+
* The log is the tombstone ledger: a `created` entry burns its handle
|
|
273
|
+
* permanently, which is what stops a deleted record's handle being handed to
|
|
274
|
+
* a different idea later. It is therefore tracked alongside the records
|
|
275
|
+
* rather than left in the ignored runtime directory — losing it does not lose
|
|
276
|
+
* history, it loses the guarantee.
|
|
277
|
+
*/
|
|
278
|
+
appendEvent(event) {
|
|
279
|
+
mkdirSync(this.root, { recursive: true });
|
|
280
|
+
appendFileSync(this.eventsPath, JSON.stringify(event) + '\n', 'utf8');
|
|
281
|
+
return { ok: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Handles named anywhere in the raw log, including on lines that fail to
|
|
286
|
+
* parse. See HANDLE_TOKEN_RE.
|
|
287
|
+
*/
|
|
288
|
+
issuedHandlesFromLog() {
|
|
289
|
+
const handles = new Set();
|
|
290
|
+
const text = this._rawLog();
|
|
291
|
+
HANDLE_TOKEN_RE.lastIndex = 0;
|
|
292
|
+
let m;
|
|
293
|
+
while ((m = HANDLE_TOKEN_RE.exec(text)) !== null) handles.add(m[1]);
|
|
294
|
+
return handles;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Parsed history, skipping unparseable lines. Handle retirement never
|
|
298
|
+
* depends on this path — see issuedHandlesFromLog. */
|
|
299
|
+
readEvents() {
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const line of this._rawLog().split('\n')) {
|
|
302
|
+
const trimmed = line.trim();
|
|
303
|
+
if (!trimmed) continue;
|
|
304
|
+
try { out.push(JSON.parse(trimmed)); } catch { /* a corrupt line must not
|
|
305
|
+
take the readable history with it */ }
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/*
|
|
312
|
+
* KNOWN GAP, accepted for the floor (unchanged from S1, narrowed by this slice)
|
|
313
|
+
* ----------------------------------------------------------------------------
|
|
314
|
+
* There is still no lock. Two processes writing the SAME handle concurrently
|
|
315
|
+
* can interleave read-modify-write, and last writer wins.
|
|
316
|
+
*
|
|
317
|
+
* What this slice did fix is the worse failure it used to sit behind: with a
|
|
318
|
+
* shared state file and an in-memory snapshot, a stale writer saving unrelated
|
|
319
|
+
* work erased records it had never read. Per-record files plus read-per-
|
|
320
|
+
* operation mean a concurrent write can now only lose an update to the ONE
|
|
321
|
+
* record being contended, never to its siblings.
|
|
322
|
+
*
|
|
323
|
+
* Handle allocation is the remaining sharp edge: two creates racing can compute
|
|
324
|
+
* the same next handle. The pre-write tombstone narrows the window but does not
|
|
325
|
+
* close it. Serializing that needs a lock, and `.compose/locks/` already exists
|
|
326
|
+
* for exactly this — it is S3b's job, when the CLI, the API and the UI are all
|
|
327
|
+
* actually writing.
|
|
328
|
+
*/
|