diff --git a/.env.example b/.env.example index 4f1292894..81210ef38 100644 --- a/.env.example +++ b/.env.example @@ -380,6 +380,12 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk # INTERNAL_EXECUTOR_REVIEW_TIMEOUT_MS=300000 # Experimental autonomous backlog replenishment (disabled by default) # INTERNAL_EXECUTOR_REPLENISH_ENABLED=false +# Enable deterministic internal harness compile/activate control-plane support +# BOSUN_HARNESS_ENABLED=false +# Path to harness profile source (JSON or markdown fenced JSON) +# BOSUN_HARNESS_SOURCE=.bosun/harness/internal-harness.md +# Validation mode: off | report | enforce +# BOSUN_HARNESS_VALIDATION_MODE=report # Minimum follow-up tasks to generate per completed task (1-2) # INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS=1 # Maximum follow-up tasks to generate per completed task (1-3) @@ -496,6 +502,7 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk # internal - local task-store source of truth (recommended primary) # github - GitHub Issues # jira - Jira Issues +# gnap - GNAP projection backend (off by default) # KANBAN_BACKEND=internal # Sync behavior: # internal-primary - internal task-store remains source-of-truth (recommended) @@ -596,6 +603,20 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk # Optional JSON custom field to store full shared state payload # JIRA_CUSTOM_FIELD_SHARED_STATE=customfield_10048 +# GNAP backend (KANBAN_BACKEND=gnap) +# Master toggle for GNAP integration. Must be enabled before selecting gnap. +# GNAP_ENABLED=false +# Path to the repo or clone that will host GNAP projection data +# GNAP_REPO_PATH= +# Synchronization mode. Bosun currently supports projection-only GNAP wiring. +# GNAP_SYNC_MODE=projection +# Where to store GNAP run metadata: git|local +# GNAP_RUN_STORAGE=git +# Where to store GNAP message projections: off|git|local +# GNAP_MESSAGE_STORAGE=off +# Optional sanitized roadmap export for shared visibility +# GNAP_PUBLIC_ROADMAP_ENABLED=false + # ─── Sandbox Policy ────────────────────────────────────────────────────────── # Controls agent sandbox isolation when using Codex SDK. # Options: @@ -1101,6 +1122,8 @@ COPILOT_CLOUD_DISABLED=true # Monitor source hot-reload watcher. Default: enabled in devmode, disabled otherwise. # Set to true to force-enable monitor source hot-restart, false to force-disable. # SELF_RESTART_WATCH_ENABLED=true +# Quiet period after the last source-file change before self-restart is allowed. +# SELF_RESTART_QUIET_MS=180000 # Status file path (default: .cache/orchestrator-status.json) # STATUS_FILE=.cache/orchestrator-status.json # Log directory (default: ./logs) @@ -1143,5 +1166,3 @@ COPILOT_CLOUD_DISABLED=true # OpenTelemetry tracing (optional) # BOSUN_OTEL_ENDPOINT=http://localhost:4318/v1/traces - - diff --git a/.gitignore b/.gitignore index d68c17fdc..dddf73852 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,17 @@ reports/mutation/ .tmp-* .bosun-monitor/backups/* -tmp/* \ No newline at end of file +tmp/* + +node-compile-cache/* +*/*.tmp +*/*.log +*.log +*.tmp +{{*}} +*tmp* +*null* +*git-cache* +_docs/2026-03-31-sibling-project-adoption-analysis.md +.playwright-cli +output/playwright \ No newline at end of file diff --git a/_docs/2026-03-31-bosun-in-house-adoption-analysis.md b/_docs/2026-03-31-bosun-in-house-adoption-analysis.md new file mode 100644 index 000000000..e73bd46d7 --- /dev/null +++ b/_docs/2026-03-31-bosun-in-house-adoption-analysis.md @@ -0,0 +1,432 @@ +# Bosun In-House Adoption Analysis + +> Date: 2026-03-31 +> Scope: local repos in `virtengine-gh` assessed for direct adoption into Bosun, including copy-and-refactor paths into `.mjs` + +## Executive Summary + +Bosun already has the beginnings of the right architecture: a SQLite-backed state ledger, a context indexer, shared knowledge primitives, multi-backend kanban support, workflow execution, and a settings-driven UI/server model. + +The highest-leverage move is not to bolt on more point features. It is to make Bosun's own runtime state model authoritative and SQL-first, then layer graph knowledge, DAG visualization, subconscious guidance, and stronger project-management projections on top of that substrate. + +If we do this well, Bosun becomes: + +- the operational source of truth for tasks, runs, claims, sessions, artifacts, and audit records +- a graph-aware repo intelligence system instead of a JSON-log-driven orchestrator +- a multi-surface control plane with first-class IDE, desktop, web, Telegram, and computer-use pathways + +## Current Bosun Baseline + +Bosun already exposes the foundation needed for this migration: + +- `lib/state-ledger-sqlite.mjs` already provides WAL-backed SQLite storage for workflow runs/events and is the correct nucleus for a broader SQL runtime store. +- `workspace/context-indexer.mjs` already builds a local SQLite index of files and symbols under `.bosun/context-index`. +- `workspace/shared-knowledge.mjs` already supports append-only knowledge capture, but its persistent registry is still JSON-based and too lightweight for long-term system memory. +- `kanban/kanban-adapter.mjs` already supports `internal`, `github`, `jira`, and optional `gnap` backends, which means Bosun already understands projection-style task backends. +- `ui/modules/settings-schema.js` already gives us an off-by-default settings plane for new subsystems. +- `bosun/.bosun/library.json` already contains a Linear MCP entry, so Linear is present as a tool capability but not as a first-class Bosun task backend. + +## What To Lift From Each Project + +| Source | Best thing to steal | Bosun fit | +|---|---|---| +| Linear / Jira / Atlassian | disciplined issue/project workflows, backlog hygiene, roadmap posture, planning UX | external tracker projection and long-term planning mode | +| Chorus-main | task DAGs, session lifecycle, worker observability, proposal approval flow, live role/status dashboards | Bosun workflow graph UI, task/session observability, reviewable planning | +| Devika | planner -> researcher -> coder decomposition | Bosun research and implementation workflow templates, not runtime architecture | +| Cline | IDE-native workflow, command/file approval UX, checkpoints, browser loop, add-context flows | Bosun VS Code plugin, task snapshots, IDE-side execution visibility | +| temm1e-main | finite-context budgeting, blueprint memory, long-running agent posture, browser/CDP pragmatism | Bosun memory tiers, reusable execution recipes, smarter context rebuilds | +| claude-subconscious-main | background "whisper" agent pattern | Bosun subconscious guidance daemon and pre-run briefings | +| UFO | DAG-based multi-device orchestration, capability profiles, computer-use across platforms | Bosun computer-use/device-agent layer and safe orchestration model | +| Corbell-main | codebase graph + embeddings + PRD/spec decomposition backed by SQLite | Bosun knowledge graph and repo intelligence service | +| airweave-main | connector/sync/retrieval layer | Bosun context ingestion and retrieval abstraction | +| agentfs-main | SQL-first auditability, reproducibility, single durable runtime store | Bosun authoritative SQL state model and event/file audit strategy | + +## Recommended Architecture Direction + +### 1. Make SQL the source of truth + +This is the most important shift. + +Bosun should stop treating JSON files and comment-derived state as primary records. Logs, transcripts, raw tool payloads, and external issue comments can remain as append-only artifacts, but every record that the UI or runtime depends on should be keyed and queryable in SQLite first. + +Target Bosun modules: + +- `lib/state-ledger-sqlite.mjs` +- `task/task-store.mjs` +- `task/task-claims.mjs` +- `workspace/execution-journal.mjs` +- `workspace/shared-knowledge.mjs` +- `server/ui-server.mjs` + +What to copy in spirit from AgentFS: + +- a normalized SQL schema for files/state/tool calls/events +- reproducible snapshots and replay-friendly audit trails +- one durable local store per Bosun root, with explicit projection/export layers + +What Bosun should do differently: + +- do not replace git worktrees or filesystem writes with a virtual FS +- do not force all file content into SQLite blobs +- do keep SQL authoritative for metadata, identity, claims, task state, artifacts, and UI queries + +Concrete result: + +- UI reads SQL views, not scattered JSON files +- agent claims, heartbeats, retries, PR links, artifacts, comments, graph nodes, and memory entries are SQL records +- raw log files become referenced evidence, not primary storage + +### 2. Build a real knowledge graph for repos and workspaces + +Bosun already has `workspace/context-indexer.mjs`, but it is closer to a symbol/file index than a structured knowledge system. + +Corbell is the strongest model here. The right direction is to extend Bosun from: + +- files +- symbols + +into: + +- repos +- workspaces +- services/modules +- files +- symbols +- call edges +- task-to-code edges +- PR-to-file edges +- workflow-to-artifact edges +- document/pattern edges +- optional embedding-backed retrieval edges + +Target Bosun modules: + +- `workspace/context-indexer.mjs` +- `workspace/shared-knowledge.mjs` +- `infra/library-manager.mjs` +- `workflow/project-detection.mjs` +- `workflow/research-evidence-sidecar.mjs` + +Direct copy/refactor candidates: + +- Corbell's SQLite graph-store shape +- Corbell's method/call graph approach +- Corbell's PRD/spec decomposition flow +- Airweave's connector -> sync -> index -> retrieve pipeline shape + +Recommended Bosun implementation: + +- new `workspace/knowledge-store.mjs` +- new `workspace/knowledge-graph-builder.mjs` +- new `workspace/knowledge-query.mjs` +- optional `workspace/embedding-store.mjs` + +Important constraint: + +- graph building must work locally without any LLM dependency +- embeddings should be optional ranking, never the only retrieval path + +### 3. Keep external trackers as projections unless explicitly promoted + +Jira is already implemented as a kanban backend. Linear is present only through MCP/library discovery today. + +The right model for Bosun is: + +- Bosun internal SQL task graph = canonical execution truth +- Jira / Linear / GitHub / GNAP = projection or synchronization targets + +Do not make external SaaS systems the primary runtime database for Bosun internals. That would repeat the same problem in a different place. + +Recommended modes: + +- `KANBAN_BACKEND=internal` remains default +- `KANBAN_BACKEND=jira` stays supported +- add `KANBAN_BACKEND=linear` only when Bosun can preserve the same shared-state contract it already has for Jira +- support `*_SYNC_MODE=projection|bidirectional`, defaulting to `projection` + +Target Bosun modules: + +- `kanban/kanban-adapter.mjs` +- `ui/modules/settings-schema.js` +- `setup.mjs` +- `server/ui-server.mjs` + +What to adopt from Linear / Jira / Atlassian: + +- stricter planning objects: initiatives, projects, cycles, tasks, milestones +- better long-term roadmap and backlog structure +- clearer separation between execution tasks and planning artifacts +- richer issue metadata surfaced in Bosun UI + +What not to copy: + +- full Atlassian platform sprawl +- external system as Bosun's runtime truth + +### 4. Add first-class DAG and session observability from Chorus and UFO + +Chorus and UFO are the clearest evidence that Bosun should make task dependency structure and agent presence visible, not implied. + +Bosun already has workflows and execution ledgers, but the operator experience is still too flat. + +What to adopt: + +- task DAG visualization +- worker/session badges on tasks +- real-time active-agent presence +- explicit claim/verify/review states +- dependency-aware planning and execution views +- device/capability-aware assignment for computer-use agents + +Target Bosun modules: + +- `workflow/workflow-engine.mjs` +- `workflow/execution-ledger.mjs` +- `task/task-executor.mjs` +- `infra/session-tracker.mjs` +- `ui/` +- `server/ui-server.mjs` + +Recommended additions: + +- `workflow/task-graph-projection.mjs` +- `infra/agent-presence-store.mjs` +- `ui/components/task-graph.*` +- `ui/components/agent-presence.*` + +This should be paired with a "gamified" but operationally useful dashboard: + +- active agents +- assigned task nodes +- blocked edges +- stale sessions +- retry hotspots +- artifact counts +- PR status cards + +### 5. Add a subconscious guidance layer, but keep it advisory + +The best idea in `claude-subconscious-main` is not Letta. It is the pattern: + +- background observer +- transcript digestion +- repo reading +- proactive guidance injection + +Temm1e adds the missing piece: memory must be fidelity-tiered and budget-aware. + +Bosun should introduce a background subsystem that: + +- watches session transcripts, diffs, failures, and repeated retries +- creates compact guidance records and reusable blueprints +- injects briefings at session start, task claim time, and failure recovery time + +Target Bosun modules: + +- `workspace/shared-knowledge.mjs` +- `infra/session-tracker.mjs` +- `agent/agent-hooks.mjs` +- `agent/fleet-coordinator.mjs` +- `workflow/research-evidence-sidecar.mjs` + +Recommended additions: + +- `workspace/subconscious-store.mjs` +- `workspace/blueprint-store.mjs` +- `workspace/briefing-builder.mjs` + +Hard rules: + +- off by default +- private local storage, not git +- advisory only; never silently mutate task state or prompts in opaque ways +- every whisper/briefing must be attributable and inspectable in the UI + +### 6. Introduce memory tiers instead of one flat knowledge bucket + +Bosun's current shared knowledge model is too undifferentiated. + +Temm1e's strongest transferable idea is not the branding around lambda memory. It is the tiering model: + +- full detail while hot +- compressed summary when cooling +- essence/hash/handle when cold +- recall back to full evidence when needed + +Bosun should separate: + +- execution evidence +- operational lessons +- reusable blueprints +- repo knowledge graph +- transient working context + +Recommended SQL tables / logical stores: + +- `memory_events` +- `memory_summaries` +- `memory_blueprints` +- `memory_recall_handles` +- `knowledge_nodes` +- `knowledge_edges` + +### 7. Build Bosun IDE and computer-use surfaces deliberately + +Cline, UFO, and Temm1e all point in the same direction: Bosun should not remain only a Telegram/web control plane. + +Two parallel bets make sense: + +#### VS Code / IDE Surface + +Use Cline as the pattern source for: + +- side-panel Bosun task view +- add-file/add-folder/add-problems/add-url context actions +- checkpoint and restore UX +- task history reconstruction +- inline diff and terminal visibility + +This should not replace Bosun's server runtime. It should be an IDE client for Bosun. + +#### Computer-Use Surface + +Use UFO and Temm1e as the pattern source for: + +- Windows-first desktop control +- later Linux/Android device agents +- capability profiles per device +- orchestration of computer-use tasks as explicit workflow nodes + +Computer-use should be treated as a special execution backend with stronger safety gates. + +Target Bosun modules: + +- `desktop/` +- `voice/vision-session-state.mjs` +- `agent/agent-custom-tools.mjs` +- `shell/` +- `workflow/workflow-nodes.mjs` + +### 8. Use Airweave ideas for ingestion and retrieval, not for full stack replacement + +Airweave's useful idea is the retrieval layer pattern: + +- connectors +- sync jobs +- normalized entities +- retrieval API +- agent-facing SDK/MCP exposure + +Bosun should adopt that shape for: + +- Jira +- Linear +- GitHub +- docs/RFCs/ADRs +- local repo graph +- session artifacts +- optional browser captures + +But Bosun should not copy: + +- the full FastAPI + Temporal + Redis + Vespa stack + +Bosun's lightweight version can stay local-first and SQLite-first until scale proves otherwise. + +## Copy / Refactor Priority + +### Direct copy-and-refactor candidates + +These are worth actively porting into `.mjs` designs: + +- AgentFS storage schema concepts +- Corbell graph-store and code-graph concepts +- Chorus task/session lifecycle and DAG/operator concepts +- Claude Subconscious background guidance pattern +- Cline checkpoint/history/context UX patterns + +### Selective borrowing only + +- UFO orchestration model and capability profiles +- Temm1e memory tiering and blueprint concepts +- Airweave connector/retrieval architecture + +### Mostly reference, not code + +- Devika overall architecture +- Atlassian/Linear homepage-level UX and planning inspiration + +## Proposed New Settings + +All new subsystems should be opt-in: + +```bash +BOSUN_SQL_RUNTIME_ENABLED=true +BOSUN_SQL_UI_SOURCE_OF_TRUTH=true + +BOSUN_KNOWLEDGE_GRAPH_ENABLED=false +BOSUN_EMBEDDINGS_ENABLED=false +BOSUN_BLUEPRINT_MEMORY_ENABLED=false +BOSUN_SUBCONSCIOUS_ENABLED=false + +BOSUN_LINEAR_ENABLED=false +BOSUN_LINEAR_SYNC_MODE=projection + +BOSUN_COMPUTER_USE_ENABLED=false +BOSUN_DEVICE_AGENT_ENABLED=false +``` + +## Recommended Implementation Order + +### Phase 1: SQL consolidation + +- extend `lib/state-ledger-sqlite.mjs` into a broader runtime store +- migrate task/session/artifact/audit reads in UI server to SQL-backed views +- demote JSON files to evidence/artifact roles only + +### Phase 2: knowledge graph + +- extend `workspace/context-indexer.mjs` +- add graph tables, graph builder, and query layer +- link tasks/runs/artifacts to code graph nodes + +### Phase 3: tracker projection layer + +- keep Jira solid +- add Linear as a proper projection/backend only if required +- build roadmap/project/cycle abstractions above raw issue trackers + +### Phase 4: DAG and presence UI + +- render task graphs and execution graphs in UI +- add session/worker badges, blocked-state views, and artifact cards + +### Phase 5: subconscious + blueprint memory + +- background summarization and whisper system +- inspectable briefings and reusable recipes + +### Phase 6: IDE + computer-use + +- Bosun VS Code extension/client +- Windows-first device agent backend +- computer-use workflow nodes with hard safety gates + +## Bottom Line + +If we only borrow one thing, it should be AgentFS/Corbell-style SQL-and-graph rigor. + +If we borrow two things, add Chorus/UFO-style DAG and agent observability. + +If we borrow three things, add Cline/Claude-Subconscious/Temm1e-style IDE memory and subconscious guidance. + +That gives Bosun a coherent architecture: + +- SQL for truth +- graph for knowledge +- DAG for execution +- projections for Jira/Linear/GitHub +- subconscious memory for guidance +- IDE/computer-use surfaces for control + +That is a much stronger direction than continuing to add isolated JSON-based features around the existing runtime. diff --git a/_docs/WORKFLOWS.md b/_docs/WORKFLOWS.md index 6c3788a23..fcb5311cd 100644 --- a/_docs/WORKFLOWS.md +++ b/_docs/WORKFLOWS.md @@ -16,6 +16,64 @@ Bosun workflows are DAGs (directed acyclic graphs) of nodes that automate orches - File-based: drop a workflow JSON file into `.bosun/workflows/`. - Templates: `POST /api/workflows/install-template` or use the UI install button. +## Research Evidence Sidecar Workflow + +Bosun's scientific research path can run an evidence-backed workflow without changing the native context indexer. The manual `research-agent` flow now supports a sidecar-aware mode that: + +- Keeps Bosun as the orchestrator and verification loop owner. +- Builds a structured evidence bundle through `workflow/research-evidence-sidecar.mjs`. +- Accepts optional local text corpora via `corpusPaths`. +- Optionally delegates to an external in-house sidecar command through a JSON stdin/stdout contract. +- Writes raw evidence artifacts to `.bosun/research-evidence/`. +- Promotes only reviewed findings into `.bosun/shared-knowledge/REVIEWED_RESEARCH.md` after the verifier returns `VERDICT: CORRECT`. + +Important boundary: the Bosun-native `workspace/context-indexer.mjs` remains unchanged and PDFs stay excluded from that index. If PDF-derived evidence is needed, extract text or notes for the sidecar corpus instead of widening the context indexer. + +## State Ledger Contract + +Bosun now dual-writes workflow, task, and claim state into a SQLite ledger at `.bosun/.cache/state-ledger.sqlite` while keeping the existing JSON and JSONL stores authoritative for runtime continuity. + +- `workflow/execution-ledger.mjs` mirrors run metadata and execution events into the ledger. +- `task/task-claims.mjs` mirrors claim snapshots and claim audit events into the ledger. +- `task/task-store.mjs` mirrors current task snapshots, including deletes, into the ledger. +- Read models may hydrate from the SQLite ledger when file-backed state is missing or truncated. + +Important boundary: phase 1 ledger adoption is for auditability, replay, analytics, and self-improvement inputs. Bosun must continue to execute correctly if the SQLite mirror is unavailable. + +## Planner Proof Contract + +Planner-heavy workflows now expose structured proof surfaces in run detail and task-linked workflow summaries. + +- `plannerTimeline` captures planner lifecycle events such as initialization, reasoning steps, attachments, completion, and blocked stages. +- `proofBundle.summary` reports planner, decision, evidence, and artifact counts. +- `proofBundle.decisions` aggregates planner decisions plus issue-advisor guidance and retry-policy outcomes. +- `proofBundle.evidence` and `proofBundle.artifacts` capture completion evidence, artifact pointers, and proof attachments for downstream review. +- `server/ui-server.mjs` includes these proof surfaces in workflow run payloads consumed by the Mini App and hosted UI. + +Operators should treat these surfaces as the canonical review payload for planner workflows instead of reconstructing proof from raw logs. + +## GNAP Projection Contract + +Bosun can project shared kanban state into a GNAP-style repository layout when GNAP is explicitly enabled in Settings. + +- `KANBAN_BACKEND=gnap` is valid only when `GNAP_ENABLED=true`, `GNAP_REPO_PATH` is set, and `GNAP_SYNC_MODE=projection`. +- Projected artifacts are written under `.gnap/`, including task state, run summaries, and collaboration messages. +- The internal Bosun task store remains authoritative for task creation, status changes, comments, claims, and workflow liveness. +- GNAP is intended for cross-user visibility and collaboration, not for replacing `task/task-claims.mjs`, shared-state coordination, or fleet heartbeats. + +Important boundary: projection mode is the only supported GNAP sync mode in this rollout. + +## Self-Improvement Contract + +Bosun self-improvement is now workflow-native rather than a separate subsystem. + +- `action.evaluate_run` benchmarks a workflow run, records durable evaluation history, and emits promotion candidates. +- `action.promote_strategy` persists verified strategies and baselines into shared knowledge when the evaluator recommends promotion or a workflow explicitly forces it. +- The reliability template uses recent-run evaluation and promotion gates to preserve good patterns and surface regressions. +- Shared knowledge entries must retain provenance back to the evaluated run and workflow before they can influence future planning. + +Important boundary: self-improvement recommendations are evidence-gated. Bosun should not automatically mutate production strategy based only on one unreviewed run. + **Workflow Definition (JSON)** Minimum example: diff --git a/agent/agent-pool.mjs b/agent/agent-pool.mjs index 06e53f00f..e62d46df5 100644 --- a/agent/agent-pool.mjs +++ b/agent/agent-pool.mjs @@ -3208,8 +3208,10 @@ function isCodexResumeTimeoutError(errorValue) { * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null }>} */ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) { - // Coerce to number — prevents string concatenation in setTimeout arithmetic - timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS; + // Coerce to number and clamp to Node.js setTimeout max (2^31-1 ms ≈ 24.8 days) + // to prevent resource exhaustion via user-controlled duration values. + const MAX_SET_TIMEOUT_MS = 2_147_483_647; + timeoutMs = Math.min(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_SET_TIMEOUT_MS); const { onEvent, abortController: externalAC, envOverrides = null } = extra; let CodexClass; diff --git a/agent/internal-harness-profile.mjs b/agent/internal-harness-profile.mjs new file mode 100644 index 000000000..c243e6441 --- /dev/null +++ b/agent/internal-harness-profile.mjs @@ -0,0 +1,500 @@ +import { createHash } from "node:crypto"; + +const SECRET_KEY_RE = /(api[_-]?key|token|secret|password|client[_-]?secret|pat)/i; +const SECRET_PLACEHOLDER_ENV_RE = /^(?:\$?\{?[A-Z0-9_:-]+\}?|<[^>]+>)$/; +const SECRET_PLACEHOLDER_TEXT_RE = /^(?:changeme|replace[-_ ]?me|your[-_ ]?key|your[-_ ]?token)$/i; +const PROMPT_INJECTION_RE = /\b(ignore (?:all |any |the )?(?:previous|prior) instructions|reveal (?:the )?(?:system|developer) prompt|bypass (?:all )?(?:guardrails|safeguards)|disable (?:all )?(?:guardrails|checks)|override (?:the )?(?:system|developer) message)\b/i; +const UNSAFE_EXECUTION_RE = /\b(?:rm\s+-rf\s+\/|git\s+reset\s+--hard|curl\b[^|\n\r]*\|\s*(?:sh|bash)|wget\b[^|\n\r]*\|\s*(?:sh|bash)|del\s+\/f\s+\/s\s+\/q|format\s+[a-z]:)\b/i; +const GATE_TOOL_ALLOWLIST = new Set([ + "approval_gate", + "manual_review", + "run_tests", + "validate_diff", + "check_quality", + "await_approval", +]); +const STAGE_TYPE_ALLOWLIST = new Set([ + "prompt", + "action", + "gate", + "repair", + "finalize", +]); + +function toArray(value) { + return Array.isArray(value) ? value : []; +} + +function toTrimmedString(value) { + return String(value ?? "").trim(); +} + +function slugify(value) { + return toTrimmedString(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-/, "") + .replace(/-$/, "") || "harness"; +} + +function safeClone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeOptionalInteger(value, fallback = null) { + if (value == null || value === "") return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.trunc(numeric) : fallback; +} + +function normalizeOptionalPositiveInteger(value, fallback = null) { + const numeric = normalizeOptionalInteger(value, fallback); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; +} + +function addIssue(bucket, issue) { + bucket.push({ + code: toTrimmedString(issue?.code || "invalid"), + message: toTrimmedString(issue?.message || "Invalid harness profile"), + path: toTrimmedString(issue?.path || ""), + stageId: toTrimmedString(issue?.stageId || ""), + }); +} + +function parseSourceObject(source) { + if (isObject(source)) return safeClone(source); + const raw = toTrimmedString(source); + if (!raw) { + throw new Error("Harness source is empty"); + } + try { + return JSON.parse(raw); + } catch { + const fenceMatch = raw.match(/```(?:json)?[ \t]*\r?\n([\s\S]*?)```/i); + if (fenceMatch?.[1]) { + return JSON.parse(fenceMatch[1]); + } + } + throw new Error("Harness source must be a JSON object or markdown fenced JSON block"); +} + +function normalizeTransitionList(stage) { + const transitions = []; + if (toTrimmedString(stage?.next)) { + transitions.push({ on: "success", to: toTrimmedString(stage.next) }); + } + if (Array.isArray(stage?.transitions)) { + for (const entry of stage.transitions) { + if (typeof entry === "string") { + const target = toTrimmedString(entry); + if (target) transitions.push({ on: "next", to: target }); + continue; + } + if (!isObject(entry)) continue; + const target = toTrimmedString(entry.to || entry.target || entry.stageId); + if (!target) continue; + transitions.push({ + on: toTrimmedString(entry.on || entry.event || "next") || "next", + to: target, + }); + } + } + if (isObject(stage?.transitionMap)) { + for (const [eventName, target] of Object.entries(stage.transitionMap)) { + const resolvedTarget = toTrimmedString(target); + if (!resolvedTarget) continue; + transitions.push({ + on: toTrimmedString(eventName || "next") || "next", + to: resolvedTarget, + }); + } + } + return transitions; +} + +function normalizeSkillEntry(entry) { + if (typeof entry === "string") { + return { + ref: toTrimmedString(entry), + pinned: false, + source: "string", + }; + } + if (!isObject(entry)) { + return { ref: "", pinned: false, source: "unknown" }; + } + return { + ref: toTrimmedString(entry.ref || entry.path || entry.id || entry.skill), + pinned: entry.pinned === true, + source: "object", + }; +} + +function normalizeStage(stage, index, defaults = {}) { + const id = toTrimmedString(stage?.id || stage?.stageId || `stage-${index + 1}`); + const typeRaw = toTrimmedString(stage?.type || stage?.kind || "prompt").toLowerCase(); + const type = STAGE_TYPE_ALLOWLIST.has(typeRaw) ? typeRaw : "prompt"; + const tools = toArray(stage?.tools) + .map((entry) => toTrimmedString(entry)) + .filter(Boolean); + const transitions = normalizeTransitionList(stage); + const repairLoop = isObject(stage?.repairLoop) + ? { + maxAttempts: Number(stage.repairLoop.maxAttempts), + targetStageId: toTrimmedString(stage.repairLoop.targetStageId || stage.repairLoop.target || ""), + backoffMs: Number(stage.repairLoop.backoffMs), + onFailure: toTrimmedString(stage.repairLoop.onFailure || stage.repairLoop.strategy || "retry"), + } + : null; + return { + id, + index, + type, + prompt: toTrimmedString(stage?.prompt || stage?.instruction || ""), + cwd: toTrimmedString(stage?.cwd || defaults.defaultCwd || ""), + sessionType: toTrimmedString(stage?.sessionType || defaults.defaultSessionType || ""), + sdk: toTrimmedString(stage?.sdk || defaults.defaultSdk || ""), + model: toTrimmedString(stage?.model || defaults.defaultModel || ""), + taskKey: toTrimmedString(stage?.taskKey || defaults.defaultTaskKey || defaults.defaultAgentId || ""), + timeoutMs: normalizeOptionalInteger(stage?.timeoutMs), + maxRetries: normalizeOptionalInteger(stage?.maxRetries), + maxContinues: normalizeOptionalInteger(stage?.maxContinues), + tools, + transitions, + repairLoop, + meta: isObject(stage?.meta) ? safeClone(stage.meta) : {}, + }; +} + +function collectObjectIssues(value, visit, path = "") { + if (Array.isArray(value)) { + value.forEach((entry, index) => collectObjectIssues(entry, visit, `${path}[${index}]`)); + return; + } + if (!isObject(value)) return; + for (const [key, entry] of Object.entries(value)) { + const nextPath = path ? `${path}.${key}` : key; + visit(key, entry, nextPath); + collectObjectIssues(entry, visit, nextPath); + } +} + +function validateSkillRefs(skillEntries, report) { + const normalized = skillEntries.map(normalizeSkillEntry); + for (const entry of normalized) { + if (!entry.ref) { + addIssue(report.errors, { + code: "skill_ref_missing", + message: "Skill entries must include a ref/path/id", + path: "skills", + }); + continue; + } + const looksPinned = + entry.pinned === true || + entry.ref.includes("/") || + entry.ref.includes("\\") || + entry.ref.includes(".md") || + entry.ref.includes("@") || + entry.ref.startsWith("$"); + if (!looksPinned) { + addIssue(report.errors, { + code: "skill_ref_unpinned", + message: `Skill ref "${entry.ref}" must be pinned to a concrete path or version`, + path: "skills", + }); + } + } + return normalized + .filter((entry) => entry.ref) + .map((entry) => ({ + ref: entry.ref, + pinned: entry.pinned === true || entry.ref.includes("/") || entry.ref.includes("\\") || entry.ref.includes(".md") || entry.ref.includes("@"), + })); +} + +function validateStages(stages, entryStageId, report) { + const knownIds = new Set(); + const stageById = new Map(); + for (const stage of stages) { + if (!stage.id) { + addIssue(report.errors, { + code: "stage_id_missing", + message: "Each stage requires a non-empty id", + path: "stages", + }); + continue; + } + if (knownIds.has(stage.id)) { + addIssue(report.errors, { + code: "stage_id_duplicate", + message: `Duplicate stage id "${stage.id}"`, + path: "stages", + stageId: stage.id, + }); + continue; + } + knownIds.add(stage.id); + stageById.set(stage.id, stage); + if (!stage.prompt) { + addIssue(report.errors, { + code: "stage_prompt_missing", + message: `Stage "${stage.id}" requires a prompt`, + path: `stages.${stage.id}.prompt`, + stageId: stage.id, + }); + } + if (PROMPT_INJECTION_RE.test(stage.prompt)) { + addIssue(report.warnings, { + code: "prompt_injection_phrase", + message: `Stage "${stage.id}" prompt contains a prompt-injection phrase`, + path: `stages.${stage.id}.prompt`, + stageId: stage.id, + }); + } + if (UNSAFE_EXECUTION_RE.test(stage.prompt)) { + addIssue(report.errors, { + code: "unsafe_execution_phrase", + message: `Stage "${stage.id}" prompt contains an unsafe execution pattern`, + path: `stages.${stage.id}.prompt`, + stageId: stage.id, + }); + } + } + + if (!knownIds.has(entryStageId)) { + addIssue(report.errors, { + code: "entry_stage_missing", + message: `Entry stage "${entryStageId}" does not exist`, + path: "entryStageId", + }); + } + + for (const stage of stages) { + for (const transition of stage.transitions) { + if (!knownIds.has(transition.to)) { + addIssue(report.errors, { + code: "stage_transition_unknown", + message: `Stage "${stage.id}" transitions to unknown stage "${transition.to}"`, + path: `stages.${stage.id}.transitions`, + stageId: stage.id, + }); + } + } + if (stage.type === "gate") { + if (stage.index === stages.length - 1) { + addIssue(report.errors, { + code: "gate_stage_terminal", + message: `Gate stage "${stage.id}" cannot be terminal`, + path: `stages.${stage.id}`, + stageId: stage.id, + }); + } + if (!stage.tools.some((tool) => GATE_TOOL_ALLOWLIST.has(tool))) { + addIssue(report.errors, { + code: "gate_stage_tool_missing", + message: `Gate stage "${stage.id}" must declare at least one gate tool (${Array.from(GATE_TOOL_ALLOWLIST).join(", ")})`, + path: `stages.${stage.id}.tools`, + stageId: stage.id, + }); + } + } + if (stage.timeoutMs != null && (!Number.isFinite(stage.timeoutMs) || stage.timeoutMs < 1)) { + addIssue(report.errors, { + code: "stage_timeout_invalid", + message: `Stage "${stage.id}" timeoutMs must be >= 1`, + path: `stages.${stage.id}.timeoutMs`, + stageId: stage.id, + }); + } + if (stage.maxRetries != null && (!Number.isFinite(stage.maxRetries) || stage.maxRetries < 0)) { + addIssue(report.errors, { + code: "stage_max_retries_invalid", + message: `Stage "${stage.id}" maxRetries must be >= 0`, + path: `stages.${stage.id}.maxRetries`, + stageId: stage.id, + }); + } + if (stage.maxContinues != null && (!Number.isFinite(stage.maxContinues) || stage.maxContinues < 0)) { + addIssue(report.errors, { + code: "stage_max_continues_invalid", + message: `Stage "${stage.id}" maxContinues must be >= 0`, + path: `stages.${stage.id}.maxContinues`, + stageId: stage.id, + }); + } + if (stage.repairLoop) { + if (!Number.isFinite(stage.repairLoop.maxAttempts) || stage.repairLoop.maxAttempts < 1) { + addIssue(report.errors, { + code: "repair_loop_max_attempts_invalid", + message: `Stage "${stage.id}" repairLoop.maxAttempts must be >= 1`, + path: `stages.${stage.id}.repairLoop.maxAttempts`, + stageId: stage.id, + }); + } + if (!stage.repairLoop.targetStageId) { + addIssue(report.errors, { + code: "repair_loop_target_missing", + message: `Stage "${stage.id}" repairLoop.targetStageId is required`, + path: `stages.${stage.id}.repairLoop.targetStageId`, + stageId: stage.id, + }); + } else if (!knownIds.has(stage.repairLoop.targetStageId)) { + addIssue(report.errors, { + code: "repair_loop_target_unknown", + message: `Stage "${stage.id}" repairLoop.targetStageId references unknown stage "${stage.repairLoop.targetStageId}"`, + path: `stages.${stage.id}.repairLoop.targetStageId`, + stageId: stage.id, + }); + } + } + } + + const reachable = new Set(); + const queue = knownIds.has(entryStageId) ? [entryStageId] : []; + while (queue.length > 0) { + const stageId = queue.shift(); + if (!stageId || reachable.has(stageId)) continue; + reachable.add(stageId); + const stage = stageById.get(stageId); + for (const transition of toArray(stage?.transitions)) { + if (transition?.to && !reachable.has(transition.to)) { + queue.push(transition.to); + } + } + } + + for (const stage of stages) { + if (!reachable.has(stage.id)) { + addIssue(report.warnings, { + code: "stage_unreachable", + message: `Stage "${stage.id}" is unreachable from entry stage "${entryStageId}"`, + path: `stages.${stage.id}`, + stageId: stage.id, + }); + } + } +} + +export function compileInternalHarnessProfile(source, options = {}) { + const profile = parseSourceObject(source); + const report = { errors: [], warnings: [] }; + + if (!isObject(profile)) { + throw new Error("Harness source must compile to an object"); + } + + collectObjectIssues(profile, (key, value, path) => { + if (typeof value !== "string") return; + const trimmed = toTrimmedString(value); + if (!trimmed) return; + if ( + SECRET_KEY_RE.test(key) && + !SECRET_PLACEHOLDER_ENV_RE.test(trimmed) && + !SECRET_PLACEHOLDER_TEXT_RE.test(trimmed) + ) { + addIssue(report.errors, { + code: "secret_literal_detected", + message: `Secret-looking field "${path}" must not contain a literal secret`, + path, + }); + } + }); + + const stagesInput = toArray(profile.stages); + if (stagesInput.length === 0) { + addIssue(report.errors, { + code: "stages_missing", + message: "Harness profile requires a non-empty stages array", + path: "stages", + }); + } + + const stages = stagesInput.map((stage, index) => normalizeStage(stage, index, options)); + const entryStageId = toTrimmedString(profile.entryStageId || stages[0]?.id || ""); + const skills = validateSkillRefs(toArray(profile.skills), report); + validateStages(stages, entryStageId, report); + + const stageCount = stages.length; + const transitionCount = stages.reduce((sum, stage) => sum + stage.transitions.length, 0); + const gateStageCount = stages.filter((stage) => stage.type === "gate").length; + const repairLoopCount = stages.filter((stage) => stage.repairLoop).length; + const unreachableStageCount = report.warnings.filter((issue) => issue.code === "stage_unreachable").length; + + const baseName = toTrimmedString(profile.name || profile.id || profile.agentId || ""); + const sourceHash = createHash("sha256") + .update(JSON.stringify(profile)) + .digest("hex"); + const agentIdBase = slugify(baseName || entryStageId || "harness"); + const agentId = `${agentIdBase}-${sourceHash.slice(0, 12)}`; + + const compiledProfile = { + schemaVersion: 1, + kind: "bosun-internal-harness-profile", + agentId, + name: baseName || agentIdBase, + description: toTrimmedString(profile.description || ""), + entryStageId, + cwd: toTrimmedString(profile.cwd || options.defaultCwd || ""), + sessionType: toTrimmedString(profile.sessionType || options.defaultSessionType || ""), + sdk: toTrimmedString(profile.sdk || options.defaultSdk || ""), + model: toTrimmedString(profile.model || options.defaultModel || ""), + taskKey: toTrimmedString(profile.taskKey || options.defaultTaskKey || agentId || ""), + skills, + metadata: { + compiledAt: new Date().toISOString(), + sourceHash, + stageCount, + transitionCount, + gateStageCount, + repairLoopCount, + unreachableStageCount, + }, + stages: stages.map((stage) => ({ + id: stage.id, + index: stage.index, + type: stage.type, + prompt: stage.prompt, + cwd: stage.cwd, + sessionType: stage.sessionType, + sdk: stage.sdk, + model: stage.model, + taskKey: stage.taskKey, + timeoutMs: stage.timeoutMs, + maxRetries: stage.maxRetries, + maxContinues: stage.maxContinues, + tools: stage.tools, + transitions: stage.transitions, + repairLoop: stage.repairLoop, + meta: stage.meta, + })), + }; + + const compiledProfileJson = JSON.stringify(compiledProfile, null, 2); + return { + agentId, + compiledProfile, + compiledProfileJson, + validationReport: { + errors: report.errors, + warnings: report.warnings, + stats: { + stageCount, + transitionCount, + gateStageCount, + repairLoopCount, + skillCount: skills.length, + unreachableStageCount, + }, + }, + isValid: report.errors.length === 0, + sourceProfile: safeClone(profile), + sourceHash, + }; +} + +export default compileInternalHarnessProfile; diff --git a/config/config-editor.mjs b/config/config-editor.mjs new file mode 100644 index 000000000..64c42ce48 --- /dev/null +++ b/config/config-editor.mjs @@ -0,0 +1,404 @@ +import { renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; + +export const CONFIG_EDITOR_SECTIONS = Object.freeze([ + { id: "general", label: "General" }, + { id: "agents", label: "Agents" }, + { id: "workflows", label: "Workflows" }, + { id: "kanban", label: "Kanban" }, + { id: "integrations", label: "Integrations" }, + { id: "cost-rates", label: "Cost Rates" }, +]); + +const SECTION_BY_ID = new Map(CONFIG_EDITOR_SECTIONS.map((section) => [section.id, section])); +const SENSITIVE_PATH_PATTERN = /(token|secret|api[_-]?key|credential|access[_-]?token|private[_-]?key|password)/i; +let cachedSchema = null; +let cachedValidator = null; + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value ?? {})); +} + +function hasOwn(obj, key) { + return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key); +} + +function isSafeKey(key) { + return key !== "__proto__" && key !== "constructor" && key !== "prototype"; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function getSchemaTypes(schema = {}) { + if (Array.isArray(schema?.type)) return schema.type.filter(Boolean); + if (schema?.type) return [schema.type]; + if (Array.isArray(schema?.enum)) return ["string"]; + if (Array.isArray(schema?.oneOf)) { + const collected = []; + for (const option of schema.oneOf) { + collected.push(...getSchemaTypes(option)); + } + return [...new Set(collected)]; + } + if (schema?.properties) return ["object"]; + if (schema?.items) return ["array"]; + return ["string"]; +} + +function getEditorKind(schema = {}) { + if (Array.isArray(schema?.enum) && schema.enum.length > 0) return "enum"; + const types = getSchemaTypes(schema); + if (types.includes("boolean")) return "boolean"; + if (types.includes("number") || types.includes("integer")) return "number"; + if (types.includes("object") || types.includes("array")) return "json"; + if (Array.isArray(schema?.oneOf) && !types.includes("string")) return "json"; + return "string"; +} + +function describeSchemaType(schema = {}) { + if (Array.isArray(schema?.enum) && schema.enum.length > 0) return "enum"; + const types = getSchemaTypes(schema); + if (types.length === 1) return types[0]; + return types.join("|"); +} + +function isGroupSchema(schema = {}) { + return isPlainObject(schema?.properties); +} + +function shouldShowAsJsonText(schema = {}) { + return getEditorKind(schema) === "json"; +} + +function determineSection(pathParts = []) { + const [root] = pathParts; + if (!root) return { sectionId: "general", subsection: null }; + if (root === "kanban") return { sectionId: "kanban", subsection: null }; + if ( + root.startsWith("workflow") + || root === "workflows" + || root === "worktreeBootstrap" + || root === "plannerMode" + || root === "triggerSystem" + ) { + return { sectionId: "workflows", subsection: null }; + } + if ( + root.startsWith("telegram") + || root.startsWith("cloudflare") + ) { + return { sectionId: "integrations", subsection: "Telegram" }; + } + if (root === "voice") { + return { sectionId: "integrations", subsection: "Voice" }; + } + if ( + root === "prAutomation" + || root === "gates" + ) { + return { sectionId: "integrations", subsection: "GitHub" }; + } + if ( + root === "auth" + || root === "internalExecutor" + || root === "executors" + || root === "failover" + || root === "distribution" + || root === "primaryAgent" + || root === "profiles" + || root === "envProfiles" + || root === "agentPrompts" + || root === "hookProfiles" + || root === "agentHooks" + || root === "markdownSafety" + || root === "interactiveShellEnabled" + || root === "shellEnabled" + || root === "codexEnabled" + ) { + return { sectionId: "agents", subsection: null }; + } + return { sectionId: "general", subsection: null }; +} + +function ensureSectionBucket(buckets, sectionId, subsection = null) { + if (!buckets.has(sectionId)) { + buckets.set(sectionId, { + id: sectionId, + label: SECTION_BY_ID.get(sectionId)?.label || sectionId, + subsections: new Map(), + items: [], + }); + } + const section = buckets.get(sectionId); + if (!subsection) return section.items; + if (!section.subsections.has(subsection)) { + section.subsections.set(subsection, []); + } + return section.subsections.get(subsection); +} + +function normalizeDisplayValue(schema, value) { + if (value === undefined) return ""; + if (value === null) return "null"; + if (shouldShowAsJsonText(schema)) return JSON.stringify(value); + if (Array.isArray(schema?.enum) && schema.enum.includes(value)) return String(value); + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : ""; + if (Array.isArray(value) || isPlainObject(value)) return JSON.stringify(value); + return String(value); +} + +export function getConfigValueAtPath(obj, pathParts = []) { + let cursor = obj; + for (const part of pathParts) { + if (!isPlainObject(cursor) && !Array.isArray(cursor)) return undefined; + if (!hasOwn(cursor, part)) return undefined; + cursor = cursor[part]; + } + return cursor; +} + +export function setConfigValueAtPath(obj, pathParts = [], value) { + let cursor = obj; + for (let index = 0; index < pathParts.length; index += 1) { + const part = pathParts[index]; + if (!isSafeKey(part)) return obj; + if (index === pathParts.length - 1) { + cursor[part] = value; + return obj; + } + if (!isPlainObject(cursor[part])) cursor[part] = {}; + cursor = cursor[part]; + } + return obj; +} + +export function getConfigSchemaProperty(schema, pathParts = []) { + let cursor = schema; + for (const part of pathParts) { + if (!isPlainObject(cursor?.properties)) return null; + cursor = cursor.properties[part]; + } + return cursor || null; +} + +export function isSensitiveConfigPath(pathParts = [], schema = {}) { + if (schema?.sensitive === true) return true; + return pathParts.some((part) => SENSITIVE_PATH_PATTERN.test(String(part || ""))); +} + +export function buildConfigEditorModel({ + schema, + configData = {}, + envOverridesByPath = new Map(), +} = {}) { + const buckets = new Map(); + const fieldIndex = new Map(); + + const visit = (node, pathParts = [], depth = 0) => { + if (!node) return; + const { sectionId, subsection } = determineSection(pathParts); + const items = ensureSectionBucket(buckets, sectionId, subsection); + const path = pathParts.join("."); + + if (pathParts.length > 0 && isGroupSchema(node)) { + items.push({ + kind: "group", + id: `group:${path}`, + path, + depth, + label: pathParts[pathParts.length - 1], + description: node.description || "", + }); + } + + if (isGroupSchema(node)) { + const childDepth = pathParts.length > 0 ? depth + 1 : depth; + for (const [childKey, childNode] of Object.entries(node.properties)) { + visit(childNode, [...pathParts, childKey], childDepth); + } + return; + } + + if (pathParts.length === 0) return; + + const envOverride = envOverridesByPath.get(path) || null; + const hasConfigValue = getConfigValueAtPath(configData, pathParts) !== undefined; + const configValue = getConfigValueAtPath(configData, pathParts); + const hasDefault = hasOwn(node, "default"); + const source = envOverride + ? "env" + : hasConfigValue + ? "config" + : "default"; + const rawValue = envOverride + ? envOverride.value + : hasConfigValue + ? configValue + : hasDefault + ? node.default + : undefined; + const field = { + kind: "field", + id: path, + path, + pathParts: [...pathParts], + depth, + label: pathParts[pathParts.length - 1], + source, + sourceLabel: source === "env" ? "from env" : source === "config" ? "from config" : "default", + readOnly: Boolean(envOverride), + envKey: envOverride?.envKey || null, + masked: isSensitiveConfigPath(pathParts, node), + editorKind: getEditorKind(node), + schemaType: describeSchemaType(node), + enumValues: Array.isArray(node.enum) ? [...node.enum] : [], + description: node.description || "", + rawValue, + valueText: normalizeDisplayValue(node, rawValue), + }; + items.push(field); + fieldIndex.set(path, field); + }; + + visit(schema, [], 0); + + const sections = CONFIG_EDITOR_SECTIONS.map((section) => { + const bucket = buckets.get(section.id); + const items = []; + if (bucket) { + items.push(...bucket.items); + for (const [subsection, subsectionItems] of bucket.subsections.entries()) { + items.push({ + kind: "subsection", + id: `subsection:${section.id}:${subsection}`, + sectionId: section.id, + label: subsection, + depth: 0, + }); + items.push(...subsectionItems); + } + } + return { + id: section.id, + label: section.label, + items, + }; + }); + + return { + sections, + fieldIndex, + }; +} + +function parseStrictBoolean(value) { + if (typeof value === "boolean") return value; + const normalized = String(value ?? "").trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error("Expected boolean value"); +} + +function parseNumericValue(value, { integer = false } = {}) { + const numeric = Number(String(value ?? "").trim()); + if (!Number.isFinite(numeric)) { + throw new Error(`Expected ${integer ? "integer" : "number"} value`); + } + if (integer && !Number.isInteger(numeric)) { + throw new Error("Expected integer value"); + } + return numeric; +} + +export function parseConfigEditorInput(schema, rawValue) { + const editorKind = getEditorKind(schema); + + if (editorKind === "boolean") { + return parseStrictBoolean(rawValue); + } + if (editorKind === "number") { + const integer = getSchemaTypes(schema).includes("integer"); + return parseNumericValue(rawValue, { integer }); + } + if (editorKind === "enum") { + const value = String(rawValue ?? ""); + if (!Array.isArray(schema?.enum) || schema.enum.includes(value)) return value; + throw new Error(`Expected one of: ${schema.enum.join(", ")}`); + } + if (editorKind === "json") { + if (Array.isArray(rawValue) || isPlainObject(rawValue)) return rawValue; + const text = String(rawValue ?? "").trim(); + if (!text) throw new Error("Expected JSON value"); + if (Array.isArray(schema?.oneOf) && !text.startsWith("{") && !text.startsWith("[")) { + return text; + } + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`Invalid JSON: ${error.message}`); + } + } + return String(rawValue ?? ""); +} + +export function validateConfigDocument(schema, candidate) { + if (!schema) return []; + if (cachedSchema !== schema || typeof cachedValidator !== "function") { + const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true }); + cachedValidator = ajv.compile(schema); + cachedSchema = schema; + } + const valid = cachedValidator(candidate); + return valid ? [] : [...(cachedValidator.errors || [])]; +} + +function escapeJsonPointer(value) { + return String(value).replace(/~/g, "~0").replace(/\//g, "~1"); +} + +export function findConfigValidationMessage(errors = [], pathParts = []) { + const targetPath = `/${pathParts.map(escapeJsonPointer).join("/")}`; + const direct = errors.find((error) => String(error?.instancePath || "") === targetPath); + if (direct) return direct.message || "Invalid value"; + + const child = errors.find((error) => String(error?.instancePath || "").startsWith(`${targetPath}/`)); + if (child) return child.message || "Invalid value"; + + const required = errors.find((error) => { + if (error?.keyword !== "required") return false; + const instancePath = String(error?.instancePath || ""); + const missingProperty = String(error?.params?.missingProperty || ""); + const fullPath = `${instancePath}/${escapeJsonPointer(missingProperty)}`; + return fullPath === targetPath; + }); + if (required) return `${pathParts[pathParts.length - 1]} is required`; + + return errors[0]?.message || "Config validation failed"; +} + +export function writeJsonFileAtomic(filePath, data) { + const targetPath = resolve(filePath); + const tempPath = resolve( + dirname(targetPath), + `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`, + ); + try { + writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + renameSync(tempPath, targetPath); + } catch (error) { + try { + unlinkSync(tempPath); + } catch { + /* best effort */ + } + throw error; + } +} + +export function cloneConfigDocument(configData = {}) { + return cloneJson(configData); +} diff --git a/docs/sibling-project-adoption-analysis-2026-03-31.md b/docs/sibling-project-adoption-analysis-2026-03-31.md new file mode 100644 index 000000000..cf534bda2 --- /dev/null +++ b/docs/sibling-project-adoption-analysis-2026-03-31.md @@ -0,0 +1,450 @@ +# Bosun adoption analysis from sibling projects + +Date: 2026-03-31 +Scope: `usezombie-main`, `pi-mono-main`, `paperclip-master`, `abtop-main`, `agentfield`, `attractor`, `OpenHands-main`, `bridge-ide-main` + +## Executive summary + +The highest-value Bosun upgrades in this set are not "replace Bosun with X". They are: + +1. Build a better internal coding harness from `pi-mono-main`, with selective reliability and harness-compilation ideas from `usezombie-main`. +2. Import `bridge-ide-main` coordination primitives into Bosun as opt-in supervision layers: hierarchy, approval gates, scope locks, liveness nudges, execution journals, and capability routing. +3. Import `paperclip-master` company-level planning structures where Bosun is currently task-first: goal ancestry, org/role metadata, budgets, and durable heartbeat records. +4. Upgrade Bosun monitoring and TUI/Web UI observability using `abtop-main` and `pi-mono-main`. +5. Treat `OpenHands-main`, `agentfield`, and `attractor` as pattern sources, not direct code-port candidates. + +The strongest direct copy/refactor candidates for Bosun `.mjs` are: + +- `bridge-ide-main/Backend/approval_gate.py` +- `bridge-ide-main/Backend/handlers/scope_locks.py` +- `bridge-ide-main/Backend/agent_liveness_supervisor.py` +- `bridge-ide-main/Backend/execution_journal.py` +- `usezombie-main/src/harness/control_plane.zig` +- `usezombie-main/src/reliability/reliable_call.zig` +- `abtop-main/src/collector/codex.rs` +- `pi-mono-main/packages/agent/src/agent-loop.ts` + +## Bosun seams that make adoption realistic + +Bosun already has the right integration seams for most of this work: + +- Runtime coordination: `bosun/task/task-claims.mjs`, `bosun/workspace/shared-state-manager.mjs`, `bosun/agent/fleet-coordinator.mjs` +- Settings and UI wiring: `bosun/ui/modules/settings-schema.js`, `bosun/server/ui-server.mjs` +- TUI shell and screens: `bosun/bosun-tui.mjs`, `bosun/tui/app.mjs`, `bosun/tui/screens/*` +- Session and event surfaces: `bosun/ui/modules/session-api.js`, `bosun/ui/modules/agent-events.js`, `bosun/ui/modules/streaming.js` +- Knowledge and durable workspace state: `bosun/workspace/shared-knowledge.mjs` +- Existing retry and monitor surface: `bosun/agent/retry-queue.mjs`, `bosun/infra/monitor.mjs` +- Optional backend seam already established elsewhere: `bosun/kanban/kanban-adapter.mjs` + +That means most of the useful work here is additive, not a rewrite. + +## Project-by-project adoption + +### 1. `bridge-ide-main` + +Recommendation: highest-priority donor for multi-agent coordination. + +What is worth adopting: + +- Team hierarchy in `Backend/team.json.example` with `level`, `team`, and `reports_to` +- Human approval queue in `Backend/approval_gate.py` +- Liveness supervision in `Backend/agent_liveness_supervisor.py` +- Path-level work exclusion and audit in `Backend/handlers/scope_locks.py` +- Durable per-run evidence in `Backend/execution_journal.py` +- Capability routing and skill/catalog ideas from the backend module layout + +Why it fits Bosun: + +- Bosun already orchestrates multiple executors; it does not yet express hierarchy as a first-class planning primitive. +- Bridge's `reports_to` chain gives Bosun a concrete model for planner -> implementer -> reviewer -> verifier routing. +- Scope locks are directly relevant to Bosun's managed worktrees and concurrent edits. +- Approval gates fit Bosun's existing operator-control posture. +- The liveness supervisor is compatible with Bosun's existing "start or nudge" style recovery pattern. + +How to apply it in Bosun: + +- Add optional hierarchy metadata to executor/team/task configuration and surface it in Settings/UI. +- Add an opt-in approval layer for irreversible actions: + - git push + - deploy + - credential access + - destructive repo operations +- Add repo/path scope locks on top of `task/task-claims.mjs` rather than replacing task claims. +- Add an execution journal under Bosun private storage for each task/run with: + - run metadata + - step jsonl + - artifact references + - stable resume identifiers +- Add a light liveness supervisor that calls Bosun's canonical restart/nudge path instead of inventing a second restart mechanism. + +Concrete Bosun landing zones: + +- `bosun/task/task-claims.mjs` +- `bosun/workspace/shared-state-manager.mjs` +- `bosun/agent/fleet-coordinator.mjs` +- `bosun/server/ui-server.mjs` +- `bosun/ui/modules/settings-schema.js` +- `bosun/infra/monitor.mjs` +- `bosun/workspace/` for private execution journal storage + +Porting stance: + +- Copy logic, not framework shape. +- Re-implement in `.mjs` with Bosun storage/runtime conventions. +- Keep off by default and user-enabled in Settings. + +### 2. `pi-mono-main` + +Recommendation: highest-priority donor for Bosun's internal coding harness, TUI, and web chat surfaces. + +What is worth adopting: + +- Extension-based coding harness from `packages/coding-agent` +- Event-driven agent loop from `packages/agent/src/agent-loop.ts` +- Differential-render TUI primitives from `packages/tui` +- Artifact/attachment/sandbox UX from `packages/web-ui/src/ChatPanel.ts` +- Session-local state and extension hooks instead of hard-coding every behavior into the core runtime + +Why it fits Bosun: + +- The user specifically wants an internal Bosun-based coding harness that Bosun can continuously improve. +- `pi-mono` is modular enough to serve as a base architecture rather than just a UI inspiration repo. +- Bosun already has chat/session/event surfaces, but `pi-mono` has a cleaner local abstraction for: + - agent loop lifecycle events + - extension hooks + - custom tool rendering + - attachment/artifact UX + - TUI composition + +How to apply it in Bosun: + +- Build `bosun/agent/internal-coding-harness/` around a Bosun-native port of the `agent-loop.ts` lifecycle: + - `agent_start` + - `turn_start` + - `message_start` + - `message_end` + - `turn_end` + - `agent_end` +- Introduce a Bosun extension API for: + - permission gates + - protected paths + - destructive action confirmation + - dynamic tools + - custom renderers + - follow-up/steering messages +- Upgrade `bosun/bosun-tui.mjs` and `bosun/tui/*` using `pi-tui` ideas: + - differential rendering + - overlays/modals + - richer editor component + - better keyboard handling + - status/footer/header composition +- Upgrade Bosun web chat UI with: + - artifact side panel + - attachment-aware tools + - runtime provider abstraction for artifacts/attachments + +Concrete Bosun landing zones: + +- `bosun/agent/` +- `bosun/ui/modules/agent-events.js` +- `bosun/ui/modules/session-api.js` +- `bosun/ui/modules/streaming.js` +- `bosun/bosun-tui.mjs` +- `bosun/tui/*` +- `bosun/ui/` and `bosun/site/ui/` + +Porting stance: + +- Strong candidate for direct TypeScript/JavaScript-to-`.mjs` refactor. +- Best used as a base for Bosun's internal coding harness, not as an embedded dependency. + +### 3. `paperclip-master` + +Recommendation: high-priority donor for organization-level planning and durable heartbeat accounting. + +What is worth adopting: + +- Goal tree model in `packages/shared/src/types/goal.ts` +- Heartbeat run, runtime state, and wakeup request models in `packages/shared/src/types/heartbeat.ts` +- Broader service model visible in `server/src/services/*`: + - goals + - budgets + - heartbeats + - approvals + - live events + - execution workspace + - org chart rendering + +Why it fits Bosun: + +- Bosun is already operationally strong, but still more execution-centric than company/goal-centric. +- Paperclip has explicit ancestry from mission -> goal -> issue -> agent action. +- Bosun can use that ancestry without inheriting Paperclip's full "run a company" framing. + +How to apply it in Bosun: + +- Add optional goal ancestry above task/workflow level: + - strategic goal + - project goal + - task + - run +- Add durable heartbeat-run records for every automated wakeup: + - invocation source + - trigger detail + - status + - stdout/stderr excerpts + - usage/cost + - retry lineage + - context snapshot +- Add per-agent or per-workflow budget windows and operator-visible spend controls. +- Add organization metadata and team charts where useful for multi-agent supervision, but keep Bosun focused on engineering operations rather than generic business orchestration. + +Concrete Bosun landing zones: + +- `bosun/workflow/` +- `bosun/task/` +- `bosun/infra/monitor.mjs` +- `bosun/agent/fleet-coordinator.mjs` +- `bosun/server/ui-server.mjs` +- `bosun/ui/modules/settings-schema.js` + +Porting stance: + +- Borrow schemas, service boundaries, and event/accounting model. +- Do not import Paperclip wholesale. + +### 4. `usezombie-main` + +Recommendation: targeted donor for harness validation, retries, and observability hardening. + +What is worth adopting: + +- Harness markdown compiler/validator in `src/harness/control_plane.zig` +- Retry wrapper in `src/reliability/reliable_call.zig` +- Broader reliability/observability modules: + - `error_classify.zig` + - `backoff.zig` + - `metrics*.zig` + - queue and pubsub modules + +Why it fits Bosun: + +- Bosun is already moving toward self-improvement and internal harnessing. +- `control_plane.zig` shows a strong "spec -> compiled profile -> validation report" model. +- The retry wrapper is small, disciplined, and immediately portable. + +How to apply it in Bosun: + +- Add a Bosun harness compiler that converts markdown/json agent profiles into validated internal executor profiles. +- Run preflight validation for: + - secrets in prompt/profile payloads + - prompt injection patterns + - unsafe execution patterns + - invalid topology/schema +- Port the retry policy ideas into Bosun wrappers for external actions: + - GitHub + - Jira + - model providers + - webhook delivery + - workflow triggers +- Add better classified retry metrics to Bosun telemetry. + +Concrete Bosun landing zones: + +- `bosun/agent/` +- `bosun/config/` +- `bosun/infra/` +- `bosun/workflow/` + +Porting stance: + +- Port specific algorithms and validation rules. +- Do not attempt Zig embedding. + +### 5. `abtop-main` + +Recommendation: medium-priority donor for observability UX. + +What is worth adopting: + +- Codex/Claude session collectors in `src/collector/*.rs` +- Session model in `src/model/session.rs` +- Operator-focused metrics: + - token usage + - context percent + - current task + - child processes + - ports + - rate limits + - orphan detection + +Why it fits Bosun: + +- Bosun already has a TUI and monitor, but it does not yet expose all live executor-state signals in one operator-first surface. +- `abtop` is focused and practical, not abstract. + +How to apply it in Bosun: + +- Add a "live sessions" screen in Bosun TUI. +- Add executor process tree, open-port, and context-window views. +- Add rate-limit and context saturation warnings per executor. +- Add a compact agent/session health strip to the web UI and Telegram summaries. + +Concrete Bosun landing zones: + +- `bosun/bosun-tui.mjs` +- `bosun/tui/screens/*` +- `bosun/infra/monitor.mjs` +- `bosun/ui/modules/agent-events.js` +- `bosun/ui/modules/session-api.js` + +Porting stance: + +- Recreate the collector logic in Node against Bosun's session stores plus local process inspection. +- Do not port the Rust TUI directly. + +### 6. `OpenHands-main` + +Recommendation: medium-priority pattern donor, low-priority code donor. + +What is worth adopting: + +- The clean separation in `openhands/README.md`: + - Agent + - AgentController + - State + - EventStream + - Runtime + - Session + - ConversationManager +- Frontend real-time architecture shape from `frontend/README.md` + +Why it fits Bosun: + +- Bosun already has equivalent pieces, but not always with the same conceptual clarity. +- OpenHands' event-stream-first architecture is a useful reference model for cleaning Bosun session/event paths. + +How to apply it in Bosun: + +- Tighten Bosun's internal event taxonomy around action/observation/run-state events. +- Standardize session routing and event delivery semantics. +- Make the "controller vs runtime vs session state" separation more explicit in docs and code layout. + +Concrete Bosun landing zones: + +- `bosun/ui/modules/session-api.js` +- `bosun/ui/modules/agent-events.js` +- `bosun/workflow/` +- `bosun/server/ui-server.mjs` + +Porting stance: + +- Use as architecture guidance, not as a direct code import. + +### 7. `agentfield` + +Recommendation: future-facing donor for trust, policy, and externalized execution state. + +What is worth adopting: + +- Generic event bus in `control-plane/internal/events/event_bus.go` +- DID/VC service model in `control-plane/internal/services/vc_service.go` +- Presence/status/storage/vector-store and webhook service layout + +Why it fits Bosun: + +- The verifiable credential layer is not core to Bosun's immediate roadmap, but it is relevant if Bosun needs stronger compliance or non-repudiation around autonomous actions. +- Event bus and status services are useful references for decoupling Bosun telemetry. + +How to apply it in Bosun: + +- Near term: borrow event bus and status/presence patterns only. +- Medium term: add optional signed execution attestations for sensitive workflows and merges. +- Keep any credential/attestation feature opt-in and off by default. + +Concrete Bosun landing zones: + +- `bosun/infra/` +- `bosun/workflow/` +- `bosun/server/` + +Porting stance: + +- Use patterns, not direct code, unless Bosun explicitly moves into compliance-heavy workflows. + +### 8. `attractor` + +Recommendation: documentation/spec donor, not code donor. + +What is worth adopting: + +- The NLSpec approach from `coding-agent-loop-spec.md` and `unified-llm-spec.md` + +Why it fits Bosun: + +- Bosun's self-improvement workflows need stable natural-language implementation targets. +- Attractor provides a way to turn desired runtime behavior into auditable specs that coding agents can implement against. + +How to apply it in Bosun: + +- Define Bosun-native NLSpecs for: + - internal coding harness contract + - execution journal contract + - scope locks + - approval gates + - liveness supervisor + - self-improvement workflow rules + +Porting stance: + +- Use as a spec-writing method only. + +## Recommended implementation order + +### Phase 1: immediate, high-confidence + +- Bosun internal coding harness architecture from `pi-mono` +- Retry and harness-validation layer from `usezombie` +- Approval gate, scope locks, and liveness nudge from `bridge` +- `abtop`-style live session telemetry in Bosun TUI + +### Phase 2: structural upgrades + +- Paperclip-style goal ancestry and durable heartbeat-run records +- Bridge execution journal and capability routing +- Pi-style artifact/attachment UX in Bosun web UI + +### Phase 3: optional and advanced + +- AgentField-style signed execution attestations +- Attractor-style NLSpec workflow specs +- OpenHands-inspired cleanup of Bosun event/session architecture docs and APIs + +## Suggested Bosun work items + +1. Create `bosun/agent/internal-harness/` and port the `pi-mono` loop + extension model into Bosun-native `.mjs`. +2. Create `bosun/agent/approval-gate.mjs` based on Bridge's approval queue semantics. +3. Create `bosun/workspace/scope-locks.mjs` based on Bridge's path-lock design and persist under Bosun private storage. +4. Create `bosun/workspace/execution-journal.mjs` for durable step/artifact journaling. +5. Add a `Live Sessions` screen to `bosun/tui/` modeled on `abtop` metrics. +6. Add `goalId`, `parentGoalId`, and `budgetWindow` concepts to Bosun task/workflow metadata. +7. Add an optional harness-profile compiler and validator to Bosun setup/config flows. + +## What not to do + +- Do not replace Bosun's current claim/lease/fleet core with Git-polled or hierarchy-only coordination. +- Do not import Bridge or Paperclip as a full runtime dependency. +- Do not move Bosun's high-churn runtime state into Git history. +- Do not make any of the new backend/attestation/hierarchy features default-on. + +## Final opinion + +If the goal is to improve Bosun in-house, the best composite strategy is: + +- `pi-mono` for the internal coding harness and UI/TUI base +- `bridge-ide` for coordination discipline and operator controls +- `paperclip` for goals, budgets, and heartbeat accounting +- `usezombie` for harness validation and reliability policy +- `abtop` for operator visibility + +That combination fits Bosun's existing shape and can be implemented incrementally in `.mjs` without surrendering Bosun's current runtime model. diff --git a/kanban/gnap-projection-store.mjs b/kanban/gnap-projection-store.mjs new file mode 100644 index 000000000..bc5159972 --- /dev/null +++ b/kanban/gnap-projection-store.mjs @@ -0,0 +1,860 @@ +import { createHash, randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const GNAP_PROTOCOL_VERSION = "1"; +const GNAP_PROJECTION_MANAGER_ID = "bosun-gnap-projection"; +const MAX_TASK_DESCRIPTION_LENGTH = 16000; +const MAX_MESSAGE_BODY_LENGTH = 12000; +const MAX_TIMELINE_ENTRIES = 24; +const MAX_ATTACHMENT_ENTRIES = 32; +const MAX_WORKFLOW_RUN_ENTRIES = 24; + +function normalizeString(value) { + const text = String(value ?? "").trim(); + return text || null; +} + +function normalizeStringList(values) { + if (!Array.isArray(values)) return []; + return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))]; +} + +function truncateText(value, maxLength) { + const text = String(value ?? ""); + if (!Number.isFinite(maxLength) || maxLength <= 0) return text; + if (text.length <= maxLength) return text; + return `${text.slice(0, Math.max(0, maxLength - 3))}...`; +} + +function sanitizeFileComponent(value, fallback) { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-/, "") + .replace(/-$/, "") + .slice(0, 64); + return normalized || fallback; +} + +function hashId(value) { + return createHash("sha256").update(String(value ?? "")).digest("hex").slice(0, 12); +} + +function createDeterministicFileName(prefix, identifier) { + return `${sanitizeFileComponent(prefix, "record")}--${hashId(identifier)}.json`; +} + +function resolveRepoPath(rawPath) { + const candidate = + normalizeString(rawPath) || + normalizeString(process.env.GNAP_REPO_PATH) || + normalizeString(process.env.REPO_ROOT) || + process.cwd(); + return path.resolve(candidate); +} + +function resolveGnapDir(repoPath) { + if (path.basename(repoPath).toLowerCase() === ".gnap") { + return repoPath; + } + return path.join(repoPath, ".gnap"); +} + +export function resolveGnapProjectionConfig(config = {}) { + const repoPath = resolveRepoPath(config?.repoPath); + const gnapDir = resolveGnapDir(repoPath); + return Object.freeze({ + enabled: config?.enabled === true, + repoPath, + gnapDir, + syncMode: normalizeString(config?.syncMode)?.toLowerCase() || "projection", + runStorage: normalizeString(config?.runStorage)?.toLowerCase() || "git", + messageStorage: normalizeString(config?.messageStorage)?.toLowerCase() || "off", + publicRoadmapEnabled: config?.publicRoadmapEnabled === true, + }); +} + +function taskFilePath(config, taskId) { + return path.join( + config.gnapDir, + "tasks", + createDeterministicFileName(`task-${taskId}`, taskId), + ); +} + +function runFilePath(config, taskId, runId) { + return path.join( + config.gnapDir, + "runs", + createDeterministicFileName(`run-${taskId}-${runId}`, `${taskId}:${runId}`), + ); +} + +function messageFilePath(config, taskId, messageId) { + return path.join( + config.gnapDir, + "messages", + createDeterministicFileName( + `message-${taskId}-${messageId}`, + `${taskId}:${messageId}`, + ), + ); +} + +async function ensureDir(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); +} + +async function writeTextIfChanged(filePath, content) { + let current = null; + try { + current = await fs.readFile(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + if (current === content) return false; + await fs.writeFile(filePath, content, "utf8"); + return true; +} + +async function writeJsonIfChanged(filePath, value) { + return writeTextIfChanged(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function safeReadDir(dirPath) { + try { + return await fs.readdir(dirPath); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } +} + +async function safeReadJson(filePath) { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + if (error?.code === "ENOENT") return null; + if (error instanceof SyntaxError) return null; + throw error; + } +} + +function toSortableTimestamp(...values) { + for (const value of values) { + const parsed = Date.parse(String(value || "").trim()); + if (Number.isFinite(parsed)) return parsed; + } + return 0; +} + +function isBosunManagedProjection(doc = {}) { + return String(doc?.managed_by || "").trim() === GNAP_PROJECTION_MANAGER_ID; +} + +function compareProjectedTaskRecords(left, right) { + const timeDelta = + toSortableTimestamp( + right?.doc?.updated_at, + right?.doc?.last_activity_at, + right?.doc?.created_at, + ) + - toSortableTimestamp( + left?.doc?.updated_at, + left?.doc?.last_activity_at, + left?.doc?.created_at, + ); + if (timeDelta !== 0) return timeDelta; + const managedDelta = Number(isBosunManagedProjection(right?.doc)) - Number(isBosunManagedProjection(left?.doc)); + if (managedDelta !== 0) return managedDelta; + return String(left?.filePath || "").localeCompare(String(right?.filePath || "")); +} + +function selectCanonicalProjectedTaskRecord(records = []) { + const list = (Array.isArray(records) ? records : []).filter( + (entry) => entry?.doc && typeof entry.doc === "object", + ); + if (list.length === 0) return null; + list.sort(compareProjectedTaskRecords); + return list[0] || null; +} + +function dedupeProjectedTaskRecords(records = []) { + const grouped = new Map(); + for (const entry of Array.isArray(records) ? records : []) { + const taskId = String(entry?.doc?.task_id || "").trim(); + if (!taskId) continue; + const list = grouped.get(taskId) || []; + list.push(entry); + grouped.set(taskId, list); + } + return Array.from(grouped.values()) + .map((entries) => selectCanonicalProjectedTaskRecord(entries)) + .filter(Boolean); +} + +function mapBosunStatusToGnapState(status) { + const key = String(status ?? "").trim().toLowerCase(); + if (key === "done") return "done"; + if (key === "inreview") return "review"; + if (key === "inprogress") return "in_progress"; + if (key === "blocked") return "blocked"; + if (key === "cancelled") return "cancelled"; + if (key === "draft") return "backlog"; + return "ready"; +} + +function mapGnapStateToBosunStatus(state) { + const key = String(state ?? "").trim().toLowerCase(); + if (key === "done") return "done"; + if (key === "review") return "inreview"; + if (key === "in_progress") return "inprogress"; + if (key === "blocked") return "blocked"; + if (key === "cancelled") return "cancelled"; + if (key === "backlog") return "draft"; + return "todo"; +} + +function normalizeAttachment(entry = {}) { + return { + id: normalizeString(entry.id) || hashId(JSON.stringify(entry)), + name: normalizeString(entry.name) || "attachment", + kind: normalizeString(entry.kind) || "file", + url: normalizeString(entry.url), + content_type: normalizeString(entry.contentType || entry.content_type), + size_bytes: Number.isFinite(Number(entry.sizeBytes || entry.size_bytes)) + ? Number(entry.sizeBytes || entry.size_bytes) + : null, + }; +} + +function normalizeTimelineEntry(entry = {}) { + return { + type: normalizeString(entry.type) || "event", + source: normalizeString(entry.source), + status: normalizeString(entry.status), + from_status: normalizeString(entry.fromStatus || entry.from_status), + to_status: normalizeString(entry.toStatus || entry.to_status), + actor: normalizeString(entry.actor), + message: normalizeString(entry.message), + timestamp: normalizeString(entry.timestamp) || new Date().toISOString(), + }; +} + +function normalizeWorkflowRunSummary(entry = {}) { + return { + workflow_id: normalizeString(entry.workflowId || entry.workflow_id), + run_id: normalizeString(entry.runId || entry.run_id), + status: normalizeString(entry.status), + started_at: normalizeString(entry.startedAt || entry.started_at), + ended_at: normalizeString(entry.endedAt || entry.ended_at), + }; +} + +function normalizeMaterializedWorkflowRun(entry = {}) { + return { + workflowId: normalizeString(entry.workflowId || entry.workflow_id), + runId: normalizeString(entry.runId || entry.run_id), + status: normalizeString(entry.status) || mapGnapStateToBosunStatus(entry.state), + startedAt: normalizeString(entry.startedAt || entry.started_at), + endedAt: normalizeString(entry.endedAt || entry.ended_at), + summary: normalizeString(entry.summary), + source: normalizeString(entry.source), + }; +} + +function mergeWorkflowRunCollections(...lists) { + const merged = new Map(); + for (const list of lists) { + for (const entry of Array.isArray(list) ? list : []) { + if (!entry || typeof entry !== "object") continue; + const normalized = normalizeMaterializedWorkflowRun(entry); + const key = + normalized.runId || + [ + normalized.workflowId, + normalized.startedAt, + normalized.endedAt, + normalized.source, + normalized.summary, + ].join("|"); + const current = merged.get(key) || {}; + merged.set(key, { + ...current, + ...normalized, + }); + } + } + return [...merged.values()]; +} + +function buildSyntheticRuns(task = {}, rawTask = {}) { + const history = Array.isArray(task.statusHistory) ? task.statusHistory : []; + if (history.length === 0) return []; + + /** @type {Array} */ + const runs = []; + let current = null; + let sequence = 0; + const startRun = (entry, nextStatus) => { + sequence += 1; + current = { + run_id: `status-${sequence}`, + task_id: String(task.id || rawTask.id || ""), + state: mapBosunStatusToGnapState(nextStatus), + status: nextStatus === "inreview" ? "reviewing" : "running", + source: normalizeString(entry.source) || "task-status", + actor: normalizeString(entry.actor) || normalizeString(task.assignee), + started_at: normalizeString(entry.timestamp) || new Date().toISOString(), + updated_at: normalizeString(entry.timestamp) || new Date().toISOString(), + ended_at: null, + workflow_id: null, + branch_name: normalizeString(task.branchName), + pr: { + number: task.prNumber ?? null, + url: normalizeString(task.prUrl), + }, + summary: `Derived from task status transition to ${nextStatus}`, + }; + runs.push(current); + }; + + const closeRun = (entry, nextStatus) => { + if (!current) return; + current.updated_at = normalizeString(entry.timestamp) || current.updated_at; + current.ended_at = normalizeString(entry.timestamp) || current.ended_at; + if (nextStatus === "done") current.status = "completed"; + else if (nextStatus === "cancelled") current.status = "cancelled"; + else if (nextStatus === "blocked") current.status = "blocked"; + else current.status = "stopped"; + current.state = mapBosunStatusToGnapState(nextStatus); + current = null; + }; + + for (const entry of history) { + const nextStatus = String(entry?.status ?? "").trim().toLowerCase(); + if (!nextStatus) continue; + if (nextStatus === "inprogress" || nextStatus === "inreview") { + if (!current) { + startRun(entry, nextStatus); + continue; + } + current.updated_at = normalizeString(entry.timestamp) || current.updated_at; + current.state = mapBosunStatusToGnapState(nextStatus); + if (nextStatus === "inreview") current.status = "reviewing"; + continue; + } + if (["done", "cancelled", "blocked", "todo", "draft"].includes(nextStatus)) { + closeRun(entry, nextStatus); + } + } + + if (current && !current.ended_at) { + current.updated_at = normalizeString(task.updatedAt || task.lastActivityAt) || current.updated_at; + if (String(task.status || "").trim().toLowerCase() === "inreview") { + current.status = "reviewing"; + } + } + return runs; +} + +function buildRunDocuments(task = {}, rawTask = {}) { + const rawRuns = Array.isArray(rawTask.runs) ? rawTask.runs : []; + const workflowRuns = Array.isArray(task.workflowRuns) ? task.workflowRuns : []; + const runs = []; + + for (const [index, run] of rawRuns.entries()) { + const runId = + normalizeString(run.runId || run.id || run.attemptId || run.attempt_id) || + `raw-${index + 1}`; + runs.push({ + protocol: "bosun-gnap-run.v1", + schema_version: 1, + managed_by: GNAP_PROJECTION_MANAGER_ID, + run_id: runId, + task_id: String(task.id || rawTask.id || ""), + status: normalizeString(run.status) || "running", + state: mapBosunStatusToGnapState(run.status || task.status), + source: normalizeString(run.source) || "task-run", + actor: normalizeString(run.actor || run.agentId || run.agent_id || task.assignee), + workflow_id: normalizeString(run.workflowId || run.workflow_id), + started_at: normalizeString(run.startedAt || run.started_at || task.createdAt), + updated_at: + normalizeString(run.updatedAt || run.updated_at || run.endedAt || run.ended_at) || + normalizeString(task.updatedAt || task.lastActivityAt), + ended_at: normalizeString(run.endedAt || run.ended_at), + branch_name: normalizeString(run.branchName || run.branch_name || task.branchName), + pr: { + number: run.prNumber ?? task.prNumber ?? null, + url: normalizeString(run.prUrl || run.pr_url || task.prUrl), + }, + summary: + normalizeString(run.summary || run.message || run.reason) || + `Bosun task run ${runId}`, + }); + } + + for (const [index, run] of workflowRuns.entries()) { + const runId = + normalizeString(run.runId || run.id || run.workflowRunId || run.workflow_run_id) || + `workflow-${index + 1}`; + runs.push({ + protocol: "bosun-gnap-run.v1", + schema_version: 1, + managed_by: GNAP_PROJECTION_MANAGER_ID, + run_id: runId, + task_id: String(task.id || rawTask.id || ""), + status: normalizeString(run.status) || "running", + state: mapBosunStatusToGnapState(run.status || task.status), + source: "workflow", + actor: normalizeString(run.actor || task.assignee), + workflow_id: normalizeString(run.workflowId || run.workflow_id), + started_at: normalizeString(run.startedAt || run.started_at), + updated_at: + normalizeString(run.endedAt || run.ended_at || run.startedAt || run.started_at) || + normalizeString(task.updatedAt || task.lastActivityAt), + ended_at: normalizeString(run.endedAt || run.ended_at), + branch_name: normalizeString(task.branchName), + pr: { + number: task.prNumber ?? null, + url: normalizeString(task.prUrl), + }, + summary: + normalizeString(run.summary) || + `Workflow ${normalizeString(run.workflowId || run.workflow_id) || runId}`, + }); + } + + if (runs.length === 0) { + runs.push(...buildSyntheticRuns(task, rawTask).map((run) => ({ + protocol: "bosun-gnap-run.v1", + schema_version: 1, + managed_by: GNAP_PROJECTION_MANAGER_ID, + ...run, + }))); + } + + const deduped = new Map(); + for (const run of runs) { + const runId = normalizeString(run.run_id) || randomUUID(); + deduped.set(runId, { + ...run, + run_id: runId, + }); + } + return [...deduped.values()]; +} + +function buildMessageDocuments(task = {}, rawTask = {}, config = {}) { + const rawComments = [] + .concat(Array.isArray(task.comments) ? task.comments : []) + .concat(Array.isArray(rawTask.comments) ? rawTask.comments : []) + .concat(Array.isArray(rawTask.meta?.comments) ? rawTask.meta.comments : []); + const docs = []; + const seen = new Set(); + for (const [index, comment] of rawComments.entries()) { + const body = truncateText(comment?.body, MAX_MESSAGE_BODY_LENGTH); + if (!normalizeString(body)) continue; + const createdAt = + normalizeString(comment?.createdAt || comment?.created_at) || + normalizeString(task.updatedAt || task.lastActivityAt) || + new Date().toISOString(); + const author = + normalizeString(comment?.author || comment?.actor || comment?.source) || + normalizeString(task.assignee) || + "bosun"; + const messageId = + normalizeString(comment?.id || comment?.messageId || comment?.message_id) || + `comment-${index + 1}-${hashId(`${body}:${createdAt}:${author}`)}`; + if (seen.has(messageId)) continue; + seen.add(messageId); + docs.push({ + protocol: "bosun-gnap-message.v1", + schema_version: 1, + managed_by: GNAP_PROJECTION_MANAGER_ID, + message_id: messageId, + task_id: String(task.id || rawTask.id || ""), + kind: "comment", + author, + source: normalizeString(comment?.source) || "bosun-comment", + created_at: createdAt, + visibility: config.publicRoadmapEnabled ? "shared" : "private", + body, + }); + } + return docs; +} + +export function buildProjectedTaskDocument(task = {}, rawTask = {}, config = {}) { + const runDocs = buildRunDocuments(task, rawTask); + const messageDocs = + config.messageStorage !== "off" + ? buildMessageDocuments(task, rawTask, config) + : []; + const attachments = [] + .concat(Array.isArray(task.attachments) ? task.attachments : []) + .concat(Array.isArray(rawTask.attachments) ? rawTask.attachments : []) + .slice(0, MAX_ATTACHMENT_ENTRIES) + .map((entry) => normalizeAttachment(entry)); + const workflowRuns = (Array.isArray(task.workflowRuns) ? task.workflowRuns : []) + .slice(0, MAX_WORKFLOW_RUN_ENTRIES) + .map((entry) => normalizeWorkflowRunSummary(entry)); + const timeline = (Array.isArray(task.timeline) ? task.timeline : []) + .filter(Boolean) + .slice(-MAX_TIMELINE_ENTRIES) + .map((entry) => normalizeTimelineEntry(entry)); + const doc = { + protocol: "bosun-gnap-task.v1", + schema_version: 1, + task_id: String(task.id || rawTask.id || ""), + title: normalizeString(task.title) || "Untitled task", + description: truncateText(task.description || "", MAX_TASK_DESCRIPTION_LENGTH), + state: mapBosunStatusToGnapState(task.status), + status: normalizeString(task.status) || "todo", + priority: normalizeString(task.priority), + assignee: normalizeString(task.assignee), + assignees: normalizeStringList(task.assignees), + project_id: normalizeString(task.projectId) || "gnap", + workspace: normalizeString(task.workspace), + repository: normalizeString(task.repository), + repositories: normalizeStringList(task.repositories), + tags: normalizeStringList(task.tags), + draft: task.draft === true, + blocked_reason: normalizeString(rawTask.blockedReason || task.meta?.blockedReason), + dependencies: normalizeStringList(rawTask.dependencyTaskIds || rawTask.dependsOn), + blocked_by: normalizeStringList(rawTask.blockedByTaskIds), + child_task_ids: normalizeStringList(rawTask.childTaskIds), + base_branch: normalizeString(task.baseBranch), + branch_name: normalizeString(task.branchName), + pr: { + number: task.prNumber ?? null, + url: normalizeString(task.prUrl), + }, + created_at: normalizeString(task.createdAt), + updated_at: normalizeString(task.updatedAt), + last_activity_at: normalizeString(task.lastActivityAt || task.updatedAt), + attachment_count: attachments.length, + comment_count: messageDocs.length, + run_count: runDocs.length, + evidence: { + attachments, + workflow_runs: workflowRuns, + }, + timeline, + source: { + system: "bosun", + backend: "internal", + managed_by: GNAP_PROJECTION_MANAGER_ID, + sync_mode: normalizeString(config.syncMode) || "projection", + run_storage: normalizeString(config.runStorage) || "git", + message_storage: normalizeString(config.messageStorage) || "off", + }, + }; + return { + task: doc, + runs: config.runStorage === "off" ? [] : runDocs, + messages: messageDocs, + }; +} + +export async function ensureProjectionScaffold(config) { + await ensureDir(config.gnapDir); + await ensureDir(path.join(config.gnapDir, "tasks")); + await ensureDir(path.join(config.gnapDir, "runs")); + await ensureDir(path.join(config.gnapDir, "messages")); + await writeTextIfChanged( + path.join(config.gnapDir, "version"), + `${GNAP_PROTOCOL_VERSION}\n`, + ); + const agentsPath = path.join(config.gnapDir, "agents.json"); + const currentAgents = await safeReadJson(agentsPath); + if (!currentAgents) { + await writeJsonIfChanged(agentsPath, { + protocol: "bosun-gnap-agents.v1", + schema_version: 1, + generated_at: new Date().toISOString(), + agents: [], + }); + } +} + +export async function upsertProjectedTask(config, taskDoc, runDocs = [], messageDocs = []) { + await ensureProjectionScaffold(config); + const taskId = String(taskDoc?.task_id || "").trim(); + if (!taskId) { + throw new Error("GNAP projection requires task_id"); + } + const existingTaskDocs = await loadSurfaceDocuments( + path.join(config.gnapDir, "tasks"), + (doc) => String(doc?.task_id || "").trim() === taskId, + ); + const canonicalTaskRecord = selectCanonicalProjectedTaskRecord(existingTaskDocs); + const canonicalTaskPath = canonicalTaskRecord?.filePath || taskFilePath(config, taskId); + await writeJsonIfChanged(canonicalTaskPath, taskDoc); + for (const entry of existingTaskDocs) { + if (!entry?.filePath || entry.filePath === canonicalTaskPath) continue; + await fs.unlink(entry.filePath).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } + + const runsDir = path.join(config.gnapDir, "runs"); + const existingRunFiles = await safeReadDir(runsDir); + const expectedRunIds = new Set(runDocs.map((run) => String(run?.run_id || "").trim()).filter(Boolean)); + for (const fileName of existingRunFiles) { + const doc = await safeReadJson(path.join(runsDir, fileName)); + if (doc?.task_id !== taskId) continue; + const managedByBosun = + String(doc?.managed_by || "").trim() === GNAP_PROJECTION_MANAGER_ID; + if (managedByBosun && !expectedRunIds.has(String(doc?.run_id || "").trim())) { + await fs.unlink(path.join(runsDir, fileName)).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } + } + for (const run of runDocs) { + const runId = String(run?.run_id || "").trim(); + if (!runId) continue; + await writeJsonIfChanged(runFilePath(config, taskId, runId), run); + } + + const messagesDir = path.join(config.gnapDir, "messages"); + const existingMessageFiles = await safeReadDir(messagesDir); + const expectedMessageIds = new Set( + messageDocs.map((message) => String(message?.message_id || "").trim()).filter(Boolean), + ); + for (const fileName of existingMessageFiles) { + const doc = await safeReadJson(path.join(messagesDir, fileName)); + if (doc?.task_id !== taskId) continue; + const managedByBosun = + String(doc?.managed_by || "").trim() === GNAP_PROJECTION_MANAGER_ID; + if (managedByBosun && !expectedMessageIds.has(String(doc?.message_id || "").trim())) { + await fs.unlink(path.join(messagesDir, fileName)).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } + } + for (const message of messageDocs) { + const messageId = String(message?.message_id || "").trim(); + if (!messageId) continue; + await writeJsonIfChanged(messageFilePath(config, taskId, messageId), message); + } +} + +export async function deleteProjectedTask(config, taskId) { + await ensureProjectionScaffold(config); + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) return; + const taskDocs = await loadSurfaceDocuments( + path.join(config.gnapDir, "tasks"), + (doc) => String(doc?.task_id || "").trim() === normalizedTaskId, + ); + for (const entry of taskDocs) { + await fs.unlink(entry.filePath).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } + await fs.unlink(taskFilePath(config, normalizedTaskId)).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + for (const surface of ["runs", "messages"]) { + const dirPath = path.join(config.gnapDir, surface); + const files = await safeReadDir(dirPath); + for (const fileName of files) { + const filePath = path.join(dirPath, fileName); + const doc = await safeReadJson(filePath); + if (doc?.task_id !== normalizedTaskId) continue; + await fs.unlink(filePath).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } + } +} + +async function loadSurfaceDocuments(dirPath, matcher = () => true) { + const fileNames = await safeReadDir(dirPath); + const docs = []; + for (const fileName of fileNames) { + const filePath = path.join(dirPath, fileName); + const doc = await safeReadJson(filePath); + if (!doc || !matcher(doc)) continue; + docs.push({ doc, filePath }); + } + return docs; +} + +export async function listProjectedTaskRecords(config) { + await ensureProjectionScaffold(config); + const docs = await loadSurfaceDocuments(path.join(config.gnapDir, "tasks")); + return dedupeProjectedTaskRecords(docs); +} + +export async function readProjectedTaskRecord(config, taskId) { + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) return null; + const docs = await loadSurfaceDocuments( + path.join(config.gnapDir, "tasks"), + (doc) => String(doc?.task_id || "").trim() === normalizedTaskId, + ); + return selectCanonicalProjectedTaskRecord(docs); +} + +export async function rebuildAgentsRegistry(config) { + await ensureProjectionScaffold(config); + const taskDocs = await listProjectedTaskRecords(config); + const runDocs = await loadSurfaceDocuments(path.join(config.gnapDir, "runs")); + const messageDocs = await loadSurfaceDocuments(path.join(config.gnapDir, "messages")); + const agents = new Map(); + + const touchAgent = (id, data = {}) => { + const normalizedId = normalizeString(id); + if (!normalizedId) return; + const current = agents.get(normalizedId) || { + id: normalizedId, + name: normalizedId, + role: "agent", + reports_to: null, + last_seen_at: null, + sources: [], + }; + current.name = normalizeString(data.name) || current.name; + current.last_seen_at = normalizeString(data.last_seen_at) || current.last_seen_at; + if (data.source) { + current.sources = normalizeStringList([...(current.sources || []), data.source]); + } + agents.set(normalizedId, current); + }; + + for (const { doc } of taskDocs) { + touchAgent(doc?.assignee, { + last_seen_at: doc?.updated_at || doc?.last_activity_at, + source: "task-assignee", + }); + for (const assignee of doc?.assignees || []) { + touchAgent(assignee, { + last_seen_at: doc?.updated_at || doc?.last_activity_at, + source: "task-assignee", + }); + } + } + + for (const { doc } of runDocs) { + touchAgent(doc?.actor, { + last_seen_at: doc?.updated_at || doc?.ended_at || doc?.started_at, + source: "run-actor", + }); + } + + for (const { doc } of messageDocs) { + touchAgent(doc?.author, { + last_seen_at: doc?.created_at, + source: "message-author", + }); + } + + await writeJsonIfChanged(path.join(config.gnapDir, "agents.json"), { + protocol: "bosun-gnap-agents.v1", + schema_version: 1, + generated_at: new Date().toISOString(), + agents: [...agents.values()].sort((left, right) => left.id.localeCompare(right.id)), + }); +} + +export async function listProjectedMessagesForTask(config, taskId) { + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) return []; + return loadSurfaceDocuments( + path.join(config.gnapDir, "messages"), + (doc) => String(doc?.task_id || "").trim() === normalizedTaskId, + ); +} + +export async function listProjectedRunsForTask(config, taskId) { + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) return []; + return loadSurfaceDocuments( + path.join(config.gnapDir, "runs"), + (doc) => String(doc?.task_id || "").trim() === normalizedTaskId, + ); +} + +export function materializeProjectedTask(doc = {}, filePath, runDocs = [], messageDocs = []) { + const status = normalizeString(doc.status) || mapGnapStateToBosunStatus(doc.state); + const workflowRuns = mergeWorkflowRunCollections( + Array.isArray(doc?.evidence?.workflow_runs) ? doc.evidence.workflow_runs : [], + runDocs.map(({ doc: run }) => ({ + workflow_id: run?.workflow_id, + run_id: run?.run_id, + status: run?.status, + state: run?.state, + started_at: run?.started_at, + ended_at: run?.ended_at, + summary: run?.summary, + source: run?.source, + })), + ); + return { + id: String(doc.task_id || ""), + title: normalizeString(doc.title) || "Untitled task", + description: String(doc.description || ""), + status, + assignee: normalizeString(doc.assignee), + assignees: normalizeStringList(doc.assignees), + priority: normalizeString(doc.priority), + projectId: normalizeString(doc.project_id) || "gnap", + baseBranch: normalizeString(doc.base_branch), + branchName: normalizeString(doc.branch_name), + prNumber: + doc?.pr?.number == null || doc?.pr?.number === "" + ? null + : Number.parseInt(String(doc.pr.number), 10), + prUrl: normalizeString(doc?.pr?.url), + backend: "gnap", + createdAt: normalizeString(doc.created_at), + updatedAt: normalizeString(doc.updated_at), + lastActivityAt: normalizeString(doc.last_activity_at || doc.updated_at), + draft: doc.draft === true, + tags: normalizeStringList(doc.tags), + workspace: normalizeString(doc.workspace), + repository: normalizeString(doc.repository), + repositories: normalizeStringList(doc.repositories), + attachments: Array.isArray(doc?.evidence?.attachments) + ? doc.evidence.attachments.map((entry) => ({ + id: normalizeString(entry?.id), + name: normalizeString(entry?.name), + kind: normalizeString(entry?.kind), + url: normalizeString(entry?.url), + contentType: normalizeString(entry?.content_type || entry?.contentType), + sizeBytes: + entry?.size_bytes == null || entry?.size_bytes === "" + ? null + : Number(entry.size_bytes), + })) + : [], + workflowRuns, + comments: messageDocs.map(({ doc: message }) => ({ + id: normalizeString(message?.message_id), + body: String(message?.body || ""), + author: normalizeString(message?.author), + source: normalizeString(message?.source), + createdAt: normalizeString(message?.created_at), + })), + meta: { + projectionOnly: true, + gnap: { + taskPath: filePath, + state: normalizeString(doc.state), + runCount: runDocs.length, + messageCount: messageDocs.length, + syncMode: normalizeString(doc?.source?.sync_mode) || "projection", + runStorage: normalizeString(doc?.source?.run_storage) || "git", + messageStorage: normalizeString(doc?.source?.message_storage) || "off", + }, + evidence: doc?.evidence || {}, + timeline: Array.isArray(doc.timeline) ? doc.timeline : [], + }, + }; +} diff --git a/lib/state-ledger-sqlite.mjs b/lib/state-ledger-sqlite.mjs new file mode 100644 index 000000000..e824331f3 --- /dev/null +++ b/lib/state-ledger-sqlite.mjs @@ -0,0 +1,3706 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve, basename } from "node:path"; +import { createRequire } from "node:module"; + +/** + * Lazy-load node:sqlite to avoid crashing on Node < 22.5 where the built-in + * module does not exist. Callers that never open an actual ledger (e.g. test + * imports that only pull type-level helpers) will never trigger the import. + */ +let _DatabaseSync; +function getDatabaseSync() { + if (!_DatabaseSync) { + try { + const require = createRequire(import.meta.url); + _DatabaseSync = require("node:sqlite").DatabaseSync; + } catch { + throw new Error( + "node:sqlite is not available in this Node.js version. " + + "SQLite state-ledger requires Node >= 22.5.0.", + ); + } + } + return _DatabaseSync; +} + +const TAG = "[state-ledger]"; +const DEFAULT_LEDGER_FILENAME = "state-ledger.sqlite"; +const DEFAULT_SCHEMA_VERSION = 6; +const DEFAULT_BUSY_TIMEOUT_MS = 5_000; +const STATE_LEDGER_CACHE_KEY = Symbol.for("bosun.stateLedger.cache"); +const _stateLedgerCache = globalThis[STATE_LEDGER_CACHE_KEY] instanceof Map + ? globalThis[STATE_LEDGER_CACHE_KEY] + : new Map(); +if (!(globalThis[STATE_LEDGER_CACHE_KEY] instanceof Map)) { + globalThis[STATE_LEDGER_CACHE_KEY] = _stateLedgerCache; +} + +function isLikelyTestRuntime() { + if (process.env.VITEST) return true; + if (process.env.VITEST_POOL_ID) return true; + if (process.env.VITEST_WORKER_ID) return true; + if (process.env.JEST_WORKER_ID) return true; + if (process.env.NODE_ENV === "test") return true; + const argv = Array.isArray(process.argv) ? process.argv.join(" ").toLowerCase() : ""; + return argv.includes("vitest") || argv.includes("jest"); +} + +function asText(value) { + if (value == null) return null; + const text = String(value).trim(); + return text || null; +} + +function asInteger(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; +} + +function normalizeTimestamp(value) { + return asText(value) || new Date().toISOString(); +} + +function toJsonText(value) { + return JSON.stringify(value ?? null); +} + +function parseJsonText(value) { + if (value == null) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function cloneJson(value) { + if (value == null) return null; + return JSON.parse(JSON.stringify(value)); +} + +function inferRepoRoot(startDir) { + let current = resolve(String(startDir || process.cwd())); + while (true) { + if (existsSync(resolve(current, ".git"))) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function resolveBosunHomeDir() { + const explicit = asText(process.env.BOSUN_HOME || process.env.BOSUN_DIR || ""); + if (explicit) return resolve(explicit); + + const base = asText( + process.env.APPDATA + || process.env.LOCALAPPDATA + || process.env.USERPROFILE + || process.env.HOME + || "", + ); + if (!base) return null; + if (/[/\\]bosun$/i.test(base)) return resolve(base); + return resolve(base, "bosun"); +} + +function findBosunDir(startPath) { + if (!startPath) return null; + let current = resolve(String(startPath)); + while (true) { + if (basename(current).toLowerCase() === ".bosun") { + return current; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function collectTaskIdentityFromEvents(events = []) { + const list = Array.isArray(events) ? [...events].reverse() : []; + for (const event of list) { + const meta = event?.meta && typeof event.meta === "object" ? event.meta : null; + const taskId = asText( + meta?.taskId || meta?.task?.id || meta?.taskInfo?.id || meta?.taskDetail?.id || "", + ); + if (!taskId) continue; + return { + taskId, + taskTitle: asText( + meta?.taskTitle || meta?.task?.title || meta?.taskInfo?.title || meta?.taskDetail?.title || "", + ), + }; + } + return { taskId: null, taskTitle: null }; +} + +function collectSessionIdentityFromEvents(events = []) { + const list = Array.isArray(events) ? [...events].reverse() : []; + for (const event of list) { + const meta = event?.meta && typeof event.meta === "object" ? event.meta : null; + const sessionId = asText( + meta?.sessionId || meta?.threadId || meta?.chatSessionId || event?.sessionId || event?.threadId || "", + ); + if (!sessionId) continue; + return { + sessionId, + sessionType: asText(meta?.sessionType || meta?.runKind || ""), + }; + } + return { sessionId: null, sessionType: null }; +} + +function inferValueType(value) { + if (value == null) return "null"; + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "string": + return "string"; + case "number": + return Number.isFinite(value) ? "number" : "string"; + case "boolean": + return "boolean"; + case "object": + return "object"; + default: + return typeof value; + } +} + +function sanitizeKeyPart(value, fallback = "item") { + const text = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return text || fallback; +} + +function extractArtifactPath(event = {}, meta = null) { + return asText( + meta?.path + || meta?.filePath + || meta?.artifactPath + || meta?.registryPath + || meta?.targetFile + || event?.path + || event?.artifactPath + || "", + ); +} + +export function resolveStateLedgerPath(options = {}) { + const explicitPath = asText(options.ledgerPath || ""); + if (explicitPath) { + return explicitPath === ":memory:" ? explicitPath : resolve(explicitPath); + } + + const anchorPath = asText(options.anchorPath || options.runsDir || options.storePath || ""); + const repoRoot = asText(options.repoRoot || process.env.REPO_ROOT || ""); + const envLedgerPath = asText(process.env.BOSUN_STATE_LEDGER_PATH || ""); + const bosunDir = asText(options.bosunDir || "") + || findBosunDir(anchorPath) + || (repoRoot ? resolve(repoRoot, ".bosun") : null) + || (() => { + const inferred = inferRepoRoot(anchorPath || process.cwd()); + return inferred ? resolve(inferred, ".bosun") : null; + })() + || resolveBosunHomeDir(); + + if (bosunDir) { + return resolve(bosunDir, ".cache", DEFAULT_LEDGER_FILENAME); + } + + if (envLedgerPath) { + return envLedgerPath === ":memory:" ? envLedgerPath : resolve(envLedgerPath); + } + + if (anchorPath) { + return resolve(dirname(resolve(anchorPath)), DEFAULT_LEDGER_FILENAME); + } + + return resolve(process.cwd(), DEFAULT_LEDGER_FILENAME); +} + +function ensureParentDir(filePath) { + if (filePath === ":memory:") return; + mkdirSync(dirname(filePath), { recursive: true }); +} + +function configureDatabase(db, { transient = false, testRuntime = false } = {}) { + const journalMode = transient ? "MEMORY" : "WAL"; + const synchronousMode = transient || testRuntime ? "NORMAL" : "FULL"; + db.exec(` + PRAGMA journal_mode = ${journalMode}; + PRAGMA synchronous = ${synchronousMode}; + PRAGMA foreign_keys = ON; + PRAGMA temp_store = MEMORY; + PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS}; + `); +} + +function ensureSchema(entry) { + entry.db.exec(` + PRAGMA user_version = ${DEFAULT_SCHEMA_VERSION}; + + CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value_text TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workflow_runs ( + run_id TEXT PRIMARY KEY, + root_run_id TEXT, + parent_run_id TEXT, + retry_of TEXT, + retry_mode TEXT, + workflow_id TEXT, + workflow_name TEXT, + run_kind TEXT, + status TEXT, + started_at TEXT, + ended_at TEXT, + updated_at TEXT, + task_id TEXT, + task_title TEXT, + session_id TEXT, + session_type TEXT, + event_count INTEGER NOT NULL DEFAULT 0, + document_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workflow_events ( + event_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + timestamp TEXT NOT NULL, + event_type TEXT NOT NULL, + root_run_id TEXT, + parent_run_id TEXT, + retry_of TEXT, + retry_mode TEXT, + run_kind TEXT, + execution_id TEXT, + execution_key TEXT, + execution_kind TEXT, + execution_label TEXT, + parent_execution_id TEXT, + caused_by_execution_id TEXT, + child_run_id TEXT, + node_id TEXT, + node_type TEXT, + node_label TEXT, + tool_id TEXT, + tool_name TEXT, + server_id TEXT, + status TEXT, + attempt INTEGER, + duration_ms INTEGER, + error_text TEXT, + summary TEXT, + reason TEXT, + meta_json TEXT, + payload_json TEXT NOT NULL, + UNIQUE(run_id, seq) + ); + + CREATE TABLE IF NOT EXISTS task_claim_snapshots ( + task_id TEXT PRIMARY KEY, + instance_id TEXT, + claim_token TEXT, + claimed_at TEXT, + expires_at TEXT, + renewed_at TEXT, + ttl_minutes INTEGER, + metadata_json TEXT, + claim_json TEXT, + registry_updated_at TEXT, + updated_at TEXT NOT NULL, + released_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS task_claim_events ( + event_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + action TEXT NOT NULL, + instance_id TEXT, + claim_token TEXT, + timestamp TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS task_snapshots ( + task_id TEXT PRIMARY KEY, + title TEXT, + status TEXT, + priority TEXT, + assignee TEXT, + project_id TEXT, + created_at TEXT, + updated_at TEXT, + last_activity_at TEXT, + sync_dirty INTEGER NOT NULL DEFAULT 0, + comment_count INTEGER NOT NULL DEFAULT 0, + attachment_count INTEGER NOT NULL DEFAULT 0, + workflow_run_count INTEGER NOT NULL DEFAULT 0, + run_count INTEGER NOT NULL DEFAULT 0, + document_json TEXT NOT NULL, + deleted_at TEXT, + is_deleted INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS task_topology ( + task_id TEXT PRIMARY KEY, + graph_root_task_id TEXT, + graph_parent_task_id TEXT, + graph_depth INTEGER NOT NULL DEFAULT 0, + graph_path_json TEXT NOT NULL, + workflow_id TEXT, + workflow_name TEXT, + latest_node_id TEXT, + latest_run_id TEXT, + root_run_id TEXT, + parent_run_id TEXT, + session_id TEXT, + latest_session_id TEXT, + root_session_id TEXT, + parent_session_id TEXT, + root_task_id TEXT, + parent_task_id TEXT, + delegation_depth INTEGER NOT NULL DEFAULT 0, + child_task_count INTEGER NOT NULL DEFAULT 0, + dependency_count INTEGER NOT NULL DEFAULT 0, + workflow_run_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + document_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tool_calls ( + call_id TEXT PRIMARY KEY, + run_id TEXT, + root_run_id TEXT, + task_id TEXT, + session_id TEXT, + execution_id TEXT, + node_id TEXT, + tool_id TEXT, + tool_name TEXT, + server_id TEXT, + provider TEXT, + status TEXT, + started_at TEXT, + completed_at TEXT, + duration_ms INTEGER, + cwd TEXT, + args_json TEXT, + request_json TEXT, + response_json TEXT, + error_text TEXT, + summary TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id TEXT PRIMARY KEY, + run_id TEXT, + root_run_id TEXT, + task_id TEXT, + session_id TEXT, + execution_id TEXT, + node_id TEXT, + kind TEXT, + path TEXT, + summary TEXT, + source_event_id TEXT, + metadata_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS key_values ( + scope TEXT NOT NULL, + scope_id TEXT NOT NULL, + key_name TEXT NOT NULL, + value_json TEXT NOT NULL, + value_type TEXT, + source TEXT, + run_id TEXT, + task_id TEXT, + session_id TEXT, + metadata_json TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (scope, scope_id, key_name) + ); + + CREATE TABLE IF NOT EXISTS operator_actions ( + action_id TEXT PRIMARY KEY, + action_type TEXT NOT NULL, + actor_id TEXT, + actor_type TEXT, + scope TEXT, + scope_id TEXT, + target_id TEXT, + run_id TEXT, + task_id TEXT, + session_id TEXT, + status TEXT, + request_json TEXT, + result_json TEXT, + metadata_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS task_trace_events ( + event_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_title TEXT, + workflow_id TEXT, + workflow_name TEXT, + run_id TEXT, + status TEXT, + node_id TEXT, + node_type TEXT, + node_label TEXT, + event_type TEXT NOT NULL, + summary TEXT, + error_text TEXT, + duration_ms INTEGER, + branch TEXT, + pr_number TEXT, + pr_url TEXT, + workspace_id TEXT, + session_id TEXT, + session_type TEXT, + agent_id TEXT, + trace_id TEXT, + span_id TEXT, + parent_span_id TEXT, + benchmark_hint_json TEXT, + meta_json TEXT, + payload_json TEXT NOT NULL, + timestamp TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS session_activity ( + session_id TEXT PRIMARY KEY, + session_type TEXT, + workspace_id TEXT, + agent_id TEXT, + latest_task_id TEXT, + latest_task_title TEXT, + latest_run_id TEXT, + latest_workflow_id TEXT, + latest_workflow_name TEXT, + latest_event_type TEXT, + latest_status TEXT, + trace_id TEXT, + last_span_id TEXT, + parent_span_id TEXT, + last_error_text TEXT, + last_summary TEXT, + started_at TEXT, + updated_at TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + document_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS agent_activity ( + agent_id TEXT PRIMARY KEY, + workspace_id TEXT, + latest_task_id TEXT, + latest_task_title TEXT, + latest_session_id TEXT, + latest_run_id TEXT, + latest_workflow_id TEXT, + latest_workflow_name TEXT, + latest_event_type TEXT, + latest_status TEXT, + trace_id TEXT, + last_span_id TEXT, + parent_span_id TEXT, + last_error_text TEXT, + last_summary TEXT, + first_seen_at TEXT, + updated_at TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + document_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS promoted_strategies ( + strategy_id TEXT PRIMARY KEY, + workflow_id TEXT, + run_id TEXT, + task_id TEXT, + session_id TEXT, + team_id TEXT, + workspace_id TEXT, + scope TEXT, + scope_level TEXT, + category TEXT, + decision TEXT, + status TEXT, + verification_status TEXT, + confidence REAL, + recommendation TEXT, + rationale TEXT, + knowledge_hash TEXT, + knowledge_registry_path TEXT, + tags_json TEXT, + evidence_json TEXT, + provenance_json TEXT, + benchmark_json TEXT, + metrics_json TEXT, + evaluation_json TEXT, + knowledge_json TEXT, + promoted_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + document_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS promoted_strategy_events ( + event_id TEXT PRIMARY KEY, + strategy_id TEXT NOT NULL, + workflow_id TEXT, + run_id TEXT, + task_id TEXT, + session_id TEXT, + scope TEXT, + scope_id TEXT, + category TEXT, + decision TEXT, + status TEXT, + verification_status TEXT, + confidence REAL, + recommendation TEXT, + rationale TEXT, + knowledge_hash TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS knowledge_entries ( + entry_hash TEXT PRIMARY KEY, + content TEXT NOT NULL, + scope TEXT, + scope_level TEXT NOT NULL, + scope_id TEXT, + agent_id TEXT, + agent_type TEXT, + category TEXT, + task_ref TEXT, + timestamp TEXT NOT NULL, + team_id TEXT, + workspace_id TEXT, + session_id TEXT, + run_id TEXT, + workflow_id TEXT, + strategy_id TEXT, + confidence REAL, + verification_status TEXT, + verified_at TEXT, + provenance_json TEXT, + evidence_json TEXT, + tags_json TEXT, + search_text TEXT, + document_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_workflow_runs_root_run_id + ON workflow_runs(root_run_id, started_at, updated_at); + CREATE INDEX IF NOT EXISTS idx_workflow_runs_task_id + ON workflow_runs(task_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id_seq + ON workflow_events(run_id, seq); + CREATE INDEX IF NOT EXISTS idx_workflow_events_timestamp + ON workflow_events(timestamp); + CREATE INDEX IF NOT EXISTS idx_task_claim_snapshots_active + ON task_claim_snapshots(is_active, updated_at); + CREATE INDEX IF NOT EXISTS idx_task_claim_events_task_id_timestamp + ON task_claim_events(task_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated_at + ON task_snapshots(is_deleted, status, updated_at); + CREATE INDEX IF NOT EXISTS idx_task_topology_root_task_id + ON task_topology(root_task_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_task_topology_parent_task_id + ON task_topology(parent_task_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_task_topology_latest_run_id + ON task_topology(latest_run_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_task_topology_latest_session_id + ON task_topology(latest_session_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_tool_calls_run_id_updated_at + ON tool_calls(run_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_artifacts_run_id_created_at + ON artifacts(run_id, created_at); + CREATE INDEX IF NOT EXISTS idx_key_values_scope_updated_at + ON key_values(scope, updated_at); + CREATE INDEX IF NOT EXISTS idx_operator_actions_scope_created_at + ON operator_actions(scope, created_at); + CREATE INDEX IF NOT EXISTS idx_task_trace_events_task_id_timestamp + ON task_trace_events(task_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_task_trace_events_session_id_timestamp + ON task_trace_events(session_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_task_trace_events_agent_id_timestamp + ON task_trace_events(agent_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_task_trace_events_trace_id_timestamp + ON task_trace_events(trace_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_session_activity_updated_at + ON session_activity(updated_at); + CREATE INDEX IF NOT EXISTS idx_agent_activity_updated_at + ON agent_activity(updated_at); + CREATE INDEX IF NOT EXISTS idx_promoted_strategies_workflow_updated_at + ON promoted_strategies(workflow_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_promoted_strategies_decision_updated_at + ON promoted_strategies(decision, updated_at); + CREATE INDEX IF NOT EXISTS idx_promoted_strategy_events_strategy_created_at + ON promoted_strategy_events(strategy_id, created_at); + CREATE INDEX IF NOT EXISTS idx_promoted_strategy_events_workflow_created_at + ON promoted_strategy_events(workflow_id, created_at); + CREATE INDEX IF NOT EXISTS idx_knowledge_entries_scope_timestamp + ON knowledge_entries(scope_level, scope_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_knowledge_entries_workspace_timestamp + ON knowledge_entries(workspace_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_knowledge_entries_session_timestamp + ON knowledge_entries(session_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_knowledge_entries_run_timestamp + ON knowledge_entries(run_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_knowledge_entries_workflow_timestamp + ON knowledge_entries(workflow_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_knowledge_entries_strategy_timestamp + ON knowledge_entries(strategy_id, timestamp); + `); + + const now = new Date().toISOString(); + prepare(entry, ` + INSERT INTO schema_meta (key, value_text, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_text = excluded.value_text, + updated_at = excluded.updated_at + `).run("schema_version", String(DEFAULT_SCHEMA_VERSION), now); +} + +function openDatabase(options = {}) { + const path = resolveStateLedgerPath(options); + const transient = path === ":memory:" || options?.transient === true; + const cacheable = !transient; + const testRuntime = isLikelyTestRuntime(); + if (cacheable && _stateLedgerCache.has(path)) { + return _stateLedgerCache.get(path); + } + + ensureParentDir(path); + const DatabaseSync = getDatabaseSync(); + const db = new DatabaseSync(path); + const entry = { + db, + path, + transient, + cacheable, + statements: new Map(), + }; + configureDatabase(db, { transient, testRuntime }); + ensureSchema(entry); + if (cacheable) { + _stateLedgerCache.set(path, entry); + } + return entry; +} + +function closeTransientDatabase(entry) { + if (!entry?.transient) return; + try { + entry.db.close(); + } catch { + /* best effort */ + } +} + +function prepare(entry, sql) { + if (!entry.cacheable) { + return entry.db.prepare(sql); + } + if (!entry.statements.has(sql)) { + entry.statements.set(sql, entry.db.prepare(sql)); + } + return entry.statements.get(sql); +} + +function withLedger(options, fn) { + const entry = openDatabase(options); + try { + return fn(entry); + } finally { + closeTransientDatabase(entry); + } +} + +function runTransaction(entry, fn) { + entry.db.exec("BEGIN IMMEDIATE"); + try { + const result = fn(); + entry.db.exec("COMMIT"); + return result; + } catch (err) { + try { + entry.db.exec("ROLLBACK"); + } catch { + /* best effort */ + } + throw err; + } +} + +function normalizeWorkflowRunDocument(runDocument = {}) { + const events = Array.isArray(runDocument.events) ? runDocument.events : []; + const taskIdentity = collectTaskIdentityFromEvents(events); + const sessionIdentity = collectSessionIdentityFromEvents(events); + const runId = asText(runDocument.runId || ""); + return { + version: asInteger(runDocument.version) || 2, + runId, + workflowId: asText(runDocument.workflowId || ""), + workflowName: asText(runDocument.workflowName || ""), + rootRunId: asText(runDocument.rootRunId || runId || ""), + parentRunId: asText(runDocument.parentRunId || ""), + retryOf: asText(runDocument.retryOf || ""), + retryMode: asText(runDocument.retryMode || ""), + runKind: asText(runDocument.runKind || ""), + startedAt: asText(runDocument.startedAt || ""), + endedAt: asText(runDocument.endedAt || ""), + status: asText(runDocument.status || ""), + updatedAt: normalizeTimestamp(runDocument.updatedAt || runDocument.startedAt), + taskId: taskIdentity.taskId, + taskTitle: taskIdentity.taskTitle, + sessionId: sessionIdentity.sessionId, + sessionType: sessionIdentity.sessionType, + events, + }; +} + +function hydrateWorkflowRunRow(entry, row) { + const parsed = parseJsonText(row?.document_json); + if (parsed && typeof parsed === "object") { + if (!Array.isArray(parsed.events)) { + parsed.events = listWorkflowEventsInternal(entry, row.run_id); + } + return normalizeWorkflowRunDocument({ + ...parsed, + runId: parsed.runId || row.run_id, + rootRunId: parsed.rootRunId || row.root_run_id || row.run_id, + updatedAt: parsed.updatedAt || row.updated_at, + }); + } + + return normalizeWorkflowRunDocument({ + runId: row?.run_id || null, + workflowId: row?.workflow_id || null, + workflowName: row?.workflow_name || null, + rootRunId: row?.root_run_id || row?.run_id || null, + parentRunId: row?.parent_run_id || null, + retryOf: row?.retry_of || null, + retryMode: row?.retry_mode || null, + runKind: row?.run_kind || null, + startedAt: row?.started_at || null, + endedAt: row?.ended_at || null, + status: row?.status || null, + updatedAt: row?.updated_at || null, + events: listWorkflowEventsInternal(entry, row?.run_id), + }); +} + +function listWorkflowEventsInternal(entry, runId) { + if (!asText(runId)) return []; + return prepare( + entry, + `SELECT payload_json + FROM workflow_events + WHERE run_id = ? + ORDER BY seq ASC`, + ).all(runId).map((row) => parseJsonText(row?.payload_json)).filter(Boolean); +} + +function upsertToolCallFromEvent(entry, runDocument, event = {}) { + const eventType = asText(event.eventType || ""); + if (!eventType || !eventType.startsWith("tool.")) return; + const meta = event?.meta && typeof event.meta === "object" ? event.meta : null; + const timestamp = normalizeTimestamp(event.timestamp); + const callId = asText( + event.executionId + || event.toolCallId + || `${runDocument.runId}:${event.nodeId || "node"}:${event.toolId || event.toolName || "tool"}`, + ); + if (!callId) return; + const startedAt = eventType === "tool.started" ? timestamp : null; + const completedAt = eventType === "tool.completed" || eventType === "tool.failed" ? timestamp : null; + prepare( + entry, + `INSERT INTO tool_calls ( + call_id, run_id, root_run_id, task_id, session_id, execution_id, node_id, + tool_id, tool_name, server_id, provider, status, started_at, completed_at, + duration_ms, cwd, args_json, request_json, response_json, error_text, summary, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(call_id) DO UPDATE SET + run_id = excluded.run_id, + root_run_id = excluded.root_run_id, + task_id = excluded.task_id, + session_id = excluded.session_id, + execution_id = excluded.execution_id, + node_id = excluded.node_id, + tool_id = excluded.tool_id, + tool_name = excluded.tool_name, + server_id = excluded.server_id, + provider = COALESCE(excluded.provider, tool_calls.provider), + status = excluded.status, + started_at = COALESCE(tool_calls.started_at, excluded.started_at), + completed_at = COALESCE(excluded.completed_at, tool_calls.completed_at), + duration_ms = COALESCE(excluded.duration_ms, tool_calls.duration_ms), + cwd = COALESCE(excluded.cwd, tool_calls.cwd), + args_json = COALESCE(excluded.args_json, tool_calls.args_json), + request_json = COALESCE(excluded.request_json, tool_calls.request_json), + response_json = COALESCE(excluded.response_json, tool_calls.response_json), + error_text = COALESCE(excluded.error_text, tool_calls.error_text), + summary = COALESCE(excluded.summary, tool_calls.summary), + updated_at = excluded.updated_at`, + ).run( + callId, + runDocument.runId, + runDocument.rootRunId, + runDocument.taskId, + runDocument.sessionId, + asText(event.executionId), + asText(event.nodeId), + asText(event.toolId), + asText(event.toolName || event.toolId), + asText(event.serverId), + asText(meta?.provider || event.provider), + asText(event.status || (eventType === "tool.started" ? "running" : null)), + startedAt, + completedAt, + asInteger(event.durationMs), + asText(meta?.cwd), + toJsonText(meta?.args ?? null), + toJsonText(eventType === "tool.started" ? event : (meta?.request ?? null)), + toJsonText(eventType === "tool.started" ? null : event), + asText(event.error), + asText(event.summary), + timestamp, + ); +} + +function appendArtifactFromEvent(entry, runDocument, event = {}) { + const eventType = asText(event.eventType || ""); + if (!["artifact.emitted", "proof.emitted", "planner.post_attachment"].includes(eventType)) return; + const meta = event?.meta && typeof event.meta === "object" ? event.meta : null; + const timestamp = normalizeTimestamp(event.timestamp); + const kind = asText(meta?.attachmentKind || meta?.kind || eventType); + const path = extractArtifactPath(event, meta); + const artifactId = asText( + event.artifactId + || event.id + || `${runDocument.runId}:${sanitizeKeyPart(kind, "artifact")}:${sanitizeKeyPart(path || event.summary || timestamp, "entry")}`, + ); + if (!artifactId) return; + prepare( + entry, + `INSERT INTO artifacts ( + artifact_id, run_id, root_run_id, task_id, session_id, execution_id, node_id, + kind, path, summary, source_event_id, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, + root_run_id = excluded.root_run_id, + task_id = excluded.task_id, + session_id = excluded.session_id, + execution_id = excluded.execution_id, + node_id = excluded.node_id, + kind = excluded.kind, + path = excluded.path, + summary = excluded.summary, + source_event_id = excluded.source_event_id, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at`, + ).run( + artifactId, + runDocument.runId, + runDocument.rootRunId, + runDocument.taskId, + runDocument.sessionId, + asText(event.executionId), + asText(event.nodeId), + kind, + path, + asText(event.summary || meta?.summary || meta?.stepLabel), + asText(event.id), + toJsonText({ + eventType, + meta, + payload: event, + }), + timestamp, + timestamp, + ); +} + +export function writeWorkflowStateLedger(payload = {}, options = {}) { + return withLedger(options, (entry) => { + const runDocument = normalizeWorkflowRunDocument(payload.runDocument || {}); + if (!runDocument.runId) { + throw new Error(`${TAG} workflow runId is required`); + } + const appendedEvent = payload.appendedEvent && typeof payload.appendedEvent === "object" + ? payload.appendedEvent + : null; + runTransaction(entry, () => { + prepare( + entry, + `INSERT INTO workflow_runs ( + run_id, root_run_id, parent_run_id, retry_of, retry_mode, + workflow_id, workflow_name, run_kind, status, started_at, ended_at, updated_at, + task_id, task_title, session_id, session_type, event_count, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET + root_run_id = excluded.root_run_id, + parent_run_id = excluded.parent_run_id, + retry_of = excluded.retry_of, + retry_mode = excluded.retry_mode, + workflow_id = excluded.workflow_id, + workflow_name = excluded.workflow_name, + run_kind = excluded.run_kind, + status = excluded.status, + started_at = excluded.started_at, + ended_at = excluded.ended_at, + updated_at = excluded.updated_at, + task_id = excluded.task_id, + task_title = excluded.task_title, + session_id = excluded.session_id, + session_type = excluded.session_type, + event_count = excluded.event_count, + document_json = excluded.document_json`, + ).run( + runDocument.runId, + runDocument.rootRunId, + runDocument.parentRunId, + runDocument.retryOf, + runDocument.retryMode, + runDocument.workflowId, + runDocument.workflowName, + runDocument.runKind, + runDocument.status, + runDocument.startedAt, + runDocument.endedAt, + runDocument.updatedAt, + runDocument.taskId, + runDocument.taskTitle, + runDocument.sessionId, + runDocument.sessionType, + runDocument.events.length, + toJsonText(runDocument), + ); + + if (appendedEvent) { + prepare( + entry, + `INSERT INTO workflow_events ( + event_id, run_id, seq, timestamp, event_type, + root_run_id, parent_run_id, retry_of, retry_mode, run_kind, + execution_id, execution_key, execution_kind, execution_label, + parent_execution_id, caused_by_execution_id, child_run_id, + node_id, node_type, node_label, tool_id, tool_name, server_id, + status, attempt, duration_ms, error_text, summary, reason, meta_json, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(event_id) DO UPDATE SET + payload_json = excluded.payload_json, + timestamp = excluded.timestamp, + status = excluded.status, + summary = excluded.summary, + reason = excluded.reason, + meta_json = excluded.meta_json`, + ).run( + asText(appendedEvent.id || `${runDocument.runId}:${appendedEvent.seq || runDocument.events.length}`), + runDocument.runId, + asInteger(appendedEvent.seq) ?? runDocument.events.length, + normalizeTimestamp(appendedEvent.timestamp), + asText(appendedEvent.eventType || "event"), + asText(appendedEvent.rootRunId || runDocument.rootRunId || runDocument.runId), + asText(appendedEvent.parentRunId || runDocument.parentRunId), + asText(appendedEvent.retryOf || runDocument.retryOf), + asText(appendedEvent.retryMode || runDocument.retryMode), + asText(appendedEvent.runKind || runDocument.runKind), + asText(appendedEvent.executionId), + asText(appendedEvent.executionKey), + asText(appendedEvent.executionKind), + asText(appendedEvent.executionLabel), + asText(appendedEvent.parentExecutionId), + asText(appendedEvent.causedByExecutionId), + asText(appendedEvent.childRunId), + asText(appendedEvent.nodeId), + asText(appendedEvent.nodeType), + asText(appendedEvent.nodeLabel), + asText(appendedEvent.toolId), + asText(appendedEvent.toolName), + asText(appendedEvent.serverId), + asText(appendedEvent.status), + asInteger(appendedEvent.attempt), + asInteger(appendedEvent.durationMs), + asText(appendedEvent.error), + asText(appendedEvent.summary), + asText(appendedEvent.reason), + toJsonText(appendedEvent.meta), + toJsonText(appendedEvent), + ); + upsertToolCallFromEvent(entry, runDocument, appendedEvent); + appendArtifactFromEvent(entry, runDocument, appendedEvent); + } + }); + return { path: entry.path }; + }); +} + +function normalizeTaskTraceEventRecord(record = {}) { + const eventId = asText( + record.eventId + || record.event_id + || record.id + || `${record.taskId || record.task_id || "task"}:${record.runId || record.run_id || "run"}:${record.eventType || record.event_type || "event"}:${record.timestamp || ""}`, + ); + const taskId = asText(record.taskId || record.task_id); + if (!taskId) { + throw new Error(`${TAG} task trace taskId is required`); + } + return { + eventId, + taskId, + taskTitle: asText(record.taskTitle || record.task_title), + workflowId: asText(record.workflowId || record.workflow_id), + workflowName: asText(record.workflowName || record.workflow_name), + runId: asText(record.runId || record.run_id), + status: asText(record.status), + nodeId: asText(record.nodeId || record.node_id), + nodeType: asText(record.nodeType || record.node_type), + nodeLabel: asText(record.nodeLabel || record.node_label), + eventType: asText(record.eventType || record.event_type) || "workflow.event", + summary: asText(record.summary), + errorText: asText(record.error || record.errorText || record.error_text), + durationMs: asInteger(record.durationMs || record.duration_ms), + branch: asText(record.branch), + prNumber: asText(record.prNumber || record.pr_number), + prUrl: asText(record.prUrl || record.pr_url), + workspaceId: asText(record.workspaceId || record.workspace_id), + sessionId: asText(record.sessionId || record.session_id), + sessionType: asText(record.sessionType || record.session_type), + agentId: asText(record.agentId || record.agent_id), + traceId: asText(record.traceId || record.trace_id), + spanId: asText(record.spanId || record.span_id), + parentSpanId: asText(record.parentSpanId || record.parent_span_id), + benchmarkHint: Object.prototype.hasOwnProperty.call(record, "benchmarkHint") + ? record.benchmarkHint + : (Object.prototype.hasOwnProperty.call(record, "benchmark_hint") ? record.benchmark_hint : null), + meta: record.meta && typeof record.meta === "object" ? record.meta : null, + payload: record && typeof record === "object" ? record : {}, + timestamp: normalizeTimestamp(record.timestamp), + }; +} + +function buildSessionActivityDocument(record = {}, current = null) { + const currentDoc = current && typeof current === "object" ? current : {}; + return { + sessionId: record.sessionId, + sessionType: record.sessionType || currentDoc.sessionType || null, + workspaceId: record.workspaceId || currentDoc.workspaceId || null, + agentId: record.agentId || currentDoc.agentId || null, + latestTaskId: record.taskId || currentDoc.latestTaskId || null, + latestTaskTitle: record.taskTitle || currentDoc.latestTaskTitle || null, + latestRunId: record.runId || currentDoc.latestRunId || null, + latestWorkflowId: record.workflowId || currentDoc.latestWorkflowId || null, + latestWorkflowName: record.workflowName || currentDoc.latestWorkflowName || null, + latestEventType: record.eventType || currentDoc.latestEventType || null, + latestStatus: record.status || currentDoc.latestStatus || null, + traceId: record.traceId || currentDoc.traceId || null, + lastSpanId: record.spanId || currentDoc.lastSpanId || null, + parentSpanId: record.parentSpanId || currentDoc.parentSpanId || null, + lastErrorText: record.errorText || currentDoc.lastErrorText || null, + lastSummary: record.summary || currentDoc.lastSummary || null, + startedAt: currentDoc.startedAt || record.timestamp, + updatedAt: record.timestamp, + eventCount: Number(currentDoc.eventCount || 0) + 1, + }; +} + +function normalizeSessionActivityRecord(record = {}) { + const sessionId = asText(record.sessionId || record.id || record.taskId || ""); + if (!sessionId) { + throw new Error(`${TAG} session activity sessionId is required`); + } + const document = cloneJson( + record.document && typeof record.document === "object" + ? record.document + : record, + ) || {}; + const metadata = document?.metadata && typeof document.metadata === "object" + ? document.metadata + : null; + const updatedAt = normalizeTimestamp( + record.updatedAt + || record.lastActiveAt + || document.updatedAt + || document.lastActiveAt + || document.createdAt, + ); + const workspaceId = asText( + record.workspaceId || document.workspaceId || metadata?.workspaceId || "", + ); + const workspaceDir = asText( + record.workspaceDir || document.workspaceDir || metadata?.workspaceDir || "", + ); + const workspaceRoot = asText( + record.workspaceRoot || document.workspaceRoot || metadata?.workspaceRoot || "", + ); + const normalized = { + sessionId, + sessionType: asText(record.sessionType || record.type || document.sessionType || document.type), + workspaceId, + agentId: asText( + record.agentId + || document.agentId + || metadata?.agentId + || metadata?.agent + || "", + ), + latestTaskId: asText(record.taskId || document.taskId || sessionId), + latestTaskTitle: asText( + record.taskTitle || record.title || document.taskTitle || document.title || "", + ), + latestRunId: asText( + record.runId || document.latestRunId || document.runId || document.rootRunId || "", + ), + latestWorkflowId: asText( + record.workflowId || document.latestWorkflowId || document.workflowId || "", + ), + latestWorkflowName: asText( + record.workflowName || document.latestWorkflowName || document.workflowName || "", + ), + latestEventType: asText( + record.latestEventType || record.lastEventType || document.latestEventType || document.lastEventType || "", + ), + latestStatus: asText( + record.lifecycleStatus + || record.status + || document.lifecycleStatus + || document.status + || "", + ), + traceId: asText(record.traceId || document.traceId || ""), + lastSpanId: asText(record.lastSpanId || document.lastSpanId || ""), + parentSpanId: asText(record.parentSpanId || document.parentSpanId || ""), + lastErrorText: asText(record.lastErrorText || document.lastErrorText || ""), + lastSummary: asText( + record.lastSummary + || record.preview + || record.summary + || document.lastSummary + || document.preview + || document.lastMessage + || document.summary + || "", + ), + startedAt: asText(record.startedAt || document.startedAt || document.createdAt || updatedAt), + updatedAt, + eventCount: Math.max( + 0, + asInteger(record.eventCount ?? record.totalEvents ?? document.eventCount ?? document.totalEvents) || 0, + ), + document, + }; + if (!normalized.document.sessionId) normalized.document.sessionId = normalized.sessionId; + if (!normalized.document.id) normalized.document.id = normalized.sessionId; + if (!normalized.document.taskId) normalized.document.taskId = normalized.latestTaskId; + if (!normalized.document.workspaceId && workspaceId) normalized.document.workspaceId = workspaceId; + if (!normalized.document.workspaceDir && workspaceDir) normalized.document.workspaceDir = workspaceDir; + if (!normalized.document.workspaceRoot && workspaceRoot) normalized.document.workspaceRoot = workspaceRoot; + if (!normalized.document.taskTitle && normalized.latestTaskTitle) { + normalized.document.taskTitle = normalized.latestTaskTitle; + } + if (!normalized.document.updatedAt) normalized.document.updatedAt = normalized.updatedAt; + if (!normalized.document.eventCount) normalized.document.eventCount = normalized.eventCount; + if (!normalized.document.metadata || typeof normalized.document.metadata !== "object") { + normalized.document.metadata = {}; + } + if (!normalized.document.metadata.workspaceId && workspaceId) { + normalized.document.metadata.workspaceId = workspaceId; + } + if (!normalized.document.metadata.workspaceDir && workspaceDir) { + normalized.document.metadata.workspaceDir = workspaceDir; + } + if (!normalized.document.metadata.workspaceRoot && workspaceRoot) { + normalized.document.metadata.workspaceRoot = workspaceRoot; + } + return normalized; +} + +function mapSessionActivityRow(row) { + if (!row) return null; + return { + sessionId: row.session_id, + sessionType: row.session_type || null, + workspaceId: row.workspace_id || null, + agentId: row.agent_id || null, + latestTaskId: row.latest_task_id || null, + latestTaskTitle: row.latest_task_title || null, + latestRunId: row.latest_run_id || null, + latestWorkflowId: row.latest_workflow_id || null, + latestWorkflowName: row.latest_workflow_name || null, + latestEventType: row.latest_event_type || null, + latestStatus: row.latest_status || null, + traceId: row.trace_id || null, + lastSpanId: row.last_span_id || null, + parentSpanId: row.parent_span_id || null, + lastErrorText: row.last_error_text || null, + lastSummary: row.last_summary || null, + startedAt: row.started_at || null, + updatedAt: row.updated_at, + eventCount: Number(row.event_count || 0) || 0, + document: parseJsonText(row.document_json), + }; +} + +function buildAgentActivityDocument(record = {}, current = null) { + const currentDoc = current && typeof current === "object" ? current : {}; + return { + agentId: record.agentId, + workspaceId: record.workspaceId || currentDoc.workspaceId || null, + latestTaskId: record.taskId || currentDoc.latestTaskId || null, + latestTaskTitle: record.taskTitle || currentDoc.latestTaskTitle || null, + latestSessionId: record.sessionId || currentDoc.latestSessionId || null, + latestRunId: record.runId || currentDoc.latestRunId || null, + latestWorkflowId: record.workflowId || currentDoc.latestWorkflowId || null, + latestWorkflowName: record.workflowName || currentDoc.latestWorkflowName || null, + latestEventType: record.eventType || currentDoc.latestEventType || null, + latestStatus: record.status || currentDoc.latestStatus || null, + traceId: record.traceId || currentDoc.traceId || null, + lastSpanId: record.spanId || currentDoc.lastSpanId || null, + parentSpanId: record.parentSpanId || currentDoc.parentSpanId || null, + lastErrorText: record.errorText || currentDoc.lastErrorText || null, + lastSummary: record.summary || currentDoc.lastSummary || null, + firstSeenAt: currentDoc.firstSeenAt || record.timestamp, + updatedAt: record.timestamp, + eventCount: Number(currentDoc.eventCount || 0) + 1, + }; +} + +function normalizePromotedStrategyRecord(record = {}) { + const strategyId = asText(record.strategyId || record.strategy_id || record?.strategy?.strategyId || ""); + if (!strategyId) { + throw new Error(`${TAG} promoted strategy strategyId is required`); + } + const promotedAt = normalizeTimestamp( + record.promotedAt || record.promoted_at || record.createdAt || record.created_at || record.updatedAt || record.updated_at, + ); + const decision = asText(record.decision || record.verificationStatus || record.verification_status || record.status) || "promote_strategy"; + const status = asText(record.status) || "promoted"; + const knowledge = cloneJson(record.knowledge); + const normalized = { + strategyId, + workflowId: asText(record.workflowId || record.workflow_id), + runId: asText(record.runId || record.run_id), + taskId: asText(record.taskId || record.task_id), + sessionId: asText(record.sessionId || record.session_id), + teamId: asText(record.teamId || record.team_id), + workspaceId: asText(record.workspaceId || record.workspace_id), + scope: asText(record.scope), + scopeId: asText(record.scopeId || record.scope_id || record.workspaceId || record.workspace_id || record.teamId || record.team_id || record.sessionId || record.session_id || record.runId || record.run_id), + scopeLevel: asText(record.scopeLevel || record.scope_level) || "workspace", + category: asText(record.category) || "strategy", + decision, + status, + verificationStatus: asText(record.verificationStatus || record.verification_status) || decision, + confidence: Number.isFinite(Number(record.confidence)) ? Number(record.confidence) : null, + recommendation: asText(record.recommendation || record.summary), + rationale: asText(record.rationale), + tags: Array.isArray(record.tags) ? cloneJson(record.tags) : (typeof record.tags === "string" ? record.tags.split(",").map((item) => item.trim()).filter(Boolean) : []), + evidence: Array.isArray(record.evidence) ? cloneJson(record.evidence) : [], + provenance: Array.isArray(record.provenance) ? cloneJson(record.provenance) : [], + benchmark: cloneJson(record.benchmark), + metrics: cloneJson(record.metrics), + evaluation: cloneJson(record.evaluation), + knowledge, + knowledgeHash: asText(record.knowledgeHash || record.knowledge_hash || knowledge?.hash), + knowledgeRegistryPath: asText(record.knowledgeRegistryPath || record.knowledge_registry_path || knowledge?.registryPath), + promotedAt, + updatedAt: normalizeTimestamp(record.updatedAt || record.updated_at || promotedAt), + }; + normalized.document = { + ...normalized, + strategy: cloneJson(record.strategy && typeof record.strategy === "object" ? record.strategy : null), + }; + normalized.eventId = asText( + record.eventId + || record.event_id + || `${normalized.strategyId}:${normalized.decision}:${normalized.promotedAt}`, + ); + return normalized; +} + +function normalizeKnowledgeEntryRecord(record = {}) { + const content = asText(record.content); + if (!content) { + throw new Error(`${TAG} knowledge entry content is required`); + } + const scopeLevel = asText(record.scopeLevel || record.scope_level) || "workspace"; + const teamId = asText(record.teamId || record.team_id); + const workspaceId = asText(record.workspaceId || record.workspace_id); + const sessionId = asText(record.sessionId || record.session_id); + const runId = asText(record.runId || record.run_id); + const scopeId = asText( + record.scopeId + || record.scope_id + || (scopeLevel === "team" ? teamId : null) + || (scopeLevel === "workspace" ? workspaceId : null) + || (scopeLevel === "session" ? sessionId : null) + || (scopeLevel === "run" ? runId : null), + ); + const provenance = Array.isArray(record.provenance) ? cloneJson(record.provenance) : []; + const evidence = Array.isArray(record.evidence) ? cloneJson(record.evidence) : []; + const tags = Array.isArray(record.tags) ? cloneJson(record.tags) : []; + const document = cloneJson( + record.document && typeof record.document === "object" + ? record.document + : record, + ) || {}; + const normalized = { + entryHash: asText(record.entryHash || record.entry_hash || record.hash), + content, + scope: asText(record.scope), + scopeLevel, + scopeId, + agentId: asText(record.agentId || record.agent_id), + agentType: asText(record.agentType || record.agent_type) || "codex", + category: asText(record.category) || "pattern", + taskRef: asText(record.taskRef || record.task_ref), + timestamp: normalizeTimestamp(record.timestamp || record.createdAt || record.updatedAt), + teamId, + workspaceId, + sessionId, + runId, + workflowId: asText(record.workflowId || record.workflow_id), + strategyId: asText(record.strategyId || record.strategy_id), + confidence: Number.isFinite(Number(record.confidence)) ? Number(record.confidence) : null, + verificationStatus: asText(record.verificationStatus || record.verification_status), + verifiedAt: asText(record.verifiedAt || record.verified_at), + provenance, + evidence, + tags, + searchText: [ + content, + asText(record.scope), + asText(record.category), + asText(record.taskRef || record.task_ref), + asText(record.agentId || record.agent_id), + teamId, + workspaceId, + sessionId, + runId, + asText(record.workflowId || record.workflow_id), + asText(record.strategyId || record.strategy_id), + ...provenance, + ...evidence, + ...tags, + ].filter(Boolean).join(" "), + document, + }; + if (!normalized.entryHash) { + throw new Error(`${TAG} knowledge entry hash is required`); + } + if (!normalized.document.hash) normalized.document.hash = normalized.entryHash; + if (!normalized.document.content) normalized.document.content = normalized.content; + if (!normalized.document.scopeLevel) normalized.document.scopeLevel = normalized.scopeLevel; + if (!normalized.document.timestamp) normalized.document.timestamp = normalized.timestamp; + if (!normalized.document.agentId && normalized.agentId) normalized.document.agentId = normalized.agentId; + if (!normalized.document.agentType) normalized.document.agentType = normalized.agentType; + if (!normalized.document.category) normalized.document.category = normalized.category; + if (!normalized.document.scopeId && normalized.scopeId) normalized.document.scopeId = normalized.scopeId; + return normalized; +} + +export function appendTaskTraceEventToStateLedger(record = {}, options = {}) { + return withLedger(options, (entry) => { + const normalized = normalizeTaskTraceEventRecord(record); + runTransaction(entry, () => { + prepare( + entry, + `INSERT INTO task_trace_events ( + event_id, task_id, task_title, workflow_id, workflow_name, run_id, status, + node_id, node_type, node_label, event_type, summary, error_text, duration_ms, + branch, pr_number, pr_url, workspace_id, session_id, session_type, agent_id, + trace_id, span_id, parent_span_id, benchmark_hint_json, meta_json, payload_json, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(event_id) DO UPDATE SET + task_title = excluded.task_title, + workflow_id = excluded.workflow_id, + workflow_name = excluded.workflow_name, + run_id = excluded.run_id, + status = excluded.status, + node_id = excluded.node_id, + node_type = excluded.node_type, + node_label = excluded.node_label, + summary = excluded.summary, + error_text = excluded.error_text, + duration_ms = excluded.duration_ms, + branch = excluded.branch, + pr_number = excluded.pr_number, + pr_url = excluded.pr_url, + workspace_id = excluded.workspace_id, + session_id = excluded.session_id, + session_type = excluded.session_type, + agent_id = excluded.agent_id, + trace_id = excluded.trace_id, + span_id = excluded.span_id, + parent_span_id = excluded.parent_span_id, + benchmark_hint_json = excluded.benchmark_hint_json, + meta_json = excluded.meta_json, + payload_json = excluded.payload_json, + timestamp = excluded.timestamp`, + ).run( + normalized.eventId, + normalized.taskId, + normalized.taskTitle, + normalized.workflowId, + normalized.workflowName, + normalized.runId, + normalized.status, + normalized.nodeId, + normalized.nodeType, + normalized.nodeLabel, + normalized.eventType, + normalized.summary, + normalized.errorText, + normalized.durationMs, + normalized.branch, + normalized.prNumber, + normalized.prUrl, + normalized.workspaceId, + normalized.sessionId, + normalized.sessionType, + normalized.agentId, + normalized.traceId, + normalized.spanId, + normalized.parentSpanId, + toJsonText(normalized.benchmarkHint), + toJsonText(normalized.meta), + toJsonText(normalized.payload), + normalized.timestamp, + ); + + if (normalized.sessionId) { + const currentSessionRow = prepare( + entry, + `SELECT document_json + FROM session_activity + WHERE session_id = ?`, + ).get(normalized.sessionId); + const sessionDocument = buildSessionActivityDocument( + normalized, + parseJsonText(currentSessionRow?.document_json), + ); + prepare( + entry, + `INSERT INTO session_activity ( + session_id, session_type, workspace_id, agent_id, latest_task_id, latest_task_title, + latest_run_id, latest_workflow_id, latest_workflow_name, latest_event_type, latest_status, + trace_id, last_span_id, parent_span_id, last_error_text, last_summary, + started_at, updated_at, event_count, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + session_type = excluded.session_type, + workspace_id = excluded.workspace_id, + agent_id = excluded.agent_id, + latest_task_id = excluded.latest_task_id, + latest_task_title = excluded.latest_task_title, + latest_run_id = excluded.latest_run_id, + latest_workflow_id = excluded.latest_workflow_id, + latest_workflow_name = excluded.latest_workflow_name, + latest_event_type = excluded.latest_event_type, + latest_status = excluded.latest_status, + trace_id = excluded.trace_id, + last_span_id = excluded.last_span_id, + parent_span_id = excluded.parent_span_id, + last_error_text = excluded.last_error_text, + last_summary = excluded.last_summary, + started_at = excluded.started_at, + updated_at = excluded.updated_at, + event_count = excluded.event_count, + document_json = excluded.document_json`, + ).run( + normalized.sessionId, + sessionDocument.sessionType, + sessionDocument.workspaceId, + sessionDocument.agentId, + sessionDocument.latestTaskId, + sessionDocument.latestTaskTitle, + sessionDocument.latestRunId, + sessionDocument.latestWorkflowId, + sessionDocument.latestWorkflowName, + sessionDocument.latestEventType, + sessionDocument.latestStatus, + sessionDocument.traceId, + sessionDocument.lastSpanId, + sessionDocument.parentSpanId, + sessionDocument.lastErrorText, + sessionDocument.lastSummary, + sessionDocument.startedAt, + sessionDocument.updatedAt, + sessionDocument.eventCount, + toJsonText(sessionDocument), + ); + } + + if (normalized.agentId) { + const currentAgentRow = prepare( + entry, + `SELECT document_json + FROM agent_activity + WHERE agent_id = ?`, + ).get(normalized.agentId); + const agentDocument = buildAgentActivityDocument( + normalized, + parseJsonText(currentAgentRow?.document_json), + ); + prepare( + entry, + `INSERT INTO agent_activity ( + agent_id, workspace_id, latest_task_id, latest_task_title, latest_session_id, + latest_run_id, latest_workflow_id, latest_workflow_name, latest_event_type, latest_status, + trace_id, last_span_id, parent_span_id, last_error_text, last_summary, + first_seen_at, updated_at, event_count, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(agent_id) DO UPDATE SET + workspace_id = excluded.workspace_id, + latest_task_id = excluded.latest_task_id, + latest_task_title = excluded.latest_task_title, + latest_session_id = excluded.latest_session_id, + latest_run_id = excluded.latest_run_id, + latest_workflow_id = excluded.latest_workflow_id, + latest_workflow_name = excluded.latest_workflow_name, + latest_event_type = excluded.latest_event_type, + latest_status = excluded.latest_status, + trace_id = excluded.trace_id, + last_span_id = excluded.last_span_id, + parent_span_id = excluded.parent_span_id, + last_error_text = excluded.last_error_text, + last_summary = excluded.last_summary, + first_seen_at = excluded.first_seen_at, + updated_at = excluded.updated_at, + event_count = excluded.event_count, + document_json = excluded.document_json`, + ).run( + normalized.agentId, + agentDocument.workspaceId, + agentDocument.latestTaskId, + agentDocument.latestTaskTitle, + agentDocument.latestSessionId, + agentDocument.latestRunId, + agentDocument.latestWorkflowId, + agentDocument.latestWorkflowName, + agentDocument.latestEventType, + agentDocument.latestStatus, + agentDocument.traceId, + agentDocument.lastSpanId, + agentDocument.parentSpanId, + agentDocument.lastErrorText, + agentDocument.lastSummary, + agentDocument.firstSeenAt, + agentDocument.updatedAt, + agentDocument.eventCount, + toJsonText(agentDocument), + ); + } + }); + return { path: entry.path, eventId: normalized.eventId }; + }); +} + +export function upsertSessionRecordToStateLedger(record = {}, options = {}) { + return withLedger(options, (entry) => { + const normalized = normalizeSessionActivityRecord(record); + prepare( + entry, + `INSERT INTO session_activity ( + session_id, session_type, workspace_id, agent_id, latest_task_id, latest_task_title, + latest_run_id, latest_workflow_id, latest_workflow_name, latest_event_type, latest_status, + trace_id, last_span_id, parent_span_id, last_error_text, last_summary, + started_at, updated_at, event_count, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + session_type = excluded.session_type, + workspace_id = excluded.workspace_id, + agent_id = excluded.agent_id, + latest_task_id = excluded.latest_task_id, + latest_task_title = excluded.latest_task_title, + latest_run_id = excluded.latest_run_id, + latest_workflow_id = excluded.latest_workflow_id, + latest_workflow_name = excluded.latest_workflow_name, + latest_event_type = excluded.latest_event_type, + latest_status = excluded.latest_status, + trace_id = excluded.trace_id, + last_span_id = excluded.last_span_id, + parent_span_id = excluded.parent_span_id, + last_error_text = excluded.last_error_text, + last_summary = excluded.last_summary, + started_at = excluded.started_at, + updated_at = excluded.updated_at, + event_count = excluded.event_count, + document_json = excluded.document_json`, + ).run( + normalized.sessionId, + normalized.sessionType, + normalized.workspaceId, + normalized.agentId, + normalized.latestTaskId, + normalized.latestTaskTitle, + normalized.latestRunId, + normalized.latestWorkflowId, + normalized.latestWorkflowName, + normalized.latestEventType, + normalized.latestStatus, + normalized.traceId, + normalized.lastSpanId, + normalized.parentSpanId, + normalized.lastErrorText, + normalized.lastSummary, + normalized.startedAt, + normalized.updatedAt, + normalized.eventCount, + toJsonText(normalized.document), + ); + return mapSessionActivityRow({ + session_id: normalized.sessionId, + session_type: normalized.sessionType, + workspace_id: normalized.workspaceId, + agent_id: normalized.agentId, + latest_task_id: normalized.latestTaskId, + latest_task_title: normalized.latestTaskTitle, + latest_run_id: normalized.latestRunId, + latest_workflow_id: normalized.latestWorkflowId, + latest_workflow_name: normalized.latestWorkflowName, + latest_event_type: normalized.latestEventType, + latest_status: normalized.latestStatus, + trace_id: normalized.traceId, + last_span_id: normalized.lastSpanId, + parent_span_id: normalized.parentSpanId, + last_error_text: normalized.lastErrorText, + last_summary: normalized.lastSummary, + started_at: normalized.startedAt, + updated_at: normalized.updatedAt, + event_count: normalized.eventCount, + document_json: toJsonText(normalized.document), + }); + }); +} + +export function getWorkflowRunFromStateLedger(runId, options = {}) { + return withLedger(options, (entry) => { + const normalizedRunId = asText(runId); + if (!normalizedRunId) return null; + const row = prepare( + entry, + `SELECT * + FROM workflow_runs + WHERE run_id = ?`, + ).get(normalizedRunId); + return row ? hydrateWorkflowRunRow(entry, row) : null; + }); +} + +export function listWorkflowRunsFromStateLedger(options = {}) { + return withLedger(options, (entry) => + prepare( + entry, + `SELECT * + FROM workflow_runs + ORDER BY COALESCE(started_at, updated_at) ASC, run_id ASC`, + ).all().map((row) => hydrateWorkflowRunRow(entry, row)), + ); +} + +export function listWorkflowRunFamilyFromStateLedger(runId, options = {}) { + return withLedger(options, (entry) => { + const normalizedRunId = asText(runId); + if (!normalizedRunId) return []; + const row = prepare( + entry, + `SELECT root_run_id, run_id + FROM workflow_runs + WHERE run_id = ?`, + ).get(normalizedRunId); + if (!row) return []; + const rootRunId = asText(row.root_run_id || row.run_id || normalizedRunId); + return prepare( + entry, + `SELECT * + FROM workflow_runs + WHERE root_run_id = ? + OR run_id = ? + ORDER BY COALESCE(started_at, updated_at) ASC, run_id ASC`, + ).all(rootRunId, rootRunId).map((familyRow) => hydrateWorkflowRunRow(entry, familyRow)); + }); +} + +export function listWorkflowTaskRunEntriesFromStateLedger(options = {}) { + return withLedger(options, (entry) => + prepare( + entry, + `SELECT run_id, root_run_id, task_id, task_title, started_at, updated_at, status + FROM workflow_runs + WHERE task_id IS NOT NULL + ORDER BY COALESCE(started_at, updated_at) ASC, run_id ASC`, + ).all().map((row) => ({ + runId: row.run_id, + rootRunId: row.root_run_id || row.run_id, + taskId: row.task_id, + taskTitle: row.task_title || null, + startedAt: row.started_at || null, + updatedAt: row.updated_at || null, + status: row.status || null, + })), + ); +} + +export function listWorkflowEventsFromStateLedger(runId, options = {}) { + return withLedger(options, (entry) => listWorkflowEventsInternal(entry, runId)); +} + +function normalizeClaim(claim = {}, taskId) { + return { + task_id: asText(claim.task_id || taskId || ""), + instance_id: asText(claim.instance_id), + claim_token: asText(claim.claim_token), + claimed_at: asText(claim.claimed_at), + expires_at: asText(claim.expires_at), + renewed_at: asText(claim.renewed_at), + ttl_minutes: asInteger(claim.ttl_minutes), + metadata: claim.metadata && typeof claim.metadata === "object" ? claim.metadata : null, + raw: claim && typeof claim === "object" ? claim : null, + }; +} + +export function syncTaskClaimsRegistryToStateLedger(registry = {}, options = {}) { + return withLedger(options, (entry) => { + const claims = registry?.claims && typeof registry.claims === "object" ? registry.claims : {}; + const registryUpdatedAt = normalizeTimestamp(registry?.updated_at); + const taskIds = new Set( + Object.keys(claims).map((taskId) => asText(taskId)).filter(Boolean), + ); + runTransaction(entry, () => { + for (const [taskId, rawClaim] of Object.entries(claims)) { + const claim = normalizeClaim(rawClaim, taskId); + if (!claim.task_id) continue; + prepare( + entry, + `INSERT INTO task_claim_snapshots ( + task_id, instance_id, claim_token, claimed_at, expires_at, renewed_at, + ttl_minutes, metadata_json, claim_json, registry_updated_at, updated_at, released_at, is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + instance_id = excluded.instance_id, + claim_token = excluded.claim_token, + claimed_at = excluded.claimed_at, + expires_at = excluded.expires_at, + renewed_at = excluded.renewed_at, + ttl_minutes = excluded.ttl_minutes, + metadata_json = excluded.metadata_json, + claim_json = excluded.claim_json, + registry_updated_at = excluded.registry_updated_at, + updated_at = excluded.updated_at, + released_at = excluded.released_at, + is_active = excluded.is_active`, + ).run( + claim.task_id, + claim.instance_id, + claim.claim_token, + claim.claimed_at, + claim.expires_at, + claim.renewed_at, + claim.ttl_minutes, + toJsonText(claim.metadata), + toJsonText(claim.raw), + registryUpdatedAt, + registryUpdatedAt, + null, + 1, + ); + } + + const existingRows = prepare( + entry, + `SELECT task_id + FROM task_claim_snapshots + WHERE is_active = 1`, + ).all(); + for (const row of existingRows) { + const taskId = asText(row?.task_id); + if (!taskId || taskIds.has(taskId)) continue; + prepare( + entry, + `UPDATE task_claim_snapshots + SET is_active = 0, + released_at = ?, + registry_updated_at = ?, + updated_at = ? + WHERE task_id = ?`, + ).run(registryUpdatedAt, registryUpdatedAt, registryUpdatedAt, taskId); + } + }); + return { path: entry.path }; + }); +} + +export function appendTaskClaimAuditToStateLedger(auditEntry = {}, options = {}) { + return withLedger(options, (entry) => { + const timestamp = normalizeTimestamp(auditEntry.timestamp); + const eventId = asText( + auditEntry.event_id + || auditEntry.id + || `${auditEntry.task_id || "task"}:${auditEntry.action || "event"}:${timestamp}:${auditEntry.claim_token || ""}`, + ); + prepare( + entry, + `INSERT INTO task_claim_events ( + event_id, task_id, action, instance_id, claim_token, timestamp, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(event_id) DO UPDATE SET + payload_json = excluded.payload_json, + timestamp = excluded.timestamp`, + ).run( + eventId, + asText(auditEntry.task_id || "") || "unknown-task", + asText(auditEntry.action || "") || "event", + asText(auditEntry.instance_id), + asText(auditEntry.claim_token), + timestamp, + toJsonText({ ...auditEntry, timestamp }), + ); + return { path: entry.path }; + }); +} + +export function getActiveTaskClaimFromStateLedger(taskId, options = {}) { + return withLedger(options, (entry) => { + const normalizedTaskId = asText(taskId); + if (!normalizedTaskId) return null; + const row = prepare( + entry, + `SELECT claim_json + FROM task_claim_snapshots + WHERE task_id = ? + AND is_active = 1`, + ).get(normalizedTaskId); + return parseJsonText(row?.claim_json); + }); +} + +export function listTaskClaimEventsFromStateLedger(taskId, options = {}) { + return withLedger(options, (entry) => { + const normalizedTaskId = asText(taskId); + const rows = normalizedTaskId + ? prepare( + entry, + `SELECT payload_json + FROM task_claim_events + WHERE task_id = ? + ORDER BY timestamp ASC, event_id ASC`, + ).all(normalizedTaskId) + : prepare( + entry, + `SELECT payload_json + FROM task_claim_events + ORDER BY timestamp ASC, event_id ASC`, + ).all(); + return rows.map((row) => parseJsonText(row?.payload_json)).filter(Boolean); + }); +} + +function normalizeTaskRecord(task = {}) { + return { + id: asText(task.id || ""), + title: asText(task.title), + status: asText(task.status), + priority: task.priority == null ? null : String(task.priority), + assignee: asText(task.assignee), + projectId: asText(task.projectId), + createdAt: asText(task.createdAt), + updatedAt: asText(task.updatedAt), + lastActivityAt: asText(task.lastActivityAt), + syncDirty: task.syncDirty === true ? 1 : 0, + commentCount: Array.isArray(task.comments) ? task.comments.length : 0, + attachmentCount: Array.isArray(task.attachments) ? task.attachments.length : 0, + workflowRunCount: Array.isArray(task.workflowRuns) ? task.workflowRuns.length : 0, + runCount: Array.isArray(task.runs) ? task.runs.length : 0, + raw: task && typeof task === "object" ? task : null, + }; +} + +function normalizeTaskTopologyRecord(task = {}) { + const taskId = asText(task.id || ""); + const topology = task?.topology && typeof task.topology === "object" ? task.topology : {}; + const graphPath = uniqueTextList(Array.isArray(topology.graphPath) ? topology.graphPath : []); + const workflowRuns = Array.isArray(task.workflowRuns) ? task.workflowRuns : []; + const dependencyIds = uniqueTextList( + Array.isArray(task.dependencyTaskIds) && task.dependencyTaskIds.length > 0 + ? task.dependencyTaskIds + : task.dependsOn, + ); + const childTaskIds = uniqueTextList(task.childTaskIds); + const parseCount = (value, fallback = 0) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback; + }; + const normalized = { + taskId, + graphRootTaskId: asText(topology.graphRootTaskId || topology.rootTaskId || graphPath[0] || taskId), + graphParentTaskId: asText(topology.graphParentTaskId || task.parentTaskId), + graphDepth: parseCount(topology.graphDepth, graphPath.length > 0 ? Math.max(0, graphPath.length - 1) : 0), + graphPath, + workflowId: asText(topology.workflowId), + workflowName: asText(topology.workflowName), + latestNodeId: asText(topology.latestNodeId), + latestRunId: asText(topology.latestRunId || topology.runId), + rootRunId: asText(topology.rootRunId), + parentRunId: asText(topology.parentRunId), + sessionId: asText(topology.sessionId || topology.latestSessionId), + latestSessionId: asText(topology.latestSessionId || topology.sessionId), + rootSessionId: asText(topology.rootSessionId), + parentSessionId: asText(topology.parentSessionId), + rootTaskId: asText(topology.rootTaskId || topology.graphRootTaskId || graphPath[0] || taskId), + parentTaskId: asText(topology.parentTaskId || task.parentTaskId), + delegationDepth: parseCount(topology.delegationDepth, 0), + childTaskCount: Math.max(childTaskIds.length, parseCount(topology.childTaskCount, 0)), + dependencyCount: Math.max(dependencyIds.length, parseCount(topology.dependencyCount, 0)), + workflowRunCount: Math.max(workflowRuns.length, parseCount(topology.workflowRunCount, 0)), + updatedAt: normalizeTimestamp(task.updatedAt || task.lastActivityAt), + }; + normalized.raw = { + ...normalized, + graphPath: [...normalized.graphPath], + childTaskIds, + dependencyTaskIds: dependencyIds, + }; + return normalized; +} + +function hydrateTaskTopologyRow(row = null) { + if (!row) return null; + const parsed = parseJsonText(row.document_json); + if (parsed && typeof parsed === "object") { + return { + ...parsed, + taskId: asText(parsed.taskId || row.task_id), + graphRootTaskId: asText(parsed.graphRootTaskId || row.graph_root_task_id), + graphParentTaskId: asText(parsed.graphParentTaskId || row.graph_parent_task_id), + graphDepth: asInteger(parsed.graphDepth ?? row.graph_depth) ?? 0, + graphPath: uniqueTextList(Array.isArray(parsed.graphPath) ? parsed.graphPath : parseJsonText(row.graph_path_json)), + workflowId: asText(parsed.workflowId || row.workflow_id), + workflowName: asText(parsed.workflowName || row.workflow_name), + latestNodeId: asText(parsed.latestNodeId || row.latest_node_id), + latestRunId: asText(parsed.latestRunId || row.latest_run_id), + rootRunId: asText(parsed.rootRunId || row.root_run_id), + parentRunId: asText(parsed.parentRunId || row.parent_run_id), + sessionId: asText(parsed.sessionId || row.session_id), + latestSessionId: asText(parsed.latestSessionId || row.latest_session_id), + rootSessionId: asText(parsed.rootSessionId || row.root_session_id), + parentSessionId: asText(parsed.parentSessionId || row.parent_session_id), + rootTaskId: asText(parsed.rootTaskId || row.root_task_id), + parentTaskId: asText(parsed.parentTaskId || row.parent_task_id), + delegationDepth: asInteger(parsed.delegationDepth ?? row.delegation_depth) ?? 0, + childTaskCount: asInteger(parsed.childTaskCount ?? row.child_task_count) ?? 0, + dependencyCount: asInteger(parsed.dependencyCount ?? row.dependency_count) ?? 0, + workflowRunCount: asInteger(parsed.workflowRunCount ?? row.workflow_run_count) ?? 0, + updatedAt: asText(parsed.updatedAt || row.updated_at), + }; + } + return { + taskId: asText(row.task_id), + graphRootTaskId: asText(row.graph_root_task_id), + graphParentTaskId: asText(row.graph_parent_task_id), + graphDepth: asInteger(row.graph_depth) ?? 0, + graphPath: uniqueTextList(parseJsonText(row.graph_path_json)), + workflowId: asText(row.workflow_id), + workflowName: asText(row.workflow_name), + latestNodeId: asText(row.latest_node_id), + latestRunId: asText(row.latest_run_id), + rootRunId: asText(row.root_run_id), + parentRunId: asText(row.parent_run_id), + sessionId: asText(row.session_id), + latestSessionId: asText(row.latest_session_id), + rootSessionId: asText(row.root_session_id), + parentSessionId: asText(row.parent_session_id), + rootTaskId: asText(row.root_task_id), + parentTaskId: asText(row.parent_task_id), + delegationDepth: asInteger(row.delegation_depth) ?? 0, + childTaskCount: asInteger(row.child_task_count) ?? 0, + dependencyCount: asInteger(row.dependency_count) ?? 0, + workflowRunCount: asInteger(row.workflow_run_count) ?? 0, + updatedAt: asText(row.updated_at), + }; +} + +export function syncTaskStoreToStateLedger(store = {}, options = {}) { + return withLedger(options, (entry) => { + const tasks = store?.tasks && typeof store.tasks === "object" ? store.tasks : {}; + const updatedAt = normalizeTimestamp(store?._meta?.updatedAt); + const currentIds = new Set( + Object.keys(tasks).map((taskId) => asText(taskId)).filter(Boolean), + ); + runTransaction(entry, () => { + for (const rawTask of Object.values(tasks)) { + const task = normalizeTaskRecord(rawTask); + const taskTopology = normalizeTaskTopologyRecord(rawTask); + if (!task.id) continue; + prepare( + entry, + `INSERT INTO task_snapshots ( + task_id, title, status, priority, assignee, project_id, created_at, updated_at, + last_activity_at, sync_dirty, comment_count, attachment_count, workflow_run_count, + run_count, document_json, deleted_at, is_deleted + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + title = excluded.title, + status = excluded.status, + priority = excluded.priority, + assignee = excluded.assignee, + project_id = excluded.project_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + last_activity_at = excluded.last_activity_at, + sync_dirty = excluded.sync_dirty, + comment_count = excluded.comment_count, + attachment_count = excluded.attachment_count, + workflow_run_count = excluded.workflow_run_count, + run_count = excluded.run_count, + document_json = excluded.document_json, + deleted_at = excluded.deleted_at, + is_deleted = excluded.is_deleted`, + ).run( + task.id, + task.title, + task.status, + task.priority, + task.assignee, + task.projectId, + task.createdAt, + task.updatedAt || updatedAt, + task.lastActivityAt, + task.syncDirty, + task.commentCount, + task.attachmentCount, + task.workflowRunCount, + task.runCount, + toJsonText(task.raw), + null, + 0, + ); + prepare( + entry, + `INSERT INTO task_topology ( + task_id, graph_root_task_id, graph_parent_task_id, graph_depth, graph_path_json, + workflow_id, workflow_name, latest_node_id, latest_run_id, root_run_id, parent_run_id, + session_id, latest_session_id, root_session_id, parent_session_id, root_task_id, parent_task_id, + delegation_depth, child_task_count, dependency_count, workflow_run_count, updated_at, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + graph_root_task_id = excluded.graph_root_task_id, + graph_parent_task_id = excluded.graph_parent_task_id, + graph_depth = excluded.graph_depth, + graph_path_json = excluded.graph_path_json, + workflow_id = excluded.workflow_id, + workflow_name = excluded.workflow_name, + latest_node_id = excluded.latest_node_id, + latest_run_id = excluded.latest_run_id, + root_run_id = excluded.root_run_id, + parent_run_id = excluded.parent_run_id, + session_id = excluded.session_id, + latest_session_id = excluded.latest_session_id, + root_session_id = excluded.root_session_id, + parent_session_id = excluded.parent_session_id, + root_task_id = excluded.root_task_id, + parent_task_id = excluded.parent_task_id, + delegation_depth = excluded.delegation_depth, + child_task_count = excluded.child_task_count, + dependency_count = excluded.dependency_count, + workflow_run_count = excluded.workflow_run_count, + updated_at = excluded.updated_at, + document_json = excluded.document_json`, + ).run( + taskTopology.taskId, + taskTopology.graphRootTaskId, + taskTopology.graphParentTaskId, + taskTopology.graphDepth, + toJsonText(taskTopology.graphPath), + taskTopology.workflowId, + taskTopology.workflowName, + taskTopology.latestNodeId, + taskTopology.latestRunId, + taskTopology.rootRunId, + taskTopology.parentRunId, + taskTopology.sessionId, + taskTopology.latestSessionId, + taskTopology.rootSessionId, + taskTopology.parentSessionId, + taskTopology.rootTaskId, + taskTopology.parentTaskId, + taskTopology.delegationDepth, + taskTopology.childTaskCount, + taskTopology.dependencyCount, + taskTopology.workflowRunCount, + taskTopology.updatedAt, + toJsonText(taskTopology.raw), + ); + } + + const existingRows = prepare( + entry, + `SELECT task_id + FROM task_snapshots + WHERE is_deleted = 0`, + ).all(); + for (const row of existingRows) { + const taskId = asText(row?.task_id); + if (!taskId || currentIds.has(taskId)) continue; + prepare( + entry, + `UPDATE task_snapshots + SET is_deleted = 1, + deleted_at = ? + WHERE task_id = ?`, + ).run(updatedAt, taskId); + prepare( + entry, + `DELETE FROM task_topology + WHERE task_id = ?`, + ).run(taskId); + } + }); + return { path: entry.path }; + }); +} + +export function getTaskSnapshotFromStateLedger(taskId, options = {}) { + return withLedger(options, (entry) => { + const normalizedTaskId = asText(taskId); + if (!normalizedTaskId) return null; + const includeDeleted = options.includeDeleted === true; + const row = prepare( + entry, + `SELECT document_json, is_deleted + FROM task_snapshots + WHERE task_id = ?`, + ).get(normalizedTaskId); + if (!row) return null; + if (!includeDeleted && Number(row.is_deleted || 0) !== 0) { + return null; + } + const document = parseJsonText(row.document_json); + if (!document || typeof document !== "object") return null; + const topology = getTaskTopologyFromStateLedger(normalizedTaskId, options); + if (topology) { + document.topology = { + ...(document.topology && typeof document.topology === "object" ? document.topology : {}), + ...topology, + }; + } + return document; + }); +} + +export function getTaskTopologyFromStateLedger(taskId, options = {}) { + return withLedger(options, (entry) => { + const normalizedTaskId = asText(taskId); + if (!normalizedTaskId) return null; + const row = prepare( + entry, + `SELECT * + FROM task_topology + WHERE task_id = ?`, + ).get(normalizedTaskId); + return hydrateTaskTopologyRow(row); + }); +} + +export function listTaskSnapshotsFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const includeDeleted = options.includeDeleted === true; + const rows = includeDeleted + ? prepare( + entry, + `SELECT document_json + FROM task_snapshots + ORDER BY COALESCE(updated_at, created_at) ASC, task_id ASC`, + ).all() + : prepare( + entry, + `SELECT document_json + FROM task_snapshots + WHERE is_deleted = 0 + ORDER BY COALESCE(updated_at, created_at) ASC, task_id ASC`, + ).all(); + return rows + .map((row) => { + const document = parseJsonText(row?.document_json); + if (!document || typeof document !== "object") return null; + const topology = getTaskTopologyFromStateLedger(document.id || document.taskId, options); + if (topology) { + document.topology = { + ...(document.topology && typeof document.topology === "object" ? document.topology : {}), + ...topology, + }; + } + return document; + }) + .filter(Boolean); + }); +} + +export function listTaskTopologiesFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const taskId = asText(options.taskId || options.task_id); + const rootTaskId = asText(options.rootTaskId || options.root_task_id); + const parentTaskId = asText(options.parentTaskId || options.parent_task_id); + const latestRunId = asText(options.latestRunId || options.latest_run_id); + const latestSessionId = asText(options.latestSessionId || options.latest_session_id); + const clauses = []; + const args = []; + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (rootTaskId) { + clauses.push("root_task_id = ?"); + args.push(rootTaskId); + } + if (parentTaskId) { + clauses.push("parent_task_id = ?"); + args.push(parentTaskId); + } + if (latestRunId) { + clauses.push("latest_run_id = ?"); + args.push(latestRunId); + } + if (latestSessionId) { + clauses.push("latest_session_id = ?"); + args.push(latestSessionId); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM task_topology + ${whereSql} + ORDER BY updated_at ASC, task_id ASC`, + ).all(...args); + return rows.map((row) => hydrateTaskTopologyRow(row)).filter(Boolean); + }); +} + +export function listToolCallsFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const runId = asText(options.runId || options.run_id); + const taskId = asText(options.taskId || options.task_id); + const sessionId = asText(options.sessionId || options.session_id); + const toolName = asText(options.toolName || options.tool_name); + const status = asText(options.status); + if (runId) { + clauses.push("run_id = ?"); + args.push(runId); + } + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (sessionId) { + clauses.push("session_id = ?"); + args.push(sessionId); + } + if (toolName) { + clauses.push("tool_name = ?"); + args.push(toolName); + } + if (status) { + clauses.push("status = ?"); + args.push(status); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM tool_calls + ${whereSql} + ORDER BY COALESCE(started_at, updated_at) ASC, call_id ASC`, + ).all(...args); + return rows.map((row) => ({ + callId: row.call_id, + runId: row.run_id || null, + rootRunId: row.root_run_id || null, + taskId: row.task_id || null, + sessionId: row.session_id || null, + executionId: row.execution_id || null, + nodeId: row.node_id || null, + toolId: row.tool_id || null, + toolName: row.tool_name || null, + serverId: row.server_id || null, + provider: row.provider || null, + status: row.status || null, + startedAt: row.started_at || null, + completedAt: row.completed_at || null, + durationMs: Number(row.duration_ms || 0) || null, + cwd: row.cwd || null, + args: parseJsonText(row.args_json), + request: parseJsonText(row.request_json), + response: parseJsonText(row.response_json), + error: row.error_text || null, + summary: row.summary || null, + updatedAt: row.updated_at, + })); + }); +} + +export function listArtifactsFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const runId = asText(options.runId || options.run_id); + const taskId = asText(options.taskId || options.task_id); + const sessionId = asText(options.sessionId || options.session_id); + const kind = asText(options.kind); + if (runId) { + clauses.push("run_id = ?"); + args.push(runId); + } + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (sessionId) { + clauses.push("session_id = ?"); + args.push(sessionId); + } + if (kind) { + clauses.push("kind = ?"); + args.push(kind); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM artifacts + ${whereSql} + ORDER BY created_at ASC, artifact_id ASC`, + ).all(...args); + return rows.map((row) => ({ + artifactId: row.artifact_id, + runId: row.run_id || null, + rootRunId: row.root_run_id || null, + taskId: row.task_id || null, + sessionId: row.session_id || null, + executionId: row.execution_id || null, + nodeId: row.node_id || null, + kind: row.kind || null, + path: row.path || null, + summary: row.summary || null, + sourceEventId: row.source_event_id || null, + metadata: parseJsonText(row.metadata_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + }); +} + +export function listTaskTraceEventsFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const runId = asText(options.runId || options.run_id); + const taskId = asText(options.taskId || options.task_id); + const sessionId = asText(options.sessionId || options.session_id); + const agentId = asText(options.agentId || options.agent_id); + const traceId = asText(options.traceId || options.trace_id); + if (runId) { + clauses.push("run_id = ?"); + args.push(runId); + } + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (sessionId) { + clauses.push("session_id = ?"); + args.push(sessionId); + } + if (agentId) { + clauses.push("agent_id = ?"); + args.push(agentId); + } + if (traceId) { + clauses.push("trace_id = ?"); + args.push(traceId); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM task_trace_events + ${whereSql} + ORDER BY timestamp ASC, event_id ASC`, + ).all(...args); + return rows.map((row) => ({ + eventId: row.event_id, + taskId: row.task_id, + taskTitle: row.task_title || null, + workflowId: row.workflow_id || null, + workflowName: row.workflow_name || null, + runId: row.run_id || null, + status: row.status || null, + nodeId: row.node_id || null, + nodeType: row.node_type || null, + nodeLabel: row.node_label || null, + eventType: row.event_type, + summary: row.summary || null, + error: row.error_text || null, + durationMs: Number(row.duration_ms || 0) || null, + branch: row.branch || null, + prNumber: row.pr_number || null, + prUrl: row.pr_url || null, + workspaceId: row.workspace_id || null, + sessionId: row.session_id || null, + sessionType: row.session_type || null, + agentId: row.agent_id || null, + traceId: row.trace_id || null, + spanId: row.span_id || null, + parentSpanId: row.parent_span_id || null, + benchmarkHint: parseJsonText(row.benchmark_hint_json), + meta: parseJsonText(row.meta_json), + payload: parseJsonText(row.payload_json), + timestamp: row.timestamp, + })); + }); +} + +export function getSessionActivityFromStateLedger(sessionId, options = {}) { + return withLedger(options, (entry) => { + const normalizedSessionId = asText(sessionId); + if (!normalizedSessionId) return null; + const row = prepare( + entry, + `SELECT * + FROM session_activity + WHERE session_id = ?`, + ).get(normalizedSessionId); + if (!row) return null; + return mapSessionActivityRow(row); + }); +} + +export function listSessionActivitiesFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const workspaceId = asText(options.workspaceId || options.workspace_id); + const sessionType = asText(options.sessionType || options.session_type || options.type); + const agentId = asText(options.agentId || options.agent_id); + const status = asText(options.status || options.latestStatus || options.latest_status); + const limit = Math.max(1, Math.min(5000, asInteger(options.limit) || 1000)); + if (workspaceId) { + clauses.push("workspace_id = ?"); + args.push(workspaceId); + } + if (sessionType) { + clauses.push("session_type = ?"); + args.push(sessionType); + } + if (agentId) { + clauses.push("agent_id = ?"); + args.push(agentId); + } + if (status) { + clauses.push("latest_status = ?"); + args.push(status); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM session_activity + ${whereSql} + ORDER BY updated_at DESC, session_id DESC + LIMIT ?`, + ).all(...args, limit); + return rows.map((row) => mapSessionActivityRow(row)).filter(Boolean); + }); +} + +export function getAgentActivityFromStateLedger(agentId, options = {}) { + return withLedger(options, (entry) => { + const normalizedAgentId = asText(agentId); + if (!normalizedAgentId) return null; + const row = prepare( + entry, + `SELECT * + FROM agent_activity + WHERE agent_id = ?`, + ).get(normalizedAgentId); + if (!row) return null; + return { + agentId: row.agent_id, + workspaceId: row.workspace_id || null, + latestTaskId: row.latest_task_id || null, + latestTaskTitle: row.latest_task_title || null, + latestSessionId: row.latest_session_id || null, + latestRunId: row.latest_run_id || null, + latestWorkflowId: row.latest_workflow_id || null, + latestWorkflowName: row.latest_workflow_name || null, + latestEventType: row.latest_event_type || null, + latestStatus: row.latest_status || null, + traceId: row.trace_id || null, + lastSpanId: row.last_span_id || null, + parentSpanId: row.parent_span_id || null, + lastErrorText: row.last_error_text || null, + lastSummary: row.last_summary || null, + firstSeenAt: row.first_seen_at || null, + updatedAt: row.updated_at, + eventCount: Number(row.event_count || 0) || 0, + document: parseJsonText(row.document_json), + }; + }); +} + +export function appendPromotedStrategyToStateLedger(record = {}, options = {}) { + return withLedger(options, (entry) => { + const normalized = normalizePromotedStrategyRecord(record); + runTransaction(entry, () => { + prepare( + entry, + `INSERT INTO promoted_strategy_events ( + event_id, strategy_id, workflow_id, run_id, task_id, session_id, + scope, scope_id, category, decision, status, verification_status, + confidence, recommendation, rationale, knowledge_hash, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(event_id) DO UPDATE SET + status = excluded.status, + verification_status = excluded.verification_status, + confidence = excluded.confidence, + recommendation = excluded.recommendation, + rationale = excluded.rationale, + knowledge_hash = excluded.knowledge_hash, + payload_json = excluded.payload_json, + created_at = excluded.created_at`, + ).run( + normalized.eventId, + normalized.strategyId, + normalized.workflowId, + normalized.runId, + normalized.taskId, + normalized.sessionId, + normalized.scope, + normalized.scopeId, + normalized.category, + normalized.decision, + normalized.status, + normalized.verificationStatus, + normalized.confidence, + normalized.recommendation, + normalized.rationale, + normalized.knowledgeHash, + toJsonText(normalized.document), + normalized.promotedAt, + ); + + prepare( + entry, + `INSERT INTO promoted_strategies ( + strategy_id, workflow_id, run_id, task_id, session_id, team_id, workspace_id, + scope, scope_level, category, decision, status, verification_status, confidence, + recommendation, rationale, knowledge_hash, knowledge_registry_path, tags_json, + evidence_json, provenance_json, benchmark_json, metrics_json, evaluation_json, + knowledge_json, promoted_at, updated_at, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(strategy_id) DO UPDATE SET + workflow_id = excluded.workflow_id, + run_id = excluded.run_id, + task_id = excluded.task_id, + session_id = excluded.session_id, + team_id = excluded.team_id, + workspace_id = excluded.workspace_id, + scope = excluded.scope, + scope_level = excluded.scope_level, + category = excluded.category, + decision = excluded.decision, + status = excluded.status, + verification_status = excluded.verification_status, + confidence = excluded.confidence, + recommendation = excluded.recommendation, + rationale = excluded.rationale, + knowledge_hash = excluded.knowledge_hash, + knowledge_registry_path = excluded.knowledge_registry_path, + tags_json = excluded.tags_json, + evidence_json = excluded.evidence_json, + provenance_json = excluded.provenance_json, + benchmark_json = excluded.benchmark_json, + metrics_json = excluded.metrics_json, + evaluation_json = excluded.evaluation_json, + knowledge_json = excluded.knowledge_json, + promoted_at = excluded.promoted_at, + updated_at = excluded.updated_at, + document_json = excluded.document_json`, + ).run( + normalized.strategyId, + normalized.workflowId, + normalized.runId, + normalized.taskId, + normalized.sessionId, + normalized.teamId, + normalized.workspaceId, + normalized.scope, + normalized.scopeLevel, + normalized.category, + normalized.decision, + normalized.status, + normalized.verificationStatus, + normalized.confidence, + normalized.recommendation, + normalized.rationale, + normalized.knowledgeHash, + normalized.knowledgeRegistryPath, + toJsonText(normalized.tags), + toJsonText(normalized.evidence), + toJsonText(normalized.provenance), + toJsonText(normalized.benchmark), + toJsonText(normalized.metrics), + toJsonText(normalized.evaluation), + toJsonText(normalized.knowledge), + normalized.promotedAt, + normalized.updatedAt, + toJsonText(normalized.document), + ); + }); + return { + path: entry.path, + strategyId: normalized.strategyId, + eventId: normalized.eventId, + }; + }); +} + +export function getPromotedStrategyFromStateLedger(strategyId, options = {}) { + return withLedger(options, (entry) => { + const normalizedStrategyId = asText(strategyId); + if (!normalizedStrategyId) return null; + const row = prepare( + entry, + `SELECT * + FROM promoted_strategies + WHERE strategy_id = ?`, + ).get(normalizedStrategyId); + if (!row) return null; + return { + strategyId: row.strategy_id, + workflowId: row.workflow_id || null, + runId: row.run_id || null, + taskId: row.task_id || null, + sessionId: row.session_id || null, + teamId: row.team_id || null, + workspaceId: row.workspace_id || null, + scope: row.scope || null, + scopeLevel: row.scope_level || null, + category: row.category || null, + decision: row.decision || null, + status: row.status || null, + verificationStatus: row.verification_status || null, + confidence: Number.isFinite(Number(row.confidence)) ? Number(row.confidence) : null, + recommendation: row.recommendation || null, + rationale: row.rationale || null, + knowledgeHash: row.knowledge_hash || null, + knowledgeRegistryPath: row.knowledge_registry_path || null, + tags: parseJsonText(row.tags_json) || [], + evidence: parseJsonText(row.evidence_json) || [], + provenance: parseJsonText(row.provenance_json) || [], + benchmark: parseJsonText(row.benchmark_json), + metrics: parseJsonText(row.metrics_json), + evaluation: parseJsonText(row.evaluation_json), + knowledge: parseJsonText(row.knowledge_json), + promotedAt: row.promoted_at, + updatedAt: row.updated_at, + document: parseJsonText(row.document_json), + }; + }); +} + +export function listPromotedStrategiesFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const workflowId = asText(options.workflowId || options.workflow_id); + const runId = asText(options.runId || options.run_id); + const taskId = asText(options.taskId || options.task_id); + const sessionId = asText(options.sessionId || options.session_id); + const decision = asText(options.decision); + const status = asText(options.status); + if (workflowId) { + clauses.push("workflow_id = ?"); + args.push(workflowId); + } + if (runId) { + clauses.push("run_id = ?"); + args.push(runId); + } + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (sessionId) { + clauses.push("session_id = ?"); + args.push(sessionId); + } + if (decision) { + clauses.push("decision = ?"); + args.push(decision); + } + if (status) { + clauses.push("status = ?"); + args.push(status); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM promoted_strategies + ${whereSql} + ORDER BY updated_at DESC, strategy_id ASC`, + ).all(...args); + return rows.map((row) => ({ + strategyId: row.strategy_id, + workflowId: row.workflow_id || null, + runId: row.run_id || null, + category: row.category || null, + decision: row.decision || null, + status: row.status || null, + verificationStatus: row.verification_status || null, + confidence: Number.isFinite(Number(row.confidence)) ? Number(row.confidence) : null, + recommendation: row.recommendation || null, + rationale: row.rationale || null, + knowledgeHash: row.knowledge_hash || null, + promotedAt: row.promoted_at, + updatedAt: row.updated_at, + document: parseJsonText(row.document_json), + })); + }); +} + +export function listPromotedStrategyEventsFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const strategyId = asText(options.strategyId || options.strategy_id); + const workflowId = asText(options.workflowId || options.workflow_id); + const runId = asText(options.runId || options.run_id); + const taskId = asText(options.taskId || options.task_id); + const sessionId = asText(options.sessionId || options.session_id); + if (strategyId) { + clauses.push("strategy_id = ?"); + args.push(strategyId); + } + if (workflowId) { + clauses.push("workflow_id = ?"); + args.push(workflowId); + } + if (runId) { + clauses.push("run_id = ?"); + args.push(runId); + } + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (sessionId) { + clauses.push("session_id = ?"); + args.push(sessionId); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM promoted_strategy_events + ${whereSql} + ORDER BY created_at ASC, event_id ASC`, + ).all(...args); + return rows.map((row) => ({ + eventId: row.event_id, + strategyId: row.strategy_id, + workflowId: row.workflow_id || null, + runId: row.run_id || null, + taskId: row.task_id || null, + sessionId: row.session_id || null, + scope: row.scope || null, + scopeId: row.scope_id || null, + category: row.category || null, + decision: row.decision || null, + status: row.status || null, + verificationStatus: row.verification_status || null, + confidence: Number.isFinite(Number(row.confidence)) ? Number(row.confidence) : null, + recommendation: row.recommendation || null, + rationale: row.rationale || null, + knowledgeHash: row.knowledge_hash || null, + payload: parseJsonText(row.payload_json), + createdAt: row.created_at, + })); + }); +} + +export function appendKnowledgeEntryToStateLedger(record = {}, options = {}) { + return withLedger(options, (entry) => { + const normalized = normalizeKnowledgeEntryRecord(record); + prepare( + entry, + `INSERT INTO knowledge_entries ( + entry_hash, content, scope, scope_level, scope_id, agent_id, agent_type, + category, task_ref, timestamp, team_id, workspace_id, session_id, run_id, + workflow_id, strategy_id, confidence, verification_status, verified_at, + provenance_json, evidence_json, tags_json, search_text, document_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(entry_hash) DO UPDATE SET + content = excluded.content, + scope = excluded.scope, + scope_level = excluded.scope_level, + scope_id = excluded.scope_id, + agent_id = excluded.agent_id, + agent_type = excluded.agent_type, + category = excluded.category, + task_ref = excluded.task_ref, + timestamp = excluded.timestamp, + team_id = excluded.team_id, + workspace_id = excluded.workspace_id, + session_id = excluded.session_id, + run_id = excluded.run_id, + workflow_id = excluded.workflow_id, + strategy_id = excluded.strategy_id, + confidence = excluded.confidence, + verification_status = excluded.verification_status, + verified_at = excluded.verified_at, + provenance_json = excluded.provenance_json, + evidence_json = excluded.evidence_json, + tags_json = excluded.tags_json, + search_text = excluded.search_text, + document_json = excluded.document_json`, + ).run( + normalized.entryHash, + normalized.content, + normalized.scope, + normalized.scopeLevel, + normalized.scopeId, + normalized.agentId, + normalized.agentType, + normalized.category, + normalized.taskRef, + normalized.timestamp, + normalized.teamId, + normalized.workspaceId, + normalized.sessionId, + normalized.runId, + normalized.workflowId, + normalized.strategyId, + normalized.confidence, + normalized.verificationStatus, + normalized.verifiedAt, + toJsonText(normalized.provenance), + toJsonText(normalized.evidence), + toJsonText(normalized.tags), + normalized.searchText, + toJsonText(normalized.document), + ); + return { + path: entry.path, + entryHash: normalized.entryHash, + }; + }); +} + +export function listKnowledgeEntriesFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const teamId = asText(options.teamId || options.team_id); + const workspaceId = asText(options.workspaceId || options.workspace_id); + const sessionId = asText(options.sessionId || options.session_id); + const runId = asText(options.runId || options.run_id); + const scopeLevel = asText(options.scopeLevel || options.scope_level); + const scope = asText(options.scope); + const workflowId = asText(options.workflowId || options.workflow_id); + const strategyId = asText(options.strategyId || options.strategy_id); + const taskRef = asText(options.taskRef || options.task_ref); + const entryHash = asText(options.entryHash || options.entry_hash || options.hash); + if (entryHash) { + clauses.push("entry_hash = ?"); + args.push(entryHash); + } + if (scopeLevel) { + clauses.push("scope_level = ?"); + args.push(scopeLevel); + } + if (scope) { + clauses.push("scope = ?"); + args.push(scope); + } + if (workflowId) { + clauses.push("workflow_id = ?"); + args.push(workflowId); + } + if (strategyId) { + clauses.push("strategy_id = ?"); + args.push(strategyId); + } + if (taskRef) { + clauses.push("task_ref = ?"); + args.push(taskRef); + } + const visibilityClauses = []; + if (teamId) visibilityClauses.push("(scope_level = 'team' AND team_id = ?)"); + if (workspaceId) visibilityClauses.push("(scope_level = 'workspace' AND workspace_id = ?)"); + if (sessionId) visibilityClauses.push("(scope_level = 'session' AND session_id = ?)"); + if (runId) visibilityClauses.push("(scope_level = 'run' AND run_id = ?)"); + if (visibilityClauses.length > 0 && !scopeLevel && !entryHash) { + clauses.push(`(${visibilityClauses.join(" OR ")})`); + if (teamId) args.push(teamId); + if (workspaceId) args.push(workspaceId); + if (sessionId) args.push(sessionId); + if (runId) args.push(runId); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const limit = Math.max(1, Math.min(5000, Number(options.limit) || 5000)); + const rows = prepare( + entry, + `SELECT * + FROM knowledge_entries + ${whereSql} + ORDER BY timestamp DESC, entry_hash ASC + LIMIT ?`, + ).all(...args, limit); + return rows.map((row) => ({ + hash: row.entry_hash, + entryHash: row.entry_hash, + content: row.content, + scope: row.scope || null, + scopeLevel: row.scope_level || "workspace", + scopeId: row.scope_id || null, + agentId: row.agent_id || "unknown", + agentType: row.agent_type || "codex", + category: row.category || "pattern", + taskRef: row.task_ref || null, + timestamp: row.timestamp, + teamId: row.team_id || null, + workspaceId: row.workspace_id || null, + sessionId: row.session_id || null, + runId: row.run_id || null, + workflowId: row.workflow_id || null, + strategyId: row.strategy_id || null, + confidence: Number.isFinite(Number(row.confidence)) ? Number(row.confidence) : null, + verificationStatus: row.verification_status || null, + verifiedAt: row.verified_at || null, + provenance: parseJsonText(row.provenance_json) || [], + evidence: parseJsonText(row.evidence_json) || [], + tags: parseJsonText(row.tags_json) || [], + document: parseJsonText(row.document_json), + })); + }); +} + +function uniqueTextList(values = []) { + const out = []; + const seen = new Set(); + for (const value of Array.isArray(values) ? values : [values]) { + const text = asText(value); + if (!text || seen.has(text)) continue; + seen.add(text); + out.push(text); + } + return out; +} + +function toAuditTimestamp(...values) { + for (const value of values) { + const text = asText(value); + if (text) return text; + } + return null; +} + +function summarizeAuditEvent(event = {}) { + const type = String(event.auditType || "").trim(); + if (type === "task_trace") { + return asText(event.summary) + || asText(event.error) + || `${event.eventType || "task.trace"} for ${event.taskId || "task"}`; + } + if (type === "workflow_event") { + return asText(event.summary) + || asText(event.error) + || `${event.eventType || "workflow.event"} for ${event.runId || "run"}`; + } + if (type === "task_claim") { + return `${event.action || "claim"}${event.instanceId ? ` by ${event.instanceId}` : ""}`; + } + if (type === "tool_call") { + const toolLabel = asText(event.toolName || event.toolId) || "tool"; + return asText(event.summary) + || (event.status ? `${toolLabel} ${event.status}` : toolLabel); + } + if (type === "artifact") { + return asText(event.summary) + || asText(event.path) + || asText(event.kind) + || "artifact recorded"; + } + if (type === "operator_action") { + return asText(event.actionType) + || asText(event.targetId) + || "operator action"; + } + if (type === "promoted_strategy") { + return asText(event.recommendation) + || asText(event.strategyId) + || "promoted strategy"; + } + return asText(event.summary) || asText(event.eventType) || type || "audit event"; +} + +function normalizeAuditEventEnvelope(record = {}, extras = {}) { + const timestamp = toAuditTimestamp( + extras.timestamp, + record.timestamp, + record.createdAt, + record.updatedAt, + record.startedAt, + record.completedAt, + ); + const auditType = asText(extras.auditType) || "audit_event"; + return { + auditId: asText(extras.auditId) + || asText(record.eventId) + || asText(record.callId) + || asText(record.artifactId) + || asText(record.actionId) + || asText(record.strategyId) + || `${auditType}:${timestamp || "unknown"}`, + auditType, + timestamp, + sortTimestamp: timestamp || "1970-01-01T00:00:00.000Z", + taskId: asText(extras.taskId) || asText(record.taskId), + runId: asText(extras.runId) || asText(record.runId), + sessionId: asText(extras.sessionId) || asText(record.sessionId), + agentId: asText(extras.agentId) || asText(record.agentId), + workflowId: asText(extras.workflowId) || asText(record.workflowId), + status: asText(extras.status) || asText(record.status), + eventType: asText(extras.eventType) || asText(record.eventType) || asText(record.actionType), + summary: summarizeAuditEvent({ ...record, ...extras, auditType }), + record, + }; +} + +function sortAuditEvents(events = [], direction = "desc") { + const multiplier = String(direction || "").trim().toLowerCase() === "asc" ? 1 : -1; + return [...(Array.isArray(events) ? events : [])].sort((left, right) => { + const leftTs = Date.parse(left?.sortTimestamp || left?.timestamp || "") || 0; + const rightTs = Date.parse(right?.sortTimestamp || right?.timestamp || "") || 0; + if (leftTs !== rightTs) return (leftTs - rightTs) * multiplier; + return String(left?.auditId || "").localeCompare(String(right?.auditId || "")) * multiplier; + }); +} + +export function listAuditEventsFromStateLedger(options = {}) { + const taskId = asText(options.taskId || options.task_id); + const runId = asText(options.runId || options.run_id); + const sessionId = asText(options.sessionId || options.session_id); + const agentId = asText(options.agentId || options.agent_id); + const strategyId = asText(options.strategyId || options.strategy_id); + const includeWorkflowEvents = options.includeWorkflowEvents !== false; + const events = []; + + if (includeWorkflowEvents) { + const runIds = runId + ? [runId] + : (taskId + ? listWorkflowRunsFromStateLedger(options) + .filter((run) => asText(run?.taskId) === taskId) + .map((run) => run.runId) + : []); + for (const workflowRunId of uniqueTextList(runIds)) { + const workflowEvents = listWorkflowEventsFromStateLedger(workflowRunId, options); + for (const event of workflowEvents) { + events.push(normalizeAuditEventEnvelope(event, { + auditType: "workflow_event", + auditId: event.eventId || `${workflowRunId}:${event.seq || event.timestamp || "event"}`, + })); + } + } + } + + for (const event of listTaskTraceEventsFromStateLedger({ + ...options, + ...(taskId ? { taskId } : {}), + ...(runId ? { runId } : {}), + ...(sessionId ? { sessionId } : {}), + ...(agentId ? { agentId } : {}), + })) { + events.push(normalizeAuditEventEnvelope(event, { + auditType: "task_trace", + auditId: event.eventId, + })); + } + + const taskClaimEvents = listTaskClaimEventsFromStateLedger(taskId || null, options) + .filter((event) => !runId || asText(event?.run_id || event?.runId) === runId) + .filter((event) => !sessionId || asText(event?.session_id || event?.sessionId) === sessionId); + for (const event of taskClaimEvents) { + events.push(normalizeAuditEventEnvelope(event, { + auditType: "task_claim", + auditId: asText(event?.event_id || event?.eventId) || `${taskId || event?.task_id || event?.taskId}:claim:${event?.timestamp || ""}`, + taskId: event?.task_id || event?.taskId, + runId: event?.run_id || event?.runId, + sessionId: event?.session_id || event?.sessionId, + status: event?.status || event?.action, + timestamp: event?.timestamp, + eventType: event?.action, + agentId: event?.agent_id || event?.agentId, + })); + } + + for (const call of listToolCallsFromStateLedger({ + ...options, + ...(taskId ? { taskId } : {}), + ...(runId ? { runId } : {}), + ...(sessionId ? { sessionId } : {}), + })) { + events.push(normalizeAuditEventEnvelope(call, { + auditType: "tool_call", + auditId: call.callId, + timestamp: call.startedAt || call.completedAt || call.updatedAt, + })); + } + + for (const artifact of listArtifactsFromStateLedger({ + ...options, + ...(taskId ? { taskId } : {}), + ...(runId ? { runId } : {}), + ...(sessionId ? { sessionId } : {}), + })) { + events.push(normalizeAuditEventEnvelope(artifact, { + auditType: "artifact", + auditId: artifact.artifactId, + timestamp: artifact.createdAt || artifact.updatedAt, + })); + } + + const operatorActions = listOperatorActionsFromStateLedger({ + ...options, + ...(taskId ? { taskId } : {}), + ...(runId ? { runId } : {}), + ...(sessionId ? { sessionId } : {}), + }).filter((action) => !agentId || asText(action?.actorId) === agentId); + for (const action of operatorActions) { + events.push(normalizeAuditEventEnvelope(action, { + auditType: "operator_action", + auditId: action.actionId, + agentId: action.actorType === "agent" ? action.actorId : null, + timestamp: action.createdAt || action.updatedAt, + })); + } + + for (const event of listPromotedStrategyEventsFromStateLedger({ + ...options, + ...(strategyId ? { strategyId } : {}), + ...(taskId ? { taskId } : {}), + ...(runId ? { runId } : {}), + ...(sessionId ? { sessionId } : {}), + })) { + events.push(normalizeAuditEventEnvelope(event, { + auditType: "promoted_strategy", + auditId: event.eventId, + timestamp: event.createdAt, + })); + } + + const normalizedSearch = asText(options.search)?.toLowerCase() || ""; + let sorted = sortAuditEvents(events, options.direction || "desc"); + if (normalizedSearch) { + sorted = sorted.filter((event) => { + const haystack = [ + event.auditType, + event.taskId, + event.runId, + event.sessionId, + event.agentId, + event.workflowId, + event.status, + event.eventType, + event.summary, + toJsonText(event.record), + ].filter(Boolean).join(" ").toLowerCase(); + return haystack.includes(normalizedSearch); + }); + } + const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Math.trunc(Number(options.limit))) : null; + return limit ? sorted.slice(0, limit) : sorted; +} + +export function getTaskAuditBundleFromStateLedger(taskId, options = {}) { + const normalizedTaskId = asText(taskId); + if (!normalizedTaskId) return null; + const task = getTaskSnapshotFromStateLedger(normalizedTaskId, options); + const taskTopology = getTaskTopologyFromStateLedger(normalizedTaskId, options); + const claim = getActiveTaskClaimFromStateLedger(normalizedTaskId, options); + const claimEvents = listTaskClaimEventsFromStateLedger(normalizedTaskId, options); + const workflowRuns = listWorkflowRunsFromStateLedger(options) + .filter((run) => asText(run?.taskId) === normalizedTaskId); + const taskTraceEvents = listTaskTraceEventsFromStateLedger({ ...options, taskId: normalizedTaskId }); + const toolCalls = listToolCallsFromStateLedger({ ...options, taskId: normalizedTaskId }); + const artifacts = listArtifactsFromStateLedger({ ...options, taskId: normalizedTaskId }); + const operatorActions = listOperatorActionsFromStateLedger({ ...options, taskId: normalizedTaskId }); + const promotedStrategies = listPromotedStrategiesFromStateLedger({ ...options, taskId: normalizedTaskId }); + const promotedStrategyEvents = listPromotedStrategyEventsFromStateLedger({ ...options, taskId: normalizedTaskId }); + const sessionIds = uniqueTextList([ + task?.sessionId, + task?.primarySessionId, + ...taskTraceEvents.map((event) => event.sessionId), + ...workflowRuns.map((run) => run.sessionId), + ...toolCalls.map((call) => call.sessionId), + ...artifacts.map((artifact) => artifact.sessionId), + ...operatorActions.map((action) => action.sessionId), + ...promotedStrategyEvents.map((event) => event.sessionId), + ]); + const agentIds = uniqueTextList([ + task?.agentId, + ...taskTraceEvents.map((event) => event.agentId), + ]); + const sessionActivity = sessionIds[0] ? getSessionActivityFromStateLedger(sessionIds[0], options) : null; + const agentActivity = agentIds[0] ? getAgentActivityFromStateLedger(agentIds[0], options) : null; + const auditEvents = listAuditEventsFromStateLedger({ ...options, taskId: normalizedTaskId }); + return { + taskId: normalizedTaskId, + task, + taskTopology, + claim, + claimEvents, + workflowRuns, + taskTraceEvents, + toolCalls, + artifacts, + operatorActions, + promotedStrategies, + promotedStrategyEvents, + sessionIds, + agentIds, + sessionActivity, + agentActivity, + auditEvents, + summary: { + eventCount: auditEvents.length, + runCount: workflowRuns.length, + claimEventCount: claimEvents.length, + taskTraceCount: taskTraceEvents.length, + toolCallCount: toolCalls.length, + artifactCount: artifacts.length, + operatorActionCount: operatorActions.length, + promotedStrategyCount: promotedStrategies.length, + latestEventAt: auditEvents[0]?.timestamp || null, + latestRunId: workflowRuns.at(-1)?.runId || null, + latestSessionId: sessionIds[0] || null, + latestAgentId: agentIds[0] || null, + delegationDepth: taskTopology?.delegationDepth ?? task?.topology?.delegationDepth ?? 0, + }, + }; +} + +export function getRunAuditBundleFromStateLedger(runId, options = {}) { + const normalizedRunId = asText(runId); + if (!normalizedRunId) return null; + const run = getWorkflowRunFromStateLedger(normalizedRunId, options); + if (!run) return null; + const workflowEvents = listWorkflowEventsFromStateLedger(normalizedRunId, options); + const taskId = asText(run?.taskId); + const taskTraceEvents = listTaskTraceEventsFromStateLedger({ ...options, runId: normalizedRunId }); + const toolCalls = listToolCallsFromStateLedger({ ...options, runId: normalizedRunId }); + const artifacts = listArtifactsFromStateLedger({ ...options, runId: normalizedRunId }); + const promotedStrategies = listPromotedStrategiesFromStateLedger({ + ...options, + runId: normalizedRunId, + ...(taskId ? { taskId } : {}), + ...(run?.workflowId ? { workflowId: run.workflowId } : {}), + }); + const promotedStrategyEvents = listPromotedStrategyEventsFromStateLedger({ + ...options, + runId: normalizedRunId, + ...(taskId ? { taskId } : {}), + ...(run?.workflowId ? { workflowId: run.workflowId } : {}), + }); + const sessionIds = uniqueTextList([ + run?.sessionId, + ...taskTraceEvents.map((event) => event.sessionId), + ...toolCalls.map((call) => call.sessionId), + ...artifacts.map((artifact) => artifact.sessionId), + ...promotedStrategyEvents.map((event) => event.sessionId), + ]); + const agentIds = uniqueTextList([ + run?.agentId, + ...taskTraceEvents.map((event) => event.agentId), + ]); + const sessionActivity = sessionIds[0] ? getSessionActivityFromStateLedger(sessionIds[0], options) : null; + const agentActivity = agentIds[0] ? getAgentActivityFromStateLedger(agentIds[0], options) : null; + const auditEvents = listAuditEventsFromStateLedger({ + ...options, + runId: normalizedRunId, + ...(taskId ? { taskId } : {}), + }); + return { + runId: normalizedRunId, + run, + workflowEvents, + taskTraceEvents, + toolCalls, + artifacts, + promotedStrategies, + promotedStrategyEvents, + sessionIds, + agentIds, + sessionActivity, + agentActivity, + auditEvents, + summary: { + eventCount: auditEvents.length, + workflowEventCount: workflowEvents.length, + taskTraceCount: taskTraceEvents.length, + toolCallCount: toolCalls.length, + artifactCount: artifacts.length, + promotedStrategyCount: promotedStrategies.length, + latestEventAt: auditEvents[0]?.timestamp || null, + latestSessionId: sessionIds[0] || null, + latestAgentId: agentIds[0] || null, + taskId, + workflowId: run?.workflowId || null, + }, + }; +} + +export function listTaskAuditSummariesFromStateLedger(options = {}) { + const normalizedSearch = asText(options.search)?.toLowerCase() || ""; + const taskSnapshots = listTaskSnapshotsFromStateLedger(options); + const snapshotMap = new Map( + taskSnapshots + .filter((task) => asText(task?.id)) + .map((task) => [asText(task.id), task]), + ); + const discoveredTaskIds = uniqueTextList([ + ...taskSnapshots.map((task) => task?.id), + ...listWorkflowTaskRunEntriesFromStateLedger(options).map((entry) => entry?.taskId), + ...listTaskTraceEventsFromStateLedger(options).map((entry) => entry?.taskId), + ]); + let summaries = discoveredTaskIds.map((taskId) => { + const task = snapshotMap.get(taskId) || null; + const bundle = getTaskAuditBundleFromStateLedger(taskId, { + ...options, + limit: options.eventLimit || 100, + }); + return { + taskId: taskId || null, + title: task?.title || bundle?.taskTraceEvents?.at(-1)?.taskTitle || null, + status: task?.status || bundle?.workflowRuns?.at(-1)?.status || null, + updatedAt: task?.updatedAt || task?.updated_at || bundle?.summary?.latestEventAt || null, + summary: bundle?.summary || null, + sessionIds: bundle?.sessionIds || [], + agentIds: bundle?.agentIds || [], + }; + }).filter((entry) => entry.taskId); + if (normalizedSearch) { + summaries = summaries.filter((entry) => { + const haystack = [ + entry?.taskId, + entry?.title, + entry?.status, + ].filter(Boolean).join(" ").toLowerCase(); + return haystack.includes(normalizedSearch); + }); + } + summaries.sort((left, right) => { + const leftTs = Date.parse(left?.updatedAt || left?.summary?.latestEventAt || "") || 0; + const rightTs = Date.parse(right?.updatedAt || right?.summary?.latestEventAt || "") || 0; + if (leftTs !== rightTs) return rightTs - leftTs; + return String(left?.taskId || "").localeCompare(String(right?.taskId || "")); + }); + const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Math.trunc(Number(options.limit))) : null; + return limit ? summaries.slice(0, limit) : summaries; +} + +export function upsertStateLedgerKeyValue(record = {}, options = {}) { + return withLedger(options, (entry) => { + const scope = asText(record.scope || "global") || "global"; + const scopeId = asText(record.scopeId || record.scope_id || "default") || "default"; + const keyName = asText(record.key || record.keyName || record.key_name || ""); + if (!keyName) { + throw new Error(`${TAG} key_values key is required`); + } + const updatedAt = normalizeTimestamp(record.updatedAt || record.updated_at); + const value = Object.prototype.hasOwnProperty.call(record, "value") ? record.value : record.value_json; + prepare( + entry, + `INSERT INTO key_values ( + scope, scope_id, key_name, value_json, value_type, source, + run_id, task_id, session_id, metadata_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(scope, scope_id, key_name) DO UPDATE SET + value_json = excluded.value_json, + value_type = excluded.value_type, + source = excluded.source, + run_id = excluded.run_id, + task_id = excluded.task_id, + session_id = excluded.session_id, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at`, + ).run( + scope, + scopeId, + keyName, + toJsonText(value), + inferValueType(value), + asText(record.source), + asText(record.runId || record.run_id), + asText(record.taskId || record.task_id), + asText(record.sessionId || record.session_id), + toJsonText(record.metadata ?? null), + updatedAt, + ); + return { + path: entry.path, + scope, + scopeId, + keyName, + updatedAt, + }; + }); +} + +export function getStateLedgerKeyValue(scope, scopeId, keyName, options = {}) { + return withLedger(options, (entry) => { + const normalizedScope = asText(scope || "global") || "global"; + const normalizedScopeId = asText(scopeId || "default") || "default"; + const normalizedKey = asText(keyName); + if (!normalizedKey) return null; + const row = prepare( + entry, + `SELECT * + FROM key_values + WHERE scope = ? + AND scope_id = ? + AND key_name = ?`, + ).get(normalizedScope, normalizedScopeId, normalizedKey); + if (!row) return null; + return { + scope: row.scope, + scopeId: row.scope_id, + key: row.key_name, + value: parseJsonText(row.value_json), + valueType: row.value_type || null, + source: row.source || null, + runId: row.run_id || null, + taskId: row.task_id || null, + sessionId: row.session_id || null, + metadata: parseJsonText(row.metadata_json), + updatedAt: row.updated_at, + }; + }); +} + +export function appendOperatorActionToStateLedger(record = {}, options = {}) { + return withLedger(options, (entry) => { + const createdAt = normalizeTimestamp(record.createdAt || record.created_at); + const actionId = asText( + record.actionId + || record.action_id + || `${record.actionType || record.action_type || "action"}:${createdAt}:${record.targetId || record.target_id || ""}`, + ); + if (!actionId) { + throw new Error(`${TAG} operator action id is required`); + } + prepare( + entry, + `INSERT INTO operator_actions ( + action_id, action_type, actor_id, actor_type, scope, scope_id, target_id, + run_id, task_id, session_id, status, request_json, result_json, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(action_id) DO UPDATE SET + status = excluded.status, + result_json = excluded.result_json, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at`, + ).run( + actionId, + asText(record.actionType || record.action_type) || "action", + asText(record.actorId || record.actor_id), + asText(record.actorType || record.actor_type), + asText(record.scope), + asText(record.scopeId || record.scope_id), + asText(record.targetId || record.target_id), + asText(record.runId || record.run_id), + asText(record.taskId || record.task_id), + asText(record.sessionId || record.session_id), + asText(record.status || "completed"), + toJsonText(record.request ?? null), + toJsonText(record.result ?? null), + toJsonText(record.metadata ?? null), + createdAt, + normalizeTimestamp(record.updatedAt || record.updated_at || createdAt), + ); + return { path: entry.path, actionId }; + }); +} + +export function listOperatorActionsFromStateLedger(options = {}) { + return withLedger(options, (entry) => { + const clauses = []; + const args = []; + const scope = asText(options.scope); + const taskId = asText(options.taskId || options.task_id); + const runId = asText(options.runId || options.run_id); + const sessionId = asText(options.sessionId || options.session_id); + const actorId = asText(options.actorId || options.actor_id); + const targetId = asText(options.targetId || options.target_id); + if (scope) { + clauses.push("scope = ?"); + args.push(scope); + } + if (taskId) { + clauses.push("task_id = ?"); + args.push(taskId); + } + if (runId) { + clauses.push("run_id = ?"); + args.push(runId); + } + if (sessionId) { + clauses.push("session_id = ?"); + args.push(sessionId); + } + if (actorId) { + clauses.push("actor_id = ?"); + args.push(actorId); + } + if (targetId) { + clauses.push("target_id = ?"); + args.push(targetId); + } + const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = prepare( + entry, + `SELECT * + FROM operator_actions + ${whereSql} + ORDER BY created_at ASC, action_id ASC`, + ).all(...args); + return rows.map((row) => ({ + actionId: row.action_id, + actionType: row.action_type, + actorId: row.actor_id || null, + actorType: row.actor_type || null, + scope: row.scope || null, + scopeId: row.scope_id || null, + targetId: row.target_id || null, + runId: row.run_id || null, + taskId: row.task_id || null, + sessionId: row.session_id || null, + status: row.status || null, + request: parseJsonText(row.request_json), + result: parseJsonText(row.result_json), + metadata: parseJsonText(row.metadata_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + }); +} + +export function appendArtifactRecordToStateLedger(record = {}, options = {}) { + return withLedger(options, (entry) => { + const createdAt = normalizeTimestamp(record.createdAt || record.created_at); + const artifactId = asText( + record.artifactId + || record.artifact_id + || `${record.runId || record.run_id || "artifact"}:${sanitizeKeyPart(record.kind || "artifact")}:${sanitizeKeyPart(record.path || createdAt)}`, + ); + if (!artifactId) { + throw new Error(`${TAG} artifact id is required`); + } + prepare( + entry, + `INSERT INTO artifacts ( + artifact_id, run_id, root_run_id, task_id, session_id, execution_id, node_id, + kind, path, summary, source_event_id, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + kind = excluded.kind, + path = excluded.path, + summary = excluded.summary, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at`, + ).run( + artifactId, + asText(record.runId || record.run_id), + asText(record.rootRunId || record.root_run_id), + asText(record.taskId || record.task_id), + asText(record.sessionId || record.session_id), + asText(record.executionId || record.execution_id), + asText(record.nodeId || record.node_id), + asText(record.kind || "artifact") || "artifact", + asText(record.path), + asText(record.summary), + asText(record.sourceEventId || record.source_event_id), + toJsonText(record.metadata ?? null), + createdAt, + normalizeTimestamp(record.updatedAt || record.updated_at || createdAt), + ); + return { path: entry.path, artifactId }; + }); +} + +export function getStateLedgerInfo(options = {}) { + return withLedger(options, (entry) => { + const schemaVersion = prepare(entry, "PRAGMA user_version").get()?.user_version ?? 0; + const tables = prepare( + entry, + `SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY name ASC`, + ).all().map((row) => row.name); + return { + path: entry.path, + schemaVersion: Number(schemaVersion || 0), + tables, + }; + }); +} + +export function resetStateLedgerCache() { + for (const entry of _stateLedgerCache.values()) { + try { + entry.db.close(); + } catch { + /* best effort */ + } + } + _stateLedgerCache.clear(); +} diff --git a/package.json b/package.json index 9c9691555..3525f1d46 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,7 @@ "entrypoint.mjs", "lib/repo-map.mjs", "lib/skill-markdown-safety.mjs", + "lib/state-ledger-sqlite.mjs", "git/conflict-resolver.mjs", "git/diff-stats.mjs", "git/git-commit-helpers.mjs", @@ -360,6 +361,7 @@ "workspace/shared-state-manager.mjs", "workspace/shared-workspace-cli.mjs", "workspace/shared-workspace-registry.mjs", + "workspace/skillbook-store.mjs", "workspace/workspace-manager.mjs", "workspace/workspace-monitor.mjs", "workspace/workspace-registry.mjs", diff --git a/workspace/shared-knowledge.mjs b/workspace/shared-knowledge.mjs index c074f410e..8f14044c8 100644 --- a/workspace/shared-knowledge.mjs +++ b/workspace/shared-knowledge.mjs @@ -14,14 +14,24 @@ * - Persistent scoped memory retrieval for team/workspace/session/run * * Knowledge entries are appended to a `## Agent Learnings` section at the - * bottom of the target file (default: AGENTS.md) and indexed in a persistent - * JSON registry for lightweight retrieval during later runs. + * bottom of the target file (default: AGENTS.md), mirrored into the SQLite + * state ledger, and projected into a compatibility JSON registry for later + * runs. */ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import crypto from "node:crypto"; +// Lazy-load state-ledger-sqlite functions to avoid pulling in node:sqlite on +// Node < 22 runtimes (e.g. Node 20 CI) where the built-in module doesn't exist. +let _stateLedgerModule; +async function getStateLedgerModule() { + if (!_stateLedgerModule) { + _stateLedgerModule = await import("../lib/state-ledger-sqlite.mjs"); + } + return _stateLedgerModule; +} // ── Constants ──────────────────────────────────────────────────────────────── @@ -66,6 +76,33 @@ function normalizeNullable(value) { return text || null; } +function normalizeStringList(value, { maxItems = 12, maxLength = 240 } = {}) { + const rawValues = Array.isArray(value) + ? value + : (typeof value === "string" && value.includes(",") + ? value.split(",") + : [value]); + const out = []; + const seen = new Set(); + for (const entry of rawValues) { + const text = normalizeText(entry); + if (!text) continue; + const clipped = text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}…` : text; + const key = clipped.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(clipped); + if (out.length >= maxItems) break; + } + return out; +} + +function normalizeConfidence(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return Math.max(0, Math.min(1, parsed)); +} + function normalizeScopeLevel(value) { const raw = normalizeText(value).toLowerCase(); if (!raw) return "workspace"; @@ -130,6 +167,13 @@ function serializeEntry(entry) { workspaceId: normalizeNullable(entry?.workspaceId), sessionId: normalizeNullable(entry?.sessionId), runId: normalizeNullable(entry?.runId), + workflowId: normalizeNullable(entry?.workflowId), + strategyId: normalizeNullable(entry?.strategyId), + confidence: normalizeConfidence(entry?.confidence), + verificationStatus: normalizeNullable(entry?.verificationStatus), + verifiedAt: normalizeNullable(entry?.verifiedAt), + provenance: normalizeStringList(entry?.provenance), + evidence: normalizeStringList(entry?.evidence), tags: Array.isArray(entry?.tags) ? entry.tags.map((tag) => normalizeText(tag)).filter(Boolean) : [], @@ -151,7 +195,7 @@ function normalizeRegistryEntry(raw) { return entry; } -async function loadRegistryEntries(repoRoot = knowledgeState.repoRoot || process.cwd()) { +async function loadLegacyRegistryEntries(repoRoot = knowledgeState.repoRoot || process.cwd()) { const registryPath = getRegistryPath(repoRoot); if (!existsSync(registryPath)) return createEmptyRegistry(); @@ -170,6 +214,44 @@ async function loadRegistryEntries(repoRoot = knowledgeState.repoRoot || process } } +async function backfillLedgerEntries(repoRoot, entries = []) { + let mod; + try { mod = await getStateLedgerModule(); } catch { return; } + for (const rawEntry of Array.isArray(entries) ? entries : []) { + const entry = normalizeRegistryEntry(rawEntry); + if (!entry) continue; + try { + mod.appendKnowledgeEntryToStateLedger(entry, { repoRoot }); + } catch { + // best-effort migration only + } + } +} + +async function loadRegistryEntries(repoRoot = knowledgeState.repoRoot || process.cwd()) { + try { + const mod = await getStateLedgerModule(); + const entries = mod.listKnowledgeEntriesFromStateLedger({ repoRoot, limit: 5000 }) + .map((entry) => normalizeRegistryEntry(entry)) + .filter(Boolean); + if (entries.length > 0) { + return { + version: REGISTRY_VERSION, + updatedAt: entries[0]?.timestamp || new Date().toISOString(), + entries, + }; + } + } catch { + // fall back to legacy registry + } + + const legacyRegistry = await loadLegacyRegistryEntries(repoRoot); + if (legacyRegistry.entries.length > 0) { + await backfillLedgerEntries(repoRoot, legacyRegistry.entries); + } + return legacyRegistry; +} + async function saveRegistryEntries(repoRoot, registry) { const registryPath = getRegistryPath(repoRoot); await ensureParentDir(registryPath); @@ -234,6 +316,11 @@ function buildSearchText(entry) { entry.workspaceId, entry.sessionId, entry.runId, + entry.workflowId, + entry.strategyId, + entry.verificationStatus, + ...(Array.isArray(entry.provenance) ? entry.provenance : []), + ...(Array.isArray(entry.evidence) ? entry.evidence : []), ...(Array.isArray(entry.tags) ? entry.tags : []), ] .filter(Boolean) @@ -306,6 +393,13 @@ export function buildKnowledgeEntry(opts = {}) { workspaceId: normalizeNullable(opts.workspaceId), sessionId: normalizeNullable(opts.sessionId), runId: normalizeNullable(opts.runId), + workflowId: normalizeNullable(opts.workflowId), + strategyId: normalizeNullable(opts.strategyId), + confidence: normalizeConfidence(opts.confidence), + verificationStatus: normalizeNullable(opts.verificationStatus), + verifiedAt: normalizeNullable(opts.verifiedAt), + provenance: normalizeStringList(opts.provenance), + evidence: normalizeStringList(opts.evidence), tags: Array.isArray(opts.tags) ? opts.tags.map((tag) => normalizeText(tag)).filter(Boolean) : [], @@ -331,6 +425,25 @@ export function formatEntryAsMarkdown(entry) { lines.push(""); lines.push(`> **Agent:** ${entry.agentId} (${entry.agentType})`); lines.push(`> **Memory Scope:** ${scopeLevel}:${scopeId}`); + if (entry.workflowId) { + lines.push(`> **Workflow:** ${entry.workflowId}`); + } + if (entry.strategyId) { + lines.push(`> **Strategy ID:** ${entry.strategyId}`); + } + if (entry.confidence != null) { + lines.push(`> **Confidence:** ${entry.confidence.toFixed(2)}`); + } + if (entry.verificationStatus || entry.verifiedAt) { + const verifiedParts = [entry.verificationStatus, entry.verifiedAt].filter(Boolean); + lines.push(`> **Verification:** ${verifiedParts.join(" @ ")}`); + } + if (Array.isArray(entry.provenance) && entry.provenance.length > 0) { + lines.push(`> **Provenance:** ${entry.provenance.join(" | ")}`); + } + if (Array.isArray(entry.evidence) && entry.evidence.length > 0) { + lines.push(`> **Evidence:** ${entry.evidence.join(" | ")}`); + } if (Array.isArray(entry.tags) && entry.tags.length > 0) { lines.push(`> **Tags:** ${entry.tags.join(", ")}`); } @@ -381,6 +494,9 @@ export function validateEntry(entry) { "convention", "tip", "bug", + "strategy", + "benchmark", + "evaluation", ]; if (entry.category && !validCategories.includes(entry.category)) { return { @@ -426,7 +542,7 @@ export function isDuplicate(entry) { // ── Write ──────────────────────────────────────────────────────────────────── -export async function appendKnowledgeEntry(entry) { +export async function appendKnowledgeEntry(entry, options = {}) { const normalizedEntry = serializeEntry(entry); const validation = validateEntry(normalizedEntry); if (!validation.valid) { @@ -435,7 +551,8 @@ export async function appendKnowledgeEntry(entry) { const agentId = normalizeText(normalizedEntry.agentId || "unknown"); const lastWriteForAgent = knowledgeState.lastWriteByAgent.get(agentId) || 0; - if (lastWriteForAgent) { + const skipRateLimit = options?.skipRateLimit === true; + if (!skipRateLimit && lastWriteForAgent) { const elapsed = Date.now() - lastWriteForAgent; if (elapsed < RATE_LIMIT_MS) { return { @@ -497,6 +614,18 @@ export async function appendKnowledgeEntry(entry) { const registry = await loadRegistryEntries(knowledgeState.repoRoot || process.cwd()); registry.entries.push(normalizedEntry); await saveRegistryEntries(knowledgeState.repoRoot || process.cwd(), registry); + let ledgerPath = null; + try { + const mod = await getStateLedgerModule(); + const ledgerResult = mod.appendKnowledgeEntryToStateLedger(normalizedEntry, { + repoRoot: knowledgeState.repoRoot || process.cwd(), + }); + ledgerPath = ledgerResult?.path || mod.resolveStateLedgerPath({ + repoRoot: knowledgeState.repoRoot || process.cwd(), + }); + } catch { + // SQLite unavailable on this Node version — skip ledger write + } knowledgeState.entryHashes.add(normalizedEntry.hash); knowledgeState.entriesWritten++; @@ -507,6 +636,7 @@ export async function appendKnowledgeEntry(entry) { success: true, hash: normalizedEntry.hash, registryPath: getRegistryPath(knowledgeState.repoRoot || process.cwd()), + ledgerPath, }; } catch (err) { return { success: false, reason: `write error: ${err.message}` }; @@ -661,4 +791,3 @@ export function formatKnowledgeSummary() { : "No writes this session", ].join("\n"); } - diff --git a/workspace/skillbook-store.mjs b/workspace/skillbook-store.mjs new file mode 100644 index 000000000..e35f86b24 --- /dev/null +++ b/workspace/skillbook-store.mjs @@ -0,0 +1,432 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const DEFAULT_SKILLBOOK_FILE = ".bosun/skillbook/strategies.json"; +const SKILLBOOK_VERSION = "1.0.0"; + +function normalizeText(value) { + return String(value ?? "").trim(); +} + +function normalizeNullable(value) { + const text = normalizeText(value); + return text || null; +} + +function normalizeTimestamp(value) { + return normalizeNullable(value) || new Date().toISOString(); +} + +function cloneJson(value) { + if (value == null) return null; + return JSON.parse(JSON.stringify(value)); +} + +function normalizeStringList(value, { maxItems = 24, maxLength = 240 } = {}) { + const rawValues = Array.isArray(value) + ? value + : (typeof value === "string" && value.includes(",") + ? value.split(",") + : [value]); + const out = []; + const seen = new Set(); + for (const raw of rawValues) { + const text = normalizeText(raw); + if (!text) continue; + const clipped = text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}…` : text; + const key = clipped.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(clipped); + if (out.length >= maxItems) break; + } + return out; +} + +function normalizeConfidence(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return Math.max(0, Math.min(1, parsed)); +} + +function tokenizeSearchTerms(value, { maxTokens = 24 } = {}) { + const rawValues = Array.isArray(value) ? value : [value]; + const out = []; + const seen = new Set(); + for (const rawValue of rawValues) { + const text = normalizeText(rawValue).toLowerCase(); + if (!text) continue; + const tokens = text + .split(/[^a-z0-9]+/i) + .map((token) => token.trim()) + .filter((token) => token.length >= 3); + for (const token of tokens) { + if (seen.has(token)) continue; + seen.add(token); + out.push(token); + if (out.length >= maxTokens) return out; + } + } + return out; +} + +function buildStrategySearchBlob(entry = {}) { + return [ + entry.strategyId, + entry.workflowId, + entry.scope, + entry.scopeLevel, + entry.category, + entry.decision, + entry.status, + entry.recommendation, + entry.rationale, + ...(Array.isArray(entry.tags) ? entry.tags : []), + ...(Array.isArray(entry.evidence) ? entry.evidence : []), + ...(Array.isArray(entry.provenance) ? entry.provenance : []), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +function countMatchingTokens(blob, tokens = []) { + if (!blob || !Array.isArray(tokens) || tokens.length === 0) return 0; + let count = 0; + for (const token of tokens) { + if (blob.includes(token)) count += 1; + } + return count; +} + +function computeRecencyBonus(updatedAt) { + const timestamp = Date.parse(String(updatedAt || "")); + if (!Number.isFinite(timestamp)) return 0; + const ageDays = Math.max(0, (Date.now() - timestamp) / (24 * 60 * 60 * 1000)); + if (ageDays <= 3) return 12; + if (ageDays <= 14) return 8; + if (ageDays <= 45) return 4; + return 0; +} + +function scoreSkillbookStrategy(entry = {}, options = {}) { + const blob = buildStrategySearchBlob(entry); + const queryTokens = tokenizeSearchTerms(options.query); + const requestedTags = normalizeStringList(options.tags || [], { maxItems: 16, maxLength: 80 }) + .map((value) => value.toLowerCase()); + const entryTags = Array.isArray(entry.tags) ? entry.tags.map((value) => String(value || "").toLowerCase()) : []; + const tagMatches = requestedTags.filter((tag) => entryTags.includes(tag)).length; + const queryMatches = countMatchingTokens(blob, queryTokens); + const confidence = normalizeConfidence(entry.confidence) ?? 0.5; + const historyLength = Array.isArray(entry.history) ? entry.history.length : 0; + let score = confidence * 100; + score += Math.min(18, historyLength * 3); + score += computeRecencyBonus(entry.updatedAt); + score += tagMatches * 8; + score += queryMatches * 6; + if (String(entry.status || "").toLowerCase() === "promoted") score += 10; + if (String(entry.status || "").toLowerCase() === "reverted") score -= 12; + if (String(entry.workflowId || "").trim() && String(entry.workflowId || "").trim() === String(options.workflowId || "").trim()) { + score += 16; + } + if (String(entry.scope || "").trim() && String(entry.scope || "").trim() === String(options.scope || "").trim()) { + score += 12; + } + if (String(entry.scopeLevel || "").trim().toLowerCase() === "workspace") score += 2; + return score; +} + +function toRankedSkillbookEntry(entry = {}, rank = 0, score = 0) { + return { + ...entry, + rank, + score, + relevanceScore: score, + }; +} + +function createEmptySkillbook() { + return { + version: SKILLBOOK_VERSION, + updatedAt: new Date().toISOString(), + strategies: [], + }; +} + +function normalizeHistoryEntry(entry = {}) { + if (!entry || typeof entry !== "object") return null; + const timestamp = normalizeTimestamp(entry.timestamp || entry.updatedAt || entry.createdAt); + const decision = normalizeNullable(entry.decision || entry.status); + const runId = normalizeNullable(entry.runId); + if (!decision && !runId) return null; + return { + timestamp, + decision: decision || "promote_strategy", + status: normalizeNullable(entry.status) || decision || "promoted", + runId, + workflowId: normalizeNullable(entry.workflowId), + score: Number.isFinite(Number(entry.score)) ? Number(entry.score) : null, + grade: normalizeNullable(entry.grade), + benchmark: cloneJson(entry.benchmark), + metrics: cloneJson(entry.metrics), + knowledgeHash: normalizeNullable(entry.knowledgeHash), + summary: normalizeNullable(entry.summary), + rationale: normalizeNullable(entry.rationale), + }; +} + +function normalizeSkillbookEntry(entry = {}, existing = null) { + const strategyId = normalizeText(entry.strategyId || entry.strategy?.strategyId || existing?.strategyId || ""); + if (!strategyId) { + throw new Error("skillbook strategyId is required"); + } + const updatedAt = normalizeTimestamp(entry.updatedAt || entry.promotedAt || entry.timestamp); + const historyEntries = Array.isArray(existing?.history) ? existing.history : []; + const appendedHistory = normalizeHistoryEntry({ + timestamp: updatedAt, + decision: entry.decision, + status: entry.status, + runId: entry.runId, + workflowId: entry.workflowId, + score: entry.evaluation?.score, + grade: entry.evaluation?.grade, + benchmark: entry.benchmark, + metrics: entry.metrics, + knowledgeHash: entry.knowledge?.hash || entry.knowledgeHash, + summary: entry.summary || entry.recommendation, + rationale: entry.rationale, + }); + const mergedHistory = [...historyEntries]; + if (appendedHistory) { + const historyKey = `${appendedHistory.timestamp}|${appendedHistory.decision}|${appendedHistory.runId || ""}`; + const existingKeys = new Set( + mergedHistory.map((item) => `${item.timestamp}|${item.decision}|${item.runId || ""}`), + ); + if (!existingKeys.has(historyKey)) { + mergedHistory.push(appendedHistory); + } + } + mergedHistory.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp))); + + return { + strategyId, + workflowId: normalizeNullable(entry.workflowId) || normalizeNullable(existing?.workflowId), + runId: normalizeNullable(entry.runId) || normalizeNullable(existing?.runId), + taskId: normalizeNullable(entry.taskId) || normalizeNullable(existing?.taskId), + sessionId: normalizeNullable(entry.sessionId) || normalizeNullable(existing?.sessionId), + teamId: normalizeNullable(entry.teamId) || normalizeNullable(existing?.teamId), + workspaceId: normalizeNullable(entry.workspaceId) || normalizeNullable(existing?.workspaceId), + scope: normalizeNullable(entry.scope) || normalizeNullable(existing?.scope), + scopeLevel: normalizeNullable(entry.scopeLevel) || normalizeNullable(existing?.scopeLevel) || "workspace", + category: normalizeNullable(entry.category) || normalizeNullable(existing?.category) || "strategy", + decision: normalizeNullable(entry.decision) || normalizeNullable(existing?.decision) || "promote_strategy", + status: normalizeNullable(entry.status) || normalizeNullable(existing?.status) || "promoted", + verificationStatus: + normalizeNullable(entry.verificationStatus) + || normalizeNullable(existing?.verificationStatus) + || normalizeNullable(entry.decision) + || "promote_strategy", + confidence: normalizeConfidence(entry.confidence ?? existing?.confidence), + recommendation: normalizeNullable(entry.recommendation) || normalizeNullable(existing?.recommendation), + rationale: normalizeNullable(entry.rationale) || normalizeNullable(existing?.rationale), + evidence: normalizeStringList(entry.evidence ?? existing?.evidence), + provenance: normalizeStringList(entry.provenance ?? existing?.provenance), + tags: normalizeStringList(entry.tags ?? existing?.tags, { maxItems: 32, maxLength: 80 }), + benchmark: cloneJson(entry.benchmark ?? existing?.benchmark), + metrics: cloneJson(entry.metrics ?? existing?.metrics), + evaluation: cloneJson(entry.evaluation ?? existing?.evaluation), + knowledge: cloneJson(entry.knowledge ?? existing?.knowledge), + firstPromotedAt: + normalizeNullable(existing?.firstPromotedAt) + || normalizeNullable(entry.firstPromotedAt) + || updatedAt, + updatedAt, + history: mergedHistory, + }; +} + +async function ensureParentDir(filePath) { + await mkdir(dirname(filePath), { recursive: true }); +} + +export function resolveSkillbookPath(options = {}) { + const repoRoot = normalizeText(options.repoRoot || process.cwd()) || process.cwd(); + const explicit = normalizeText(options.skillbookPath || options.path || ""); + return resolve(repoRoot, explicit || DEFAULT_SKILLBOOK_FILE); +} + +export async function loadSkillbook(options = {}) { + const skillbookPath = resolveSkillbookPath(options); + if (!existsSync(skillbookPath)) { + return createEmptySkillbook(); + } + try { + const raw = JSON.parse(await readFile(skillbookPath, "utf8")); + const strategies = Array.isArray(raw?.strategies) + ? raw.strategies.map((entry) => normalizeSkillbookEntry(entry)).filter(Boolean) + : []; + strategies.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + return { + version: normalizeText(raw?.version) || SKILLBOOK_VERSION, + updatedAt: normalizeTimestamp(raw?.updatedAt), + strategies, + }; + } catch { + return createEmptySkillbook(); + } +} + +async function saveSkillbook(skillbook, options = {}) { + const skillbookPath = resolveSkillbookPath(options); + await ensureParentDir(skillbookPath); + await writeFile(skillbookPath, JSON.stringify({ + version: SKILLBOOK_VERSION, + updatedAt: new Date().toISOString(), + strategies: Array.isArray(skillbook?.strategies) ? skillbook.strategies : [], + }, null, 2), "utf8"); + return skillbookPath; +} + +export async function upsertSkillbookStrategy(record = {}, options = {}) { + const skillbook = await loadSkillbook(options); + const strategies = Array.isArray(skillbook.strategies) ? skillbook.strategies : []; + const strategyId = normalizeText(record.strategyId || record.strategy?.strategyId || ""); + if (!strategyId) { + throw new Error("skillbook strategyId is required"); + } + const existingIndex = strategies.findIndex((entry) => entry?.strategyId === strategyId); + const existing = existingIndex >= 0 ? strategies[existingIndex] : null; + const normalized = normalizeSkillbookEntry(record, existing); + if (existingIndex >= 0) { + strategies[existingIndex] = normalized; + } else { + strategies.push(normalized); + } + strategies.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + const path = await saveSkillbook({ ...skillbook, strategies }, options); + return { + success: true, + strategyId, + entry: normalized, + path, + }; +} + +export async function getSkillbookStrategy(strategyId, options = {}) { + const normalizedId = normalizeText(strategyId); + if (!normalizedId) return null; + const skillbook = await loadSkillbook(options); + return skillbook.strategies.find((entry) => entry.strategyId === normalizedId) || null; +} + +export async function listSkillbookStrategies(options = {}) { + const { + workflowId, + category, + scopeLevel, + scope, + decision, + status, + tags, + query, + minConfidence, + sort = "recent", + limit, + } = options; + const skillbook = await loadSkillbook(options); + let entries = skillbook.strategies; + if (workflowId) { + const normalized = normalizeText(workflowId); + entries = entries.filter((entry) => entry.workflowId === normalized); + } + if (category) { + const normalized = normalizeText(category).toLowerCase(); + entries = entries.filter((entry) => String(entry.category || "").toLowerCase() === normalized); + } + if (scopeLevel) { + const normalized = normalizeText(scopeLevel).toLowerCase(); + entries = entries.filter((entry) => String(entry.scopeLevel || "").toLowerCase() === normalized); + } + if (scope) { + const normalized = normalizeText(scope); + entries = entries.filter((entry) => String(entry.scope || "") === normalized); + } + if (decision) { + const normalized = normalizeText(decision).toLowerCase(); + entries = entries.filter((entry) => String(entry.decision || "").toLowerCase() === normalized); + } + if (status) { + const normalized = normalizeText(status).toLowerCase(); + entries = entries.filter((entry) => String(entry.status || "").toLowerCase() === normalized); + } + if (tags) { + const normalizedTags = normalizeStringList(tags, { maxItems: 16, maxLength: 80 }) + .map((value) => value.toLowerCase()); + if (normalizedTags.length > 0) { + entries = entries.filter((entry) => { + const entryTags = Array.isArray(entry.tags) ? entry.tags.map((value) => String(value || "").toLowerCase()) : []; + return normalizedTags.every((tag) => entryTags.includes(tag)); + }); + } + } + if (Number.isFinite(Number(minConfidence))) { + const minimum = Math.max(0, Math.min(1, Number(minConfidence))); + entries = entries.filter((entry) => (normalizeConfidence(entry.confidence) ?? 0) >= minimum); + } + const queryTokens = tokenizeSearchTerms(query); + if (queryTokens.length > 0) { + entries = entries.filter((entry) => countMatchingTokens(buildStrategySearchBlob(entry), queryTokens) > 0); + } + if (String(sort || "").trim().toLowerCase() === "ranked") { + entries = entries + .map((entry) => ({ entry, score: scoreSkillbookStrategy(entry, options) })) + .sort((left, right) => right.score - left.score || String(right.entry.updatedAt).localeCompare(String(left.entry.updatedAt))) + .map(({ entry, score }, index) => toRankedSkillbookEntry(entry, index + 1, score)); + } + const limitNumber = Number(limit); + if (Number.isFinite(limitNumber) && limitNumber > 0) { + entries = entries.slice(0, Math.trunc(limitNumber)); + } + return entries; +} + +export function buildSkillbookGuidanceSummary(strategies = [], options = {}) { + const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Math.trunc(Number(options.limit))) : 5; + const selected = (Array.isArray(strategies) ? strategies : []).slice(0, limit); + if (selected.length === 0) return ""; + const lines = ["Reusable strategy guidance:"]; + for (const entry of selected) { + const recommendation = normalizeNullable(entry?.recommendation) || normalizeNullable(entry?.strategyId) || "Unnamed strategy"; + const rationale = normalizeNullable(entry?.rationale); + const confidence = normalizeConfidence(entry?.confidence); + const tags = Array.isArray(entry?.tags) ? entry.tags.slice(0, 4).join(", ") : ""; + lines.push( + [ + `- ${recommendation}`, + confidence != null ? `confidence=${confidence.toFixed(2)}` : "", + tags ? `tags=${tags}` : "", + ].filter(Boolean).join(" | "), + ); + if (rationale) { + lines.push(` rationale: ${rationale}`); + } + } + return lines.join("\n"); +} + +export async function findReusableSkillbookStrategies(options = {}) { + const strategies = await listSkillbookStrategies({ + ...options, + sort: "ranked", + }); + const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Math.trunc(Number(options.limit))) : 5; + const selected = strategies.slice(0, limit); + return { + skillbookPath: resolveSkillbookPath(options), + total: strategies.length, + matched: selected.length, + strategies: selected, + guidanceSummary: buildSkillbookGuidanceSummary(selected, options), + }; +}