@ijfw/memory-server 1.4.4 → 1.5.1
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/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +6 -3
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +754 -159
- package/src/cross-orchestrator.js +1065 -17
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +5 -1
- package/src/dashboard-server.js +73 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +26 -2
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +201 -6
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hardware-signer.js +4 -2
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +595 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +267 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migration-runner.js +6 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +152 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +46 -15
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +277 -0
- package/src/orchestrator/review.js +38 -3
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1932 -0
- package/src/orchestrator/status-protocol.js +84 -17
- package/src/orchestrator/subagent-telemetry.js +471 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +200 -16
- package/src/orchestrator/wave-state.js +332 -23
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-resolver.js +5 -3
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +961 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -1
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +795 -112
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +102 -17
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +44 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +44 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +440 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
- package/src/dashboard-charts.js +0 -239
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-events.js -- v1.5.0 T5: per-subagent event log + tap + poll reader.
|
|
3
|
+
*
|
|
4
|
+
* Binds verbatim to .planning/v150-gap-closure/STATE-SDK-CONTRACT.md §5
|
|
5
|
+
* (CROSS-CUTTING MODEL 3 -- Event record + log rotation).
|
|
6
|
+
*
|
|
7
|
+
* ROLES:
|
|
8
|
+
* * `emitEvent(envelope)` -- the implementation behind the dispatcher's
|
|
9
|
+
* `_emitEvent` observability tap. Fire-and-forget, AFTER lock release,
|
|
10
|
+
* idempotent on no-arg/malformed input (swallows all I/O errors -- never
|
|
11
|
+
* propagates). Appends one envelope-shaped JSONL record per call.
|
|
12
|
+
* * `assignNextSeqAndAppendUnderLock({...})` -- the SHARED seq+append helper
|
|
13
|
+
* called by the `event.emit` verb (which is journaled and runs INSIDE its
|
|
14
|
+
* own §3 lock). Same seq stream as the tap (per-path); same rotation
|
|
15
|
+
* behaviour; same size cap.
|
|
16
|
+
* * `pollEvents({since})` -- explicit-interval reader. Returns events with
|
|
17
|
+
* `seq > since` across the current file + any rotated archive. NEVER uses
|
|
18
|
+
* `fs.watch`.
|
|
19
|
+
* * `resolveEventLogPath(root, waveId, subId)` -- single source of truth for
|
|
20
|
+
* the per-subagent log path AND the fallback for tap-events without a
|
|
21
|
+
* subagent context.
|
|
22
|
+
*
|
|
23
|
+
* SEQ MONOTONICITY ACROSS ROTATION:
|
|
24
|
+
* The jsonl-rotation primitive archives the current log to a gzipped
|
|
25
|
+
* sibling (`<stem>.<date>.jsonl.gz`) and truncates the live file -- so a
|
|
26
|
+
* naive read-tail of the current log to derive the next seq would reset to 1
|
|
27
|
+
* after every rotation. We persist a tiny sidecar `<log>.seq` containing the
|
|
28
|
+
* last-assigned seq, written via tmp-rename atomic so a crash leaves either
|
|
29
|
+
* the old or the new value -- never half. On startup of an event stream the
|
|
30
|
+
* sidecar is read; if absent (first-ever emit OR a manual wipe), we fall
|
|
31
|
+
* back to scanning the current file + the most-recent archive for the max
|
|
32
|
+
* seq, then write the sidecar.
|
|
33
|
+
*
|
|
34
|
+
* IN-PROCESS APPEND SERIALIZATION:
|
|
35
|
+
* The tap fires off the critical section with NO §3 lock held. Concurrent
|
|
36
|
+
* tap emits to the same log (multiple verbs in flight) would race on seq
|
|
37
|
+
* assignment + appendFile. We serialize tap appends per-log-path with a
|
|
38
|
+
* simple in-process Promise-chain mutex. Cross-process serialization is not
|
|
39
|
+
* required because the tap fires only from one orchestrator process and the
|
|
40
|
+
* `event.emit` verb takes the §3 event-log lock itself (Model 3).
|
|
41
|
+
*
|
|
42
|
+
* NO PRODUCTION DEPENDENCIES; ESM; Node >=18.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync,
|
|
47
|
+
renameSync, statSync, writeFileSync,
|
|
48
|
+
} from 'node:fs';
|
|
49
|
+
import { join, dirname, basename } from 'node:path';
|
|
50
|
+
import { gunzipSync } from 'node:zlib';
|
|
51
|
+
|
|
52
|
+
import { rotateJsonlIfNeeded, DEFAULT_ROTATE_SIZE } from '../lib/jsonl-rotation.js';
|
|
53
|
+
|
|
54
|
+
// -- Contract constants ----------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Contract §5 -- 4 MiB byte ceiling per event log. */
|
|
57
|
+
export const EVENT_BYTE_CEILING = DEFAULT_ROTATE_SIZE; // 4 * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
/** Contract §5 -- 10000-line ceiling per event log. */
|
|
60
|
+
export const EVENT_LINE_CEILING = 10000;
|
|
61
|
+
|
|
62
|
+
/** Contract §5 -- per-event 4 KiB size cap. Truncate, never drop. */
|
|
63
|
+
export const EVENT_MAX_LINE_BYTES = 4 * 1024;
|
|
64
|
+
|
|
65
|
+
// -- Path resolution -------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-subagent event-log path per contract §1 + §5.
|
|
69
|
+
* `<projectRoot>/.ijfw/wave-<waveId>/events-<subId>.jsonl`
|
|
70
|
+
*
|
|
71
|
+
* Routing is total -- the tap never silently drops:
|
|
72
|
+
* - Both present: `<projectRoot>/.ijfw/wave-<waveId>/events-<subId>.jsonl`.
|
|
73
|
+
* - waveId present, subagentId absent: route under the wave dir with the
|
|
74
|
+
* §5-canonical `'parent'` subId fallback —
|
|
75
|
+
* `<projectRoot>/.ijfw/wave-<waveId>/events-parent.jsonl`. The waveId is
|
|
76
|
+
* honored; the no-subagent caller surfaces as `subagentId:'parent'` per §5.
|
|
77
|
+
* - subagentId present, waveId absent: legacy fallback under the system dir,
|
|
78
|
+
* `<projectRoot>/.ijfw/state/events-<sub>.jsonl`, because there is no wave
|
|
79
|
+
* directory to anchor to. (Rare in practice — verbs that carry a subagent
|
|
80
|
+
* carry a wave too.)
|
|
81
|
+
* - Both absent: system fallback `<projectRoot>/.ijfw/state/events-system.jsonl`
|
|
82
|
+
* (e.g. dispatcher-tap events for verbs called without a `waveId` payload,
|
|
83
|
+
* like `state.validate`). Ratified by contract §5 (see Model 3 note).
|
|
84
|
+
*/
|
|
85
|
+
export function resolveEventLogPath(projectRoot, waveId, subagentId) {
|
|
86
|
+
if (typeof projectRoot !== 'string' || !projectRoot) {
|
|
87
|
+
throw new Error('state-events: projectRoot required');
|
|
88
|
+
}
|
|
89
|
+
const safeId = (v) => (typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null);
|
|
90
|
+
const wid = safeId(waveId);
|
|
91
|
+
const sid = safeId(subagentId);
|
|
92
|
+
if (wid && sid) return join(projectRoot, '.ijfw', `wave-${wid}`, `events-${sid}.jsonl`);
|
|
93
|
+
if (wid) return join(projectRoot, '.ijfw', `wave-${wid}`, 'events-parent.jsonl');
|
|
94
|
+
if (sid) return join(projectRoot, '.ijfw', 'state', `events-${sid}.jsonl`);
|
|
95
|
+
return join(projectRoot, '.ijfw', 'state', 'events-system.jsonl');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Sidecar path holding the last-assigned seq for a given event log. */
|
|
99
|
+
function seqSidecarPath(eventLogPath) {
|
|
100
|
+
const dir = dirname(eventLogPath);
|
|
101
|
+
const base = basename(eventLogPath);
|
|
102
|
+
return join(dir, `.${base}.seq`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -- Internal helpers ------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function ensureDir(dir) {
|
|
108
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nowIso() { return new Date().toISOString(); }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read + parse the seq sidecar. Returns 0 when absent / corrupt.
|
|
115
|
+
*/
|
|
116
|
+
function readSeqSidecar(eventLogPath) {
|
|
117
|
+
const sidecar = seqSidecarPath(eventLogPath);
|
|
118
|
+
if (!existsSync(sidecar)) return 0;
|
|
119
|
+
try {
|
|
120
|
+
const raw = readFileSync(sidecar, 'utf8').trim();
|
|
121
|
+
const n = Number.parseInt(raw, 10);
|
|
122
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
123
|
+
} catch {
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Atomic sidecar write (tmp-rename) so a crash leaves either old or new. */
|
|
129
|
+
function writeSeqSidecar(eventLogPath, seq) {
|
|
130
|
+
const sidecar = seqSidecarPath(eventLogPath);
|
|
131
|
+
ensureDir(dirname(sidecar));
|
|
132
|
+
const tmp = `${sidecar}.tmp.${process.pid}`;
|
|
133
|
+
writeFileSync(tmp, String(seq), { mode: 0o600 });
|
|
134
|
+
renameSync(tmp, sidecar);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Recover the last-emitted seq when the sidecar is absent (first-ever emit
|
|
139
|
+
* for this log, or sidecar wiped). Scans the live file + the newest .jsonl.gz
|
|
140
|
+
* archive and returns the max `seq` field. Returns 0 when nothing found.
|
|
141
|
+
*/
|
|
142
|
+
function recoverLastSeqFromDisk(eventLogPath) {
|
|
143
|
+
let max = 0;
|
|
144
|
+
const seenSeq = (line) => {
|
|
145
|
+
const t = line.trim();
|
|
146
|
+
if (!t) return;
|
|
147
|
+
try {
|
|
148
|
+
const obj = JSON.parse(t);
|
|
149
|
+
if (obj && typeof obj.seq === 'number' && obj.seq > max) max = obj.seq;
|
|
150
|
+
} catch { /* skip a corrupt line */ }
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Live file.
|
|
154
|
+
if (existsSync(eventLogPath)) {
|
|
155
|
+
for (const line of readFileSync(eventLogPath, 'utf8').split('\n')) seenSeq(line);
|
|
156
|
+
}
|
|
157
|
+
// Newest archive sibling (lexicographically sorted .jsonl.gz files).
|
|
158
|
+
const dir = dirname(eventLogPath);
|
|
159
|
+
const base = basename(eventLogPath); // e.g. events-W12-A1.jsonl
|
|
160
|
+
const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
|
|
161
|
+
if (existsSync(dir)) {
|
|
162
|
+
let archives = [];
|
|
163
|
+
try {
|
|
164
|
+
archives = readdirSync(dir)
|
|
165
|
+
.filter((n) => n.startsWith(`${stem}.`) && n.endsWith('.jsonl.gz'))
|
|
166
|
+
.sort()
|
|
167
|
+
.reverse(); // newest first by date-suffix
|
|
168
|
+
} catch { /* ignore */ }
|
|
169
|
+
for (const a of archives) {
|
|
170
|
+
try {
|
|
171
|
+
const raw = gunzipSync(readFileSync(join(dir, a))).toString('utf8');
|
|
172
|
+
for (const line of raw.split('\n')) seenSeq(line);
|
|
173
|
+
} catch { /* corrupt archive -- skip */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return max;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* In-process serializer keyed by log path. Multiple tap emits to the same
|
|
181
|
+
* log from this process queue on a single Promise chain so seq assignment +
|
|
182
|
+
* append + sidecar update are atomic w.r.t. concurrent callers in-process.
|
|
183
|
+
*/
|
|
184
|
+
const APPEND_QUEUES = new Map();
|
|
185
|
+
|
|
186
|
+
function queueAppend(path, work) {
|
|
187
|
+
const prev = APPEND_QUEUES.get(path) || Promise.resolve();
|
|
188
|
+
const next = prev.then(work, work);
|
|
189
|
+
// Store `next` itself (NOT a `.finally()`-wrapped copy) so the cleanup
|
|
190
|
+
// check below `=== next` actually identifies the queue head. A `.finally()`
|
|
191
|
+
// wrapper returns a different Promise object, which would make the
|
|
192
|
+
// identity-check `APPEND_QUEUES.get(path) === next` permanently false and
|
|
193
|
+
// leak Map entries (one per unique log path).
|
|
194
|
+
APPEND_QUEUES.set(path, next);
|
|
195
|
+
next.then(() => {
|
|
196
|
+
// Only delete when no follow-up emit has chained onto `next` -- if a
|
|
197
|
+
// subsequent `queueAppend(path, ...)` call has already set a new head,
|
|
198
|
+
// leave it in place. This keeps the Map bounded by the number of
|
|
199
|
+
// CURRENTLY-IN-FLIGHT log paths rather than ever-seen ones.
|
|
200
|
+
if (APPEND_QUEUES.get(path) === next) APPEND_QUEUES.delete(path);
|
|
201
|
+
}, () => {
|
|
202
|
+
if (APPEND_QUEUES.get(path) === next) APPEND_QUEUES.delete(path);
|
|
203
|
+
});
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Count newline-terminated lines in the live file (cheap -- only the live
|
|
209
|
+
* file, not archives, because rotation is gated by the live file's size+lines).
|
|
210
|
+
*/
|
|
211
|
+
function countLines(path) {
|
|
212
|
+
if (!existsSync(path)) return 0;
|
|
213
|
+
const raw = readFileSync(path, 'utf8');
|
|
214
|
+
if (!raw) return 0;
|
|
215
|
+
let n = 0;
|
|
216
|
+
for (let i = 0; i < raw.length; i += 1) if (raw.charCodeAt(i) === 10) n += 1;
|
|
217
|
+
return n;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Apply the per-event 4 KiB cap. Returns the (possibly-truncated) record.
|
|
222
|
+
* If the serialized form would exceed `EVENT_MAX_LINE_BYTES`, we keep the
|
|
223
|
+
* envelope (seq/verb/subagentId/ts/verbId/outcome/payloadDigest) and add a
|
|
224
|
+
* `truncated:true` marker, truncating the digest if even THAT is too large.
|
|
225
|
+
*/
|
|
226
|
+
function applySizeCap(record) {
|
|
227
|
+
let line = JSON.stringify(record);
|
|
228
|
+
if (Buffer.byteLength(line, 'utf8') <= EVENT_MAX_LINE_BYTES) return record;
|
|
229
|
+
// Reduce to envelope-only + truncation marker.
|
|
230
|
+
const envelope = {
|
|
231
|
+
seq: record.seq,
|
|
232
|
+
verb: record.verb,
|
|
233
|
+
subagentId: record.subagentId,
|
|
234
|
+
ts: record.ts,
|
|
235
|
+
verbId: record.verbId,
|
|
236
|
+
outcome: record.outcome,
|
|
237
|
+
payloadDigest: record.payloadDigest,
|
|
238
|
+
truncated: true,
|
|
239
|
+
};
|
|
240
|
+
line = JSON.stringify(envelope);
|
|
241
|
+
if (Buffer.byteLength(line, 'utf8') <= EVENT_MAX_LINE_BYTES) return envelope;
|
|
242
|
+
// Last resort -- truncate payloadDigest itself. We still keep the prefix
|
|
243
|
+
// so the truncation is visibly a sha256-<hex>... cut, not a dropped event.
|
|
244
|
+
const room = EVENT_MAX_LINE_BYTES - Buffer.byteLength(JSON.stringify({
|
|
245
|
+
...envelope, payloadDigest: '',
|
|
246
|
+
}), 'utf8') - 8; // 8 bytes of safety slack
|
|
247
|
+
const dig = String(record.payloadDigest || '');
|
|
248
|
+
envelope.payloadDigest = dig.slice(0, Math.max(0, room));
|
|
249
|
+
return envelope;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -- Rotation ---------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Rotate the live event log if it has crossed the byte OR line ceiling.
|
|
256
|
+
* Reuses the shared `jsonl-rotation` primitive for the byte ceiling (it
|
|
257
|
+
* gzip-archives + truncates atomically). The library is byte-only, so we
|
|
258
|
+
* implement the line ceiling by re-calling the primitive with `maxBytes: 1`
|
|
259
|
+
* once the line count is at the ceiling -- which forces a rotation because
|
|
260
|
+
* any non-empty file is necessarily larger than 1 byte. Choosing `1` (rather
|
|
261
|
+
* than `0`) is deliberate: `jsonl-rotation.js`'s argument-normaliser treats
|
|
262
|
+
* `maxBytes <= 0` as "fall back to DEFAULT_ROTATE_SIZE", which would silently
|
|
263
|
+
* defeat the force-rotate. `1` survives the normaliser and is unconditionally
|
|
264
|
+
* below any real file's size.
|
|
265
|
+
*
|
|
266
|
+
* Test override: `rotateOptions.maxBytes` / `rotateOptions.maxLines` allow
|
|
267
|
+
* tests to force rotation at small thresholds without writing megabytes.
|
|
268
|
+
*/
|
|
269
|
+
function rotateIfNeeded(eventLogPath, rotateOptions = {}) {
|
|
270
|
+
const maxBytes = Number.isFinite(rotateOptions.maxBytes)
|
|
271
|
+
? rotateOptions.maxBytes : EVENT_BYTE_CEILING;
|
|
272
|
+
const maxLines = Number.isFinite(rotateOptions.maxLines)
|
|
273
|
+
? rotateOptions.maxLines : EVENT_LINE_CEILING;
|
|
274
|
+
|
|
275
|
+
// Byte path -- delegate to the library.
|
|
276
|
+
const byteResult = rotateJsonlIfNeeded(eventLogPath, { maxBytes });
|
|
277
|
+
if (byteResult.rotated) return byteResult;
|
|
278
|
+
|
|
279
|
+
// Line path -- the library is byte-only, so we force a rotation by
|
|
280
|
+
// calling it again with maxBytes=1 IF the live line count is at/past the
|
|
281
|
+
// ceiling. `1` (not `0`) is critical: the lib normalises `maxBytes <= 0`
|
|
282
|
+
// back to DEFAULT_ROTATE_SIZE (4 MiB), so `0` would not actually force.
|
|
283
|
+
const lineCount = countLines(eventLogPath);
|
|
284
|
+
if (lineCount >= maxLines && existsSync(eventLogPath) && statSync(eventLogPath).size > 0) {
|
|
285
|
+
return rotateJsonlIfNeeded(eventLogPath, { maxBytes: 1 });
|
|
286
|
+
}
|
|
287
|
+
return byteResult;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// -- The shared append core -------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Append one event to the per-subagent log. Assigns `seq` (monotonic across
|
|
294
|
+
* rotation), applies the size cap, rotates if at ceiling, writes the line,
|
|
295
|
+
* then persists the seq sidecar. SYNCHRONOUS file I/O so that the post-tap
|
|
296
|
+
* sequence (rotate -> append -> sidecar) is observably atomic from the
|
|
297
|
+
* caller's point of view.
|
|
298
|
+
*
|
|
299
|
+
* The caller is responsible for serializing concurrent invocations on the
|
|
300
|
+
* SAME `path` (either via an in-process queue -- the tap's `emitEvent` does
|
|
301
|
+
* this -- or via a §3 fs lock -- the `event.emit` verb does this).
|
|
302
|
+
*
|
|
303
|
+
* Returns the persisted event record (with assigned seq + any truncation).
|
|
304
|
+
*/
|
|
305
|
+
export function assignNextSeqAndAppend({ path, envelope, rotateOptions }) {
|
|
306
|
+
ensureDir(dirname(path));
|
|
307
|
+
|
|
308
|
+
// Determine the next seq.
|
|
309
|
+
let lastSeq = readSeqSidecar(path);
|
|
310
|
+
if (lastSeq === 0) {
|
|
311
|
+
// First-ever emit OR sidecar wiped -- recover from disk.
|
|
312
|
+
lastSeq = recoverLastSeqFromDisk(path);
|
|
313
|
+
}
|
|
314
|
+
const nextSeq = lastSeq + 1;
|
|
315
|
+
|
|
316
|
+
// Rotation BEFORE the append (per contract §5 -- "on reaching either
|
|
317
|
+
// ceiling, rotate ... and start a fresh log; seq continues monotonically
|
|
318
|
+
// across rotation"). seq does NOT reset because we keep the sidecar.
|
|
319
|
+
rotateIfNeeded(path, rotateOptions);
|
|
320
|
+
|
|
321
|
+
// Compose the record + size cap.
|
|
322
|
+
const record = applySizeCap({ ...envelope, seq: nextSeq });
|
|
323
|
+
|
|
324
|
+
// Append the JSONL line.
|
|
325
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
326
|
+
|
|
327
|
+
// Persist the sidecar AFTER the append succeeds. If we crash between the
|
|
328
|
+
// append and the sidecar write, the next emit's recovery scans disk and
|
|
329
|
+
// recovers the true max seq -- so we are crash-safe with at most a
|
|
330
|
+
// re-derived seq, never a reset.
|
|
331
|
+
writeSeqSidecar(path, nextSeq);
|
|
332
|
+
|
|
333
|
+
return record;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// -- Public surface --------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The implementation behind the dispatcher's `_emitEvent` observability tap.
|
|
340
|
+
* Fire-and-forget, AFTER lock release. Swallows all errors -- writes any
|
|
341
|
+
* failure to stderr and returns. Never throws, never propagates.
|
|
342
|
+
*
|
|
343
|
+
* @param {{projectRoot:string, waveId?:string, subagentId?:string,
|
|
344
|
+
* verb:string, verbId:string, outcome:string, payloadDigest:string,
|
|
345
|
+
* ts?:string, rotateOptions?:object}} input
|
|
346
|
+
* @returns {Promise<void>}
|
|
347
|
+
*/
|
|
348
|
+
export async function emitEvent(input) {
|
|
349
|
+
if (!input || typeof input !== 'object') return;
|
|
350
|
+
const {
|
|
351
|
+
projectRoot, waveId, subagentId, verb, verbId, outcome, payloadDigest,
|
|
352
|
+
ts, rotateOptions,
|
|
353
|
+
} = input;
|
|
354
|
+
if (!projectRoot || !verb || !verbId) return; // soft-fail, no throw
|
|
355
|
+
const path = resolveEventLogPath(projectRoot, waveId, subagentId);
|
|
356
|
+
|
|
357
|
+
// Build the envelope per contract §5.
|
|
358
|
+
const envelope = {
|
|
359
|
+
verb,
|
|
360
|
+
subagentId: subagentId || 'parent',
|
|
361
|
+
ts: ts || nowIso(),
|
|
362
|
+
verbId,
|
|
363
|
+
outcome: outcome || 'ok',
|
|
364
|
+
payloadDigest: payloadDigest || '',
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Serialize per-path so concurrent tap emits to the same log don't race
|
|
368
|
+
// on the seq sidecar.
|
|
369
|
+
await queueAppend(path, async () => {
|
|
370
|
+
try {
|
|
371
|
+
assignNextSeqAndAppend({ path, envelope, rotateOptions });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
// NEVER propagate -- the tap is fire-and-forget. Log to stderr.
|
|
374
|
+
try {
|
|
375
|
+
process.stderr.write(`[ijfw state-events] emit failed: ${err?.message || err}\n`);
|
|
376
|
+
} catch { /* even stderr failed -- swallow */ }
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Synchronous core for callers that ALREADY hold a §3 lock on the event log
|
|
383
|
+
* (currently: the `event.emit` verb in state-sdk.js). Bypasses the in-process
|
|
384
|
+
* queue -- the lock serializes; returns the persisted record. Errors here DO
|
|
385
|
+
* propagate -- the caller is journaled and wants to surface the failure.
|
|
386
|
+
*/
|
|
387
|
+
export function appendUnderHeldLock({ path, envelope, rotateOptions }) {
|
|
388
|
+
return assignNextSeqAndAppend({ path, envelope, rotateOptions });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// -- pollEvents reader -----------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Explicit-interval reader. Returns events with `seq > since` across the
|
|
395
|
+
* live file and any rotated archive(s). NEVER uses `fs.watch`.
|
|
396
|
+
*
|
|
397
|
+
* Cursor shape: a plain number (the highest seq the consumer has already
|
|
398
|
+
* processed). `since: 0` -> the entire stream.
|
|
399
|
+
*
|
|
400
|
+
* Return shape: `{ events: <array>, cursor: <number> }` -- `cursor` is the
|
|
401
|
+
* highest seq present (suitable to feed back as `since` on the next poll).
|
|
402
|
+
*
|
|
403
|
+
* Spans rotation: scans the most-recent .jsonl.gz archive(s) when the
|
|
404
|
+
* cursor predates the live file's first line.
|
|
405
|
+
*/
|
|
406
|
+
export function pollEvents(input) {
|
|
407
|
+
const { projectRoot, waveId, subagentId } = input || {};
|
|
408
|
+
const since = Number.isFinite(input?.since) ? input.since : 0;
|
|
409
|
+
const path = resolveEventLogPath(projectRoot, waveId, subagentId);
|
|
410
|
+
|
|
411
|
+
const out = [];
|
|
412
|
+
let maxSeq = since;
|
|
413
|
+
|
|
414
|
+
const consumeRaw = (raw) => {
|
|
415
|
+
for (const line of raw.split('\n')) {
|
|
416
|
+
const t = line.trim();
|
|
417
|
+
if (!t) continue;
|
|
418
|
+
let obj;
|
|
419
|
+
try { obj = JSON.parse(t); } catch { continue; }
|
|
420
|
+
if (!obj || typeof obj.seq !== 'number') continue;
|
|
421
|
+
if (obj.seq > since) out.push(obj);
|
|
422
|
+
if (obj.seq > maxSeq) maxSeq = obj.seq;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Read archives FIRST so the returned `events` array stays seq-sorted.
|
|
427
|
+
// Archives are date-stamped; we scan ALL .jsonl.gz siblings so a poll with
|
|
428
|
+
// a very old `since` recovers events from a rotated archive.
|
|
429
|
+
const dir = dirname(path);
|
|
430
|
+
const base = basename(path);
|
|
431
|
+
const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
|
|
432
|
+
if (existsSync(dir)) {
|
|
433
|
+
let archives = [];
|
|
434
|
+
try {
|
|
435
|
+
archives = readdirSync(dir)
|
|
436
|
+
.filter((n) => n.startsWith(`${stem}.`) && n.endsWith('.jsonl.gz'))
|
|
437
|
+
.sort(); // oldest first by date-suffix
|
|
438
|
+
} catch { /* ignore */ }
|
|
439
|
+
for (const a of archives) {
|
|
440
|
+
try {
|
|
441
|
+
const raw = gunzipSync(readFileSync(join(dir, a))).toString('utf8');
|
|
442
|
+
consumeRaw(raw);
|
|
443
|
+
} catch { /* skip corrupt archive */ }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Then the live file.
|
|
448
|
+
if (existsSync(path)) {
|
|
449
|
+
consumeRaw(readFileSync(path, 'utf8'));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Sort by seq (archives + live may overlap in degenerate cases).
|
|
453
|
+
out.sort((a, b) => a.seq - b.seq);
|
|
454
|
+
|
|
455
|
+
// Return cursor = max seen seq, or `since` if nothing seen + no file at all.
|
|
456
|
+
// When the log is absent entirely AND since:0 was passed, cursor stays 0.
|
|
457
|
+
const cursor = out.length > 0 ? out[out.length - 1].seq : maxSeq;
|
|
458
|
+
return { events: out, cursor };
|
|
459
|
+
}
|