@smartmemory/compose 0.3.7 → 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/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +470 -351
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.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-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- 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 +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- 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 +3 -3
- 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 +57 -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 -23
- 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/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- 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 +396 -199
- 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 +271 -36
- 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 +109 -23
- package/server/compose-mcp.js +88 -882
- 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 +34 -2
- 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-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/smartmemory-provider.js — the SmartMemory fluid provider (COMP-FOH FOH-1).
|
|
3
|
+
*
|
|
4
|
+
* Implements the fluid-store seam over SmartMemory's generic MemoryItem CRUD
|
|
5
|
+
* routes, via `lib/smartmemory-client.js`. Storage only: this slice declares
|
|
6
|
+
* RECORDS, EVENTS and LINKS and nothing else. Recall, challenge, conviction,
|
|
7
|
+
* calibration and contradiction stay refused by the base class until FOH-2
|
|
8
|
+
* implements and declares them together.
|
|
9
|
+
*
|
|
10
|
+
* Storage layout (blueprint D-FOH-2):
|
|
11
|
+
* - a record → one MemoryItem, `memory_type: fluid_<kind>`, the canonical
|
|
12
|
+
* record JSON-encoded into `metadata.fluid_record_json`
|
|
13
|
+
* - an event → one MemoryItem, `memory_type: fluid_event`, payload in
|
|
14
|
+
* `metadata.fluid_event_json`
|
|
15
|
+
*
|
|
16
|
+
* WHY THE RECORD IS AN OPAQUE STRING
|
|
17
|
+
* ----------------------------------
|
|
18
|
+
* SmartMemory treats `metadata` as an open bag of graph properties it owns and
|
|
19
|
+
* rewrites. A structured record stored there does not survive the round trip:
|
|
20
|
+
* nested dicts are exploded into `parent__child` properties, empty containers
|
|
21
|
+
* are dropped, the server overwrites `created_at`, and merge-mode updates hoist
|
|
22
|
+
* old metadata to the top level as a stale shadow copy.
|
|
23
|
+
*
|
|
24
|
+
* Worse, a field CANNOT BE CLEARED: null and empty-string values are filtered
|
|
25
|
+
* out before the write and the SET simply omits them, so the previous value
|
|
26
|
+
* survives behind a 200 (filed upstream as smart-memory-core#3). Under any
|
|
27
|
+
* structured mapping, clearing a record's `priority` would silently no-op.
|
|
28
|
+
*
|
|
29
|
+
* One JSON string is inert to every one of those paths. It is never null and
|
|
30
|
+
* never empty, so it is always written; the metadata merge is a one-level
|
|
31
|
+
* spread, so one key replaces the whole record; and the backend codec
|
|
32
|
+
* marker-escapes JSON-looking strings and returns them verbatim, making the
|
|
33
|
+
* round trip byte-stable by construction rather than by hope.
|
|
34
|
+
*
|
|
35
|
+
* The cost is that record fields are not server-queryable. That costs nothing
|
|
36
|
+
* here: `/memory/list` accepts a single filter pair, the floor already filters
|
|
37
|
+
* client-side, and the seam exposes no field-query API. `handle`, `kind` and
|
|
38
|
+
* `fluid_ns` stay flat beside the blob precisely so lookup still works.
|
|
39
|
+
*
|
|
40
|
+
* WHAT THIS PROVIDER OWNS THAT THE FLOOR DOES NOT
|
|
41
|
+
* -----------------------------------------------
|
|
42
|
+
* Nothing below it enforces the seam's invariants. `memory_type` is frozen at
|
|
43
|
+
* creation server-side and the blob is opaque, so a `kind` change would diverge
|
|
44
|
+
* permanently and silently keep the old embedding policy. There is still no
|
|
45
|
+
* uniqueness CONSTRAINT on a metadata field — handle uniqueness is produced
|
|
46
|
+
* here, by allocating from a server counter and serializing every mutation
|
|
47
|
+
* behind a server lease, not enforced by the store.
|
|
48
|
+
*
|
|
49
|
+
* COORDINATION (COMP-FLUID-SEAM-GUARANTEES)
|
|
50
|
+
* -----------------------------------------
|
|
51
|
+
* Two different server primitives, for two different problems:
|
|
52
|
+
*
|
|
53
|
+
* - `SVC-ALLOC-1` sequences allocate handles. `$inc` is atomic, so concurrent
|
|
54
|
+
* creates get distinct numbers with no lock at all.
|
|
55
|
+
* - `SVC-LEASE-1` leases serialize mutation, because read-modify-write on one
|
|
56
|
+
* opaque blob cannot be made atomic by a counter.
|
|
57
|
+
*
|
|
58
|
+
* Together they are what lets `mutationScope()` answer CLUSTER instead of NONE.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { randomUUID } from 'node:crypto';
|
|
62
|
+
|
|
63
|
+
import { createSmartmemoryClient } from '../smartmemory-client.js';
|
|
64
|
+
import {
|
|
65
|
+
CAP,
|
|
66
|
+
CHALLENGEABLE_KINDS,
|
|
67
|
+
CONVICTION_STRATEGIES,
|
|
68
|
+
FluidAmbiguousMatch,
|
|
69
|
+
FluidConfigError,
|
|
70
|
+
FluidInvalidStrategy,
|
|
71
|
+
FluidInvalidTarget,
|
|
72
|
+
FluidKindUnsupported,
|
|
73
|
+
FluidProvider,
|
|
74
|
+
FluidRecordNotFound,
|
|
75
|
+
FluidResolutionConflict,
|
|
76
|
+
FluidResolutionIndeterminate,
|
|
77
|
+
FluidResolutionNoOp,
|
|
78
|
+
KIND,
|
|
79
|
+
MUTATION_SCOPE,
|
|
80
|
+
STORAGE_CAP,
|
|
81
|
+
normalizeRecallLimit,
|
|
82
|
+
} from './provider.js';
|
|
83
|
+
import {
|
|
84
|
+
HANDLE_PREFIX,
|
|
85
|
+
HANDLE_RE,
|
|
86
|
+
UNPATCHABLE,
|
|
87
|
+
assertHandle,
|
|
88
|
+
assertLink,
|
|
89
|
+
assertPatchable,
|
|
90
|
+
assertStatus,
|
|
91
|
+
eventTypeForUpdate,
|
|
92
|
+
normalizeRecord,
|
|
93
|
+
} from './record-shape.js';
|
|
94
|
+
import { assertValid, getFluidValidator } from './schema.js';
|
|
95
|
+
|
|
96
|
+
/** Namespace markers. The analogue of the floor's `fluid_ext` presence check:
|
|
97
|
+
* a MemoryItem without the marker is invisible to this provider, so a
|
|
98
|
+
* workspace shared with other SmartMemory content stays safe to read. */
|
|
99
|
+
const RECORD_NS = 'compose.fluid.v1';
|
|
100
|
+
const EVENT_NS = 'compose.fluid.events.v1';
|
|
101
|
+
|
|
102
|
+
/** Wire type prefix (blueprint D-FOH-3).
|
|
103
|
+
*
|
|
104
|
+
* `decision` is ALREADY a registered SmartMemory memory_type with a different
|
|
105
|
+
* meaning, so a bare kind would merge two populations in one workspace — and,
|
|
106
|
+
* because the per-kind embedding config key is derived from `memory_type`,
|
|
107
|
+
* would make Compose's recallability policy rewrite embedding behaviour for
|
|
108
|
+
* native SmartMemory decisions deployment-wide. The prefix keeps the blast
|
|
109
|
+
* radius inside Compose's own namespace. */
|
|
110
|
+
const WIRE_PREFIX = 'fluid_';
|
|
111
|
+
const EVENT_WIRE_TYPE = 'fluid_event';
|
|
112
|
+
|
|
113
|
+
/** The route's own default page size. Every enumeration here loops; see
|
|
114
|
+
* `_listAllItems`. */
|
|
115
|
+
const PAGE_SIZE = 50;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Kinds `recall()` may return.
|
|
119
|
+
*
|
|
120
|
+
* `cluster` stays out: it is a hand-authored grouping label, not a fuzzy-recall
|
|
121
|
+
* target. It remains stored and readable the moment you name its handle.
|
|
122
|
+
*
|
|
123
|
+
* `decision` is IN, by owner ruling 2026-08-04, reversing architecture.md §Q3's
|
|
124
|
+
* provisional INDEXED default (which flagged itself as the one item worth a
|
|
125
|
+
* second look). §Q3's stated fear was surfacing a superseded decision "as if it
|
|
126
|
+
* were live" — and the word doing the work there is *as if*. A hit carries the
|
|
127
|
+
* whole record, `status` and `killed` included, so a killed decision is
|
|
128
|
+
* distinguishable by anything that looks. See the residual limitation below.
|
|
129
|
+
*
|
|
130
|
+
* THIS LIST IS THE ENFORCEMENT, and it stays that way even once the server can
|
|
131
|
+
* be told what to embed. Filtering the OUTPUT holds regardless of how the
|
|
132
|
+
* server is configured; making it contingent on a server flag would put recall
|
|
133
|
+
* correctness at the mercy of deployment state.
|
|
134
|
+
*
|
|
135
|
+
* (Corrected 2026-08-05: the reason used to be stated as "Compose can neither
|
|
136
|
+
* set nor verify" the per-request override. Right about the outcome, wrong
|
|
137
|
+
* about the cause — core HAS the override and honours it ahead of every
|
|
138
|
+
* memory_type and config default (`_embed`, crud.py:100 and :160). No REST
|
|
139
|
+
* route plumbs it through, which is a field to expose rather than a mechanism
|
|
140
|
+
* to build. Tracked in SmartMemory as SVC-EMBED-CONTROL-1.)
|
|
141
|
+
*
|
|
142
|
+
* CORRECTED 2026-08-05, verified against live FalkorDB. This comment used to
|
|
143
|
+
* claim supersession was invisible on the superseded record — that detecting it
|
|
144
|
+
* "would mean scanning every record for links targeting this handle, which
|
|
145
|
+
* recall cannot do per hit". That was WRONG. The old node carries plain
|
|
146
|
+
* properties, readable per hit with zero extra queries:
|
|
147
|
+
*
|
|
148
|
+
* superseded=true, superseded_by=<new_id>, superseded_at=<ts>
|
|
149
|
+
*
|
|
150
|
+
* Set by both `SmartMemory.supersede()` and `ingest_superseding()`
|
|
151
|
+
* (smart_memory.py:3531, :3546 — the latter's docstring states it outright).
|
|
152
|
+
* So a superseded decision IS distinguishable in recall today, and `decision`
|
|
153
|
+
* belongs in this set with no asterisk.
|
|
154
|
+
*
|
|
155
|
+
* The one real narrowing is different and narrower than what was claimed: both
|
|
156
|
+
* REST supersede routes CREATE the replacement (`ingest_superseding`, reached
|
|
157
|
+
* via POST /memory/{item_id}/supersede). The link-two-existing-records form,
|
|
158
|
+
* `supersede(old_id, new_id)`, is not exposed over REST — so "record B, already
|
|
159
|
+
* stored, supersedes record A" has no REST path. Tracked in SmartMemory as
|
|
160
|
+
* SVC-SUPERSEDE-LINK-1; do not build a Compose workaround for it.
|
|
161
|
+
*/
|
|
162
|
+
export const RECALLABLE_KINDS = Object.freeze(
|
|
163
|
+
new Set([KIND.IDEA, KIND.THREAD, KIND.QUESTION, KIND.DECISION]),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Over-fetch factor, floor and ceiling for the single recall query.
|
|
168
|
+
*
|
|
169
|
+
* One unfiltered query is issued and filtered client-side, so the fetch has to
|
|
170
|
+
* be wide enough to survive dropping every non-fluid and non-recallable hit. The
|
|
171
|
+
* floor matters more than the factor: at `limit: 1` a bare factor would fetch 4,
|
|
172
|
+
* which almost any workspace could fill with items that get filtered away.
|
|
173
|
+
*
|
|
174
|
+
* The ceiling is not decoration — `top_k` is unconstrained on the wire and the
|
|
175
|
+
* route doubles it before searching, so an unbounded value here becomes an
|
|
176
|
+
* unbounded fetch and a proportional ranking cost on the server.
|
|
177
|
+
*/
|
|
178
|
+
const OVERFETCH = 4;
|
|
179
|
+
const MIN_FETCH = 20;
|
|
180
|
+
const TOP_K_CAP = 200;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Mutation lease (SVC-LEASE-1).
|
|
184
|
+
*
|
|
185
|
+
* ONE lease for the whole store rather than one per record, mirroring the
|
|
186
|
+
* floor's single `withDirLock` path and for the same reasons: writes here are
|
|
187
|
+
* human-scale, and a single lease also orders allocation against mutation. Two
|
|
188
|
+
* granularities would buy nothing and cost a deadlock-ordering rule.
|
|
189
|
+
*
|
|
190
|
+
* The TTL is generous because a critical section is several NETWORK calls, not
|
|
191
|
+
* a few file writes — a create is peek + allocate + event + record. It is
|
|
192
|
+
* renewed on a heartbeat rather than simply set high: a crashed holder must not
|
|
193
|
+
* strand the store for the whole TTL, and the heartbeat is what lets the TTL
|
|
194
|
+
* stay short enough for that while still covering a slow section.
|
|
195
|
+
*
|
|
196
|
+
* Server bounds (`lease.py`): 5..300s. The retry schedule is bounded and
|
|
197
|
+
* explicit — acquisition FAILS rather than waiting forever, because a caller
|
|
198
|
+
* blocked indefinitely on a lease is indistinguishable from a hung CLI.
|
|
199
|
+
*/
|
|
200
|
+
/**
|
|
201
|
+
* Challenge deadline (FOH-3). The client default is 3s and it spans the body
|
|
202
|
+
* read; with the LLM cascade running over up to ~10 related facts, a challenge
|
|
203
|
+
* routinely exceeds it. This is a per-call override, so CRUD/recall keep the
|
|
204
|
+
* tight default that catches a hung service.
|
|
205
|
+
*/
|
|
206
|
+
const CHALLENGE_TIMEOUT_MS = 30_000;
|
|
207
|
+
|
|
208
|
+
// Conviction (COMP-FOH FOH-4). The resolve call is deterministic (no LLM) but
|
|
209
|
+
// still does a server-side get + decay + update; 8s is generous ON PURPOSE —
|
|
210
|
+
// an abort here manufactures an ambiguous outcome the provider then has to
|
|
211
|
+
// reconcile, so a spurious timeout is strictly worse than a slow success.
|
|
212
|
+
const RESOLVE_TIMEOUT_MS = 8_000;
|
|
213
|
+
// The reconciliation poll after an ambiguous transport outcome: bounded and
|
|
214
|
+
// read-only. Short, because the server mutates synchronously — if the decay is
|
|
215
|
+
// going to land at all, it lands within the request's own lifetime.
|
|
216
|
+
const RESOLVE_RECONCILE_ATTEMPTS = 3;
|
|
217
|
+
const RESOLVE_RECONCILE_DELAY_MS = 250;
|
|
218
|
+
// Contradiction linkage (COMP-FOH FOH-5). The best-effort edge write after a
|
|
219
|
+
// landed decay. Safe to retry (idempotent MERGE), so a small bounded retry
|
|
220
|
+
// closes over a transient network blip without ever risking a double-decay.
|
|
221
|
+
const LINK_RETRY_ATTEMPTS = 3;
|
|
222
|
+
const LINK_RETRY_DELAY_MS = 200;
|
|
223
|
+
// Uppercase to match SmartMemory's managed contradiction path (framework.py:887)
|
|
224
|
+
// so a Compose-written edge and a native-written one read back under one label.
|
|
225
|
+
const CONTRADICTS_EDGE = 'CONTRADICTS';
|
|
226
|
+
/** Numeric tolerance for the decay postcondition. The confidence value round
|
|
227
|
+
* trips Python → JSON → JS; 1.0/0.5/0.0 are exact doubles but a legacy value
|
|
228
|
+
* like 0.9 is not, and a strict `===` on a destructive-write check would
|
|
229
|
+
* false-alarm on representation noise. */
|
|
230
|
+
const CONFIDENCE_EPSILON = 1e-9;
|
|
231
|
+
|
|
232
|
+
/** Python `[:200]` slices Unicode CODE POINTS; JS `String.slice` cuts UTF-16
|
|
233
|
+
* code units. The attribution check compares our text against what the server
|
|
234
|
+
* stored, so the truncation must match Python's or an astral character near
|
|
235
|
+
* the boundary mis-classifies a landed decay. */
|
|
236
|
+
function codePointSlice(s, n) {
|
|
237
|
+
return Array.from(String(s ?? '')).slice(0, n).join('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Classify what a resolve attempt actually did, from the confidence-history
|
|
242
|
+
* reads bracketing it (COMP-FOH FOH-4 D4). Pure — shared by the clean-response
|
|
243
|
+
* path and the reconciliation poll so the two can never disagree.
|
|
244
|
+
*
|
|
245
|
+
* Never trusts the `/resolve` response: the server ignores `apply_decay`'s
|
|
246
|
+
* success and its `confidence` field is ambiguous. The verdict comes from
|
|
247
|
+
* SmartMemory's deterministic contract instead: one decay = challenge_count+1,
|
|
248
|
+
* confidence = max(0, old - 0.5), one attributed history event.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} pre confidence-history envelope read before the resolve
|
|
251
|
+
* @param {object} post confidence-history envelope read after
|
|
252
|
+
* @param {string} expectedFact our new_fact, truncated to 200 code points —
|
|
253
|
+
* matched against the newest event so someone ELSE's numerically identical
|
|
254
|
+
* decay is never mistaken for ours
|
|
255
|
+
* @returns {'landed'|'no-op'|'conflict'|'indeterminate'}
|
|
256
|
+
*/
|
|
257
|
+
export function classifyResolution(pre, post, expectedFact) {
|
|
258
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
259
|
+
const near = (a, b) => finite(a) && finite(b) && Math.abs(a - b) < CONFIDENCE_EPSILON;
|
|
260
|
+
// The envelope must be structurally sound AND be about the same item as the
|
|
261
|
+
// pre-read — a wrong-item envelope must never classify anything.
|
|
262
|
+
if (!post || !finite(post.current_confidence)
|
|
263
|
+
|| !Number.isInteger(post.challenge_count) || !Array.isArray(post.history)
|
|
264
|
+
|| post.item_id !== pre.item_id) {
|
|
265
|
+
return 'indeterminate';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const delta = post.challenge_count - pre.challenge_count;
|
|
269
|
+
if (delta === 1) {
|
|
270
|
+
const ev = post.history[post.history.length - 1];
|
|
271
|
+
// Ours? The reason is fixed by the strategy and the fact text is ours.
|
|
272
|
+
const attributed = !!ev
|
|
273
|
+
&& typeof ev.timestamp === 'string'
|
|
274
|
+
&& ev.reason === 'manual_resolution:accept_new'
|
|
275
|
+
&& ev.conflicting_fact === expectedFact;
|
|
276
|
+
if (!attributed) return 'conflict'; // someone else's decay landed; ours did not
|
|
277
|
+
const expected = Math.max(0, pre.current_confidence - 0.5);
|
|
278
|
+
const numeric = near(post.current_confidence, expected)
|
|
279
|
+
&& near(ev.old_confidence, pre.current_confidence)
|
|
280
|
+
&& near(ev.new_confidence, expected)
|
|
281
|
+
&& ev.decay_factor === 0.5;
|
|
282
|
+
// Attributed but numerically wrong = a partial or corrupt write (the
|
|
283
|
+
// pre-fix-runtime shape: count moves, confidence does not). Not retryable.
|
|
284
|
+
return numeric ? 'landed' : 'indeterminate';
|
|
285
|
+
}
|
|
286
|
+
if (delta === 0) {
|
|
287
|
+
// A genuine no-op only if NOTHING moved — confidence AND the history
|
|
288
|
+
// CONTENT. Comparing `history_count` alone is not enough twice over: the
|
|
289
|
+
// real route reads the item and the history separately (a torn read can
|
|
290
|
+
// pair old confidence with new history), and once the history sits at its
|
|
291
|
+
// server-side cap (20) an external decay rotates entries without changing
|
|
292
|
+
// the count. Both must land in 'indeterminate', never in the one verdict
|
|
293
|
+
// that invites a retry. Same JSON round-trip on both reads, so exact
|
|
294
|
+
// comparison is right here.
|
|
295
|
+
const unchanged = post.current_confidence === pre.current_confidence
|
|
296
|
+
&& JSON.stringify(post.history) === JSON.stringify(pre.history);
|
|
297
|
+
return unchanged ? 'no-op' : 'indeterminate';
|
|
298
|
+
}
|
|
299
|
+
if (delta > 1) return 'conflict'; // more than one decay interleaved
|
|
300
|
+
return 'indeterminate'; // count regression: malformed or incompatible runtime
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const LEASE_TTL_SECONDS = 60;
|
|
304
|
+
const LEASE_RENEW_MS = 20_000;
|
|
305
|
+
const LEASE_BACKOFF_MS = Object.freeze([0, 50, 150, 400, 900, 1500, 2500, 4000]);
|
|
306
|
+
const LEASE_KEY = 'compose.fluid.mutate';
|
|
307
|
+
|
|
308
|
+
/** Handle counters, one sequence per kind. Server-side these are scoped to the
|
|
309
|
+
* workspace, so the name carries only the namespace and the kind. Matches the
|
|
310
|
+
* service's `SEQUENCE_NAME_PATTERN`. */
|
|
311
|
+
function sequenceNameFor(kind) { return `compose.fluid.handle.${kind}`; }
|
|
312
|
+
|
|
313
|
+
function nowIso() { return new Date().toISOString(); }
|
|
314
|
+
|
|
315
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
316
|
+
|
|
317
|
+
/** `memory_type` for a record kind. Never the bare kind. */
|
|
318
|
+
function wireTypeFor(kind) { return `${WIRE_PREFIX}${kind}`; }
|
|
319
|
+
|
|
320
|
+
export class SmartMemoryFluidProvider extends FluidProvider {
|
|
321
|
+
name() { return 'smartmemory'; }
|
|
322
|
+
|
|
323
|
+
/** Storage, plus RECALL (FOH-2), CHALLENGE (FOH-3), CONVICTION (FOH-4) and
|
|
324
|
+
* CONTRADICTION (FOH-5). Declaring a capability without implementing it is the
|
|
325
|
+
* one thing PROVIDER-SEAM forbids, so each line here moves with its method
|
|
326
|
+
* (`recall()`, `challenge()`, `conviction()`/`resolveConflict()`,
|
|
327
|
+
* `contradictions()`) — CALIBRATION stays undeclared (no subject exists) and
|
|
328
|
+
* keeps inheriting refusal. */
|
|
329
|
+
capabilities() {
|
|
330
|
+
return new Set([
|
|
331
|
+
STORAGE_CAP.RECORDS, STORAGE_CAP.EVENTS, STORAGE_CAP.LINKS,
|
|
332
|
+
CAP.RECALL, CAP.CHALLENGE, CAP.CONVICTION, CAP.CONTRADICTION,
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The floor's full kind set, not `idea` alone.
|
|
338
|
+
*
|
|
339
|
+
* The pilot workload requires it: the ideabox import creates CLUSTERS before
|
|
340
|
+
* ideas, because members reference them by handle, so an idea-only provider
|
|
341
|
+
* throws on the import's first write. Kinds are free here because storage is
|
|
342
|
+
* generic — `memory_type` is a free-form string with no typed schema behind
|
|
343
|
+
* it — so matching the floor exactly makes a provider swap lossless rather
|
|
344
|
+
* than parity-gapped.
|
|
345
|
+
*
|
|
346
|
+
* `position` and `joint` stay refused, matching the floor: the judgment layer
|
|
347
|
+
* already owns both kinds with its own store and write tools, and accepting
|
|
348
|
+
* them would give one kind two canons.
|
|
349
|
+
*/
|
|
350
|
+
supportedKinds() {
|
|
351
|
+
return new Set([KIND.IDEA, KIND.DECISION, KIND.THREAD, KIND.QUESTION, KIND.CLUSTER]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* CLUSTER since COMP-FLUID-SEAM-GUARANTEES. This used to be a confession.
|
|
356
|
+
*
|
|
357
|
+
* It was NONE because allocation read the issued set and added one across two
|
|
358
|
+
* remote calls with nothing holding the gap, so N concurrent creates allocated
|
|
359
|
+
* the same handle. No record was LOST: this store writes by `item_id`, so all
|
|
360
|
+
* N persisted and N-1 were silently orphaned behind a handle resolving to one
|
|
361
|
+
* of them. The floor loses them outright because it keys the file by handle —
|
|
362
|
+
* do not carry the floor's loss claim across this seam (measured 2026-08-05).
|
|
363
|
+
* `lib/dir-lock.js`
|
|
364
|
+
* could not have fixed it: it is a local mutex and this store is reachable from
|
|
365
|
+
* every machine sharing the workspace, which is the entire reason to use it.
|
|
366
|
+
*
|
|
367
|
+
* Both halves now rest on server primitives, and they are different primitives
|
|
368
|
+
* on purpose:
|
|
369
|
+
*
|
|
370
|
+
* - ALLOCATION is not serialized at all, because it does not need to be. A
|
|
371
|
+
* sequence `$inc` is atomic per document, so concurrent callers receive
|
|
372
|
+
* distinct numbers with no lock and no contention. Wrapping it in the
|
|
373
|
+
* lease would be strictly worse — slower, and no safer.
|
|
374
|
+
* - MUTATION is serialized by a scoped lease, because read-modify-write on
|
|
375
|
+
* one record genuinely cannot be made atomic by a counter. This is the
|
|
376
|
+
* quieter half: a lost update leaves no trace, both writers succeed and one
|
|
377
|
+
* edit is simply gone.
|
|
378
|
+
*
|
|
379
|
+
* Declared honestly in BOTH directions — the conformance suite asserts the
|
|
380
|
+
* failure when a provider declares NONE, so a stale declaration here starts
|
|
381
|
+
* failing rather than silently over-promising.
|
|
382
|
+
*/
|
|
383
|
+
mutationScope() { return MUTATION_SCOPE.CLUSTER; }
|
|
384
|
+
|
|
385
|
+
/** A workspace on a server. Reachable from every machine holding the key. */
|
|
386
|
+
isShared() { return true; }
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @param {string} cwd project root
|
|
390
|
+
* @param {object} config merged fluid.smartmemory + top-level smartmemory config
|
|
391
|
+
* @param {string} config.baseUrl SmartMemory endpoint
|
|
392
|
+
* @param {string} config.apiKeyEnv env var holding the API key
|
|
393
|
+
* @param {string} config.workspaceId configured workspace (owner ruling: never derived)
|
|
394
|
+
* @param {object} [config.client] injected client, for tests
|
|
395
|
+
*/
|
|
396
|
+
async init(cwd, config = {}) {
|
|
397
|
+
this.cwd = cwd;
|
|
398
|
+
this.config = config;
|
|
399
|
+
|
|
400
|
+
// Fail loud, before any network call, naming the exact missing setting.
|
|
401
|
+
//
|
|
402
|
+
// All four are checked, not just the block's presence: the client
|
|
403
|
+
// interpolates baseUrl straight into the URL, so an unset endpoint would
|
|
404
|
+
// otherwise surface as a request failure against the literal string
|
|
405
|
+
// "undefined" — blaming the server for a local misconfiguration.
|
|
406
|
+
if (!config.baseUrl) {
|
|
407
|
+
throw new FluidConfigError(
|
|
408
|
+
'compose: fluid provider "smartmemory" requires smartmemory.baseUrl to be set',
|
|
409
|
+
{ provider: 'smartmemory', setting: 'smartmemory.baseUrl' }
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (!config.apiKeyEnv) {
|
|
413
|
+
throw new FluidConfigError(
|
|
414
|
+
'compose: fluid provider "smartmemory" requires smartmemory.apiKeyEnv to be set',
|
|
415
|
+
{ provider: 'smartmemory', setting: 'smartmemory.apiKeyEnv' }
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (!process.env[config.apiKeyEnv]) {
|
|
419
|
+
throw new FluidConfigError(
|
|
420
|
+
`compose: fluid provider "smartmemory" needs an API key in $${config.apiKeyEnv}, ` +
|
|
421
|
+
'which is unset or empty. The key needs the read:memories, write:memories AND ' +
|
|
422
|
+
'delete:memories scopes — a key missing delete fails only on deleteRecord, long ' +
|
|
423
|
+
'after setup appears to have worked.',
|
|
424
|
+
{ provider: 'smartmemory', setting: config.apiKeyEnv }
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
// Owner ruling (blueprint C4): the workspace id is CONFIGURED, never
|
|
428
|
+
// derived. A locally derived project tag was never usable — the service
|
|
429
|
+
// validates the header against the principal's memberships and 403s a
|
|
430
|
+
// workspace they are not a member of.
|
|
431
|
+
if (!config.workspaceId) {
|
|
432
|
+
throw new FluidConfigError(
|
|
433
|
+
'compose: fluid provider "smartmemory" requires fluid.smartmemory.workspaceId. ' +
|
|
434
|
+
'It must be a workspace the configured API key is a member of; it is not derived ' +
|
|
435
|
+
'from the project directory.',
|
|
436
|
+
{ provider: 'smartmemory', setting: 'fluid.smartmemory.workspaceId' }
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.client = config.client ?? createSmartmemoryClient({
|
|
441
|
+
baseUrl: config.baseUrl,
|
|
442
|
+
apiKeyEnv: config.apiKeyEnv,
|
|
443
|
+
workspaceId: config.workspaceId,
|
|
444
|
+
timeoutMs: config.timeoutMs,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
/** The serialization mechanism this provider's `mutationScope()` claims.
|
|
448
|
+
* Exposed under this name because the conformance suite cross-checks the
|
|
449
|
+
* declaration against a mechanism — the floor exposes `lockPath`, a
|
|
450
|
+
* server-backed provider exposes this. Leases are workspace-scoped
|
|
451
|
+
* server-side, so the key needs no workspace component. */
|
|
452
|
+
this.leaseClient = this.client;
|
|
453
|
+
this.leaseKey = LEASE_KEY;
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// -------------------------------------------------------------------------
|
|
458
|
+
// Mapping
|
|
459
|
+
// -------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The searchable projection (blueprint D-FOH-1).
|
|
463
|
+
*
|
|
464
|
+
* Title, body and discussion text, not the title alone. Embeddings are
|
|
465
|
+
* generated from `content`, so a title-only projection would leave a later
|
|
466
|
+
* recall slice unable to reach an idea's prose while the architecture
|
|
467
|
+
* declares ideas fully recallable — a failure that would surface only after
|
|
468
|
+
* that slice shipped, as empty results.
|
|
469
|
+
*
|
|
470
|
+
* DISCLOSED LIMITATION: PATCH does not reindex, so an edited record keeps its
|
|
471
|
+
* original embedding. Nothing reads embeddings in FOH-1, so nothing is broken
|
|
472
|
+
* today; this is FOH-2's entry gate. Do NOT work around it by delete +
|
|
473
|
+
* recreate, which burns a handle the tombstone invariant forbids.
|
|
474
|
+
*/
|
|
475
|
+
_renderContent(record) {
|
|
476
|
+
const parts = [record.title];
|
|
477
|
+
if (record.body) parts.push(record.body);
|
|
478
|
+
for (const entry of record.discussion ?? []) {
|
|
479
|
+
if (entry?.text) parts.push(entry.text);
|
|
480
|
+
}
|
|
481
|
+
return parts.join('\n\n');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Record → wire metadata. Three flat lookup fields, then the sealed blob. */
|
|
485
|
+
_toMetadata(record) {
|
|
486
|
+
return {
|
|
487
|
+
fluid_ns: RECORD_NS,
|
|
488
|
+
handle: record.handle,
|
|
489
|
+
kind: record.kind,
|
|
490
|
+
fluid_record_json: JSON.stringify(record),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Wire item → record, or null when the item is not one of ours / unreadable.
|
|
496
|
+
*
|
|
497
|
+
* A blob that fails to parse is treated as absent rather than thrown: these
|
|
498
|
+
* items live in a shared workspace, and one corrupt row must not make every
|
|
499
|
+
* enumeration fail. The `handle` is reported so it is findable.
|
|
500
|
+
*/
|
|
501
|
+
_fromItem(item) {
|
|
502
|
+
const meta = item?.metadata ?? {};
|
|
503
|
+
if (meta.fluid_ns !== RECORD_NS) return null;
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = JSON.parse(meta.fluid_record_json);
|
|
507
|
+
} catch {
|
|
508
|
+
process.emitWarning(
|
|
509
|
+
`fluid(smartmemory): record ${meta.handle ?? item?.item_id} has an unreadable ` +
|
|
510
|
+
'fluid_record_json blob and was skipped',
|
|
511
|
+
);
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
515
|
+
// Normalize on every read: a record written before a field existed, or
|
|
516
|
+
// imported from the floor, reads back in today's shape without a migration.
|
|
517
|
+
return normalizeRecord(parsed);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// -------------------------------------------------------------------------
|
|
521
|
+
// Enumeration
|
|
522
|
+
// -------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Every matching item, following pagination to the end.
|
|
526
|
+
*
|
|
527
|
+
* The route defaults to `limit=50`. A provider that silently saw only the
|
|
528
|
+
* first page would under-report the ideabox — and once the projection is
|
|
529
|
+
* generated from it, DELETE the rest from `ideabox.md`.
|
|
530
|
+
*
|
|
531
|
+
* Only one metadata filter pair is supported server-side, so the namespace
|
|
532
|
+
* check is applied client-side by the caller's mapper.
|
|
533
|
+
*/
|
|
534
|
+
async _listAllItems({ metadataKey, metadataValue } = {}) {
|
|
535
|
+
const out = [];
|
|
536
|
+
let offset = 0;
|
|
537
|
+
for (;;) {
|
|
538
|
+
const page = await this.client.listItems({
|
|
539
|
+
limit: PAGE_SIZE, offset, metadataKey, metadataValue,
|
|
540
|
+
});
|
|
541
|
+
const items = page.items ?? [];
|
|
542
|
+
out.push(...items);
|
|
543
|
+
// Stop on a short page OR on reaching `total`. Either alone is fragile:
|
|
544
|
+
// a full last page with no `total` would loop forever without the first,
|
|
545
|
+
// and a `total` that lags a concurrent write would truncate without the
|
|
546
|
+
// second.
|
|
547
|
+
if (items.length < PAGE_SIZE) break;
|
|
548
|
+
if (typeof page.total === 'number' && out.length >= page.total) break;
|
|
549
|
+
offset += items.length;
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Every live record item, namespace-filtered. */
|
|
555
|
+
async _allRecordItems() {
|
|
556
|
+
const items = await this._listAllItems({
|
|
557
|
+
metadataKey: 'fluid_ns', metadataValue: RECORD_NS,
|
|
558
|
+
});
|
|
559
|
+
return items.filter((it) => it?.metadata?.fluid_ns === RECORD_NS);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// -------------------------------------------------------------------------
|
|
563
|
+
// Handle resolution and duplicate repair (blueprint D-FOH-4)
|
|
564
|
+
// -------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Every live item carrying this handle, in a deterministic total order.
|
|
568
|
+
*
|
|
569
|
+
* Ordered by the SERVER-stamped `metadata.created_at`, ties broken by
|
|
570
|
+
* `item_id`. The server stamps that field itself on every add, so this is one
|
|
571
|
+
* clock rather than each client's — and a racing writer cannot forge an
|
|
572
|
+
* earlier one. The record's own `created_at`, inside the blob, is the
|
|
573
|
+
* record's account of itself and is deliberately NOT used for ordering.
|
|
574
|
+
*/
|
|
575
|
+
async _resolveItems(handle) {
|
|
576
|
+
const items = await this._listAllItems({ metadataKey: 'handle', metadataValue: handle });
|
|
577
|
+
return items
|
|
578
|
+
.filter((it) => it?.metadata?.fluid_ns === RECORD_NS && it?.metadata?.handle === handle)
|
|
579
|
+
.sort((a, b) => {
|
|
580
|
+
const ca = a.metadata?.created_at ?? '';
|
|
581
|
+
const cb = b.metadata?.created_at ?? '';
|
|
582
|
+
if (ca !== cb) return ca < cb ? -1 : 1;
|
|
583
|
+
return String(a.item_id) < String(b.item_id) ? -1 : 1;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Read resolution: deterministic, never writes, never throws on a duplicate.
|
|
589
|
+
*
|
|
590
|
+
* SmartMemory has no uniqueness constraint on a metadata field, so a duplicate
|
|
591
|
+
* is still REPRESENTABLE even though the counter and the lease mean this
|
|
592
|
+
* provider no longer produces one: a workspace written by a pre-SVC-ALLOC-1
|
|
593
|
+
* Compose can already hold a pair, and nothing retroactively repairs history.
|
|
594
|
+
* Throwing here — the earlier design — makes that state PERMANENTLY
|
|
595
|
+
* unreadable, which is strictly worse than the ambiguity it was meant to flag.
|
|
596
|
+
* Earliest wins, loudly.
|
|
597
|
+
*/
|
|
598
|
+
async _resolveOne(handle) {
|
|
599
|
+
const items = await this._resolveItems(handle);
|
|
600
|
+
if (items.length === 0) return null;
|
|
601
|
+
if (items.length > 1) {
|
|
602
|
+
process.emitWarning(
|
|
603
|
+
`fluid(smartmemory): handle ${handle} resolves to ${items.length} live records; ` +
|
|
604
|
+
'using the earliest. The duplicate is repaired on the next write to this handle.',
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
return items[0];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Write resolution: repairs a duplicate before proceeding.
|
|
612
|
+
*
|
|
613
|
+
* Keeps the earliest and reassigns every later item a freshly allocated
|
|
614
|
+
* handle, appending a `reassigned` event so the trail is auditable. Nothing
|
|
615
|
+
* is discarded and no handle is ever reissued — which makes this strictly
|
|
616
|
+
* better than the local floor, where two racing creates collide on one path
|
|
617
|
+
* and one idea is silently lost.
|
|
618
|
+
*/
|
|
619
|
+
async _resolveForWrite(handle) {
|
|
620
|
+
const items = await this._resolveItems(handle);
|
|
621
|
+
if (items.length === 0) return null;
|
|
622
|
+
if (items.length === 1) return items[0];
|
|
623
|
+
|
|
624
|
+
const [keep, ...duplicates] = items;
|
|
625
|
+
for (const dup of duplicates) {
|
|
626
|
+
const record = this._fromItem(dup);
|
|
627
|
+
if (!record) continue;
|
|
628
|
+
const fresh = await this._nextHandle(record.kind);
|
|
629
|
+
const moved = { ...record, handle: fresh, updated_at: nowIso() };
|
|
630
|
+
await this.client.updateItem(dup.item_id, {
|
|
631
|
+
content: this._renderContent(moved),
|
|
632
|
+
metadata: this._toMetadata(moved),
|
|
633
|
+
});
|
|
634
|
+
await this.appendEvent({
|
|
635
|
+
handle: fresh,
|
|
636
|
+
type: 'created',
|
|
637
|
+
at: nowIso(),
|
|
638
|
+
detail: { kind: record.kind, reassigned_from: handle },
|
|
639
|
+
});
|
|
640
|
+
process.emitWarning(
|
|
641
|
+
`fluid(smartmemory): duplicate handle ${handle} repaired — the later record ` +
|
|
642
|
+
`was reassigned ${fresh}`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return keep;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// -------------------------------------------------------------------------
|
|
649
|
+
// Handle allocation
|
|
650
|
+
// -------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
/** Every handle ever issued — live records UNION the append-only event log.
|
|
653
|
+
* Derived from live records alone it would miss a deleted record's handle,
|
|
654
|
+
* and handles are quoted in docs, commits and conversation.
|
|
655
|
+
*
|
|
656
|
+
* This is the expensive read in the file: two full paginated enumerations.
|
|
657
|
+
* It used to run on EVERY create; since COMP-FLUID-SEAM-GUARANTEES it runs only on the
|
|
658
|
+
* explicit-handle path (the import) and once per kind to seed the counter.
|
|
659
|
+
* Keep it that way — anything that puts it back in the automatic path undoes
|
|
660
|
+
* the change. */
|
|
661
|
+
async _issuedHandles() {
|
|
662
|
+
const issued = new Set();
|
|
663
|
+
for (const item of await this._allRecordItems()) {
|
|
664
|
+
const h = item?.metadata?.handle;
|
|
665
|
+
if (h) issued.add(h);
|
|
666
|
+
}
|
|
667
|
+
for (const event of await this.readEvents()) {
|
|
668
|
+
if (event?.handle) issued.add(event.handle);
|
|
669
|
+
}
|
|
670
|
+
return issued;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The highest number already issued under `prefix`. Only ever read to SEED
|
|
674
|
+
* the counter — never to allocate from. See `_sequenceFloor`. */
|
|
675
|
+
async _maxIssued(prefix) {
|
|
676
|
+
let max = 0;
|
|
677
|
+
for (const handle of await this._issuedHandles()) {
|
|
678
|
+
const m = HANDLE_RE.exec(handle);
|
|
679
|
+
if (m && m[1] === prefix) max = Math.max(max, Number(m[2]));
|
|
680
|
+
}
|
|
681
|
+
return max;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The `floor` to send with an allocation, or `undefined` when none is needed.
|
|
686
|
+
*
|
|
687
|
+
* `floor` is a high-water mark applied with `$max` BEFORE the `$inc`, so it can
|
|
688
|
+
* only ever raise the counter — idempotent, and safe to race.
|
|
689
|
+
*
|
|
690
|
+
* It is computed from a full scan EXACTLY ONCE per kind per workspace: on the
|
|
691
|
+
* first touch, when the counter does not exist yet. That case is not
|
|
692
|
+
* hypothetical — a workspace written by a pre-sequence Compose already holds
|
|
693
|
+
* handles the counter has never seen, and a counter starting at 1 would reissue
|
|
694
|
+
* every one of them. Once seeded, the counter is authoritative and the scan
|
|
695
|
+
* never runs again, which is the whole point of the change: allocation drops
|
|
696
|
+
* from two full paginated enumerations (every record UNION every event) to one
|
|
697
|
+
* cheap GET plus one POST.
|
|
698
|
+
*
|
|
699
|
+
* Racing two seeders is harmless: both scan the same store, both send the same
|
|
700
|
+
* `$max`, and the `$inc` still hands them distinct numbers.
|
|
701
|
+
*
|
|
702
|
+
* @param {string} kind
|
|
703
|
+
* @param {number} atLeast raise the counter to at least this, for a handle
|
|
704
|
+
* supplied by the caller rather than allocated
|
|
705
|
+
*/
|
|
706
|
+
async _sequenceFloor(kind, atLeast = 0) {
|
|
707
|
+
const seeded = await this.client.peekSequence(sequenceNameFor(kind)) !== null;
|
|
708
|
+
if (seeded) return atLeast > 0 ? atLeast : undefined;
|
|
709
|
+
return Math.max(await this._maxIssued(HANDLE_PREFIX[kind]), atLeast);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The next handle for `kind`, from the server's monotonic counter.
|
|
714
|
+
*
|
|
715
|
+
* Deliberately NOT wrapped in the mutation lease. `$inc` is atomic per
|
|
716
|
+
* document, so concurrent callers receive distinct numbers with no lock;
|
|
717
|
+
* holding the lease across it would serialize allocations that never needed
|
|
718
|
+
* serializing. The counter is also the reason the old
|
|
719
|
+
* `if (issued.has(candidate)) throw` guard is gone: it defended against a
|
|
720
|
+
* derivation that could repeat itself, and a counter cannot.
|
|
721
|
+
*
|
|
722
|
+
* Numbers allocated but not used are LOST. Gaps are guaranteed by the
|
|
723
|
+
* primitive and cost nothing here — a handle is a citation, not a count.
|
|
724
|
+
*/
|
|
725
|
+
async _nextHandle(kind) {
|
|
726
|
+
const floor = await this._sequenceFloor(kind);
|
|
727
|
+
const { value } = await this.client.allocateSequence(sequenceNameFor(kind), { floor });
|
|
728
|
+
return `${HANDLE_PREFIX[kind]}-${value}`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Raise the counter past a handle the CALLER supplied, so a later allocation
|
|
733
|
+
* cannot hand it out a second time.
|
|
734
|
+
*
|
|
735
|
+
* Passing `n - 1` rather than `n` is what keeps a sequential import gapless:
|
|
736
|
+
* `$max(n-1)` then `$inc` leaves the counter at exactly `n`, so the next
|
|
737
|
+
* automatic handle is `n + 1`. The number this call consumes is discarded —
|
|
738
|
+
* there is no way to raise the floor without consuming one, and one wasted
|
|
739
|
+
* number per explicit handle is a great deal cheaper than a counter that lags
|
|
740
|
+
* a live handle.
|
|
741
|
+
*/
|
|
742
|
+
async _burnHandle(kind, handle) {
|
|
743
|
+
const m = HANDLE_RE.exec(handle);
|
|
744
|
+
if (!m) return;
|
|
745
|
+
const floor = await this._sequenceFloor(kind, Math.max(Number(m[2]) - 1, 0));
|
|
746
|
+
await this.client.allocateSequence(sequenceNameFor(kind), { floor });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async _handleWasIssued(handle) {
|
|
750
|
+
return (await this._issuedHandles()).has(handle);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Was this handle issued, never made live, and never deleted?
|
|
755
|
+
*
|
|
756
|
+
* The seam's `reclaimAborted` predicate, mirroring the floor's
|
|
757
|
+
* `_isAbortedAllocation` deliberately — the narrowness IS the safety. Only a
|
|
758
|
+
* handle stranded between its tombstone and its record qualifies:
|
|
759
|
+
*
|
|
760
|
+
* - a handle whose record EXISTS is in use, and
|
|
761
|
+
* - a handle with a `deleted` event is retired, and retired handles stay
|
|
762
|
+
* retired, because reissuing one repoints every existing citation at a
|
|
763
|
+
* different record.
|
|
764
|
+
*
|
|
765
|
+
* So the only thing this can hand back is a handle that names nothing and
|
|
766
|
+
* never will — which is exactly the state an interrupted import leaves and
|
|
767
|
+
* nothing else produces.
|
|
768
|
+
*/
|
|
769
|
+
async _isAbortedAllocation(handle) {
|
|
770
|
+
if (await this.getRecord(handle)) return false;
|
|
771
|
+
for (const event of await this.readEvents()) {
|
|
772
|
+
if (event?.handle === handle && event.type === 'deleted') return false;
|
|
773
|
+
}
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// -------------------------------------------------------------------------
|
|
778
|
+
// Mutation lease (SVC-LEASE-1) — what makes mutationScope() CLUSTER
|
|
779
|
+
// -------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Run `fn` while holding the store's single mutation lease.
|
|
783
|
+
*
|
|
784
|
+
* Every public mutating method is a thin wrapper over a `*Locked` body for the
|
|
785
|
+
* same reason the floor's are: **this is NOT reentrant.** A locked body that
|
|
786
|
+
* called another public method would ask the server for a lease it already
|
|
787
|
+
* holds, be told `lock_held` by its own other half, and fail after the full
|
|
788
|
+
* backoff. Compose by calling the inner form.
|
|
789
|
+
*
|
|
790
|
+
* `appendEvent` deliberately stays unlocked: it is an append-only write that
|
|
791
|
+
* every locked body performs, and locking it would deadlock all of them.
|
|
792
|
+
*
|
|
793
|
+
* Failure is loud and total. A caller that believes it is serialized and is
|
|
794
|
+
* not loses records without an error — the exact failure this whole mechanism
|
|
795
|
+
* exists to make impossible — so exhausting the backoff throws rather than
|
|
796
|
+
* proceeding unserialized.
|
|
797
|
+
*/
|
|
798
|
+
async _withLease(op, fn) {
|
|
799
|
+
let lease = null;
|
|
800
|
+
for (const wait of LEASE_BACKOFF_MS) {
|
|
801
|
+
if (wait) await sleep(wait);
|
|
802
|
+
lease = await this.client.acquireLock(this.leaseKey, { ttlSeconds: LEASE_TTL_SECONDS });
|
|
803
|
+
if (lease) break;
|
|
804
|
+
}
|
|
805
|
+
if (!lease) {
|
|
806
|
+
throw new Error(
|
|
807
|
+
`fluid(smartmemory): ${op} could not acquire the mutation lease ` +
|
|
808
|
+
`"${this.leaseKey}" after ${LEASE_BACKOFF_MS.length} attempts — another writer ` +
|
|
809
|
+
'is holding it. Nothing was written.',
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Heartbeat rather than a long TTL: a crashed holder must not strand every
|
|
814
|
+
// other writer for the full lease, and renewing is what lets the TTL stay
|
|
815
|
+
// short enough for that while still covering a slow section.
|
|
816
|
+
const heartbeat = setInterval(() => {
|
|
817
|
+
this.client.renewLock(this.leaseKey, lease.token, { ttlSeconds: LEASE_TTL_SECONDS })
|
|
818
|
+
.then((renewed) => {
|
|
819
|
+
if (renewed === null) {
|
|
820
|
+
process.emitWarning(
|
|
821
|
+
`fluid(smartmemory): the mutation lease expired during ${op} and was taken by ` +
|
|
822
|
+
'another writer; this write is no longer serialized',
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
.catch((err) => {
|
|
827
|
+
process.emitWarning(`fluid(smartmemory): lease renewal failed during ${op}: ${err.message}`);
|
|
828
|
+
});
|
|
829
|
+
}, LEASE_RENEW_MS);
|
|
830
|
+
// Never hold the event loop open for a renewal nobody is waiting on.
|
|
831
|
+
heartbeat.unref?.();
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
return await fn();
|
|
835
|
+
} finally {
|
|
836
|
+
clearInterval(heartbeat);
|
|
837
|
+
try {
|
|
838
|
+
await this.client.releaseLock(this.leaseKey, lease.token);
|
|
839
|
+
} catch (err) {
|
|
840
|
+
// Releasing is an optimisation, not a correctness requirement — the TTL
|
|
841
|
+
// reclaims it either way. Warn rather than mask the caller's own error.
|
|
842
|
+
process.emitWarning(
|
|
843
|
+
`fluid(smartmemory): releasing the mutation lease after ${op} failed ` +
|
|
844
|
+
`(${err.message}); it expires within ${LEASE_TTL_SECONDS}s`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// -------------------------------------------------------------------------
|
|
851
|
+
// Records
|
|
852
|
+
// -------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
/** Absence is `null`, matching the floor. Only mutating paths throw. A
|
|
855
|
+
* malformed handle is a miss, not a crash — this is the lookup a caller
|
|
856
|
+
* makes with untrusted input. */
|
|
857
|
+
async getRecord(handle) {
|
|
858
|
+
if (!HANDLE_RE.test(handle ?? '')) return null;
|
|
859
|
+
const item = await this._resolveOne(handle);
|
|
860
|
+
return item ? this._fromItem(item) : null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async listRecords(filter = {}) {
|
|
864
|
+
const items = await this._allRecordItems();
|
|
865
|
+
let records = items.map((it) => this._fromItem(it)).filter(Boolean);
|
|
866
|
+
if (filter.kind) records = records.filter((r) => r.kind === filter.kind);
|
|
867
|
+
if (filter.status) records = records.filter((r) => r.status === filter.status);
|
|
868
|
+
if (filter.cluster !== undefined) records = records.filter((r) => r.cluster === filter.cluster);
|
|
869
|
+
// Same stable order as the floor, so a projection regenerated after a
|
|
870
|
+
// provider swap does not churn.
|
|
871
|
+
return records.sort((a, b) => {
|
|
872
|
+
const ca = a.cluster_order ?? Number.MAX_SAFE_INTEGER;
|
|
873
|
+
const cb = b.cluster_order ?? Number.MAX_SAFE_INTEGER;
|
|
874
|
+
if (ca !== cb) return ca - cb;
|
|
875
|
+
return (Number(HANDLE_RE.exec(a.handle)?.[2] ?? 0)) - (Number(HANDLE_RE.exec(b.handle)?.[2] ?? 0));
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Genuinely atomic since COMP-FLUID-SEAM-GUARANTEES: the lookup and the create happen
|
|
881
|
+
* inside ONE hold of the mutation lease, so a second writer racing the same
|
|
882
|
+
* title waits, then finds the record the first one made instead of creating a
|
|
883
|
+
* twin. Overridden rather than inherited because the base implementation
|
|
884
|
+
* composes on the PUBLIC `createRecord`, which would try to re-acquire.
|
|
885
|
+
*/
|
|
886
|
+
async findOrCreateRecord({ kind, title }, input = {}) {
|
|
887
|
+
return this._withLease('findOrCreateRecord', async () => {
|
|
888
|
+
const matches = (await this.listRecords({ kind }))
|
|
889
|
+
.filter((r) => r.title.toLowerCase() === String(title).toLowerCase());
|
|
890
|
+
if (matches.length > 1) {
|
|
891
|
+
throw new FluidAmbiguousMatch(kind, title, matches.map((m) => m.handle));
|
|
892
|
+
}
|
|
893
|
+
if (matches.length === 1) return { record: matches[0], created: false };
|
|
894
|
+
return { record: await this._createRecordLocked({ ...input, kind, title }), created: true };
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async createRecord(input) {
|
|
899
|
+
return this._withLease('createRecord', () => this._createRecordLocked(input));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async _createRecordLocked(input) {
|
|
903
|
+
const kind = input.kind ?? KIND.IDEA;
|
|
904
|
+
this.requireKind(kind);
|
|
905
|
+
if (!input.title) throw new Error('fluid: createRecord requires a title');
|
|
906
|
+
|
|
907
|
+
let handle;
|
|
908
|
+
if (input.handle === undefined) {
|
|
909
|
+
handle = await this._nextHandle(kind);
|
|
910
|
+
} else {
|
|
911
|
+
handle = assertHandle(input.handle, kind);
|
|
912
|
+
// Checked against every handle EVER issued, not just the live ones. A
|
|
913
|
+
// retired handle is still spoken for: reissuing it would repoint existing
|
|
914
|
+
// citations at a different record.
|
|
915
|
+
//
|
|
916
|
+
// `reclaimAborted` is honoured here with the floor's exact semantics
|
|
917
|
+
// (COMP-FLUID-SEAM-GUARANTEES). It was ignored until now, and the guard
|
|
918
|
+
// below threw unconditionally — which made the one-time import
|
|
919
|
+
// NON-RESTARTABLE against this provider. The window it has to survive is
|
|
920
|
+
// far wider here than on the floor: creation burns the handle in one
|
|
921
|
+
// network call and writes the record in the next, so any blip between them
|
|
922
|
+
// stranded that handle permanently and `ensureIdeaboxMigrated`'s resume
|
|
923
|
+
// path then failed forever, since `importIdeabox` passes `reclaimAborted`
|
|
924
|
+
// and this provider dropped it.
|
|
925
|
+
//
|
|
926
|
+
// Needs no server primitive — it is a question about this provider's own
|
|
927
|
+
// event log and record set, both already readable, which is why it was
|
|
928
|
+
// fixed before SVC-LEASE-1 landed rather than waiting on it. The counter
|
|
929
|
+
// does not subsume this check and never will: it knows the numbers it has
|
|
930
|
+
// handed out, not which of them were retired.
|
|
931
|
+
if (await this._handleWasIssued(handle)
|
|
932
|
+
&& !(input.reclaimAborted && await this._isAbortedAllocation(handle))) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`fluid: handle ${handle} has already been issued and cannot be reused ` +
|
|
935
|
+
`(handles are external citations; retired ones stay retired)`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
// A caller-supplied handle bypasses the counter, so the counter has to be
|
|
939
|
+
// told about it — otherwise a later automatic allocation could hand out
|
|
940
|
+
// the same number. Done BEFORE anything is written, so a failure here
|
|
941
|
+
// leaves no record whose handle the counter does not know.
|
|
942
|
+
await this._burnHandle(kind, handle);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
for (const link of input.links ?? []) assertLink(link);
|
|
946
|
+
const status = assertStatus(input.status ?? 'new');
|
|
947
|
+
const now = nowIso();
|
|
948
|
+
const record = normalizeRecord({
|
|
949
|
+
handle,
|
|
950
|
+
kind,
|
|
951
|
+
title: input.title,
|
|
952
|
+
body: input.body ?? '',
|
|
953
|
+
status,
|
|
954
|
+
status_label: input.status_label ?? null,
|
|
955
|
+
priority: input.priority ?? null,
|
|
956
|
+
effort: input.effort ?? null,
|
|
957
|
+
impact: input.impact ?? null,
|
|
958
|
+
cluster: input.cluster ?? null,
|
|
959
|
+
cluster_order: input.cluster_order ?? null,
|
|
960
|
+
tags: input.tags ?? [],
|
|
961
|
+
source: input.source ?? null,
|
|
962
|
+
links: input.links ?? [],
|
|
963
|
+
killed: input.killed ?? null,
|
|
964
|
+
discussion: input.discussion ?? [],
|
|
965
|
+
provenance: {
|
|
966
|
+
origin: input.provenance?.origin ?? 'cli:ideabox',
|
|
967
|
+
recorded_at: input.provenance?.recorded_at ?? now,
|
|
968
|
+
author: input.provenance?.author ?? null,
|
|
969
|
+
},
|
|
970
|
+
// Provider-assigned and provider-scoped, per the contract: `id` changes
|
|
971
|
+
// when a record moves providers, while `handle` survives the swap.
|
|
972
|
+
// Deliberately NOT SmartMemory's item_id, which does not exist until the
|
|
973
|
+
// write returns — the contract requires `id` on the object being written.
|
|
974
|
+
id: randomUUID(),
|
|
975
|
+
created_at: now,
|
|
976
|
+
updated_at: now,
|
|
977
|
+
});
|
|
978
|
+
assertValid('record', record, 'record');
|
|
979
|
+
|
|
980
|
+
// The tombstone is written BEFORE the record exists. Persisting first and
|
|
981
|
+
// appending after means a failed append leaves a discoverable record whose
|
|
982
|
+
// handle was never burned — delete it and the handle is reissued, defeating
|
|
983
|
+
// the invariant. Burning first can at worst waste a handle, and a wasted
|
|
984
|
+
// handle is free while a reissued one is unrecoverable.
|
|
985
|
+
await this.appendEvent({
|
|
986
|
+
handle,
|
|
987
|
+
type: input.provenance?.origin === 'import:ideabox' ? 'imported' : 'created',
|
|
988
|
+
at: now,
|
|
989
|
+
detail: { kind },
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
await this.client.createItem({
|
|
993
|
+
content: this._renderContent(record),
|
|
994
|
+
memoryType: wireTypeFor(kind),
|
|
995
|
+
metadata: this._toMetadata(record),
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
return record;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async updateRecord(handle, patch) {
|
|
1002
|
+
return this._withLease('updateRecord', () => this._updateRecordLocked(handle, patch));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async _updateRecordLocked(handle, patch) {
|
|
1006
|
+
const item = await this._resolveForWrite(handle);
|
|
1007
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1008
|
+
|
|
1009
|
+
assertPatchable(patch, this.name());
|
|
1010
|
+
|
|
1011
|
+
const current = this._fromItem(item);
|
|
1012
|
+
if (!current) throw new FluidRecordNotFound(handle, this.name());
|
|
1013
|
+
|
|
1014
|
+
const merged = {
|
|
1015
|
+
...current,
|
|
1016
|
+
...patch,
|
|
1017
|
+
...Object.fromEntries(UNPATCHABLE.map((f) => [f, current[f]])),
|
|
1018
|
+
updated_at: nowIso(),
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
// Validate the RAW merge, then normalize — order is load-bearing.
|
|
1022
|
+
// Normalizing first would hand the schema an already-sanitized object and
|
|
1023
|
+
// the schema would approve what the sanitizer had quietly repaired.
|
|
1024
|
+
assertStatus(merged.status);
|
|
1025
|
+
assertValid('record', merged, 'record');
|
|
1026
|
+
for (const link of merged.links) assertLink(link);
|
|
1027
|
+
|
|
1028
|
+
const next = normalizeRecord(merged);
|
|
1029
|
+
const eventType = eventTypeForUpdate(current, next, patch);
|
|
1030
|
+
|
|
1031
|
+
// One metadata key carrying the whole record. The server's merge is a
|
|
1032
|
+
// one-level spread, so this replaces the record wholesale while leaving
|
|
1033
|
+
// every server-owned metadata key untouched. `properties` is deliberately
|
|
1034
|
+
// not used: it bypasses the merge and is the mass-assignment surface the
|
|
1035
|
+
// server's protected fields exist to guard.
|
|
1036
|
+
await this.client.updateItem(item.item_id, {
|
|
1037
|
+
content: this._renderContent(next),
|
|
1038
|
+
metadata: this._toMetadata(next),
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
await this.appendEvent({
|
|
1042
|
+
handle,
|
|
1043
|
+
type: eventType,
|
|
1044
|
+
at: nowIso(),
|
|
1045
|
+
detail: { fields: Object.keys(patch) },
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
return next;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** The only way discussion grows. Append-only in the API, not merely by
|
|
1052
|
+
* convention in the contract — a deliberation trail that can be rewritten is
|
|
1053
|
+
* not evidence. */
|
|
1054
|
+
async appendDiscussion(handle, entry) {
|
|
1055
|
+
return this._withLease('appendDiscussion', () => this._appendDiscussionLocked(handle, entry));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async _appendDiscussionLocked(handle, entry) {
|
|
1059
|
+
const item = await this._resolveForWrite(handle);
|
|
1060
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1061
|
+
if (!entry?.text) throw new Error('fluid: a discussion entry requires text');
|
|
1062
|
+
|
|
1063
|
+
const record = this._fromItem(item);
|
|
1064
|
+
if (!record) throw new FluidRecordNotFound(handle, this.name());
|
|
1065
|
+
|
|
1066
|
+
record.discussion.push({
|
|
1067
|
+
at: entry.at ?? nowIso(),
|
|
1068
|
+
text: entry.text,
|
|
1069
|
+
author: entry.author ?? null,
|
|
1070
|
+
});
|
|
1071
|
+
record.updated_at = nowIso();
|
|
1072
|
+
assertValid('record', record, 'record');
|
|
1073
|
+
|
|
1074
|
+
await this.client.updateItem(item.item_id, {
|
|
1075
|
+
content: this._renderContent(record),
|
|
1076
|
+
metadata: this._toMetadata(record),
|
|
1077
|
+
});
|
|
1078
|
+
await this.appendEvent({ handle, type: 'discussed', at: nowIso(), detail: {} });
|
|
1079
|
+
return record;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Hard delete. NOT the lifecycle path — killing an idea is
|
|
1084
|
+
* `updateRecord(handle, {status: 'killed'})`, which keeps the record and its
|
|
1085
|
+
* reasoning.
|
|
1086
|
+
*
|
|
1087
|
+
* The discussion is carried into the append-only log before the record goes,
|
|
1088
|
+
* so "append-only" is not true of every path except the one that erases it.
|
|
1089
|
+
*/
|
|
1090
|
+
async deleteRecord(handle) {
|
|
1091
|
+
return this._withLease('deleteRecord', () => this._deleteRecordLocked(handle));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async _deleteRecordLocked(handle) {
|
|
1095
|
+
const item = await this._resolveForWrite(handle);
|
|
1096
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1097
|
+
const record = this._fromItem(item);
|
|
1098
|
+
|
|
1099
|
+
await this.appendEvent({
|
|
1100
|
+
handle,
|
|
1101
|
+
type: 'deleted',
|
|
1102
|
+
at: nowIso(),
|
|
1103
|
+
detail: {
|
|
1104
|
+
kind: record?.kind,
|
|
1105
|
+
title: record?.title,
|
|
1106
|
+
discussion: record?.discussion ?? [],
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
await this.client.deleteItem(item.item_id);
|
|
1111
|
+
return { ok: true };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// -------------------------------------------------------------------------
|
|
1115
|
+
// Links
|
|
1116
|
+
// -------------------------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
async addLink(handle, link) {
|
|
1119
|
+
return this._withLease('addLink', () => this._addLinkLocked(handle, link));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async _addLinkLocked(handle, link) {
|
|
1123
|
+
assertLink(link);
|
|
1124
|
+
const item = await this._resolveForWrite(handle);
|
|
1125
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1126
|
+
const record = this._fromItem(item);
|
|
1127
|
+
if (!record) throw new FluidRecordNotFound(handle, this.name());
|
|
1128
|
+
|
|
1129
|
+
const exists = record.links.some((l) => l.type === link.type && l.target === link.target);
|
|
1130
|
+
// Idempotent, and deliberately emits no event on a repeat: a `linked` event
|
|
1131
|
+
// per repeat would inflate the lifecycle history a later semantic layer
|
|
1132
|
+
// reads as signal.
|
|
1133
|
+
if (!exists) {
|
|
1134
|
+
record.links.push({ ...link });
|
|
1135
|
+
record.updated_at = nowIso();
|
|
1136
|
+
await this.client.updateItem(item.item_id, {
|
|
1137
|
+
content: this._renderContent(record),
|
|
1138
|
+
metadata: this._toMetadata(record),
|
|
1139
|
+
});
|
|
1140
|
+
await this.appendEvent({ handle, type: 'linked', at: nowIso(), detail: { ...link } });
|
|
1141
|
+
}
|
|
1142
|
+
return record;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async removeLink(handle, link) {
|
|
1146
|
+
return this._withLease('removeLink', () => this._removeLinkLocked(handle, link));
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async _removeLinkLocked(handle, link) {
|
|
1150
|
+
const item = await this._resolveForWrite(handle);
|
|
1151
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1152
|
+
const record = this._fromItem(item);
|
|
1153
|
+
if (!record) throw new FluidRecordNotFound(handle, this.name());
|
|
1154
|
+
|
|
1155
|
+
const before = record.links.length;
|
|
1156
|
+
record.links = record.links.filter((l) => !(l.type === link.type && l.target === link.target));
|
|
1157
|
+
if (record.links.length !== before) {
|
|
1158
|
+
record.updated_at = nowIso();
|
|
1159
|
+
await this.client.updateItem(item.item_id, {
|
|
1160
|
+
content: this._renderContent(record),
|
|
1161
|
+
metadata: this._toMetadata(record),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return record;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// -------------------------------------------------------------------------
|
|
1168
|
+
// Recall (CAP.RECALL)
|
|
1169
|
+
// -------------------------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Semantic recall over fluid records.
|
|
1173
|
+
*
|
|
1174
|
+
* One unfiltered, over-fetched query, then filtered client-side to this
|
|
1175
|
+
* provider's namespace and to `RECALLABLE_KINDS`. Filtering the OUTPUT rather
|
|
1176
|
+
* than scoping the REQUEST is deliberate: `memory_type` on the wire takes a
|
|
1177
|
+
* single value, so covering three recallable kinds would need three queries,
|
|
1178
|
+
* and scores from separate searches are not comparable — merging them would
|
|
1179
|
+
* fabricate a ranking.
|
|
1180
|
+
*
|
|
1181
|
+
* DISCLOSED BOUND: because filtering happens after ranking, recall can return
|
|
1182
|
+
* fewer than `limit` results even when more matching records exist, if enough
|
|
1183
|
+
* higher-scoring non-recallable items fill the over-fetch. The alternative is
|
|
1184
|
+
* unbounded fetching. Asserted in the tests so a future change is deliberate.
|
|
1185
|
+
*
|
|
1186
|
+
* DISCLOSED BOUND: an edited record keeps its original embedding, because
|
|
1187
|
+
* PATCH does not reindex and no per-item reindex exists over HTTP
|
|
1188
|
+
* (smart-memory-core#4). The record returned here is CURRENT — search hydrates
|
|
1189
|
+
* from the live graph node — so what lags is why a record matched and where it
|
|
1190
|
+
* ranked, not what you receive. The lexical channels still see current text.
|
|
1191
|
+
*
|
|
1192
|
+
* @param {string} query
|
|
1193
|
+
* @param {{limit?: number}} [opts]
|
|
1194
|
+
* @returns {Promise<import('./provider.js').RecallHit[]>}
|
|
1195
|
+
*/
|
|
1196
|
+
async recall(query, opts = {}) {
|
|
1197
|
+
this.require(CAP.RECALL);
|
|
1198
|
+
|
|
1199
|
+
const limit = normalizeRecallLimit(opts?.limit);
|
|
1200
|
+
const topK = Math.min(Math.max(limit * OVERFETCH, MIN_FETCH), TOP_K_CAP);
|
|
1201
|
+
|
|
1202
|
+
const raw = await this.client.searchItems(query, { topK });
|
|
1203
|
+
|
|
1204
|
+
const hits = [];
|
|
1205
|
+
const seen = new Map();
|
|
1206
|
+
for (const result of raw?.results ?? []) {
|
|
1207
|
+
const meta = result?.metadata ?? {};
|
|
1208
|
+
// Namespace first: a shared workspace holds items that are not ours at
|
|
1209
|
+
// all, and they must never reach a caller.
|
|
1210
|
+
if (meta.fluid_ns !== RECORD_NS) continue;
|
|
1211
|
+
|
|
1212
|
+
const record = this._fromItem(result);
|
|
1213
|
+
// `_fromItem` warns and returns null on an unreadable blob. One corrupt
|
|
1214
|
+
// row must degrade to "that record is not in these results", never to a
|
|
1215
|
+
// failed recall.
|
|
1216
|
+
if (!record) continue;
|
|
1217
|
+
|
|
1218
|
+
// THE ENFORCEMENT. Holds with the deployment's embedding config unset,
|
|
1219
|
+
// which is exactly the case the config dial cannot defend.
|
|
1220
|
+
if (!RECALLABLE_KINDS.has(record.kind)) continue;
|
|
1221
|
+
|
|
1222
|
+
const score = typeof result?.score === 'number' ? result.score : null;
|
|
1223
|
+
const createdAt = meta.created_at ?? '';
|
|
1224
|
+
const itemId = String(result?.item_id ?? '');
|
|
1225
|
+
|
|
1226
|
+
// Collapse duplicate handles using D-FOH-4's full order — earliest
|
|
1227
|
+
// server-stamped created_at, ties broken by item_id. Dropping the second
|
|
1228
|
+
// key would leave same-instant duplicates resolving nondeterministically,
|
|
1229
|
+
// and disagreeing with getRecord(). Both keys are already on the hit, so
|
|
1230
|
+
// this costs no extra request.
|
|
1231
|
+
const existing = seen.get(record.handle);
|
|
1232
|
+
if (existing) {
|
|
1233
|
+
const isEarlier = createdAt !== existing.createdAt
|
|
1234
|
+
? createdAt < existing.createdAt
|
|
1235
|
+
: itemId < existing.itemId;
|
|
1236
|
+
if (isEarlier) {
|
|
1237
|
+
existing.hit.record = record;
|
|
1238
|
+
existing.createdAt = createdAt;
|
|
1239
|
+
existing.itemId = itemId;
|
|
1240
|
+
}
|
|
1241
|
+
// The surviving hit keeps its ORIGINAL rank and score: the duplicate is
|
|
1242
|
+
// a storage artifact, and letting it reshuffle the ranking would leak
|
|
1243
|
+
// that artifact into the answer.
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const hit = { handle: record.handle, score, record };
|
|
1248
|
+
seen.set(record.handle, { hit, createdAt, itemId });
|
|
1249
|
+
hits.push(hit);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Server order is the ranking. Filtering removes entries; it never reorders
|
|
1253
|
+
// the survivors, and nothing here recomputes a score.
|
|
1254
|
+
return hits.slice(0, limit);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// -------------------------------------------------------------------------
|
|
1258
|
+
// Challenge (CAP.CHALLENGE)
|
|
1259
|
+
// -------------------------------------------------------------------------
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Same-kind contradiction detection (FOH-3).
|
|
1263
|
+
*
|
|
1264
|
+
* A record is challenged against OTHERS OF ITS OWN KIND, because the service's
|
|
1265
|
+
* `memory_type` is an exact filter with no wildcard (`search.py:123`): one call
|
|
1266
|
+
* covers one kind, and we send the record's exact wire type. Only `decision`
|
|
1267
|
+
* and `idea` are challengeable — the endpoint runs its cascade directly with no
|
|
1268
|
+
* `should_challenge` gate, so a non-assertional kind is refused, not fed to it.
|
|
1269
|
+
*
|
|
1270
|
+
* Two filters compose. The SERVER filters candidates to the requested type;
|
|
1271
|
+
* THIS method then drops anything that is not one of ours (namespace) or is the
|
|
1272
|
+
* challenged record itself, exactly as recall does — a shared workspace holds
|
|
1273
|
+
* items that must never reach a caller. Because that second filter changes the
|
|
1274
|
+
* conflict set, `hasConflicts` and `confidence` are recomputed from what
|
|
1275
|
+
* survives, never the service's pre-filter aggregates (which would let a result
|
|
1276
|
+
* claim conflicts it then shows none of).
|
|
1277
|
+
*
|
|
1278
|
+
* @param {string} handle
|
|
1279
|
+
* @param {{useLlm?: boolean, timeoutMs?: number}} [opts]
|
|
1280
|
+
* @returns {Promise<import('./provider.js').ChallengeResult>}
|
|
1281
|
+
*/
|
|
1282
|
+
async challenge(handle, opts = {}) {
|
|
1283
|
+
this.require(CAP.CHALLENGE);
|
|
1284
|
+
|
|
1285
|
+
const record = await this.getRecord(handle);
|
|
1286
|
+
if (!record) throw new FluidRecordNotFound(handle, this.name());
|
|
1287
|
+
if (!CHALLENGEABLE_KINDS.has(record.kind)) {
|
|
1288
|
+
throw new FluidKindUnsupported(record.kind, this.name(), [...CHALLENGEABLE_KINDS]);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const assertion = this._renderContent(record);
|
|
1292
|
+
const raw = await this.client.challenge(assertion, {
|
|
1293
|
+
memoryType: wireTypeFor(record.kind), // exact fluid_<kind> — the crux
|
|
1294
|
+
useLlm: opts.useLlm ?? true,
|
|
1295
|
+
timeoutMs: opts.timeoutMs ?? CHALLENGE_TIMEOUT_MS,
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
const conflicts = [];
|
|
1299
|
+
for (const c of raw?.conflicts ?? []) {
|
|
1300
|
+
const item = await this.client.getItem(String(c?.existing_item_id ?? ''));
|
|
1301
|
+
const meta = item?.metadata ?? {};
|
|
1302
|
+
if (meta.fluid_ns !== RECORD_NS) continue; // not ours → drop (D1a)
|
|
1303
|
+
const conflictHandle = meta.handle;
|
|
1304
|
+
if (!conflictHandle || conflictHandle === record.handle) continue; // self → drop (D1b)
|
|
1305
|
+
conflicts.push({
|
|
1306
|
+
handle: conflictHandle,
|
|
1307
|
+
existingText: c.existing_fact ?? '',
|
|
1308
|
+
conflictType: c.conflict_type ?? '',
|
|
1309
|
+
confidence: typeof c.confidence === 'number' ? c.confidence : 0,
|
|
1310
|
+
explanation: c.explanation ?? '',
|
|
1311
|
+
suggestedResolution: c.suggested_resolution ?? '',
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
conflicts.sort((a, b) => b.confidence - a.confidence); // best-first
|
|
1315
|
+
|
|
1316
|
+
// D1c: aggregates from the RETAINED set. The service's confidence formula,
|
|
1317
|
+
// recomputed over what the caller actually receives (challenger.py:256-262).
|
|
1318
|
+
const hasConflicts = conflicts.length > 0;
|
|
1319
|
+
const confidence = hasConflicts
|
|
1320
|
+
? Math.max(0, 1 - (conflicts.reduce((s, c) => s + c.confidence, 0) / conflicts.length) * 0.5)
|
|
1321
|
+
: 1.0;
|
|
1322
|
+
|
|
1323
|
+
return { assertion, hasConflicts, confidence, conflicts };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// -------------------------------------------------------------------------
|
|
1327
|
+
// Conviction (CAP.CONVICTION) — COMP-FOH FOH-4
|
|
1328
|
+
// -------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
/** One `/confidence-history` envelope → the seam's ConvictionResult. Used by
|
|
1331
|
+
* the read AND by a landed resolution, so both return the same shape from
|
|
1332
|
+
* the same source. `challenged`/`lastChallengedAt` are DERIVED — the
|
|
1333
|
+
* envelope is the read path's single call, no `getItem` behind it. */
|
|
1334
|
+
_toConvictionResult(handle, raw) {
|
|
1335
|
+
const history = raw.history.map((e) => ({
|
|
1336
|
+
timestamp: e.timestamp,
|
|
1337
|
+
oldConfidence: e.old_confidence,
|
|
1338
|
+
newConfidence: e.new_confidence,
|
|
1339
|
+
decayFactor: e.decay_factor,
|
|
1340
|
+
reason: e.reason,
|
|
1341
|
+
...(e.conflicting_fact !== undefined ? { conflictingFact: e.conflicting_fact } : {}),
|
|
1342
|
+
}));
|
|
1343
|
+
return {
|
|
1344
|
+
handle,
|
|
1345
|
+
confidence: raw.current_confidence,
|
|
1346
|
+
challenged: raw.challenge_count > 0,
|
|
1347
|
+
challengeCount: raw.challenge_count,
|
|
1348
|
+
lastChallengedAt: history.length ? history[history.length - 1].timestamp : null,
|
|
1349
|
+
history,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Belief-strength read. A never-resolved record honestly returns
|
|
1355
|
+
* `{confidence: 1.0, challenged: false, history: []}` — nothing has moved it.
|
|
1356
|
+
*
|
|
1357
|
+
* @param {string} handle
|
|
1358
|
+
* @returns {Promise<import('./provider.js').ConvictionResult>}
|
|
1359
|
+
*/
|
|
1360
|
+
async conviction(handle) {
|
|
1361
|
+
this.require(CAP.CONVICTION);
|
|
1362
|
+
if (!HANDLE_RE.test(handle ?? '')) throw new FluidRecordNotFound(handle, this.name());
|
|
1363
|
+
const item = await this._resolveOne(handle);
|
|
1364
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1365
|
+
let raw;
|
|
1366
|
+
try {
|
|
1367
|
+
raw = await this.client.confidenceHistory(String(item.item_id));
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
// The item vanished between resolution and read (a race with a delete).
|
|
1370
|
+
if (err?.status === 404) throw new FluidRecordNotFound(handle, this.name());
|
|
1371
|
+
throw err;
|
|
1372
|
+
}
|
|
1373
|
+
return this._toConvictionResult(handle, raw);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Gated resolution: decay `targetHandle`'s confidence because `sourceHandle`
|
|
1378
|
+
* supersedes it. See the base class JSDoc for the caller-facing contract
|
|
1379
|
+
* (explicit strategy, near-irreversible, only NoOp retryable, trust
|
|
1380
|
+
* boundary); what lives HERE is the safety machinery:
|
|
1381
|
+
*
|
|
1382
|
+
* - Every authorization invariant is enforced at this seam — the server's
|
|
1383
|
+
* `/resolve` does no re-detection and would decay whatever id it is
|
|
1384
|
+
* handed. Handles resolve via the READ-ONLY `_resolveOne` (the write
|
|
1385
|
+
* resolver repairs duplicates as a side effect, which must not run
|
|
1386
|
+
* before authorization).
|
|
1387
|
+
* - The contradicting fact is derived from the SOURCE record's own stored
|
|
1388
|
+
* content, exactly as `challenge()` derives its assertion. No caller
|
|
1389
|
+
* text reaches the wire.
|
|
1390
|
+
* - The whole sequence runs inside the workspace lease, which serializes
|
|
1391
|
+
* Compose-side mutations. It does NOT serialize direct SmartMemory
|
|
1392
|
+
* callers — which is why the postcondition has a 'conflict' verdict.
|
|
1393
|
+
* - The `/resolve` response is never trusted. The outcome is classified
|
|
1394
|
+
* from confidence-history reads bracketing the call
|
|
1395
|
+
* ({@link classifyResolution}); an ambiguous transport failure (abort,
|
|
1396
|
+
* malformed 2xx, gateway 5xx — none of which prove the server's
|
|
1397
|
+
* synchronous mutation stopped) goes through a bounded read-only
|
|
1398
|
+
* reconciliation poll and, failing a positive match, surfaces as
|
|
1399
|
+
* Indeterminate rather than inviting a double-decaying retry.
|
|
1400
|
+
*
|
|
1401
|
+
* @param {string} sourceHandle
|
|
1402
|
+
* @param {string} targetHandle
|
|
1403
|
+
* @param {{strategy: string, timeoutMs?: number}} [opts]
|
|
1404
|
+
* @returns {Promise<import('./provider.js').ConvictionResult>}
|
|
1405
|
+
*/
|
|
1406
|
+
async resolveConflict(sourceHandle, targetHandle, opts = {}) {
|
|
1407
|
+
this.require(CAP.CONVICTION);
|
|
1408
|
+
const strategy = opts.strategy;
|
|
1409
|
+
if (!CONVICTION_STRATEGIES.includes(strategy)) {
|
|
1410
|
+
throw new FluidInvalidStrategy(strategy, [...CONVICTION_STRATEGIES]);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// The lease serializes the DECAY. The best-effort contradiction link (FOH-5)
|
|
1414
|
+
// is deliberately done AFTER the lease releases: the edge write is idempotent
|
|
1415
|
+
// (MERGE by identity) and touches no confidence, so holding the mutation lease
|
|
1416
|
+
// across its network retries would only serialize unrelated callers for no
|
|
1417
|
+
// safety gain. The closure therefore returns {result, link}: `link` is set on
|
|
1418
|
+
// EITHER landed exit (clean or reconciled), null on every throw.
|
|
1419
|
+
const { result, link } = await this._withLease('resolveConflict', async () => {
|
|
1420
|
+
const sourceItem = await this._resolveOne(sourceHandle);
|
|
1421
|
+
if (!sourceItem) throw new FluidRecordNotFound(sourceHandle, this.name());
|
|
1422
|
+
const targetItem = await this._resolveOne(targetHandle);
|
|
1423
|
+
if (!targetItem) throw new FluidRecordNotFound(targetHandle, this.name());
|
|
1424
|
+
// _fromItem is null for a corrupt blob even on a namespaced item — refuse
|
|
1425
|
+
// typed, before touching `.kind`.
|
|
1426
|
+
const sourceRec = this._fromItem(sourceItem);
|
|
1427
|
+
if (!sourceRec) throw new FluidRecordNotFound(sourceHandle, this.name());
|
|
1428
|
+
const targetRec = this._fromItem(targetItem);
|
|
1429
|
+
if (!targetRec) throw new FluidRecordNotFound(targetHandle, this.name());
|
|
1430
|
+
if (!CHALLENGEABLE_KINDS.has(sourceRec.kind)) {
|
|
1431
|
+
throw new FluidKindUnsupported(sourceRec.kind, this.name(), [...CHALLENGEABLE_KINDS]);
|
|
1432
|
+
}
|
|
1433
|
+
if (targetRec.kind !== sourceRec.kind) {
|
|
1434
|
+
throw new FluidKindUnsupported(targetRec.kind, this.name(), [sourceRec.kind]);
|
|
1435
|
+
}
|
|
1436
|
+
if (String(sourceItem.item_id) === String(targetItem.item_id)) {
|
|
1437
|
+
throw new FluidInvalidTarget(targetHandle, 'self-target');
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const newFact = this._renderContent(sourceRec);
|
|
1441
|
+
const expectedFact = codePointSlice(newFact, 200);
|
|
1442
|
+
const targetId = String(targetItem.item_id);
|
|
1443
|
+
let pre;
|
|
1444
|
+
try {
|
|
1445
|
+
pre = await this.client.confidenceHistory(targetId);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
// The target vanished between resolution and the pre-read (a direct
|
|
1448
|
+
// caller's delete) — the same race conviction() maps. Nothing mutated.
|
|
1449
|
+
if (err?.status === 404) throw new FluidRecordNotFound(targetHandle, this.name());
|
|
1450
|
+
throw err;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
try {
|
|
1454
|
+
await this.client.resolveConflict({
|
|
1455
|
+
existingItemId: targetId,
|
|
1456
|
+
newFact,
|
|
1457
|
+
strategy,
|
|
1458
|
+
timeoutMs: opts.timeoutMs ?? RESOLVE_TIMEOUT_MS,
|
|
1459
|
+
});
|
|
1460
|
+
} catch (err) {
|
|
1461
|
+
const status = typeof err?.status === 'number' ? err.status : 0;
|
|
1462
|
+
const ambiguous = status === 0 || err?.kind === 'malformed-response' || status >= 500;
|
|
1463
|
+
if (!ambiguous) {
|
|
1464
|
+
// A real 4xx: rejected before the mutation. Nothing to reconcile.
|
|
1465
|
+
if (status === 404) throw new FluidRecordNotFound(targetHandle, this.name());
|
|
1466
|
+
throw err;
|
|
1467
|
+
}
|
|
1468
|
+
// Returns only on a landed decay (else throws) — so a value here means
|
|
1469
|
+
// the decay landed and the contradiction link is owed.
|
|
1470
|
+
return {
|
|
1471
|
+
result: await this._reconcileResolution(targetHandle, targetId, pre, expectedFact),
|
|
1472
|
+
link: { sourceId: String(sourceItem.item_id), targetId },
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Clean response: the handler finished, so one immediate re-read is
|
|
1477
|
+
// authoritative. If even that read fails, fall back to the poll — a
|
|
1478
|
+
// reconciliation-read failure must not surface as its own error.
|
|
1479
|
+
let post;
|
|
1480
|
+
try {
|
|
1481
|
+
post = await this.client.confidenceHistory(targetId);
|
|
1482
|
+
} catch {
|
|
1483
|
+
// Returns only on a landed decay (else throws) — so a value here means
|
|
1484
|
+
// the decay landed and the contradiction link is owed.
|
|
1485
|
+
return {
|
|
1486
|
+
result: await this._reconcileResolution(targetHandle, targetId, pre, expectedFact),
|
|
1487
|
+
link: { sourceId: String(sourceItem.item_id), targetId },
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
const verdict = classifyResolution(pre, post, expectedFact);
|
|
1491
|
+
if (verdict === 'landed') {
|
|
1492
|
+
return {
|
|
1493
|
+
result: this._toConvictionResult(targetHandle, post),
|
|
1494
|
+
link: { sourceId: String(sourceItem.item_id), targetId },
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
if (verdict === 'no-op') throw new FluidResolutionNoOp(targetHandle);
|
|
1498
|
+
if (verdict === 'conflict') throw new FluidResolutionConflict(targetHandle);
|
|
1499
|
+
throw new FluidResolutionIndeterminate(targetHandle);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
// Post-success epilogue (FOH-5), OUTSIDE the lease. The decay is already
|
|
1503
|
+
// durable and authoritative; the CONTRADICTS edge is a best-effort enhancement
|
|
1504
|
+
// so `contradictions()` can resolve this decay back to its source. A failed
|
|
1505
|
+
// link never fails the resolution — it degrades `contradictions()` to a lower
|
|
1506
|
+
// bound, which its contract already permits.
|
|
1507
|
+
if (link) await this._linkContradiction(link.sourceId, link.targetId);
|
|
1508
|
+
return result;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Best-effort durable link: source CONTRADICTS target, written after the decay
|
|
1513
|
+
* landed. Bounded retry, because the edge write is safe to repeat (idempotent
|
|
1514
|
+
* MERGE by identity, no confidence touched) — unlike the resolve it follows.
|
|
1515
|
+
* On persistent failure it WARNS and returns; it never throws, because the
|
|
1516
|
+
* resolution it belongs to already succeeded and the caller must not be handed
|
|
1517
|
+
* a failure for a durability detail.
|
|
1518
|
+
*/
|
|
1519
|
+
async _linkContradiction(sourceId, targetId) {
|
|
1520
|
+
for (let attempt = 0; attempt < LINK_RETRY_ATTEMPTS; attempt += 1) {
|
|
1521
|
+
try {
|
|
1522
|
+
await this.client.addEdge({
|
|
1523
|
+
sourceId,
|
|
1524
|
+
targetId,
|
|
1525
|
+
relationType: CONTRADICTS_EDGE,
|
|
1526
|
+
properties: { origin: 'fluid:resolveConflict' },
|
|
1527
|
+
});
|
|
1528
|
+
return;
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
if (attempt + 1 < LINK_RETRY_ATTEMPTS) {
|
|
1531
|
+
await sleep(LINK_RETRY_DELAY_MS);
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
process.emitWarning(
|
|
1535
|
+
`fluid(smartmemory): decayed ${targetId} but could not link its contradiction from `
|
|
1536
|
+
+ `${sourceId} after ${LINK_RETRY_ATTEMPTS} attempts (${err?.message ?? err}); `
|
|
1537
|
+
+ 'contradictions() will under-report this pair until re-linked',
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Bounded read-only poll after an ambiguous outcome. 'no-op' is deliberately
|
|
1545
|
+
* NOT trusted here: an aborted request's mutation may still be in flight
|
|
1546
|
+
* server-side, so "unchanged" only means "not yet" — the round-3 race. Only
|
|
1547
|
+
* a positive 'landed' (ours, verified) or 'conflict' (someone else moved it)
|
|
1548
|
+
* ends the poll early; anything less resolves to Indeterminate.
|
|
1549
|
+
*/
|
|
1550
|
+
async _reconcileResolution(targetHandle, targetId, pre, expectedFact) {
|
|
1551
|
+
for (let attempt = 0; attempt < RESOLVE_RECONCILE_ATTEMPTS; attempt += 1) {
|
|
1552
|
+
await sleep(RESOLVE_RECONCILE_DELAY_MS);
|
|
1553
|
+
let post;
|
|
1554
|
+
try {
|
|
1555
|
+
post = await this.client.confidenceHistory(targetId);
|
|
1556
|
+
} catch {
|
|
1557
|
+
continue; // a failed read is a failed attempt, never its own error
|
|
1558
|
+
}
|
|
1559
|
+
const verdict = classifyResolution(pre, post, expectedFact);
|
|
1560
|
+
if (verdict === 'landed') return this._toConvictionResult(targetHandle, post);
|
|
1561
|
+
if (verdict === 'conflict') throw new FluidResolutionConflict(targetHandle);
|
|
1562
|
+
}
|
|
1563
|
+
throw new FluidResolutionIndeterminate(targetHandle);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// -------------------------------------------------------------------------
|
|
1567
|
+
// Contradiction (CAP.CONTRADICTION) — COMP-FOH FOH-5
|
|
1568
|
+
// -------------------------------------------------------------------------
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* The records that contradict `handle`. See the base-class JSDoc for the
|
|
1572
|
+
* caller-facing contract (best-effort lower bound, canonicalized, handle is
|
|
1573
|
+
* authority); the read machinery lives here:
|
|
1574
|
+
*
|
|
1575
|
+
* - Direction: a resolution writes `source CONTRADICTS target`, so the
|
|
1576
|
+
* records contradicting `handle` are its INCOMING CONTRADICTS neighbours.
|
|
1577
|
+
* - The target-deletion race (handle resolves, then vanishes before the
|
|
1578
|
+
* neighbours read) maps to FluidRecordNotFound, exactly as {@link conviction}.
|
|
1579
|
+
* - Each hit is canonicalized: a neighbour is emitted only when it IS the
|
|
1580
|
+
* canonical item for its own handle, so `hit.record` can never disagree
|
|
1581
|
+
* with a later `getRecord(hit.handle)`. Later duplicates are skipped.
|
|
1582
|
+
* - Per-neighbour failure is typed: a 404 is a deletion race (skip via
|
|
1583
|
+
* `getItem`'s null), a corrupt/non-fluid item is skipped (`_fromItem`
|
|
1584
|
+
* already warned), and ANY other fetch failure PROPAGATES — a silent
|
|
1585
|
+
* partial is worse than an error for a read that is already a lower bound.
|
|
1586
|
+
*
|
|
1587
|
+
* @param {string} handle
|
|
1588
|
+
* @returns {Promise<import('./provider.js').ContradictionHit[]>}
|
|
1589
|
+
*/
|
|
1590
|
+
async contradictions(handle) {
|
|
1591
|
+
this.require(CAP.CONTRADICTION);
|
|
1592
|
+
if (!HANDLE_RE.test(handle ?? '')) throw new FluidRecordNotFound(handle, this.name());
|
|
1593
|
+
const item = await this._resolveOne(handle);
|
|
1594
|
+
if (!item) throw new FluidRecordNotFound(handle, this.name());
|
|
1595
|
+
|
|
1596
|
+
let neighbours;
|
|
1597
|
+
try {
|
|
1598
|
+
neighbours = await this.client.neighbors(String(item.item_id));
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
// Target deleted between resolve and read — the race conviction() maps.
|
|
1601
|
+
if (err?.status === 404) throw new FluidRecordNotFound(handle, this.name());
|
|
1602
|
+
throw err;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const hits = [];
|
|
1606
|
+
const seen = new Set();
|
|
1607
|
+
for (const n of neighbours) {
|
|
1608
|
+
if (n?.link_type !== CONTRADICTS_EDGE || n?.direction !== 'incoming') continue;
|
|
1609
|
+
const sourceId = String(n.item_id);
|
|
1610
|
+
|
|
1611
|
+
// `getItem` returns null on 404 (deletion race → skip) and THROWS on any
|
|
1612
|
+
// other failure, which is exactly the "fail rather than under-report"
|
|
1613
|
+
// semantics — so it is deliberately not caught here.
|
|
1614
|
+
const raw = await this.client.getItem(sourceId);
|
|
1615
|
+
if (raw === null) continue;
|
|
1616
|
+
|
|
1617
|
+
const record = this._fromItem(raw);
|
|
1618
|
+
if (!record) continue; // corrupt / non-fluid: _fromItem already warned
|
|
1619
|
+
|
|
1620
|
+
// `_fromItem` guards a bad-JSON blob but NOT a parseable-but-schema-invalid
|
|
1621
|
+
// one (e.g. `{}` or a handle with no kind). Such a blob would otherwise be
|
|
1622
|
+
// emitted as a malformed hit, or — with no handle — crash `_resolveOne`'s
|
|
1623
|
+
// paired metadata filter below. A shared workspace can hold one, so skip it
|
|
1624
|
+
// exactly like a corrupt row rather than letting it break the whole read.
|
|
1625
|
+
if (!getFluidValidator().validate('record', record).valid) {
|
|
1626
|
+
process.emitWarning(
|
|
1627
|
+
`fluid(smartmemory): CONTRADICTS neighbour ${sourceId} holds a schema-invalid `
|
|
1628
|
+
+ 'record blob; skipped',
|
|
1629
|
+
);
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Canonical-handle rule: emit only when this neighbour is the item
|
|
1634
|
+
// `getRecord(record.handle)` resolves to (the earliest). Otherwise the
|
|
1635
|
+
// returned record would disagree with the authority the handle names.
|
|
1636
|
+
const canonical = await this._resolveOne(record.handle);
|
|
1637
|
+
if (!canonical || String(canonical.item_id) !== sourceId) continue;
|
|
1638
|
+
if (seen.has(record.handle)) continue;
|
|
1639
|
+
seen.add(record.handle);
|
|
1640
|
+
|
|
1641
|
+
hits.push({ handle: record.handle, kind: record.kind, record });
|
|
1642
|
+
}
|
|
1643
|
+
return hits;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// -------------------------------------------------------------------------
|
|
1647
|
+
// Lifecycle events
|
|
1648
|
+
// -------------------------------------------------------------------------
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Events are SEPARATE items on purpose.
|
|
1652
|
+
*
|
|
1653
|
+
* Stored inside a record's metadata they would be destroyed with the record,
|
|
1654
|
+
* and handle retirement is precisely what must outlive deletion. They are
|
|
1655
|
+
* written and never updated or deleted.
|
|
1656
|
+
*/
|
|
1657
|
+
async appendEvent(event) {
|
|
1658
|
+
const full = { at: nowIso(), ...event };
|
|
1659
|
+
// The log is append-only, so a malformed entry is permanent. Validate
|
|
1660
|
+
// before it lands rather than on a later read with no caller to blame.
|
|
1661
|
+
assertValid('lifecycle_event', full, 'lifecycle event');
|
|
1662
|
+
await this.client.createItem({
|
|
1663
|
+
content: `${full.type} ${full.handle}`,
|
|
1664
|
+
memoryType: EVENT_WIRE_TYPE,
|
|
1665
|
+
metadata: {
|
|
1666
|
+
fluid_ns: EVENT_NS,
|
|
1667
|
+
handle: full.handle,
|
|
1668
|
+
fluid_event_json: JSON.stringify(full),
|
|
1669
|
+
},
|
|
1670
|
+
});
|
|
1671
|
+
return full;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
async readEvents(handle) {
|
|
1675
|
+
const items = await this._listAllItems({
|
|
1676
|
+
metadataKey: 'fluid_ns', metadataValue: EVENT_NS,
|
|
1677
|
+
});
|
|
1678
|
+
const events = [];
|
|
1679
|
+
for (const item of items) {
|
|
1680
|
+
const meta = item?.metadata ?? {};
|
|
1681
|
+
if (meta.fluid_ns !== EVENT_NS) continue;
|
|
1682
|
+
if (handle && meta.handle !== handle) continue;
|
|
1683
|
+
try {
|
|
1684
|
+
events.push(JSON.parse(meta.fluid_event_json));
|
|
1685
|
+
} catch {
|
|
1686
|
+
process.emitWarning(
|
|
1687
|
+
`fluid(smartmemory): event item ${item?.item_id} has an unreadable payload and was skipped`,
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
// Chronological, matching the floor's append-only file order. Handle
|
|
1692
|
+
// allocation reads this, so a stable order keeps allocation reproducible.
|
|
1693
|
+
return events.sort((a, b) => String(a?.at ?? '').localeCompare(String(b?.at ?? '')));
|
|
1694
|
+
}
|
|
1695
|
+
}
|