feat(sf): align node sqlite uok runtime

This commit is contained in:
Mikael Hugo 2026-05-08 02:52:05 +02:00 committed by Mikael Hugo
parent 760564dbfb
commit 19bfc3d3f6
93 changed files with 30516 additions and 27240 deletions

View file

@ -77,7 +77,7 @@ body:
attributes: attributes:
label: Node.js version label: Node.js version
description: Run `node --version`. description: Run `node --version`.
placeholder: "e.g. v24.14.0" placeholder: "e.g. v26.1.0"
- type: input - type: input
id: os id: os

View file

@ -106,7 +106,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
cache: "npm" cache: "npm"

View file

@ -105,7 +105,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
- name: Validate skill references - name: Validate skill references
run: node scripts/check-skill-references.mjs run: node scripts/check-skill-references.mjs
@ -129,7 +129,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
@ -181,7 +181,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
@ -225,7 +225,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
@ -273,7 +273,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies

View file

@ -15,7 +15,7 @@ jobs:
steps: steps:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- name: Unpublish old dev versions - name: Unpublish old dev versions

View file

@ -40,7 +40,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'
@ -111,7 +111,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'

View file

@ -39,7 +39,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'
@ -102,7 +102,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'

View file

@ -38,7 +38,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'
@ -96,7 +96,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'
@ -165,7 +165,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'

View file

@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
# Use the GitHub API to get changed files — no fork code is executed. # Use the GitHub API to get changed files — no fork code is executed.
- name: Get changed files - name: Get changed files

View file

@ -30,7 +30,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '24.15' node-version: '26.1'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
cache: 'npm' cache: 'npm'

2
.mise.toml Normal file
View file

@ -0,0 +1,2 @@
[tools]
node = "26.1.0"

View file

@ -1 +1 @@
24.15.0 26.1.0

2
.nvmrc
View file

@ -1 +1 @@
24.15.0 26.1.0

View file

@ -303,7 +303,7 @@ Open the repository in VS Code with the Dev Containers extension, or run:
devcontainer up --workspace-folder . devcontainer up --workspace-folder .
``` ```
The container includes Node 24, Rust, GitHub CLI, Docker-in-Docker, and recommended VS Code extensions. The container includes Node 26, Rust, GitHub CLI, Docker-in-Docker, and recommended VS Code extensions.
## Dependency Updates ## Dependency Updates

View file

@ -3,7 +3,7 @@
# Image: ghcr.io/singularity-ng/singularity-foundry # Image: ghcr.io/singularity-ng/singularity-foundry
# Used by: end users via docker run # Used by: end users via docker run
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
FROM node:24.15-slim AS runtime FROM node:26.1-slim AS runtime
# Git is required for SF's git operations # Git is required for SF's git operations
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \

View file

@ -10,7 +10,7 @@ Sources checked 2026-05-08:
<https://github.com/features/copilot/cli> <https://github.com/features/copilot/cli>
- GitHub Changelog, "GitHub Copilot CLI is now generally available" - GitHub Changelog, "GitHub Copilot CLI is now generally available"
<https://github.blog/changelog/2026-02-25-github-copilot-cli-is-now-generally-available/> <https://github.blog/changelog/2026-02-25-github-copilot-cli-is-now-generally-available/>
- GitHub Changelog, "GitHub Copilot CLI now supports BYOK and local models" - GitHub Changelog, "Copilot CLI now supports BYOK and local models"
<https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/> <https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/>
- Factory Droid, "Autonomy Level" - Factory Droid, "Autonomy Level"
<https://docs.factory.ai/cli/user-guides/auto-run> <https://docs.factory.ai/cli/user-guides/auto-run>
@ -102,6 +102,8 @@ modelMode: fast | smart | deep
surface: tui | web | headless | rpc surface: tui | web | headless | rpc
``` ```
Note: `repair` is a `workMode`, not a separate subsystem. The `/doctor` command is the diagnostic engine; `/repair` switches `workMode` to `repair`.
Examples: Examples:
```text ```text
@ -295,9 +297,9 @@ Required surfaces:
This should not use `/sf`. This should not use `/sf`.
## Repair Mode ## Repair Work Mode
`repair` is the right name for doctor-like work. `repair` is a `workMode`, not a separate subsystem.
Commands: Commands:
@ -434,7 +436,7 @@ Direct command:
It should show: It should show:
- autonomous units - autonomous units (durable state: todo | in_progress | review | done | retrying | failed | cancelled)
- parallel workers - parallel workers
- scheduled autonomous dispatches - scheduled autonomous dispatches
- background shell sessions - background shell sessions
@ -443,10 +445,12 @@ It should show:
- current cost/budget state - current cost/budget state
- last checkpoint and next action - last checkpoint and next action
Task lifecycle uses ORCH-style states. `todo` means ready to run, not "queued."
This complements, not replaces: This complements, not replaces:
- `/status` - `/status`
- `/queue` - `/queue` (milestone dispatch order, not task state)
- `/parallel status` - `/parallel status`
- `/session-report` - `/session-report`
- `/logs` - `/logs`
@ -1040,3 +1044,176 @@ If Node 26 passes those gates, SF should run itself on Node 26 internally even
before raising public `engines.node`. Once stable, raise the repo baseline and before raising public `engines.node`. Once stable, raise the repo baseline and
start replacing fragile `Date`/millisecond logic with Temporal in the schedule, start replacing fragile `Date`/millisecond logic with Temporal in the schedule,
lease, journal, and background task surfaces. lease, journal, and background task surfaces.
---
## Appendix A: Related Source Files
This section maps the concepts in this document to actual code in the repo.
### A.1 Operating Model (Already Exists)
**File:** `src/resources/extensions/sf/operating-model.js`
Already exports canonical vocabulary:
```js
export const RUN_CONTROL_MODES = ["manual", "assisted", "autonomous"];
export const PERMISSION_PROFILES = ["restricted", "normal", "trusted", "unrestricted"];
```
Tests: `src/resources/extensions/sf/tests/operating-model.test.mjs`
**Gap:** No `workMode` or `modelMode` constants yet. Add to this file.
### A.2 Execution Policy (Already Exists)
**File:** `src/resources/extensions/sf/execution-policy.js`
Maps permission profiles to concrete tool restrictions:
```js
EXECUTION_POLICY_PROFILES = {
restricted: { filesystem: "read-mostly", network: "read-only", git: "read-only", mutation: "planning-artifacts-only" },
normal: { filesystem: "workspace-write", network: "allowed", git: "normal", mutation: "workspace" },
trusted: { filesystem: "workspace-write", network: "allowed", git: "normal", mutation: "workspace" },
unrestricted: { filesystem: "danger-full-access", network: "allowed", git: "dangerous", mutation: "host" }
};
```
**Gap:** Not yet wired to tool-call boundaries. Enforcement is in `write-gate.js` and `destructive-guard.js` but not unified.
### A.3 Auto Session State (Already Exists)
**File:** `src/resources/extensions/sf/auto/session.js`
`AutoSession` class holds:
- `active`, `paused`, `stepMode`, `canAskUser`
- `currentUnit`, `currentMilestoneId`
- `autoModeStartModel`, `currentUnitModel`
**Gap:** No `workMode` property. Add to `AutoSession` and `reset()`.
### A.4 Command Registration (Already Exists)
**File:** `src/resources/extensions/sf/commands/index.js`
Registers direct commands via `pi.registerCommand()`:
```js
for (const command of DIRECT_SF_COMMANDS) {
pi.registerCommand(command.cmd, { ... });
}
```
**File:** `src/resources/extensions/sf/commands/catalog.js`
Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`.
**Gap:** Commands still use `/sf` prefix in user-facing strings. `SF_COMMAND_DESCRIPTION` lists `/sf help|start|...`.
### A.5 TUI Extension (Already Exists)
**File:** `src/resources/extensions/sf-tui/index.js`
Registers shortcuts:
- `Ctrl+Alt+H` — prompt history
- `Ctrl+Shift+H` — prompt history fallback
- `Ctrl+Alt+M` — marketplace
**File:** `src/resources/extensions/sf-tui/header.js`
Renders header with project name, branch, model. No mode badge yet.
**File:** `src/resources/extensions/sf-tui/footer.js`
Renders footer with git status, cost, context usage. No mode badge yet.
**File:** `src/resources/extensions/sf-tui/extension-manifest.json`
Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`.
**Gap:** No mode badge rendering. No mode-switching shortcuts. Header hidden during auto mode.
### A.6 UOK Parity Report (Already Uses runControl)
**File:** `src/resources/extensions/sf/tests/uok-parity-report.test.mjs`
Tests verify `runControl` and `permissionProfile` in UOK events:
```js
assert.equal(events[0].runControl, "autonomous");
assert.equal(events[0].permissionProfile, "normal");
```
**Gap:** No `workMode` in UOK events yet.
### A.7 Routing History (Already Exists)
**File:** `src/resources/extensions/sf/routing-history.js`
Tracks model tier success/failure per task pattern.
**Gap:** Not yet connected to `modelMode` (`fast`/`smart`/`deep`). Currently uses `light`/`standard`/`heavy` tiers.
### A.8 Doctor System (Already Exists)
**File:** `src/resources/extensions/sf/doctor.js`
**File:** `src/resources/extensions/sf/doctor-proactive.js`
**File:** `src/resources/extensions/sf/doctor-checks.js`
Health checks, auto-fix, proactive monitoring.
**Gap:** No `repair` work mode. Doctor is diagnostic-only, not a workflow.
### A.9 Self-Feedback (Already Exists)
**File:** `src/resources/extensions/sf/self-feedback.js`
Records anomalies, blocking entries, version-bump resolution.
**Gap:** Not connected to `workMode` transitions.
### A.10 Skills (Partially Exists)
**File:** `src/resources/extensions/sf/skill-discovery.js`
**File:** `src/resources/extensions/sf/skill-health.js`
**File:** `src/resources/extensions/sf/skill-telemetry.js`
Skill loading, health monitoring, telemetry.
**Gap:** No `.agents/skills/` directory structure. No YAML frontmatter. No auto-creation flow.
---
## Appendix B: Implementation Priority
| Priority | Item | Files to Touch | Effort |
|----------|------|----------------|--------|
| P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small |
| P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small |
| P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small |
| P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small |
| P0 | Deprecate `/sf` prefix in commands | `commands/catalog.js`, `commands/index.js` | Medium |
| P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium |
| P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/write-gate.js`, `safety/destructive-guard.js` | Medium |
| P1 | Add `/tasks` background work surface | New: `tasks-overlay.js`, `tasks-db.js` | Large |
| P1 | Make `repair` first-class work mode | `commands/handlers/core.js`, `doctor.js` | Medium |
| P2 | Add `.agents/skills/` structure | New: `skills-directory.js`, skill templates | Large |
| P2 | Add skill YAML frontmatter parser | New: `skill-frontmatter.js` | Medium |
| P2 | Add skill eval harness | New: `skill-eval.js`, eval templates | Large |
| P2 | Adopt Temporal in `sf schedule` | `schedule/*.js` | Medium |
| P2 | Node 26 canary | `package.json`, CI | Medium |
---
## Appendix C: Open Questions
1. Should paused autonomous show previous badge dimmed, or `[P]` for paused?
2. Should mode be per-session or per-project? (Current: per-session)
3. Should badge appear in tmux/terminal window titles?
4. Should mode transitions have sound/notification?
5. Should `repair` auto-transition be `ask` by default for new projects?
6. Should skill eval cases run in CI or only on-demand?
7. Should `/tasks` be a TUI overlay or a separate scrollable panel?
8. Should `modelMode` replace or supplement the existing tier system (`light`/`standard`/`heavy`)?

View file

@ -3,7 +3,7 @@
# Image: ghcr.io/sf-build/sf-ci-builder # Image: ghcr.io/sf-build/sf-ci-builder
# Used by: pipeline.yml Dev stage # Used by: pipeline.yml Dev stage
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
FROM node:24-bookworm FROM node:26-bookworm
# Rust toolchain (stable, minimal profile) # Rust toolchain (stable, minimal profile)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal

View file

@ -4,7 +4,7 @@
# Purpose: Isolated environment for SF auto mode # Purpose: Isolated environment for SF auto mode
# Usage: docker sandbox create --template ./docker # Usage: docker sandbox create --template ./docker
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
FROM node:24-bookworm-slim FROM node:26-bookworm-slim
# System dependencies required by SF # System dependencies required by SF
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \

View file

@ -249,7 +249,7 @@ Persist learning: when a unit produces a gotcha or anti-pattern, write to sf's m
| Dependency down | Behaviour | | Dependency down | Behaviour |
|---|---| |---|---|
| Native engine (`forge_engine.node`) | Fall back to JS implementations; log degraded mode. Never silently proceed without confirming fallback path is wired. | | Native engine (`forge_engine.node`) | Fall back to JS implementations; log degraded mode. Never silently proceed without confirming fallback path is wired. |
| `node:sqlite` and `better-sqlite3` both unavailable | Block DB-owned operations; there is no normal no-DB planning mode. Read files only as human evidence. | | `node:sqlite` unavailable | Block DB-owned operations; there is no normal no-DB planning mode or alternate SQLite engine fallback. Read files only as human evidence. |
| LLM provider | Try next allowed provider per `~/.sf/preferences.md`; if exhausted, halt unit with `ErrModelUnavailable` (no silent skip). | | LLM provider | Try next allowed provider per `~/.sf/preferences.md`; if exhausted, halt unit with `ErrModelUnavailable` (no silent skip). |
| SOPS unavailable | Use already-exported env vars; log that secret refresh is unavailable. Block secret-touching commands. | | SOPS unavailable | Use already-exported env vars; log that secret refresh is unavailable. Block secret-touching commands. |

View file

@ -48,7 +48,7 @@ UOK Kernel (executes units)
↓ records outcomes via ↓ records outcomes via
Unit Runtime (recordUnitOutcomeInMemory) Unit Runtime (recordUnitOutcomeInMemory)
↓ stores patterns in ↓ stores patterns in
Memory System (SQLite, Node 24 native) Memory System (SQLite, Node 26 native)
↓ queried by ↓ queried by
Dispatch (enhanceUnitRankingWithMemory) Dispatch (enhanceUnitRankingWithMemory)
↓ boosts scores for matching patterns ↓ boosts scores for matching patterns
@ -70,7 +70,7 @@ Dispatch (enhanceUnitRankingWithMemory)
1. **Maximize kernel + DB** — Single UOK kernel, memory as DB layer, no multiplication 1. **Maximize kernel + DB** — Single UOK kernel, memory as DB layer, no multiplication
2. **Fire-and-forget async** — Memory never blocks critical path; safe degradation 2. **Fire-and-forget async** — Memory never blocks critical path; safe degradation
3. **Existing infrastructure** — SF already has 10 memory modules; no duplication 3. **Existing infrastructure** — SF already has 10 memory modules; no duplication
4. **Node 24 native SQLite** — No external dependencies; efficient storage 4. **Node 26 native SQLite** — No external dependencies; efficient storage
5. **Confidence scoring** — Learned patterns inform but don't dominate decisions 5. **Confidence scoring** — Learned patterns inform but don't dominate decisions
6. **Pure diagnostic gates** — Gate failures become learning opportunities, not gate logic change 6. **Pure diagnostic gates** — Gate failures become learning opportunities, not gate logic change
@ -119,7 +119,7 @@ All memory operations fail silently without blocking:
- Category assignment - Category assignment
- Unit type extraction - Unit type extraction
**Phase 2 Tests:** 21 test cases (syntax correct, require Node 24.15) **Phase 2 Tests:** 21 test cases (syntax correct, require Node 26.1)
- Memory-enhanced ranking - Memory-enhanced ranking
- Embedding computation - Embedding computation
- Score boosting formula - Score boosting formula

View file

@ -2,7 +2,7 @@
## Overview ## Overview
Singularity-forge includes a **complete autonomous memory system** built on SQLite (Node 24 native) with no external dependencies. The memory system enables SF to: Singularity-forge includes a **complete autonomous memory system** built on SQLite (Node 26 native) with no external dependencies. The memory system enables SF to:
- **Learn** from unit execution patterns and outcomes - **Learn** from unit execution patterns and outcomes
- **Recall** similar past situations for context-aware decisions - **Recall** similar past situations for context-aware decisions
@ -286,7 +286,7 @@ memory_sources (
### **sf-db.js** (SQLite Backend) ### **sf-db.js** (SQLite Backend)
**Location:** `src/resources/extensions/sf/sf-db.js` **Location:** `src/resources/extensions/sf/sf-db.js`
**Purpose:** Core SQLite database abstraction (Node 24 native, no external deps). **Purpose:** Core SQLite database abstraction (Node 26 native, no external deps).
**Tables:** **Tables:**
- `memories` — Memory entries - `memories` — Memory entries
@ -295,7 +295,7 @@ memory_sources (
- `memory_sources` — Source tracking - `memory_sources` — Source tracking
- Plus other SF tables (uok, env, etc.) - Plus other SF tables (uok, env, etc.)
**Key Advantage:** Node 24.15+ has native SQLite support (`node:sqlite`) **Key Advantage:** Node 26.1+ has native SQLite support (`node:sqlite`)
--- ---
@ -443,7 +443,7 @@ const gotchas = await getRelevantMemoriesRanked(
│ ↓ │ │ ↓ │
│ ┌─────────────────────────────┐ │ │ ┌─────────────────────────────┐ │
│ │ SQLite (sf-db.js) │ │ │ │ SQLite (sf-db.js) │ │
│ │ Node 24 native sqlite │ │ │ │ Node 26 native sqlite │ │
│ └─────────────────────────────┘ │ │ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
@ -517,7 +517,7 @@ All memory operations follow the **fire-and-forget pattern**:
**Memory is NOT exposed as MCP server.** **Memory is NOT exposed as MCP server.**
- **SF is an MCP *client* only** — SF consumes MCP tools from external services - **SF is an MCP *client* only** — SF consumes MCP tools from external services
- **Memory is internal SF infrastructure** — uses SQLite (Node 24 native) - **Memory is internal SF infrastructure** — uses SQLite (Node 26 native)
- **Memory exported as SF tools** — LLM agents within SF call memory functions - **Memory exported as SF tools** — LLM agents within SF call memory functions
- **No external exposure** — Memory system is not a service; it's SF's autonomous learning mechanism - **No external exposure** — Memory system is not a service; it's SF's autonomous learning mechanism

View file

@ -269,7 +269,7 @@ all.forEach(m => console.log(`${m.id}: ${m.hit_count} hits`));
## Architecture: Memory is Internal ## Architecture: Memory is Internal
- **No MCP server** — memory stays inside SF - **No MCP server** — memory stays inside SF
- **SQLite only** — Node 24 native (no external deps) - **SQLite only** — Node 26 native (no external deps)
- **Fire-and-forget** — never blocks dispatch - **Fire-and-forget** — never blocks dispatch
- **Private learning** — autonomous pattern extraction - **Private learning** — autonomous pattern extraction

View file

@ -1,312 +1,46 @@
# SQLite Migration Guide for Model Learning # SQLite Runtime Baseline
**Status**: Planned for Node 24.15.0 upgrade **Status:** complete for the Node 26.1 runtime baseline.
**Current**: JSON-based storage (model-learner.js, self-report-fixer.js)
**Target**: Native `node:sqlite` integration
## Why SQLite? SF uses built-in `node:sqlite` and the project-local `.sf/sf.db` as the
canonical structured store for planning state, UOK execution state, learning
outcomes, schedules, memory extraction queues, and generated projections.
1. **Zero dependencies**: Node 24+ has built-in `node:sqlite` (no package install) ## Runtime Rule
2. **Queryable**: SQL joins with UOK's `llm_task_outcomes` table for unified learning database
3. **Transactional**: Atomic outcome recording prevents partial state corruption
4. **Performant**: Indexes on (task_type, model_id) for per-task-type ranking queries
5. **Durable**: WAL mode ensures data survives crashes
## Current State (Node 20) - **Node baseline:** 26.1+
- **Canonical database:** `.sf/sf.db`
- **SQLite binding:** built-in `node:sqlite`
- **Sidecar stores:** not allowed for active SF state
### JSON-Based Storage Runtime code must not introduce `sql.js`, `better-sqlite3`, `sqlite3` CLI
- `model-learner.js`: `.sf/model-performance.json` (nested object hierarchy) shell-outs, or JSON fallback databases for DB-owned state. If a feature needs
```json ordering, validation, joins, leases, TTLs, or history, put it in `.sf/sf.db`.
{
"execute-task": {
"gpt-4o": {
"successes": 42,
"failures": 3,
"successRate": 0.93
}
}
}
```
- `self-report-fixer.js`: Stateless (no persistent storage)
- `triage-self-feedback.js`: Reads/writes `REQUIREMENTS.md`, `ARCHITECTURE.md`
### Pain Points ## Current Surfaces
- Entire file read/write on every outcome (O(n) latency)
- No queryable schema (must load all data, filter in-memory)
- No transactions (partial failures possible)
- No natural joins with UOK database
## SQLite Schema (Target) - **Learning outcomes:** `llm_task_outcomes`
- **UOK execution graphs:** `uok_execution_graphs`, `uok_graph_nodes`,
`uok_graph_progress`
- **UOK coordination:** `uok_kv`, `uok_stream_entries`, `uok_queue_items`
- **Memory extraction:** `threads`, `stage1_outputs`, `jobs`
- **Schedules:** `schedule_entries`
### Table 1: model_outcomes ## Development Rules
Raw event log for every model outcome.
```sql 1. Use SF query/writer helpers when operating inside the SF extension.
CREATE TABLE model_outcomes ( 2. Use `node:sqlite` directly only for isolated tooling or package code that
id INTEGER PRIMARY KEY AUTOINCREMENT, cannot import the SF extension runtime.
task_type TEXT NOT NULL, -- "execute-task", "plan-slice", etc. 3. Prefer read-only SQLite handles for monitors and inspection overlays.
model_id TEXT NOT NULL, -- "gpt-4o", "claude-opus", etc. 4. Do not keep compatibility code for retired JSON or sidecar DB stores unless
success INTEGER NOT NULL, -- 1 = success, 0 = failure an explicit migration command owns it.
timeout INTEGER NOT NULL DEFAULT 0, -- 1 = timed out, 0 = normal 5. Add behavior tests before changing persistence semantics.
tokens_used INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0.0,
timestamp TEXT NOT NULL, -- ISO 8601
FOREIGN KEY (task_type, model_id) REFERENCES model_stats(task_type, model_id)
);
CREATE INDEX idx_outcomes_task_model ON model_outcomes(task_type, model_id); ## Verification
CREATE INDEX idx_outcomes_timestamp ON model_outcomes(timestamp DESC);
```bash
npm run typecheck:extensions
npx vitest run --config vitest.config.ts \
src/resources/extensions/sf/learning/*.test.mjs \
src/resources/extensions/sf/tests/uok-*.test.mjs
``` ```
### Table 2: model_stats
Aggregated per-task-per-model statistics (updated atomically with each outcome).
```sql
CREATE TABLE model_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
model_id TEXT NOT NULL,
successes INTEGER NOT NULL DEFAULT 0,
failures INTEGER NOT NULL DEFAULT 0,
timeouts INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_cost REAL NOT NULL DEFAULT 0.0,
last_used TEXT, -- ISO 8601 timestamp of last outcome
UNIQUE(task_type, model_id)
);
CREATE INDEX idx_stats_task_model ON model_stats(task_type, model_id);
```
## Migration Steps
### Phase 1: Refactor `ModelPerformanceTracker` (model-learner.js)
**Before** (JSON):
```javascript
recordOutcome(taskType, modelId, outcome) {
if (!this.data[taskType]) this.data[taskType] = {};
if (!this.data[taskType][modelId]) {
this.data[taskType][modelId] = { successes: 0, failures: 0, ... };
}
const stats = this.data[taskType][modelId];
if (outcome.success) stats.successes += 1;
else stats.failures += 1;
this._save(); // Entire file rewrite
}
```
**After** (SQLite):
```javascript
recordOutcome(taskType, modelId, outcome) {
this.db.exec("BEGIN");
// Insert event
const insertStmt = this.db.prepare(`
INSERT INTO model_outcomes (task_type, model_id, success, timeout, ...)
VALUES (?, ?, ?, ?, ...)
`);
insertStmt.run(taskType, modelId, outcome.success ? 1 : 0, ...);
// Upsert stats
const updateStmt = this.db.prepare(`
INSERT INTO model_stats (task_type, model_id, successes, ...)
VALUES (?, ?, ?, ...)
ON CONFLICT(task_type, model_id) DO UPDATE SET
successes = successes + ?,
failures = failures + ?,
...
`);
updateStmt.run(...);
this.db.exec("COMMIT");
}
```
**Benefits**:
- O(1) outcome recording (single INSERT)
- Atomic transaction (both tables updated together)
- No full-file rewrite
### Phase 2: Update Query Methods
**getRankedModels** → SQL SELECT with ORDER BY
```javascript
getRankedModels(taskType, minSamples = 3) {
const query = this.db.prepare(`
SELECT model_id, successes, failures, total_tokens, total_cost, last_used
FROM model_stats
WHERE task_type = ? AND (successes + failures) >= ?
ORDER BY (CAST(successes AS FLOAT) / (successes + failures)) DESC
`);
return query.all(taskType, minSamples).map(row => ({
modelId: row.model_id,
successRate: row.successes / (row.successes + row.failures),
...
}));
}
```
### Phase 3: Integrate with UOK Database (Optional)
If UOK stores outcomes in its database, consider a **federated schema**:
- Keep model_learner SQLite database separate (`.sf/model-performance.db`)
- OR: Create view in UOK database that joins with UOK's `llm_task_outcomes`
```sql
-- In UOK database:
CREATE VIEW model_performance AS
SELECT
outcome.task_type,
outcome.model_id,
COUNT(CASE WHEN outcome.success = 1 THEN 1 END) as successes,
COUNT(CASE WHEN outcome.success = 0 THEN 1 END) as failures,
SUM(outcome.tokens_used) as total_tokens,
SUM(outcome.cost_usd) as total_cost
FROM llm_task_outcomes outcome
GROUP BY outcome.task_type, outcome.model_id;
```
### Phase 4: Data Migration (JSON → SQLite)
Create migration function in constructor:
```javascript
_initDb() {
const db = new DatabaseSync(this.dbPath);
// ... create tables ...
// Migrate existing JSON data
if (existsSync(this.oldJsonPath)) {
const jsonData = JSON.parse(readFileSync(this.oldJsonPath, 'utf-8'));
this._migrateFromJson(db, jsonData);
// After migration: delete old JSON or archive
}
return db;
}
_migrateFromJson(db, jsonData) {
db.exec("BEGIN");
for (const [taskType, models] of Object.entries(jsonData)) {
for (const [modelId, stats] of Object.entries(models)) {
const insertStmt = db.prepare(`
INSERT INTO model_stats
(task_type, model_id, successes, failures, timeouts, total_tokens, total_cost, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertStmt.run(
taskType, modelId,
stats.successes, stats.failures, stats.timeouts || 0,
stats.totalTokens, stats.totalCost, stats.lastUsed
);
}
}
db.exec("COMMIT");
}
```
## Testing Strategy
### Unit Tests (No Changes Needed)
Existing tests in `model-learner.test.ts` should pass unchanged:
- `recordOutcome()` API remains the same
- `getRankedModels()` returns same shape
- `shouldDemote()`, `getABTestCandidates()` unchanged
### Integration Tests (Add SQLite-Specific)
```typescript
test("persists to SQLite database", () => {
const learner = new ModelLearner(basePath);
learner.recordOutcome("execute-task", "gpt-4o", { success: true, tokensUsed: 100 });
// Verify record in model_outcomes table
const query = learner.tracker.db.prepare(`
SELECT COUNT(*) as count FROM model_outcomes
WHERE task_type = ? AND model_id = ?
`);
const result = query.get("execute-task", "gpt-4o");
expect(result.count).toBe(1);
});
test("transactions are atomic", () => {
// Simulate failure during upsert
// Verify both INSERT and UPDATE succeed or both rollback
});
```
## Timeline
1. **When Node 24.15.0 becomes standard** (6-8 weeks)
- Update `.nvmrc`, `package.json` engines
- Enable snap to run Node 24
2. **Migration PR** (2 days of work)
- Refactor `ModelPerformanceTracker` class
- Add migration function
- Test with existing unit tests
3. **Rollout** (1 day)
- Deploy with backward-compatible JSON→SQLite auto-migration
- Monitor for edge cases
- Archive old JSON files after 1 week
## Backward Compatibility
- **Auto-migrate**: On first run with Node 24, detect `.sf/model-performance.json` and import to SQLite
- **Keep JSON**: Don't delete old JSON file immediately (keep for 1 week as backup)
- **Graceful fallback**: If SQLite init fails, log error and fall back to JSON (degraded mode)
## Future Opportunities
Once SQLite is in place:
1. **Dashboard**: Query performance metrics
```sql
SELECT model_id,
ROUND(100.0 * successes / (successes + failures), 1) as success_rate,
total_tokens, total_cost
FROM model_stats
WHERE task_type = ?
ORDER BY success_rate DESC;
```
2. **Trend analysis**: Model performance over time
```sql
SELECT DATE(timestamp) as day, model_id, COUNT(*) as attempts,
SUM(success) as wins,
ROUND(100.0 * SUM(success) / COUNT(*), 1) as daily_success_rate
FROM model_outcomes
WHERE task_type = ? AND timestamp > date('now', '-30 days')
GROUP BY day, model_id
ORDER BY day DESC;
```
3. **A/B testing**: Compare challenger vs incumbent in detail
```sql
SELECT
model_id,
COUNT(*) as trials,
SUM(success) as wins,
ROUND(AVG(tokens_used), 0) as avg_tokens,
ROUND(AVG(cost_usd), 4) as avg_cost
FROM model_outcomes
WHERE task_type = ? AND timestamp > ?
GROUP BY model_id;
```
4. **Federated learning**: Export performance data for cross-project analysis
```sql
SELECT * FROM model_stats
WHERE successes + failures >= 10 -- High-confidence entries only
ORDER BY success_rate DESC;
```
## References
- Node.js `node:sqlite` docs: https://nodejs.org/api/sqlite.html
- UOK `llm_task_outcomes` schema: See `docs/dev/UOK-SELF-EVOLUTION.md`
- SQLite WAL mode: https://www.sqlite.org/wal.html

View file

@ -153,8 +153,8 @@ For `@dev` or `@next` rollbacks, the next successful merge will overwrite the ta
| Image | Base | Purpose | Tags | | Image | Base | Purpose | Tags |
|-------|------|---------|------| |-------|------|---------|------|
| `ghcr.io/singularity-forge/sf-ci-builder` | `node:24-bookworm` | CI build environment with Rust toolchain | `:latest`, `:<date>` | | `ghcr.io/singularity-forge/sf-ci-builder` | `node:26-bookworm` | CI build environment with Rust toolchain | `:latest`, `:<date>` |
| `ghcr.io/singularity-forge/sf-run` | `node:24-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` | | `ghcr.io/singularity-forge/sf-run` | `node:26-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` |
The CI builder image is rebuilt automatically when the `Dockerfile` changes. It eliminates ~3-5 min of toolchain setup per CI run. The CI builder image is rebuilt automatically when the `Dockerfile` changes. It eliminates ~3-5 min of toolchain setup per CI run.

View file

@ -1,194 +1,35 @@
## M001-6377a4: Consolidate Memory Systems into Unified node:sqlite Store ## M001-6377a4: Consolidate Memory State into `.sf/sf.db`
**Gathered:** 2026-05-07 **Gathered:** 2026-05-07
**Status:** Promoted plan **Status:** Implemented baseline, remaining work is product integration.
**Source:** Promoted from `.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md`
## Project Description ## Purpose
Replace three fragmented memory systems with a single unified store backed by `node:sqlite`. All memory ingestion, querying, and prompt injection flows through one canonical database table in `sf.db`. Memory state must follow the same rule as the rest of SF state: structured,
queryable, project-owned data lives in `.sf/sf.db`. Runtime memory code should
not write global sidecar databases or introduce a second SQLite engine.
**Three systems being consolidated:** ## Current Baseline
1. **`memory-store.js`** (SF, `src/resources/extensions/sf/memory-store.js`) — function-based API backed by `sf-db.js``node:sqlite``sf.db`. Already uses `node:sqlite`. Exports: `createMemory`, `updateMemoryContent`, `reinforceMemory`, `supersedeMemory`, `getActiveMemoriesRanked`, `getRelevantMemoriesRanked`, `formatMemoriesForPrompt`. Tables: `memories`, `memory_embeddings`, `memory_relations`, `memory_processed_units`. - SF memory APIs use `node:sqlite` through the project database.
- The Pi memory extraction queue now opens `<cwd>/.sf/sf.db`.
- Extracted memory artifacts are written under `<cwd>/.sf/memory`.
- Runtime package dependencies no longer include a WASM SQLite engine for the
memory pipeline.
2. **Memory extension** (`packages/pi-coding-agent/src/resources/extensions/memory/`) — LLM-based session transcript extraction that writes to `agent.db` via `sql.js` (WASM SQLite). Pipeline: scan → filter → phase1 LLM extraction → phase2 consolidation → `MEMORY.md` output. ## Remaining Product Work
3. **`knowledge-injector.js`** (SF, `src/resources/extensions/sf/knowledge-injector.js`) — parses markdown knowledge entries and injects into prompts via semantic similarity matching. Called by prompt assembly before agent start. - Make `/memory view` prefer DB-backed memory rows over generated markdown when
both exist.
- Connect extracted stage outputs to the existing `memories` table instead of
only producing markdown summaries.
- Add a migration command only if we decide old user data should be imported.
Do not keep passive compatibility code in startup paths.
## Why This Milestone ## Acceptance
**What problem this solves:** Three parallel memory systems create maintenance fragmentation, competing injection paths into system prompts, and two SQLite implementations (`node:sqlite` in SF + `sql.js` WASM in pi-coding-agent). Adding a `source` column and wiring all paths to `sf.db` eliminates the duplication and provides a single canonical store. - `/memory rebuild` records extraction queue state in `.sf/sf.db`.
- `/memory view` reads the same project memory source that UOK and prompt
**Why now:** The existing `memory-store.js` is already well-designed. The migration and wiring work is tractable. Post-consolidation, future memory features (embedding reranking, relation boosting) have one place to land. assembly use.
- Grep finds no runtime memory import of alternate SQLite engines.
## User-Visible Outcome - Tests cover the DB path, rebuild path, and clear path.
### When this milestone is complete, the user can:
- Run `/memory view` and see memories from `sf.db` (not from `agent.db` or `MEMORY.md`)
- Trigger `/memory rebuild` and watch extraction write directly to `sf.db`
- Invoke the `capture_thought` tool and see it persist to `sf.db` with a source tag
- Query memories via `memory_query` and receive ranked results via cosine + relation boost
### Entry point / environment
- Entry point: `sf` CLI, `/memory` command, `capture_thought` and `memory_query` tool calls
- Environment: local dev, CI, production (single-user, per-project sf.db)
- Live dependencies: LLM provider (for extraction), `node:sqlite` (built-in Node >= 24)
## Completion Class
- **Contract complete** means: `sf.db` `memories` table passes CRUD + ranking tests; `capture_thought` and `memory_query` are registered native tools with schema validation; migration script has dry-run + backup modes.
- **Integration complete** means: session transcript pipeline writes to `sf.db`; `/memory` command reads from `sf.db`; all three legacy paths are removed or no-op'd.
- **Operational complete** means: WAL contention does not block session startup (extraction is fire-and-forget); no memory-related background processes leak resources.
## Final Integrated Acceptance
To call this milestone complete, we must prove:
- **Behavioral regression test passes:** A Playwright or shell test starts a session, triggers extraction, and verifies `/memory view` shows entries from `sf.db` — not `agent.db` or `MEMORY.md`.
- **`grep` verification passes:** `grep -r "sql.js|better-sqlite3" src/ packages/ --include="*.ts" --include="*.js" | grep -v "test\|spec\|deprecated"` returns zero matches in memory-related code paths.
- **`capture_thought`/`memory_query` are native tools:** Registered with proper TypeBox schema, validated in tool registry tests.
## Architectural Decisions
### Use function-based API, not a class wrapper
**Decision:** Extend the existing `memory-store.js` function-based API rather than wrapping it in a `MemoryStore` class.
**Rationale:** The existing functions (`createMemory`, `getRelevantMemoriesRanked`, etc.) are already the right abstraction. Adding a class wrapper introduces churn with no clear benefit — the pipeline can call functions directly. This minimizes risk during consolidation.
**Alternatives Considered:**
- Class wrapper (`MemoryStore` class) — higher churn, no functional benefit; rejected.
### Add `source` column to `memories` table
**Decision:** Add a `source` column (`'capture' | 'extracted' | 'migrated' | 'manual'`) to distinguish ingestion paths.
**Rationale:** Different sources have different confidence defaults and maintenance semantics. `capture_thought` entries start at confidence 0.8; extracted memories start at 0.7; migrated entries preserve original confidence. The column enables source-filtered queries and targeted deduplication.
### Register `capture_thought` and `memory_query` as native pi tools
**Decision:** Register `capture_thought` and `memory_query` as native pi tools (like `vectordrive_store`) with TypeBox parameter schemas, rather than relying solely on LLM tool-call convention in prompts.
**Rationale:** Native tool registration provides: (1) proper schema validation, (2) tool descriptions surfaced to the LLM, (3) consistent error handling. The current approach (LLM calls named tools in prompts) is fragile — the tool isn't actually registered, so errors are silently dropped.
**Alternatives Considered:**
- LLM tool-call convention only — already works but fragile; no schema validation; rejected.
### Keep `memory_embeddings` table as-is
**Decision:** Leave the existing `memory_embeddings` table in `sf.db` (BLOB storage for vectors) and the associated `memory-embeddings.js` / `memory-embeddings-llm-gateway.js` modules unchanged.
**Rationale:** The embedding infrastructure is pre-existing and functional. The consolidation goal is storage/unification, not embedding redesign. Wiring to VectorDrive is a future optimization, not required for this milestone.
**Alternatives Considered:**
- Wire embeddings to VectorDrive — VectorDrive has Rust SQLite vector support, but it is a separate system; adds complexity; deferred to a future milestone.
- Pure JS vector similarity — viable for small scale, but the existing infrastructure is sufficient.
### Migrate `agent.db` in S03, delete after import
**Decision:** S03 migration script reads `agent.db` stage1_outputs, imports memories to `sf.db` with `source='extracted'`, then deletes `agent.db`.
**Rationale:** Deleting after successful import is the cleanest cutover. Keeping the file around creates dual-write risk and user confusion. Dry-run mode + automatic `sf.db` backup mitigate migration risk.
**Alternatives Considered:**
- Delete at end of S04 — leaves dual-write window open longer; rejected.
- Leave orphaned (don't delete) — leaves cruft; rejected.
### Full scope: SF + pi-coding-agent
**Decision:** Consolidate both SF's `memory-store.js`/`knowledge-injector.js` AND pi-coding-agent's memory extension into `sf.db`.
**Rationale:** The memory extension's extraction pipeline is the primary source of extracted memories. If it still writes to `agent.db`, the consolidation is incomplete. Porting it to write to `sf.db` via `MemoryStore` is the correct scope.
## Error Handling Strategy
- **DB unavailable:** All `memory-store.js` functions degrade gracefully — return `[]` / `null` / `false` instead of throwing. `capture_thought` tool returns an error message, not a crash.
- **Migration failures:** S03 script skips corrupted records with a warning, continues processing remaining entries, and reports final counts. Never partially migrates without reporting.
- **LLM extraction failures:** Session startup extraction runs fire-and-forget; errors are caught and logged but do not block dispatch.
- **Token budget overflow:** `formatMemoriesForPrompt` respects `tokenBudget` parameter (~4 chars/token) and truncates at budget. Category grouping preserves priority order (gotcha → convention → architecture → pattern → environment → preference).
## Risks and Unknowns
- **Data loss during migration** — Users may have valuable accumulated memories in `agent.db` and `KNOWLEDGE.md` that would be lost if migration fails. **Mitigation:** Dry-run mode reports counts without modifying DB; automatic backup of `sf.db` before migration; skip-on-error with warning for corrupted records.
- **WAL contention on `sf.db`** — The `sf.db` already has a single-writer invariant. Adding memory extraction writes during session startup could create lock contention. **Mitigation:** Extraction runs fire-and-forget (does not block dispatch). If contention occurs, the single-writer invariant ensures serialized writes.
- **Breaking memory extension API contract** — The memory extension is a Pi extension with hooks and commands. Changing its storage backend changes observable behavior for external consumers. **Mitigation:** The `/memory` command output format is preserved; migration script ensures no data loss.
- **`capture_thought`/`memory_query` registration scope** — These tools should be registered in the pi-agent-core tool registry. The registration point needs to be identified before S01 implementation.
- **Node.js version requirement**`node:sqlite` (DatabaseSync) requires Node >= 24. The project currently documents this as a minimum version. No change needed.
## Existing Codebase / Prior Art
- `src/resources/extensions/sf/memory-store.js` — Source of truth for the existing function-based API; already uses `node:sqlite` via `sf-db.js`. **Not to be rewritten; extended.**
- `src/resources/extensions/sf/sf-db.js` — Single-writer SQLite adapter using `node:sqlite` DatabaseSync. **Already correct; no changes needed.**
- `src/resources/extensions/sf/memory-embeddings.js` — LLM gateway for embedding computation. **Pre-existing; out of scope.**
- `src/resources/extensions/sf/memory-embeddings-llm-gateway.js` — Cross-encoder reranking. **Pre-existing; out of scope.**
- `packages/pi-coding-agent/src/resources/extensions/memory/storage.ts``sql.js`-based `MemoryStorage` class. **Replaced in S02.**
- `packages/pi-coding-agent/src/resources/extensions/memory/pipeline.ts` — Two-phase extraction pipeline. **Ported to `sf.db` in S02.**
- `src/resources/extensions/vectordrive/` — Rust N-API vector database. **Pre-existing; embedding integration deferred to future milestone.**
- `src/resources/extensions/sf/knowledge-injector.js` — Markdown knowledge parser and semantic similarity. **Removed or no-op'd in S03.**
## Relevant Requirements
- **Unified memory storage** — Covered: all three systems consolidate into `sf.db`.
- **Semantic search** — Covered: `getRelevantMemoriesRanked` with cosine + relation boost + optional rerank.
- **Session-based learning** — Covered: extraction pipeline ports to `sf.db` in S02.
- **Cross-session context persistence** — Partially covered: memories survive across sessions via `sf.db`. Multi-project sharing deferred.
## Scope
### In Scope
- Add `source` column to `memories` table in `sf.db`
- Register `capture_thought` and `memory_query` as native pi tools with TypeBox schemas
- Port memory extension extraction pipeline from `sql.js`/`agent.db` to `sf.db` via `memory-store.js` functions
- Migration script: `KNOWLEDGE.md``sf.db` and `agent.db``sf.db`
- Behavioral regression test (shell/Playwright) for end-to-end verification
- Remove or no-op `knowledge-injector.js` after migration
- Remove `sql.js` dependency from `packages/pi-coding-agent`
- Remove `memory_embeddings` table and embedding code **NOT in scope** — pre-existing, functional
### Out of Scope / Non-Goals
- Redesigning the embedding infrastructure (VectorDrive wiring, pure-JS vectors) — deferred to future milestone
- Multi-project memory sharing or cloud sync
- Changing the `memory-embeddings.js` / `memory-embeddings-llm-gateway.js` modules
- Changing `sf-db.js` schema initialization logic
- Supporting Node < 24
## Technical Constraints
- **Node >= 24 required**`node:sqlite` DatabaseSync is built-in since Node 24. Earlier versions would need a polyfill or different approach.
- **Single-writer invariant on `sf.db`**`sf-db.js` is the only writer. Memory functions must go through the adapter, not direct SQL.
- **`sql.js` WASM bundle** — Currently in `packages/pi-coding-agent/package.json`. Removing it requires updating the build output and verifying no other packages depend on it.
## Integration Points
- **LLM provider** — Extraction pipeline calls `completeSimple` for phase 1 (memory extraction) and phase 2 (consolidation). No API key changes needed.
- **`sf.db`** — Canonical store. Schema already has `memories` table; only needs `source` column added.
- **`agent.db`** — Legacy store. Migrated in S03, then deleted.
- **`KNOWLEDGE.md`** — Legacy file. Migrated in S03, then read-only fallback (removed from injection path).
- **pi-coding-agent package** — Owns the extraction pipeline and `/memory` command. S02 rewires it to `sf.db`.
- **VectorDrive** — Pre-existing vector DB. Embedding integration deferred.
## Testing Requirements
- **Unit tests (S01):** CRUD operations on `memories` table, ranking formula (`confidence * (1 + hit_count * 0.1)`), source filtering, graceful degradation when DB unavailable, `formatMemoriesForPrompt` truncation and category grouping.
- **Contract tests (S02):** Pipeline writes to `sf.db` with correct `source` value; `/memory view` reads from `sf.db`; fire-and-forget does not block dispatch.
- **Migration tests (S03):** Dry-run reports correct counts; backup created before migration; `KNOWLEDGE.md` entries imported with `source='migrated'`; `agent.db` stage1_outputs imported with `source='extracted'`; skip-on-error for corrupted records.
- **Behavioral regression test (S04):** Playwright or shell test that starts a session, triggers extraction, and asserts `/memory view` output contains entries from `sf.db`.
## Acceptance Criteria
1. `sf.db` `memories` table has `source` column; all `memory-store.js` functions accept/return `source` field.
2. `capture_thought` and `memory_query` are registered native pi tools with TypeBox schemas and are called without errors.
3. Session extraction pipeline writes to `sf.db` with `source='extracted'`; `/memory view` reads from `sf.db`.
4. S03 migration script: dry-run mode reports correct counts; backup created; `agent.db` and `KNOWLEDGE.md` entries imported; old files removed.
5. `grep` finds zero `sql.js` or `better-sqlite3` imports in memory-related code paths.
6. Behavioral regression test passes: `/memory view` output originates from `sf.db`.
## Open Questions
- **`capture_thought`/`memory_query` registration point** — These tools should be registered in `pi-agent-core`'s tool registry or the sf-run bootstrap. The exact registration module needs to be identified before S01 implementation. Current hypothesis: `src/resources/extensions/sf/` bootstrap or a new `memory-tools.js` module. **TBD: investigate `sf-run` tool registration flow.**
- **S04 behavioral test format** — Playwright (requires browser) or shell script (requires `sf` binary)? Shell script with `--print` output parsing is simpler and faster in CI. **Decision needed: test framework for behavioral regression.**

View file

@ -0,0 +1,623 @@
# SF Agent Mode System
> **Status:** Draft specification. Promoted from `copilot-thoughts.md` research notes.
> **Scope:** TUI mode surface, command structure, orthogonal state axes, skills, background work, runtime target.
> **Decision authority:** Product + architecture review required before implementation.
---
## 1. Problem Statement
SF's current command surface (`/sf autonomous`, `/sf next`, `/sf pause`, `/sf stop`) treats mode switching as separate commands rather than persistent states. There is no visible indicator of the current mode, and the `/sf` prefix positions SF as a plugin rather than the system itself.
Competitors (Copilot CLI, Factory Droid, Amp) have cleaner mode surfaces with visible state and orthogonal controls. SF has deeper autonomous machinery but weaker presentation.
**Goal:** Make SF's mode system as obvious as Vim's insert/normal mode indicator, with the control depth of Factory Droid's autonomy levels and the skill system of Amp.
---
## 2. Orthogonal State Axes
SF state is five independent axes, not one overloaded "mode."
```text
workMode: chat | plan | build | review | repair | research
runControl: manual | assisted | autonomous
permissionProfile: restricted | normal | trusted | unrestricted
modelMode: fast | smart | deep
surface: tui | web | headless | rpc
```
### 2.1 Axis Definitions
| Axis | Question It Answers | Values |
|------|---------------------|--------|
| `workMode` | What kind of work is SF doing? | `chat`, `plan`, `build`, `review`, `repair`, `research` |
| `runControl` | Who advances the loop? | `manual` (user), `assisted` (one unit then pause), `autonomous` (continuous) |
| `permissionProfile` | What may proceed without approval? | `restricted`, `normal`, `trusted`, `unrestricted` |
| `modelMode` | Speed/cost/reasoning posture? | `fast` (cheap), `smart` (balanced), `deep` (reasoning) |
| `surface` | How is the user connected? | `tui`, `web`, `headless`, `rpc` |
### 2.2 Example Combinations
```text
plan | manual | normal | deep → user plans with reasoning model
build | autonomous | trusted | smart → continuous implementation
repair | assisted | normal | smart → one repair unit at a time
research | autonomous | restricted | deep → continuous research, read-only
review | manual | restricted | deep → user reviews with reasoning model
```
### 2.3 Rules
- `permissionProfile` never implies `runControl`. Autonomous run with `restricted` permissions is valid.
- `runControl` never implies `permissionProfile`. Manual run with `unrestricted` permissions is valid.
- Denylists and safety gates override `permissionProfile` regardless of value.
- Every risk decision logs all five axis values.
---
## 3. Work Modes
### 3.1 `chat`
Default conversational mode. Questions, explanations, low-commitment exploration. No durable artifacts created without explicit user request.
### 3.2 `plan`
Research, clarify, write/update specs, derive tasks, produce explicit acceptance point before implementation. Primary user journey starts here.
**Plan → Build handoff:**
```text
plan | manual | normal | deep
accept plan
build | autonomous | selected-permission-profile | smart
```
Surfaces:
- TUI: plan acceptance prompt includes "run autonomously" button
- Web: plan acceptance button includes "run autonomously"
- Headless: `--autonomous` chains into direct `/autonomous`
- RPC: machine event records transition explicitly
### 3.3 `build`
Implement, test, lint, typecheck, verify, prepare commit-ready changes. The autonomous default.
### 3.4 `review`
Inspect diffs, tests, risks, regressions, security issues, missing evidence. Requires reasoning model (`deep`).
### 3.5 `repair`
Fix SF health, repo health, runtime drift, broken generated state, bad command surfaces, failing workflow infrastructure, stale locks, broken installed runtime copies.
**Doctor is the diagnostic engine, not the mode.** `/doctor` inspects. `/repair` switches work mode.
Commands:
```text
/doctor → inspect and report
/doctor fix → deterministic auto-fix
/doctor heal → LLM-assisted deep healing
/repair → switch workMode to repair
/repair --autonomous → repair until clean, blocked, or limit-hit
```
**Auto-transition to repair:**
```text
build | autonomous | trusted | smart
→ repair | autonomous | normal | smart
```
Allowed when:
- pre-dispatch health gate fails
- installed runtime drift detected
- SF cannot dispatch safely
- repo workflow state corrupted
Policy: configurable per project. Options: `auto`, `ask`, `log-only`.
### 3.6 `research`
Longer-form codebase, competitor, design, API, or dependency research. Uses web search, local code exploration, cross-repo research, helper agents.
---
## 4. Run Control
| Value | Behavior |
|-------|----------|
| `manual` | User drives every step. Tool calls require approval. |
| `assisted` | SF executes one unit, then pauses for user review. |
| `autonomous` | SF continues until done, blocked, interrupted, budget-hit, or limit-hit. |
### 4.1 Commands
```text
/control manual
/control assisted
/control autonomous
/autonomous → alias for /control autonomous
/next → alias for /control assisted (one unit)
/pause → pause autonomous, preserve state
/stop → stop autonomous, clear state
```
### 4.2 Transition Scopes
| Scope | Behavior |
|-------|----------|
| `now` | Apply immediately if no tool active. Abort current tool if policy allows. |
| `after-current-tool` | Finish active tool, then switch. |
| `after-current-unit` | Finish current SF unit, then switch. |
| `next-milestone` | Switch after current milestone completes. |
Autonomous changes affect future decisions, never mutate active tool calls mid-execution.
### 4.3 Transition Logging
Every transition persists:
```json
{
"timestamp": "2026-05-08T10:00:00Z",
"from": {"workMode": "build", "runControl": "autonomous"},
"to": {"workMode": "repair", "runControl": "autonomous"},
"reason": "pre-dispatch health gate failed",
"scope": "after-current-unit",
"sessionId": "..."
}
```
---
## 5. Permission Profiles
| Profile | Description |
|---------|-------------|
| `restricted` | Read-only and explicitly allowlisted actions. |
| `normal` | Safe edits, non-destructive local commands. |
| `trusted` | Build/test/install/local commits and bounded repo automation. |
| `unrestricted` | High-risk orchestration only in intentionally trusted environments. |
### 5.1 Enforcement
Permission profile is enforced at three layers:
1. **Tool registry:** Each tool declares required profile. Tools below profile are hidden from model.
2. **Execution gate:** Each tool call checks profile at invocation. Violation = error.
3. **Safety harness:** Destructive operations (delete, push to production, etc.) require explicit confirmation regardless of profile.
### 5.2 Commands
```text
/trust restricted
/trust normal
/trust trusted
/trust unrestricted
```
---
## 6. Model Modes
| Mode | Use Case | Routing Hint |
|------|----------|--------------|
| `fast` | Small bounded tasks | Cheapest available model |
| `smart` | Default balanced work | Default routing table |
| `deep` | Planning, debugging, research, review | Reasoning model (o1, Claude Opus, etc.) |
`modelMode` guides routing. It does not replace explicit `/model` selection.
---
## 7. Mode Switching UX
### 7.1 Direct Commands
```text
/mode chat
/mode plan
/mode build
/mode review
/mode repair
/mode research
/control manual
/control assisted
/control autonomous
/trust restricted
/trust normal
/trust trusted
/trust unrestricted
/model-mode fast
/model-mode smart
/model-mode deep
```
### 7.2 Combined Forms
```text
/mode repair --autonomous --trust normal
/mode build --autonomous --trust trusted
/mode research --autonomous --trust restricted --model-mode deep
```
### 7.3 Autonomous Steering
```text
/steer mode repair
/steer mode review after-current-unit
/steer trust restricted now
/steer model-mode deep for-next-unit
```
### 7.4 Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+Shift+M` | Cycle workMode: chat → plan → build → review → repair → research → chat |
| `Ctrl+Shift+A` | Set runControl to autonomous |
| `Ctrl+Shift+S` | Set runControl to assisted (step) |
| `Ctrl+Shift+I` | Set runControl to manual (interactive) |
| `Ctrl+Shift+R` | Set workMode to repair |
| `Ctrl+Shift+P` | Cycle permissionProfile: restricted → normal → trusted → unrestricted → restricted |
---
## 8. Status and Mode Badge
### 8.1 Full Status Line
```text
SF build | autonomous | trusted | smart
```
### 8.2 Compact Badge Form
For narrow terminals (< 80 cols):
```text
[B][A][T][S]
```
### 8.3 Critical State Labels
When workMode is `repair` or `review`, show full labels regardless of width:
```text
repair | autonomous | normal | smart
review | assisted | normal | deep
```
### 8.4 Badge Placement
| Surface | Placement |
|---------|-----------|
| TUI header | Left side, after "SF" logo |
| TUI status bar | Bottom line when header hidden |
| tmux/terminal title | `SF[build|A|trusted|smart] project-name` |
| Web | Top bar, color-coded chip |
### 8.5 Badge During Auto Mode
Current code hides header/footer during auto mode (`if (isAutoActive()) return []`). This must change:
- Show **minimal header** during auto: badge + project name only
- Or show badge in **dedicated status bar** separate from header/footer
- Badge color pulses slowly during autonomous execution (subtle animation)
### 8.6 Badge Colors
| Axis | Value | Color |
|------|-------|-------|
| workMode | `chat` | dim |
| workMode | `plan` | accent |
| workMode | `build` | success |
| workMode | `review` | warning |
| workMode | `repair` | error |
| workMode | `research` | info |
| runControl | `manual` | dim |
| runControl | `assisted` | warning |
| runControl | `autonomous` | success (pulsing) |
| permissionProfile | `restricted` | success |
| permissionProfile | `normal` | dim |
| permissionProfile | `trusted` | warning |
| permissionProfile | `unrestricted` | error |
---
## 9. Background Work Surface (`/tasks`)
Unified view of all background work. Replaces scattered `/status`, `/queue`, `/parallel status` for work inspection.
### 9.1 What `/tasks` Shows
- autonomous units (current + queued)
- parallel workers
- scheduled autonomous dispatches
- background shell sessions
- stuck or resumable sessions
- remote questions waiting for answers
- current cost/budget state
- last checkpoint and next action
### 9.2 Data Model
SQLite tables:
```sql
-- Durable task state
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
work_mode TEXT NOT NULL,
run_control TEXT NOT NULL,
permission_profile TEXT NOT NULL,
model_mode TEXT NOT NULL,
status TEXT NOT NULL, -- pending | running | review | done | retrying | failed | cancelled
dependency_blockers TEXT, -- JSON array of task IDs
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
checkpoint_ref TEXT, -- git ref or patch file
cost_budget REAL,
cost_spent REAL DEFAULT 0,
created_at TEXT, -- Temporal.Instant ISO
started_at TEXT,
completed_at TEXT,
next_action_at TEXT, -- Temporal.ZonedDateTime for scheduled
intent_claim TEXT -- for parallel workers: "I will edit src/foo.ts lines 10-50"
);
-- Ephemeral running state
CREATE TABLE task_runtime (
task_id TEXT PRIMARY KEY REFERENCES tasks(id),
process_pid INTEGER,
worktree_path TEXT,
current_model TEXT,
context_usage_percent REAL,
last_heartbeat_at TEXT, -- Temporal.Instant
FOREIGN KEY (task_id) REFERENCES tasks(id)
);
-- Transition log
CREATE TABLE task_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
reason TEXT,
scope TEXT,
timestamp TEXT NOT NULL -- Temporal.Instant
);
```
### 9.3 Complementary Commands
`/tasks` does not replace:
- `/status` → project health dashboard
- `/queue` → milestone/slice dispatch order
- `/parallel status` → parallel orchestrator detail
- `/session-report` → cost/token summary
- `/logs` → activity logs
- `/forensics` → execution forensics
---
## 10. Skills System
### 10.1 Directory Structure
```text
.agents/skills/<skill-name>/
SKILL.md -- skill definition with YAML frontmatter
scripts/ -- supporting scripts
schemas/ -- JSON schemas for inputs/outputs
checklists/ -- verification checklists
mcp.json -- MCP server config if applicable
```
### 10.2 Skill Frontmatter
```yaml
---
name: forge-command-surface
description: Use when changing SF slash commands, browser command parity, or headless command dispatch.
user-invocable: true
model-invocable: true
side-effects: code-edits
permission-profile: normal
---
```
Fields:
- `name`: unique identifier
- `description`: when to use this skill
- `user-invocable`: can user explicitly invoke?
- `model-invocable`: can model auto-invoke when relevant?
- `side-effects`: `none`, `code-edits`, `production-mutation`, etc.
- `permission-profile`: minimum profile required
### 10.3 Skill Categories
| Type | Example | `model-invocable` |
|------|---------|-------------------|
| Background knowledge | `forge-autonomous-runtime` | true |
| User tool | `production-deploy` | false |
| Shared capability | `forge-command-surface` | true |
Dangerous skills (`production-mutation`) are never model-invoked by default.
### 10.4 Auto-Creation Flow
1. Detect repeated repo-specific evidence (same files, commands, failure modes, rules)
2. Propose skill in manual/restricted contexts
3. Generate/update automatically only when policy allows
4. Record source evidence in `.sf` state
5. Keep narrow and testable
6. Commit with repo when accepted
### 10.5 Skill Eval Cases
Every auto-created skill needs eval cases:
```text
.agents/skills/<skill-name>/evals/
case-1/
task.md -- user-like prompt
grader.js -- deterministic checker
hidden/ -- reference answers (not visible to agent)
work/ -- agent workspace
```
Graders inspect: files, artifacts, `answer.json`, `trace.jsonl`, result state.
Failed trials preserve workspace for debugging.
---
## 11. Migration from `/sf` Commands
### 11.1 Command Mapping
| Old | New | Status |
|-----|-----|--------|
| `/sf` | `/next` | migrate |
| `/sf autonomous` | `/autonomous` | migrate |
| `/sf next` | `/next` | migrate |
| `/sf stop` | `/stop` | migrate |
| `/sf pause` | `/pause` | migrate |
| `/sf status` | `/status` | migrate |
| `/sf doctor` | `/doctor` | migrate |
| `/sf rate` | `/rate` | migrate |
| `/sf session-report` | `/session-report` | migrate |
| `/sf parallel` | `/parallel` | migrate |
| `/sf remote` | `/remote` | migrate |
| `/sf tasks` | `/tasks` | new |
### 11.2 Migration Timeline
| Phase | Action |
|-------|--------|
| Phase 1 (now) | Accept both `/sf X` and `/X`. Log deprecation warning for `/sf`. |
| Phase 2 (2 releases) | `/sf X` shows warning: "Use /X instead. /sf will be removed." |
| Phase 3 (4 releases) | `/sf X` errors: "Unknown command. Did you mean /X?" |
| Phase 4 (6 releases) | Remove `/sf` handler entirely. |
### 11.3 Shell Surface
Machine surface remains prefixed:
```text
sf headless autonomous
sf headless --autonomous ...
```
---
## 12. Runtime Target: Node 26
### 12.1 Policy
```text
current compatibility floor: Node 26.1+
internal target runtime: Node 26.1
canonical baseline: Node 26.1
Node 25: skip except quick probes
```
### 12.2 Why Node 26
- `Temporal` enabled by default
- V8 14.6 baseline
- Undici 8 HTTP/fetch baseline
- Removes legacy APIs, hardens against old assumptions
### 12.3 Temporal Adoption
Store semantic type, not just formatted string:
| Concept | Temporal Type | Use Case |
|---------|---------------|----------|
| Exact instant | `Temporal.Instant` | Journal events, checkpoints, lock leases |
| Local time | `Temporal.ZonedDateTime` | Reminders, schedules, audits |
| Calendar date | `Temporal.PlainDate` | Daily reports, milestone reviews |
| Wall-clock time | `Temporal.PlainTime` | Recurring policies |
| Time amount | `Temporal.Duration` | Budgets, leases, cooldowns, retry delays |
### 12.4 Adoption Priority
1. `sf schedule` — highest user-visible impact
2. Lock/lease — highest operational correctness
3. Journals/traces — highest debugging impact
4. Session reports — nice to have
5. Background tasks — future work
### 12.5 Gate
```text
node@26 --version
npm run lint
npm run typecheck:extensions
npm run build
npm test
sf --version
sf --help
sf --print "ping"
```
---
## 13. Implementation Pull-Through
### 13.1 Already Directionally Right
- UOK lifecycle records carry `runControl`
- UOK lifecycle records carry `permissionProfile`
- Schedule command state uses `autonomous_dispatch`
- DB-backed state, recovery, verification, scheduling, captures, forensics
- Skills and project-specific skill paths exist
- Parallel orchestration and remote-question infrastructure
### 13.2 Still Needed
| Priority | Item | Effort |
|----------|------|--------|
| P0 | Remove `/sf` internal dispatch, docs, tests, help text | Medium |
| P0 | Make `workMode` durable state (SQLite + `.sf/`) | Medium |
| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | Medium |
| P0 | Add visible mode badge to TUI header/status bar | Small |
| P1 | Make `--autonomous` chain into direct `/autonomous` | Small |
| P1 | Expose autonomous continuation limits in settings and status | Small |
| P1 | Add `/tasks` with durable + ephemeral state | Large |
| P1 | Make `repair` first-class workflow over `doctor` | Medium |
| P2 | Policy-aware project skill suggestion/generation | Large |
| P2 | Skill eval cases for generated skills | Large |
| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium |
| P2 | Intent/claim records for parallel workers | Medium |
| P2 | Audit subagent provider/model/permission inheritance | Medium |
| P2 | Audit remote steering as full-session surface | Medium |
---
## 14. Open Questions
1. Should `plan` mode show badge `[P]` or `plan` text in full?
2. Should paused autonomous show previous badge dimmed, or `[P]` for paused?
3. Should mode be per-session or per-project? (Current: per-session)
4. Should badge appear in tmux/terminal window titles?
5. Should mode transitions have sound/notification?
6. Should `repair` auto-transition be `ask` by default for new projects?
7. Should skill eval cases run in CI or only on-demand?
8. Should `/tasks` be a TUI overlay or a separate scrollable panel?
---
## 15. References
- GitHub Docs, "Allowing GitHub Copilot CLI to work autonomously" — <https://docs.github.com/en/copilot/concepts/agents/copilot-cli/autopilot>
- Factory Droid, "Autonomy Level" — <https://docs.factory.ai/cli/user-guides/auto-run>
- Amp manual — <https://ampcode.com/manual>
- Smelt (mode cycling) — <https://github.com/leonardcser/smelt>
- ORCH (task state machine) — <https://github.com/oxgeneral/ORCH>
- AgentPlane (schema-first tasks) — <https://github.com/basilisk-labs/agentplane>
- Relay (channels and tickets) — <https://github.com/jcast90/relay>
- Sage (runtime-neutral orchestration) — <https://github.com/youwangd/SageCLI>
- Wit (symbol-level locks) — <https://github.com/amaar-mc/wit>

View file

@ -55,8 +55,8 @@ sudo pacman -S nodejs npm git
```bash ```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
source ~/.bashrc # or ~/.zshrc source ~/.bashrc # or ~/.zshrc
nvm install 24 nvm install 26
nvm use 24 nvm use 26
``` ```
#### All distros: Steps 2-7 #### All distros: Steps 2-7
@ -64,7 +64,7 @@ nvm use 24
**Step 2 — Verify dependencies are installed:** **Step 2 — Verify dependencies are installed:**
```bash ```bash
node --version # should print v24.x or higher node --version # should print v26.x or higher
git --version # should print 2.20+ git --version # should print 2.20+
``` ```
@ -309,7 +309,7 @@ Or from within a session:
| `sf` runs `git svn dcommit` | oh-my-zsh conflict — `unalias sf` or use `sf-cli` | | `sf` runs `git svn dcommit` | oh-my-zsh conflict — `unalias sf` or use `sf-cli` |
| Permission errors on `npm install -g` | Fix npm prefix (see Linux notes) or use nvm | | Permission errors on `npm install -g` | Fix npm prefix (see Linux notes) or use nvm |
| Can't connect to LLM | Check API key with `sf config`, verify network access | | Can't connect to LLM | Check API key with `sf config`, verify network access |
| `sf` hangs on start | Check Node.js version: `node --version` (need 24+) | | `sf` hangs on start | Check Node.js version: `node --version` (need 26+) |
For more, see [Troubleshooting](./troubleshooting.md). For more, see [Troubleshooting](./troubleshooting.md).

View file

@ -151,11 +151,11 @@ rm -rf "$(dirname .sf)/.sf.lock"
- If the error persists, close tools that may be holding the file open and then retry. - If the error persists, close tools that may be holding the file open and then retry.
- If repeated failures continue, run `/doctor` to confirm the repo state is still healthy and report the exact path + error code. - If repeated failures continue, run `/doctor` to confirm the repo state is still healthy and report the exact path + error code.
### Node v24 web boot failure ### Node v26 web boot failure
**Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v24. **Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v26.
**Cause:** Node v24 changed type-stripping behavior for `node_modules`, breaking the Next.js web build. **Cause:** Node v26 changed type-stripping behavior for `node_modules`, breaking the Next.js web build.
**Fix:** Fixed in v2.42.0+ (#1864). Upgrade to the latest version. **Fix:** Fixed in v2.42.0+ (#1864). Upgrade to the latest version.

View file

@ -52,9 +52,9 @@ The web server binds to `localhost:3000` by default. Use `--host`, `--port`, and
|----------|-------------| |----------|-------------|
| `SF_WEB_PROJECT_CWD` | Default project path when `?project=` is not specified | | `SF_WEB_PROJECT_CWD` | Default project path when `?project=` is not specified |
## Node v24 Compatibility ## Node v26 Compatibility
Node v24 introduced breaking changes to type stripping that caused `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on web boot. This is fixed in v2.42.0+ (#1864). If you encounter this error, upgrade SF. Node v26 introduced breaking changes to type stripping that caused `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on web boot. This is fixed in v2.42.0+ (#1864). If you encounter this error, upgrade SF.
## Auth Token Persistence ## Auth Token Persistence

28059
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,7 @@
"configDir": ".sf" "configDir": ".sf"
}, },
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"packageManager": "npm@11.13.0", "packageManager": "npm@11.13.0",
"scripts": { "scripts": {
@ -148,7 +148,6 @@
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
"sql.js": "^1.14.1",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"undici": "^7.24.2", "undici": "^7.24.2",
"unified": "^11.0.5", "unified": "^11.0.5",
@ -159,7 +158,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.14", "@biomejs/biome": "^2.4.14",
"@types/node": "^24.12.0", "@types/node": "^25.6.2",
"@types/picomatch": "^4.0.2", "@types/picomatch": "^4.0.2",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",

View file

@ -36,11 +36,11 @@
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.0", "@types/node": "^25.6.2",
"typescript": "^5.4.0" "typescript": "^5.4.0"
}, },
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"files": [ "files": [
"dist", "dist",

View file

@ -81,22 +81,22 @@ describe("generatePlist", () => {
it("uses the absolute node path from opts", () => { it("uses the absolute node path from opts", () => {
const opts = basePlistOpts({ const opts = basePlistOpts({
nodePath: "/home/user/.nvm/versions/node/v24.0.0/bin/node", nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node",
}); });
const xml = generatePlist(opts); const xml = generatePlist(opts);
assert.ok( assert.ok(
xml.includes( xml.includes(
"<string>/home/user/.nvm/versions/node/v24.0.0/bin/node</string>", "<string>/home/user/.nvm/versions/node/v26.1.0/bin/node</string>",
), ),
); );
}); });
it("includes NVM bin directory in PATH", () => { it("includes NVM bin directory in PATH", () => {
const opts = basePlistOpts({ const opts = basePlistOpts({
nodePath: "/home/user/.nvm/versions/node/v24.0.0/bin/node", nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node",
}); });
const xml = generatePlist(opts); const xml = generatePlist(opts);
assert.ok(xml.includes("/home/user/.nvm/versions/node/v24.0.0/bin")); assert.ok(xml.includes("/home/user/.nvm/versions/node/v26.1.0/bin"));
}); });
it("sets KeepAlive with SuccessfulExit false", () => { it("sets KeepAlive with SuccessfulExit false", () => {

View file

@ -93,7 +93,7 @@
"dist" "dist"
], ],
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@singularity-forge/engine-darwin-arm64": ">=2.75.0", "@singularity-forge/engine-darwin-arm64": ">=2.75.0",

View file

@ -3,7 +3,7 @@
* for Node.js module resolution (ESM/CJS compatibility). * for Node.js module resolution (ESM/CJS compatibility).
* *
* Regression test for #2861: "type": "module" + "import"-only export * Regression test for #2861: "type": "module" + "import"-only export
* conditions caused crashes on Node.js v24 when the parent package also * conditions caused crashes on Node.js v26 when the parent package also
* declared "type": "module" and strict ESM resolution was enforced. * declared "type": "module" and strict ESM resolution was enforced.
*/ */
@ -26,7 +26,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
assert.notEqual( assert.notEqual(
pkg.type, pkg.type,
"module", "module",
'package.json must not set "type": "module" — this causes crashes on Node.js v24 ' + 'package.json must not set "type": "module" — this causes crashes on Node.js v26 ' +
"when the parent package also declares ESM (see #2861)", "when the parent package also declares ESM (see #2861)",
); );
}); });
@ -55,7 +55,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
assert.ok( assert.ok(
!conditions.import || conditions.default, !conditions.import || conditions.default,
`exports["${subpath}"] uses "import" condition without "default" — ` + `exports["${subpath}"] uses "import" condition without "default" — ` +
`this breaks CJS consumers and Node.js v24 strict resolution`, `this breaks CJS consumers and Node.js v26 strict resolution`,
); );
} }
}); });

View file

@ -16,6 +16,6 @@
}, },
"dependencies": {}, "dependencies": {},
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
} }
} }

View file

@ -44,6 +44,6 @@
"@smithy/node-http-handler": "^4.5.0" "@smithy/node-http-handler": "^4.5.0"
}, },
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
} }
} }

View file

@ -33,18 +33,16 @@
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"undici": "^7.24.2", "undici": "^7.24.2",
"sql.js": "^1.14.1",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"express": "^4.19.2" "express": "^4.19.2"
}, },
"devDependencies": { "devDependencies": {
"@types/sql.js": "^1.4.9",
"@types/diff": "^7.0.2", "@types/diff": "^7.0.2",
"@types/hosted-git-info": "^3.0.5", "@types/hosted-git-info": "^3.0.5",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/express": "^4.17.21" "@types/express": "^4.17.21"
}, },
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
} }
} }

View file

@ -11,7 +11,6 @@
* - /memory command: view, clear, rebuild, stats * - /memory command: view, clear, rebuild, stats
*/ */
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, rmSync } from "node:fs"; import { existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { completeSimple } from "@singularity-forge/pi-ai"; import { completeSimple } from "@singularity-forge/pi-ai";
@ -23,26 +22,25 @@ import {
import { getFullMemory, getMemorySummary, runStartup } from "./pipeline.js"; import { getFullMemory, getMemorySummary, runStartup } from "./pipeline.js";
import { MemoryStorage } from "./storage.js"; import { MemoryStorage } from "./storage.js";
/** Encode cwd to a filesystem-safe directory name */
function encodeCwd(cwd: string): string {
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
}
/** Get the memory directory for a project */ /** Get the memory directory for a project */
function getMemoryDir(cwd: string): string { function getMemoryDir(cwd: string): string {
return join(getAgentDir(), "memories", encodeCwd(cwd)); return join(cwd, ".sf", "memory");
} }
/** Get the database path */ /** Get the database path */
function getDbPath(): string { function getDbPath(cwd: string): string {
return join(getAgentDir(), "agent.db"); return join(cwd, ".sf", "sf.db");
} }
let storageInstance: MemoryStorage | null = null; let storageInstance: MemoryStorage | null = null;
let storageDbPath: string | null = null;
async function getStorage(): Promise<MemoryStorage> { async function getStorage(cwd: string): Promise<MemoryStorage> {
if (!storageInstance) { const dbPath = getDbPath(cwd);
storageInstance = await MemoryStorage.create(getDbPath()); if (!storageInstance || storageDbPath !== dbPath) {
storageInstance?.close();
storageInstance = await MemoryStorage.create(dbPath);
storageDbPath = dbPath;
} }
return storageInstance; return storageInstance;
} }
@ -130,7 +128,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
// Fire and forget // Fire and forget
runStartup( runStartup(
await getStorage(), await getStorage(cwd),
{ {
sessionsDir, sessionsDir,
memoryDir, memoryDir,
@ -212,7 +210,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
"Delete all extracted memories for this project?", "Delete all extracted memories for this project?",
); );
if (confirmed) { if (confirmed) {
(await getStorage()).clearForCwd(ctx.cwd); (await getStorage(ctx.cwd)).clearForCwd(ctx.cwd);
if (existsSync(projectMemoryDir)) { if (existsSync(projectMemoryDir)) {
rmSync(projectMemoryDir, { recursive: true, force: true }); rmSync(projectMemoryDir, { recursive: true, force: true });
} }
@ -227,7 +225,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
"Re-extract all memories from session history? This may take a while.", "Re-extract all memories from session history? This may take a while.",
); );
if (confirmed) { if (confirmed) {
(await getStorage()).resetAllForCwd(ctx.cwd); (await getStorage(ctx.cwd)).resetAllForCwd(ctx.cwd);
if (existsSync(projectMemoryDir)) { if (existsSync(projectMemoryDir)) {
rmSync(projectMemoryDir, { recursive: true, force: true }); rmSync(projectMemoryDir, { recursive: true, force: true });
} }
@ -240,7 +238,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
} }
case "stats": { case "stats": {
const stats = (await getStorage()).getStats(); const stats = (await getStorage(ctx.cwd)).getStats();
const statsText = [ const statsText = [
"Memory Pipeline Statistics:", "Memory Pipeline Statistics:",
` Total sessions tracked: ${stats.totalThreads}`, ` Total sessions tracked: ${stats.totalThreads}`,
@ -274,6 +272,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
if (storageInstance) { if (storageInstance) {
storageInstance.close(); storageInstance.close();
storageInstance = null; storageInstance = null;
storageDbPath = null;
} }
}); });
} }

View file

@ -1,5 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, it } from "vitest"; import { afterEach, describe, it } from "vitest";
@ -10,11 +10,7 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "sf-memory-storage-test-")); return mkdtempSync(join(tmpdir(), "sf-memory-storage-test-"));
} }
function wait(ms: number): Promise<void> { describe("MemoryStorage node:sqlite persistence", () => {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("MemoryStorage debounced persistence", () => {
let dir: string; let dir: string;
afterEach(() => { afterEach(() => {
@ -23,14 +19,11 @@ describe("MemoryStorage debounced persistence", () => {
} }
}); });
it("multiple rapid mutations only trigger one persist write", async () => { it("multiple rapid mutations are immediately queryable", async () => {
dir = makeTmpDir(); dir = makeTmpDir();
const dbPath = join(dir, "test.db"); const dbPath = join(dir, "test.db");
const storage = await MemoryStorage.create(dbPath); const storage = await MemoryStorage.create(dbPath);
const initialStat = readFileSync(dbPath);
const _initialMtime = initialStat.length;
storage.upsertThreads([ storage.upsertThreads([
{ {
threadId: "t1", threadId: "t1",
@ -59,35 +52,18 @@ describe("MemoryStorage debounced persistence", () => {
}, },
]); ]);
const afterMutationsBuf = readFileSync(dbPath); assert.equal(existsSync(dbPath), true);
assert.deepEqual(
afterMutationsBuf,
initialStat,
"File should not have been written yet (debounce window has not elapsed)",
);
await wait(700);
const afterDebounceBuf = readFileSync(dbPath);
assert.notDeepEqual(
afterDebounceBuf,
initialStat,
"File should have been written after debounce window elapsed",
);
const stats = storage.getStats(); const stats = storage.getStats();
assert.equal(stats.totalThreads, 3); assert.equal(stats.totalThreads, 3);
storage.close(); storage.close();
}); });
it("close() flushes pending changes immediately without waiting for debounce", async () => { it("close() releases the database and persisted rows reopen", async () => {
dir = makeTmpDir(); dir = makeTmpDir();
const dbPath = join(dir, "test.db"); const dbPath = join(dir, "test.db");
const storage = await MemoryStorage.create(dbPath); const storage = await MemoryStorage.create(dbPath);
const initialBuf = readFileSync(dbPath);
storage.upsertThreads([ storage.upsertThreads([
{ {
threadId: "t1", threadId: "t1",
@ -98,22 +74,8 @@ describe("MemoryStorage debounced persistence", () => {
}, },
]); ]);
const beforeCloseBuf = readFileSync(dbPath);
assert.deepEqual(
beforeCloseBuf,
initialBuf,
"File should not have been written yet (debounce window has not elapsed)",
);
storage.close(); storage.close();
const afterCloseBuf = readFileSync(dbPath);
assert.notDeepEqual(
afterCloseBuf,
initialBuf,
"File should have been written immediately on close()",
);
const reopened = await MemoryStorage.create(dbPath); const reopened = await MemoryStorage.create(dbPath);
const stats = reopened.getStats(); const stats = reopened.getStats();
assert.equal( assert.equal(

View file

@ -8,9 +8,9 @@
*/ */
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import initSqlJs, { type Database as SqlJsDatabase } from "sql.js"; import { DatabaseSync, type SQLInputValue } from "node:sqlite";
export interface ThreadRow { export interface ThreadRow {
thread_id: string; thread_id: string;
@ -44,13 +44,10 @@ export interface JobRow {
} }
export class MemoryStorage { export class MemoryStorage {
private db: SqlJsDatabase; private db: DatabaseSync;
private dbPath: string;
private persistTimer: ReturnType<typeof setTimeout> | null = null;
private constructor(db: SqlJsDatabase, dbPath: string) { private constructor(db: DatabaseSync) {
this.db = db; this.db = db;
this.dbPath = dbPath;
} }
static async create(dbPath: string): Promise<MemoryStorage> { static async create(dbPath: string): Promise<MemoryStorage> {
@ -59,36 +56,23 @@ export class MemoryStorage {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
const SQL = await initSqlJs(); const db = new DatabaseSync(dbPath);
const buffer = existsSync(dbPath) ? readFileSync(dbPath) : undefined;
const db = buffer ? new SQL.Database(buffer) : new SQL.Database();
db.run("PRAGMA journal_mode = WAL"); db.exec("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL"); db.exec("PRAGMA synchronous = NORMAL");
db.run("PRAGMA busy_timeout = 5000"); db.exec("PRAGMA busy_timeout = 5000");
const storage = new MemoryStorage(db, dbPath); const storage = new MemoryStorage(db);
storage.initSchema(); storage.initSchema();
return storage; return storage;
} }
private persist(): void { private run(sql: string, params: unknown[] = []): void {
const data = this.db.export(); this.db.prepare(sql).run(...(params as SQLInputValue[]));
writeFileSync(this.dbPath, Buffer.from(data));
}
private schedulePersist(): void {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
}
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
this.persist();
}, 500);
} }
private initSchema(): void { private initSchema(): void {
this.db.run(` this.db.exec(`
CREATE TABLE IF NOT EXISTS threads ( CREATE TABLE IF NOT EXISTS threads (
thread_id TEXT PRIMARY KEY, thread_id TEXT PRIMARY KEY,
file_path TEXT NOT NULL, file_path TEXT NOT NULL,
@ -101,7 +85,7 @@ export class MemoryStorage {
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
) )
`); `);
this.db.run(` this.db.exec(`
CREATE TABLE IF NOT EXISTS stage1_outputs ( CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY, thread_id TEXT PRIMARY KEY,
extraction_json TEXT NOT NULL, extraction_json TEXT NOT NULL,
@ -109,7 +93,7 @@ export class MemoryStorage {
FOREIGN KEY (thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE FOREIGN KEY (thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE
) )
`); `);
this.db.run(` this.db.exec(`
CREATE TABLE IF NOT EXISTS jobs ( CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
phase TEXT NOT NULL, phase TEXT NOT NULL,
@ -123,30 +107,23 @@ export class MemoryStorage {
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
) )
`); `);
this.db.run( this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_jobs_phase_status ON jobs(phase, status)", "CREATE INDEX IF NOT EXISTS idx_jobs_phase_status ON jobs(phase, status)",
); );
this.db.run( this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status)", "CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status)",
); );
this.db.run("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)"); this.db.exec("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)");
this.persist();
} }
private queryAll<T>(sql: string, params: unknown[] = []): T[] { private queryAll<T>(sql: string, params: unknown[] = []): T[] {
const stmt = this.db.prepare(sql); return this.db.prepare(sql).all(...(params as SQLInputValue[])) as T[];
stmt.bind(params as (string | number | null | Uint8Array)[]);
const rows: T[] = [];
while (stmt.step()) {
rows.push(stmt.getAsObject() as T);
}
stmt.free();
return rows;
} }
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined { private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
const rows = this.queryAll<T>(sql, params); return this.db.prepare(sql).get(...(params as SQLInputValue[])) as
return rows[0]; | T
| undefined;
} }
/** /**
@ -177,11 +154,11 @@ export class MemoryStorage {
); );
if (!existing) { if (!existing) {
this.db.run( this.run(
"INSERT INTO threads (thread_id, file_path, file_size, file_mtime, cwd, status) VALUES (?, ?, ?, ?, ?, 'pending')", "INSERT INTO threads (thread_id, file_path, file_size, file_mtime, cwd, status) VALUES (?, ?, ?, ?, ?, 'pending')",
[t.threadId, t.filePath, t.fileSize, t.fileMtime, t.cwd], [t.threadId, t.filePath, t.fileSize, t.fileMtime, t.cwd],
); );
this.db.run( this.run(
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')", "INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
[randomUUID(), t.threadId], [randomUUID(), t.threadId],
); );
@ -190,12 +167,12 @@ export class MemoryStorage {
existing.file_size !== t.fileSize || existing.file_size !== t.fileSize ||
existing.file_mtime !== t.fileMtime existing.file_mtime !== t.fileMtime
) { ) {
this.db.run( this.run(
"UPDATE threads SET file_path = ?, file_size = ?, file_mtime = ?, cwd = ?, status = 'pending', updated_at = datetime('now') WHERE thread_id = ?", "UPDATE threads SET file_path = ?, file_size = ?, file_mtime = ?, cwd = ?, status = 'pending', updated_at = datetime('now') WHERE thread_id = ?",
[t.filePath, t.fileSize, t.fileMtime, t.cwd, t.threadId], [t.filePath, t.fileSize, t.fileMtime, t.cwd, t.threadId],
); );
if (existing.status === "done" || existing.status === "error") { if (existing.status === "done" || existing.status === "error") {
this.db.run( this.run(
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')", "INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
[randomUUID(), t.threadId], [randomUUID(), t.threadId],
); );
@ -206,7 +183,6 @@ export class MemoryStorage {
} }
} }
this.schedulePersist();
return { inserted, updated, skipped }; return { inserted, updated, skipped };
} }
@ -222,7 +198,7 @@ export class MemoryStorage {
const token = randomUUID(); const token = randomUUID();
const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString(); const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
this.db.run( this.run(
`UPDATE jobs SET `UPDATE jobs SET
status = 'claimed', status = 'claimed',
worker_id = ?, worker_id = ?,
@ -243,8 +219,6 @@ export class MemoryStorage {
[token], [token],
); );
this.schedulePersist();
return rows.map((r) => ({ return rows.map((r) => ({
jobId: r.id, jobId: r.id,
threadId: r.thread_id, threadId: r.thread_id,
@ -256,34 +230,32 @@ export class MemoryStorage {
* Mark a stage1 job as complete and store the extraction output. * Mark a stage1 job as complete and store the extraction output.
*/ */
completeStage1Job(threadId: string, output: string): void { completeStage1Job(threadId: string, output: string): void {
this.db.run( this.run(
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'", "UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
[threadId], [threadId],
); );
this.db.run( this.run(
"INSERT OR REPLACE INTO stage1_outputs (thread_id, extraction_json, created_at) VALUES (?, ?, datetime('now'))", "INSERT OR REPLACE INTO stage1_outputs (thread_id, extraction_json, created_at) VALUES (?, ?, datetime('now'))",
[threadId, output], [threadId, output],
); );
this.db.run( this.run(
"UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?", "UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?",
[threadId], [threadId],
); );
this.schedulePersist();
} }
/** /**
* Mark a stage1 job as errored. * Mark a stage1 job as errored.
*/ */
failStage1Job(threadId: string, errorMessage: string): void { failStage1Job(threadId: string, errorMessage: string): void {
this.db.run( this.run(
"UPDATE jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'", "UPDATE jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
[errorMessage, threadId], [errorMessage, threadId],
); );
this.db.run( this.run(
"UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?", "UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?",
[errorMessage, threadId], [errorMessage, threadId],
); );
this.schedulePersist();
} }
/** /**
@ -322,12 +294,11 @@ export class MemoryStorage {
} }
const jobId = randomUUID(); const jobId = randomUUID();
this.db.run( this.run(
"INSERT INTO jobs (id, phase, status, worker_id, ownership_token, lease_expires_at) VALUES (?, 'stage2', 'claimed', ?, ?, ?)", "INSERT INTO jobs (id, phase, status, worker_id, ownership_token, lease_expires_at) VALUES (?, 'stage2', 'claimed', ?, ?, ?)",
[jobId, workerId, token, expiresAt], [jobId, workerId, token, expiresAt],
); );
this.schedulePersist();
return { jobId, ownershipToken: token }; return { jobId, ownershipToken: token };
} }
@ -335,11 +306,10 @@ export class MemoryStorage {
* Complete the phase 2 consolidation job. * Complete the phase 2 consolidation job.
*/ */
completePhase2Job(jobId: string): void { completePhase2Job(jobId: string): void {
this.db.run( this.run(
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'", "UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'",
[jobId], [jobId],
); );
this.schedulePersist();
} }
/** /**
@ -432,41 +402,39 @@ export class MemoryStorage {
* Clear all data (for /memory clear). * Clear all data (for /memory clear).
*/ */
clearAll(): void { clearAll(): void {
this.db.run("DELETE FROM stage1_outputs"); this.db.exec("DELETE FROM stage1_outputs");
this.db.run("DELETE FROM jobs"); this.db.exec("DELETE FROM jobs");
this.db.run("DELETE FROM threads"); this.db.exec("DELETE FROM threads");
this.schedulePersist();
} }
/** /**
* Clear data for a specific cwd (for /memory clear in project scope). * Clear data for a specific cwd (for /memory clear in project scope).
*/ */
clearForCwd(cwd: string): void { clearForCwd(cwd: string): void {
this.db.run( this.run(
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)", "DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd], [cwd],
); );
this.db.run( this.run(
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)", "DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd], [cwd],
); );
this.db.run("DELETE FROM threads WHERE cwd = ?", [cwd]); this.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
this.schedulePersist();
} }
/** /**
* Reset all threads to pending (for /memory rebuild). * Reset all threads to pending (for /memory rebuild).
*/ */
resetAllForCwd(cwd: string): void { resetAllForCwd(cwd: string): void {
this.db.run( this.run(
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)", "DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd], [cwd],
); );
this.db.run( this.run(
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)", "DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd], [cwd],
); );
this.db.run( this.run(
"UPDATE threads SET status = 'pending', updated_at = datetime('now') WHERE cwd = ?", "UPDATE threads SET status = 'pending', updated_at = datetime('now') WHERE cwd = ?",
[cwd], [cwd],
); );
@ -477,20 +445,14 @@ export class MemoryStorage {
); );
for (const t of threads) { for (const t of threads) {
this.db.run( this.run(
"INSERT INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')", "INSERT INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
[randomUUID(), t.thread_id], [randomUUID(), t.thread_id],
); );
} }
this.schedulePersist();
} }
close(): void { close(): void {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
this.persist();
this.db.close(); this.db.close();
} }
} }

View file

@ -26,32 +26,6 @@ declare module "proper-lockfile" {
export default lockfile; export default lockfile;
} }
declare module "sql.js" {
export interface Statement {
bind(values: (string | number | null | Uint8Array)[]): void;
step(): boolean;
getAsObject(): Record<string, unknown>;
free(): void;
}
export interface Database {
run(sql: string, params?: unknown[]): void;
prepare(sql: string): Statement;
export(): Uint8Array;
close(): void;
}
export interface SqlJsStatic {
Database: new (data?: Uint8Array | ArrayBuffer | Buffer) => Database;
}
export interface SqlJsConfig {
locateFile?: (file: string) => string;
}
export default function initSqlJs(config?: SqlJsConfig): Promise<SqlJsStatic>;
}
declare module "hosted-git-info" { declare module "hosted-git-info" {
export interface HostedGitInfo { export interface HostedGitInfo {
domain?: string; domain?: string;

View file

@ -28,6 +28,6 @@
"koffi": "^2.9.0" "koffi": "^2.9.0"
}, },
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
} }
} }

View file

@ -29,6 +29,6 @@
"test": "node --test dist/rpc-client.test.js" "test": "node --test dist/rpc-client.test.js"
}, },
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
} }
} }

View file

@ -2,7 +2,7 @@
"name": "sf", "name": "sf",
"version": "2.75.3", "version": "2.75.3",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"piConfig": { "piConfig": {
"name": "sf", "name": "sf",

View file

@ -25,7 +25,7 @@
* Data sources: * Data sources:
* .sf/parallel/M0xx.status.json heartbeat, cost, state (written by orchestrator) * .sf/parallel/M0xx.status.json heartbeat, cost, state (written by orchestrator)
* .sf/worktrees/M0xx/.sf/auto.lock current unit type + ID (written by worker) * .sf/worktrees/M0xx/.sf/auto.lock current unit type + ID (written by worker)
* .sf/worktrees/M0xx/.sf/sf.db task/slice completion (SQLite, queried via cli) * .sf/worktrees/M0xx/.sf/sf.db task/slice completion (read-only node:sqlite query)
* .sf/parallel/M0xx.stdout.log NDJSON events (cost extraction, notify messages) * .sf/parallel/M0xx.stdout.log NDJSON events (cost extraction, notify messages)
* .sf/parallel/M0xx.stderr.log error surfacing * .sf/parallel/M0xx.stderr.log error surfacing
* *
@ -44,6 +44,7 @@
import { execSync, spawn, spawnSync } from "node:child_process"; import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { DatabaseSync } from "node:sqlite";
// ─── Configuration ─────────────────────────────────────────────────────────── // ─── Configuration ───────────────────────────────────────────────────────────
@ -175,26 +176,38 @@ function readAutoLock(mid) {
return readJsonSafe(lockPath); return readJsonSafe(lockPath);
} }
function queryRows(dbPath, sql, params = []) {
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
} finally {
db.close();
}
}
function querySliceProgress(mid) { function querySliceProgress(mid) {
const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`); const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`);
if (!fs.existsSync(dbPath)) return []; if (!fs.existsSync(dbPath)) return [];
try { try {
const sql = `SELECT s.id, s.status, COUNT(t.id), SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) FROM slices s LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id WHERE s.milestone_id='${mid}' GROUP BY s.id ORDER BY s.id`; return queryRows(
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, { dbPath,
timeout: 3000, `SELECT s.id AS id,
encoding: "utf-8", s.status AS status,
}).trim(); COUNT(t.id) AS total,
if (!out) return []; SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
return out.split("\n").map((line) => { FROM slices s
const [id, status, total, done] = line.split("|"); LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
return { WHERE s.milestone_id=?
id, GROUP BY s.id
status, ORDER BY s.id`,
total: parseInt(total, 10), [mid],
done: parseInt(done || "0", 10), ).map((row) => ({
}; id: row.id,
}); status: row.status,
total: Number(row.total ?? 0),
done: Number(row.done ?? 0),
}));
} catch { } catch {
return []; return [];
} }
@ -631,17 +644,23 @@ function queryRecentCompletions(mid) {
try { try {
// Completed tasks with timestamps, most recent first // Completed tasks with timestamps, most recent first
const sql = `SELECT id, slice_id, one_liner, completed_at FROM tasks WHERE milestone_id='${mid}' AND status='complete' AND completed_at IS NOT NULL ORDER BY completed_at DESC LIMIT 5`; return queryRows(
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, { dbPath,
timeout: 3000, `SELECT id AS taskId,
encoding: "utf-8", slice_id AS sliceId,
}).trim(); one_liner AS oneLiner,
if (!out) return []; completed_at AS completedAt
return out.split("\n").map((line) => { FROM tasks
const [taskId, sliceId, oneLiner, completedAt] = line.split("|"); WHERE milestone_id=?
AND status='complete'
AND completed_at IS NOT NULL
ORDER BY completed_at DESC
LIMIT 5`,
[mid],
).map((row) => {
return { return {
ts: completedAt ? new Date(completedAt).getTime() : Date.now(), ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(),
msg: `${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`, msg: `${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
mid, mid,
}; };
}); });

View file

@ -44,7 +44,7 @@ import { stopWebMode } from "./web-mode.js";
import { loadStoredEnvKeys } from "./wizard.js"; import { loadStoredEnvKeys } from "./wizard.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// V8 compile cache — Node 24+ can cache compiled bytecode across runs, // V8 compile cache — Node 26+ can cache compiled bytecode across runs,
// eliminating repeated parse/compile overhead for unchanged modules. // eliminating repeated parse/compile overhead for unchanged modules.
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit. // Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -180,7 +180,7 @@ if (
// package.json (already parsed above) and verifies git is available. // package.json (already parsed above) and verifies git is available.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
{ {
const MIN_NODE_MAJOR = 24; const MIN_NODE_MAJOR = 26;
const red = "\x1b[31m"; const red = "\x1b[31m";
const bold = "\x1b[1m"; const bold = "\x1b[1m";
const dim = "\x1b[2m"; const dim = "\x1b[2m";

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"scripts": { "scripts": {
"test": "node --test tests/*.test.mjs" "test": "node --test tests/*.test.mjs"

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -4,7 +4,7 @@
"type": "module", "type": "module",
"description": "cmux integration library — used by other extensions, not an extension itself", "description": "cmux integration library — used by other extensions, not an extension itself",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": {} "pi": {}
} }

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -1,6 +1,6 @@
{ {
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
} }
} }

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -334,7 +334,7 @@ export function syncProjectRootToWorktree(
} }
// Always clean up WAL/SHM sidecar files when the main DB was deleted // Always clean up WAL/SHM sidecar files when the main DB was deleted
// or is already missing. Orphaned WAL/SHM files cause SQLite WAL // or is already missing. Orphaned WAL/SHM files cause SQLite WAL
// recovery on next open, which triggers a CPU spin on Node 24's // recovery on next open, which triggers a CPU spin on Node 26's
// node:sqlite DatabaseSync implementation (#2478). // node:sqlite DatabaseSync implementation (#2478).
if (deleteSidecars) { if (deleteSidecars) {
for (const suffix of ["-wal", "-shm"]) { for (const suffix of ["-wal", "-shm"]) {

View file

@ -15,6 +15,14 @@
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level * auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
* `let` or `var` declarations. * `let` or `var` declarations.
*/ */
import {
buildModeState,
resolveModelMode,
resolvePermissionProfile,
resolveRunControlMode,
resolveWorkMode,
} from "../operating-model.js";
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
export const MAX_UNIT_DISPATCHES = 3; export const MAX_UNIT_DISPATCHES = 3;
export const STUB_RECOVERY_THRESHOLD = 2; export const STUB_RECOVERY_THRESHOLD = 2;
@ -48,6 +56,31 @@ export class AutoSession {
activeEngineId = null; activeEngineId = null;
activeRunDir = null; activeRunDir = null;
cmdCtx = null; cmdCtx = null;
// ── Mode state ───────────────────────────────────────────────────────────
/**
* Current work mode: chat | plan | build | review | repair | research.
* Defaults to "chat" for new sessions.
*/
workMode = "chat";
/**
* Current permission profile: restricted | normal | trusted | unrestricted.
* Defaults to "restricted" for safety.
*/
permissionProfile = "restricted";
/**
* Current model mode: fast | smart | deep.
* Defaults to "smart".
*/
modelMode = "smart";
/**
* Surface identifier: tui | web | headless | rpc.
* Defaults to "tui".
*/
surface = "tui";
/**
* ISO timestamp of last mode transition.
*/
modeUpdatedAt = null;
// ── Paths ──────────────────────────────────────────────────────────────── // ── Paths ────────────────────────────────────────────────────────────────
basePath = ""; basePath = "";
originalBasePath = ""; originalBasePath = "";
@ -224,6 +257,12 @@ export class AutoSession {
this.activeEngineId = null; this.activeEngineId = null;
this.activeRunDir = null; this.activeRunDir = null;
this.cmdCtx = null; this.cmdCtx = null;
// Mode state
this.workMode = "chat";
this.permissionProfile = "restricted";
this.modelMode = "smart";
this.surface = "tui";
this.modeUpdatedAt = null;
// Paths // Paths
this.basePath = ""; this.basePath = "";
this.originalBasePath = ""; this.originalBasePath = "";
@ -296,6 +335,47 @@ export class AutoSession {
this.sigtermHandler = null; this.sigtermHandler = null;
// Loop promise state lives in auto-loop.ts module scope // Loop promise state lives in auto-loop.ts module scope
} }
/**
* Update mode state with validation and timestamp.
*
* Purpose: centralize mode transitions so every change is logged and
* validated against canonical vocabulary.
*
* Consumer: command handlers and auto health gates.
*/
setMode({
workMode,
runControl,
permissionProfile,
modelMode,
surface,
} = {}) {
const prev = this.getMode();
if (workMode !== undefined) this.workMode = resolveWorkMode(workMode);
if (runControl !== undefined) {
const mode = resolveRunControlMode(runControl);
this.stepMode = mode === "assisted";
}
if (permissionProfile !== undefined) {
this.permissionProfile = resolvePermissionProfile(permissionProfile);
}
if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode);
if (surface !== undefined) this.surface = surface;
this.modeUpdatedAt = new Date().toISOString();
return { from: prev, to: this.getMode() };
}
/**
* Get current mode state as a canonical object.
*/
getMode() {
return buildModeState({
workMode: this.workMode,
runControl: this.stepMode ? "assisted" : this.active ? "autonomous" : "manual",
permissionProfile: this.permissionProfile,
modelMode: this.modelMode,
surface: this.surface,
});
}
toJSON() { toJSON() {
return { return {
active: this.active, active: this.active,
@ -307,6 +387,7 @@ export class AutoSession {
currentMilestoneId: this.currentMilestoneId, currentMilestoneId: this.currentMilestoneId,
currentUnit: this.currentUnit, currentUnit: this.currentUnit,
unitDispatchCount: Object.fromEntries(this.unitDispatchCount), unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
mode: this.getMode(),
}; };
} }
} }

View file

@ -318,7 +318,7 @@ test("writeFallbackChains warns via log when project-level .sf/agent/settings.js
} }
}); });
test("writeFallbackChains emits Kimi K2.6 through the Kimi Code wire route", () => { test("writeFallbackChains emits canonical Kimi K2.6 on the Kimi Code provider", () => {
const { dir, settingsPath } = makeTempSettingsDir(); const { dir, settingsPath } = makeTempSettingsDir();
try { try {
// Deps deliberately minimal — no overrides, no enabledModels — so // Deps deliberately minimal — no overrides, no enabledModels — so
@ -334,8 +334,7 @@ test("writeFallbackChains emits Kimi K2.6 through the Kimi Code wire route", ()
assert.equal(mainChain.length, 1, "main chain has exactly 1 direct entry"); assert.equal(mainChain.length, 1, "main chain has exactly 1 direct entry");
assert.equal(mainChain[0].provider, "kimi-coding"); assert.equal(mainChain[0].provider, "kimi-coding");
// Provider wire ID for Kimi K2.6. assert.equal(mainChain[0].model, "kimi-k2.6");
assert.equal(mainChain[0].model, "kimi-for-coding");
assert.equal(mainChain[0].priority, 0); assert.equal(mainChain[0].priority, 0);
assert.ok( assert.ok(
@ -379,7 +378,7 @@ test("hardcoded main chain coexists with blender-computed per-unit-type chains",
assert.ok(Array.isArray(chains.main), "main chain present"); assert.ok(Array.isArray(chains.main), "main chain present");
assert.equal(chains.main.length, 1); assert.equal(chains.main.length, 1);
assert.equal(chains.main[0].provider, "kimi-coding"); assert.equal(chains.main[0].provider, "kimi-coding");
assert.equal(chains.main[0].model, "kimi-for-coding"); assert.equal(chains.main[0].model, "kimi-k2.6");
// Blender-computed per-unit-type chain also present // Blender-computed per-unit-type chain also present
assert.ok(Array.isArray(chains.planning), "planning chain present"); assert.ok(Array.isArray(chains.planning), "planning chain present");

View file

@ -279,7 +279,7 @@ test("registerRoutingHook: registers handler + reload command and routes a simul
const pi = makeFakePi(); const pi = makeFakePi();
// Route the DB to a non-existent path so the lazy open returns null and // Route the DB to a non-existent path so the lazy open returns null and
// the handler runs in priors-only mode (no better-sqlite3 dependency). // the handler runs in priors-only mode (no persisted DB dependency).
registerRoutingHook(pi, { registerRoutingHook(pi, {
dbPath: "/tmp/sf-learning-test-nonexistent.db", dbPath: "/tmp/sf-learning-test-nonexistent.db",
notify: true, notify: true,

View file

@ -4,7 +4,7 @@
* Wires together the four S01-S04 modules into a single registerable plugin: * Wires together the four S01-S04 modules into a single registerable plugin:
* *
* loadCapabilityOverrides priors (per (unit_type, model)) * loadCapabilityOverrides priors (per (unit_type, model))
* outcome-recorder write llm_task_outcomes rows * sf-db outcome writer write llm_task_outcomes rows
* outcome-aggregator rolling-window observed stats * outcome-aggregator rolling-window observed stats
* bayesian-blender α · prior + (1-α) · observed + UCB1 * bayesian-blender α · prior + (1-α) · observed + UCB1
* hook-handler translates the above into a before_model_select handler * hook-handler translates the above into a before_model_select handler
@ -13,7 +13,6 @@
* *
* import { init } from "./index.mjs"; * import { init } from "./index.mjs";
* const plugin = await init(pi, { * const plugin = await init(pi, {
* dbPath: "~/.sf/sf-learning.db",
* priorsPath: "./src/data/model-benchmarks.json", * priorsPath: "./src/data/model-benchmarks.json",
* weightsPath: "./src/data/unit-weights.json", * weightsPath: "./src/data/unit-weights.json",
* nPrior: 10, * nPrior: 10,
@ -25,23 +24,27 @@
* // plugin.unregister() on tear down * // plugin.unregister() on tear down
* *
* ## Side effects * ## Side effects
* - Opens (or creates) a SQLite database at the resolved dbPath * - Uses the already-open `.sf/sf.db`, or opens `<basePath>/.sf/sf.db`
* - Bootstraps the schema if absent * - Relies on the shared SF DB bootstrap for `llm_task_outcomes`
* - Registers a hook on the supplied pi instance * - Registers a hook on the supplied pi instance
* *
* ## Errors * ## Errors
* - Init failures are wrapped with a stage label so callers can see where * - Init failures are wrapped with a stage label so callers can see where
* things broke ("loading priors", "opening db", "applying schema", * things broke ("loading priors", "opening db", "registering hook")
* "registering hook")
* - Once init succeeds, the running handler is fire-and-forget it cannot * - Once init succeeds, the running handler is fire-and-forget it cannot
* crash the dispatch path * crash the dispatch path
* *
* @module sf-learning * @module sf-learning
*/ */
import { readFileSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { resolve } from "node:path"; import { dirname, join } from "node:path";
import {
getDatabase,
insertLlmTaskOutcome,
openDatabase as openSfDatabase,
} from "../sf-db.js";
import { writeFallbackChains } from "./fallback-chain-writer.mjs"; import { writeFallbackChains } from "./fallback-chain-writer.mjs";
import { import {
createBeforeModelSelectHandler, createBeforeModelSelectHandler,
@ -49,26 +52,24 @@ import {
} from "./hook-handler.mjs"; } from "./hook-handler.mjs";
import { loadCapabilityOverrides } from "./loadCapabilityOverrides.mjs"; import { loadCapabilityOverrides } from "./loadCapabilityOverrides.mjs";
import { aggregateAllForUnitType } from "./outcome-aggregator.mjs"; import { aggregateAllForUnitType } from "./outcome-aggregator.mjs";
import { ensureSchema, recordOutcome } from "./outcome-recorder.mjs";
const MODULE_DIRECTORY = import.meta.dirname;
const SCHEMA_PATH = resolve(MODULE_DIRECTORY, "outcome-schema.sql");
const DEFAULT_DB_PATH = "~/.sf/sf-learning.db";
const DEFAULT_N_PRIOR = 10; const DEFAULT_N_PRIOR = 10;
const DEFAULT_ROLLING_DAYS = 30; const DEFAULT_ROLLING_DAYS = 30;
const DEFAULT_EXPLORATION_C = 1.4; const DEFAULT_EXPLORATION_C = 1.4;
const DEFAULT_DB_SUBPATH = [".sf", "sf.db"];
const HOME_REGEX = /^~(?=$|\/)/; const HOME_REGEX = /^~(?=$|\/)/;
/** /**
* @typedef {Object} PluginConfig * @typedef {Object} PluginConfig
* @property {string} [dbPath] - default: ~/.sf/sf-learning.db * @property {string} [basePath] - default: process.cwd()
* @property {string} [dbPath] - default: <basePath>/.sf/sf.db
* @property {string} [priorsPath] - default: <plugin>/data/model-benchmarks.json * @property {string} [priorsPath] - default: <plugin>/data/model-benchmarks.json
* @property {string} [weightsPath] - default: <plugin>/data/unit-weights.json * @property {string} [weightsPath] - default: <plugin>/data/unit-weights.json
* @property {number} [nPrior=10] * @property {number} [nPrior=10]
* @property {number} [rollingDays=30] * @property {number} [rollingDays=30]
* @property {number} [explorationC=1.4] * @property {number} [explorationC=1.4]
* @property {boolean} [explorationEnabled=true] * @property {boolean} [explorationEnabled=true]
* @property {Object} [db] - pre-opened db handle (overrides dbPath) * @property {Object} [db] - pre-opened db handle for hook reads
* @property {(msg: string) => void} [log] * @property {(msg: string) => void} [log]
*/ */
@ -92,93 +93,45 @@ function expandPath(path) {
} }
/** /**
* Load the outcome-schema SQL file. Read once at init time; cheap. * Resolve the shared SF database path for learning state.
* *
* @returns {string} * @returns {string}
*/ */
function loadSchemaSql() { function defaultDbPath(config) {
return readFileSync(SCHEMA_PATH, "utf8"); return join(config.basePath ?? process.cwd(), ...DEFAULT_DB_SUBPATH);
} }
/** /**
* Detect whether we're running under Bun. better-sqlite3 is a Node native * Resolve the learning outcomes database from SF's shared database handle.
* addon and Bun has not shipped compatibility yet (tracked upstream in
* https://github.com/oven-sh/bun/issues/4290), so under Bun we use the
* built-in `bun:sqlite` module instead its Statement API (`prepare`,
* `run`, `get`, `all`, `exec`, `transaction`) is a drop-in superset of the
* surface this plugin consumes.
* *
* @returns {boolean} * Purpose: keep UOK outcome learning, model stats, and learned routing on one
*/ * `.sf/sf.db` ledger instead of splitting feedback into a sidecar database.
function isBunRuntime() {
return typeof globalThis.Bun !== "undefined";
}
/**
* Dynamically import bun's built-in sqlite module. Only callable under Bun
* the import specifier `bun:sqlite` throws under Node.
*
* @returns {Promise<Function|null>}
*/
async function tryImportBunSqlite() {
try {
const mod = await import("bun:sqlite");
return mod.Database ?? mod.default ?? null;
} catch (_err) {
return null;
}
}
/**
* Dynamically import better-sqlite3. Returns null if the package is not
* installed so we can produce a clear error rather than an opaque module
* resolution failure.
*
* @returns {Promise<Function|null>} the better-sqlite3 default export, or null
*/
async function tryImportBetterSqlite() {
try {
const mod = await import("better-sqlite3");
return mod.default ?? mod;
} catch (_err) {
return null;
}
}
/**
* Open a database handle, either from the caller-supplied one or by
* dynamically loading a sqlite binding. Prefers `bun:sqlite` when running
* under Bun (better-sqlite3 is a Node native addon that Bun can't load),
* and falls back to `better-sqlite3` everywhere else.
* *
* @param {PluginConfig} config * @param {PluginConfig} config
* @returns {Promise<Object>} duck-typed sqlite handle * @returns {Object} duck-typed sqlite handle
*/ */
async function openDatabase(config) { function openDatabase(config) {
if (config.db) { if (config.db) {
return config.db; return config.db;
} }
const dbPath = expandPath(config.dbPath ?? DEFAULT_DB_PATH); const activeDb = getDatabase();
if (activeDb) {
if (isBunRuntime()) { return activeDb;
const BunDatabase = await tryImportBunSqlite();
if (!BunDatabase) {
throw new Error(
"sf-learning is running under Bun but failed to import `bun:sqlite`. This module ships with Bun itself — if this fails the Bun install is broken.",
);
}
return new BunDatabase(dbPath);
} }
const Database = await tryImportBetterSqlite(); const dbPath = expandPath(config.dbPath ?? defaultDbPath(config));
if (!Database) { if (dbPath !== ":memory:") {
throw new Error( mkdirSync(dirname(dbPath), { recursive: true });
"sf-learning needs better-sqlite3 to open the outcomes database. Install it with `npm install better-sqlite3` or `bun add better-sqlite3`, or pass a pre-opened db handle via config.db.",
);
} }
if (!openSfDatabase(dbPath)) {
return new Database(dbPath); throw new Error(`failed to open shared SF database at ${dbPath}`);
}
const db = getDatabase();
if (!db) {
throw new Error(`shared SF database did not become available at ${dbPath}`);
}
return db;
} }
/** /**
@ -225,7 +178,7 @@ function wrapInitError(stage, err) {
} }
/** /**
* Initialize the plugin: load priors, open db, bootstrap schema, register hook. * Initialize the plugin: load priors, resolve shared db, register hook.
* *
* @param {Object} pi * @param {Object} pi
* @param {PluginConfig} [config={}] * @param {PluginConfig} [config={}]
@ -249,13 +202,6 @@ export async function init(pi, config = {}) {
throw wrapInitError("opening db", err); throw wrapInitError("opening db", err);
} }
try {
const schemaSql = loadSchemaSql();
ensureSchema(db, schemaSql);
} catch (err) {
throw wrapInitError("applying schema", err);
}
const deps = buildHookDeps(db, priors, config); const deps = buildHookDeps(db, priors, config);
let unregister; let unregister;
@ -293,7 +239,7 @@ export async function init(pi, config = {}) {
return { return {
unregister, unregister,
fallbackWriteSummary, fallbackWriteSummary,
recordOutcome: (outcome) => recordOutcome(db, outcome), recordOutcome: (outcome) => insertLlmTaskOutcome(outcome),
reloadPriors: async () => { reloadPriors: async () => {
const fresh = await loadCapabilityOverrides({ const fresh = await loadCapabilityOverrides({
benchmarksPath: config.priorsPath, benchmarksPath: config.priorsPath,

View file

@ -0,0 +1,63 @@
/**
* sf-learning plugin entrypoint tests.
*
* Exercises init-time wiring against a real Node built-in SQLite database so
* the plugin cannot regress to an undeclared external SQLite binding.
*/
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import { closeDatabase } from "../sf-db.js";
import { init } from "./index.mjs";
function createMockPi() {
const handlers = [];
return {
handlers,
on(event, handler) {
if (event === "before_model_select") handlers.push(handler);
},
off(event, handler) {
if (event !== "before_model_select") return;
const index = handlers.indexOf(handler);
if (index >= 0) handlers.splice(index, 1);
},
};
}
test("init_when_no_db_is_injected_uses_shared_sf_database", async () => {
const tmp = mkdtempSync(join(tmpdir(), "sf-learning-"));
const dbPath = join(tmp, ".sf", "sf.db");
let plugin;
try {
plugin = await init(createMockPi(), {
basePath: tmp,
explorationEnabled: false,
});
assert.equal(existsSync(dbPath), true);
assert.equal(
plugin.recordOutcome({
modelId: "test/model",
provider: "test",
unitType: "execute-task",
unitId: "M001/S01/T01",
succeeded: true,
}),
true,
);
const row = plugin.deps.db
.prepare("SELECT COUNT(*) AS count FROM llm_task_outcomes")
.get();
assert.equal(row.count, 1);
} finally {
plugin?.unregister?.();
closeDatabase();
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -19,13 +19,13 @@ import {
} from "./hook-handler.mjs"; } from "./hook-handler.mjs";
/** /**
* Fake in-memory db that mimics enough of better-sqlite3 for * Fake in-memory db that mimics enough of node:sqlite DatabaseSync for
* outcome-recorder + outcome-aggregator to operate against array-backed rows. * outcome-recorder + outcome-aggregator to operate against array-backed rows.
* *
* The aggregator runs SELECT ... GROUP BY model_id; rather than implementing a * The aggregator runs SELECT ... GROUP BY model_id; rather than implementing a
* SQL parser, we recognize each statement by regex and compute the aggregate * SQL parser, we recognize each statement by regex and compute the aggregate
* in JavaScript. This is sufficient for these tests and isolates them from a * in JavaScript. This is sufficient for these tests and isolates them from a
* real native dependency. * real file-backed database.
*/ */
function createFakeDb() { function createFakeDb() {
const rows = []; const rows = [];

View file

@ -12,7 +12,8 @@
* *
* ## Dependencies * ## Dependencies
* - Duck-typed SQLite handle exposing `prepare(sql).get(...params)` and * - Duck-typed SQLite handle exposing `prepare(sql).get(...params)` and
* `prepare(sql).all(...params)`. Compatible with `better-sqlite3`. * `prepare(sql).all(...params)`. Compatible with Node's `node:sqlite`
* DatabaseSync.
* *
* ## Contract * ## Contract
* - All SQL is parameterized no string interpolation of caller input. * - All SQL is parameterized no string interpolation of caller input.

View file

@ -10,4 +10,3 @@ export declare function recordOutcomeBatch(
db: unknown, db: unknown,
outcomes: Array<Record<string, unknown>>, outcomes: Array<Record<string, unknown>>,
): number; ): number;
export declare function ensureSchema(db: unknown, schemaSql?: string): void;

View file

@ -6,7 +6,6 @@
* ## Responsibilities * ## Responsibilities
* - Validate outcome shape before insertion * - Validate outcome shape before insertion
* - Insert one or many outcomes via parameterized SQL * - Insert one or many outcomes via parameterized SQL
* - Bootstrap the schema on a fresh database
* *
* ## Contract fire-and-forget * ## Contract fire-and-forget
* `recordOutcome` and `recordOutcomeBatch` must NEVER throw. They catch * `recordOutcome` and `recordOutcomeBatch` must NEVER throw. They catch
@ -17,7 +16,7 @@
* ## Dependencies * ## Dependencies
* - Duck-typed SQLite handle exposing `prepare(sql).run(...params)`, * - Duck-typed SQLite handle exposing `prepare(sql).run(...params)`,
* `prepare(sql).get(...params)`, `prepare(sql).all(...params)` and * `prepare(sql).get(...params)`, `prepare(sql).all(...params)` and
* ideally `exec(sql)`. Compatible with `better-sqlite3`. * ideally `exec(sql)`. Compatible with Node's `node:sqlite` DatabaseSync.
* - No hard import of any SQLite library keeps this module standalone * - No hard import of any SQLite library keeps this module standalone
* and unit-testable with an in-memory fake. * and unit-testable with an in-memory fake.
* *
@ -233,8 +232,9 @@ export function recordOutcome(db, outcome) {
* Record many outcomes in a single transaction. Fire-and-forget never throws. * Record many outcomes in a single transaction. Fire-and-forget never throws.
* *
* Invalid rows are skipped and counted; valid rows are inserted. If the * Invalid rows are skipped and counted; valid rows are inserted. If the
* database supports `transaction()` (better-sqlite3 style), the inserts run * database supports `transaction()`, the inserts run inside it. With
* inside it; otherwise they run sequentially. * `node:sqlite`, batches are wrapped in an explicit SQL transaction to avoid
* repeated writer-lock churn.
* *
* @param {object} db Duck-typed SQLite handle * @param {object} db Duck-typed SQLite handle
* @param {Outcome[]} outcomes * @param {Outcome[]} outcomes
@ -273,6 +273,23 @@ export function recordOutcomeBatch(db, outcomes) {
if (typeof db.transaction === "function") { if (typeof db.transaction === "function") {
const txn = db.transaction(insertAll); const txn = db.transaction(insertAll);
txn(); txn();
} else if (typeof db.exec === "function") {
let began = false;
try {
db.exec("BEGIN IMMEDIATE");
began = true;
insertAll();
db.exec("COMMIT");
} catch (err) {
if (began) {
try {
db.exec("ROLLBACK");
} catch (_rollbackErr) {
// Preserve the original failure for the outer catch.
}
}
throw err;
}
} else { } else {
insertAll(); insertAll();
} }
@ -284,43 +301,3 @@ export function recordOutcomeBatch(db, outcomes) {
return result; return result;
} }
/**
* Bootstrap the schema on a fresh database. Fire-and-forget never throws.
*
* Uses `db.exec(sql)` if available (better-sqlite3 style) so multi-statement
* DDL works in one call. Otherwise splits on `;` and runs each statement
* via `db.prepare(stmt).run()`.
*
* @param {object} db Duck-typed SQLite handle
* @param {string} schemaSql Raw schema SQL (CREATE TABLE / CREATE INDEX ...)
* @returns {boolean} true if schema applied, false on error
*
* @example
* import {readFileSync} from "node:fs";
* const sql = readFileSync(new URL("./outcome-schema.sql", import.meta.url), "utf8");
* ensureSchema(db, sql);
*/
export function ensureSchema(db, schemaSql) {
if (typeof schemaSql !== "string" || schemaSql.length === 0) {
return false;
}
try {
if (typeof db.exec === "function") {
db.exec(schemaSql);
return true;
}
const statements = schemaSql
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith("--"));
for (const stmt of statements) {
db.prepare(stmt).run();
}
return true;
} catch (_err) {
return false;
}
}

View file

@ -2,7 +2,7 @@
* sf-learning: outcome-recorder + outcome-aggregator tests * sf-learning: outcome-recorder + outcome-aggregator tests
* *
* Uses node:test with a minimal in-memory fake `db` that mimics the * Uses node:test with a minimal in-memory fake `db` that mimics the
* better-sqlite3 surface (`prepare(sql).run/get/all`, `exec`, * node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
* `transaction`). The fake parses just enough SQL to verify the * `transaction`). The fake parses just enough SQL to verify the
* insert and aggregate semantics without spinning up real SQLite. * insert and aggregate semantics without spinning up real SQLite.
*/ */
@ -16,14 +16,13 @@ import {
totalSamples, totalSamples,
} from "./outcome-aggregator.mjs"; } from "./outcome-aggregator.mjs";
import { import {
ensureSchema,
recordOutcome, recordOutcome,
recordOutcomeBatch, recordOutcomeBatch,
validateOutcome, validateOutcome,
} from "./outcome-recorder.mjs"; } from "./outcome-recorder.mjs";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Minimal in-memory fake of better-sqlite3 // Minimal in-memory fake of the SQLite surface consumed by sf-learning.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const INSERT_COLUMNS = [ const INSERT_COLUMNS = [
@ -42,8 +41,9 @@ const INSERT_COLUMNS = [
"recorded_at", "recorded_at",
]; ];
function createFakeDb({ throwOnPrepare = false } = {}) { function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) {
const rows = []; const rows = [];
const execSql = [];
let nextId = 1; let nextId = 1;
function prepare(sql) { function prepare(sql) {
@ -92,7 +92,6 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
}; };
} }
// CREATE TABLE / CREATE INDEX from ensureSchema fallback path
if ( if (
normalized.startsWith("create table") || normalized.startsWith("create table") ||
normalized.startsWith("create index") normalized.startsWith("create index")
@ -107,7 +106,8 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
throw new Error(`fake db: unsupported sql: ${normalized.slice(0, 80)}`); throw new Error(`fake db: unsupported sql: ${normalized.slice(0, 80)}`);
} }
function exec(_sql) { function exec(sql) {
execSql.push(sql);
// no-op — schema bootstrap success path // no-op — schema bootstrap success path
} }
@ -117,12 +117,16 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
}; };
} }
return { const db = {
prepare, prepare,
exec, exec,
transaction,
_rows: rows, _rows: rows,
_execSql: execSql,
}; };
if (includeTransaction) {
db.transaction = transaction;
}
return db;
} }
function runAggregate(sql, params, rows) { function runAggregate(sql, params, rows) {
@ -363,30 +367,14 @@ test("recordOutcomeBatch handles empty array", () => {
assert.deepEqual(result, { inserted: 0, skipped: 0 }); assert.deepEqual(result, { inserted: 0, skipped: 0 });
}); });
// --------------------------------------------------------------------------- test("recordOutcomeBatch_when_no_transaction_helper_wraps_batch_with_sql_transaction", () => {
// ensureSchema const db = createFakeDb({ includeTransaction: false });
// --------------------------------------------------------------------------- const result = recordOutcomeBatch(db, [
minimalOutcome({ unitId: "T01" }),
test("ensureSchema returns true via db.exec path", () => { minimalOutcome({ unitId: "T02" }),
const db = createFakeDb(); ]);
const ok = ensureSchema(db, "CREATE TABLE foo (x INTEGER);"); assert.deepEqual(result, { inserted: 2, skipped: 0 });
assert.equal(ok, true); assert.deepEqual(db._execSql, ["BEGIN IMMEDIATE", "COMMIT"]);
});
test("ensureSchema returns false on empty input", () => {
const db = createFakeDb();
assert.equal(ensureSchema(db, ""), false);
assert.equal(ensureSchema(db, null), false);
});
test("ensureSchema falls back to per-statement prepare when no exec()", () => {
const db = createFakeDb();
delete db.exec;
const ok = ensureSchema(
db,
"CREATE TABLE foo (x INTEGER); CREATE INDEX idx_foo ON foo(x);",
);
assert.equal(ok, true);
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -1,30 +0,0 @@
-- sf-learning: llm_task_outcomes
-- Records per-unit LLM dispatch outcomes for Bayesian learning.
-- Shape is compatible with ace-coder's approved 2026-03-06 design so
-- cross-project data sharing can happen later without migration pain.
CREATE TABLE IF NOT EXISTS llm_task_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id TEXT NOT NULL,
provider TEXT NOT NULL,
unit_type TEXT NOT NULL,
unit_id TEXT NOT NULL,
succeeded INTEGER NOT NULL CHECK (succeeded IN (0, 1)),
retries INTEGER NOT NULL DEFAULT 0,
escalated INTEGER NOT NULL DEFAULT 0 CHECK (escalated IN (0, 1)),
verification_passed INTEGER CHECK (verification_passed IS NULL OR verification_passed IN (0, 1)),
blocker_discovered INTEGER NOT NULL DEFAULT 0 CHECK (blocker_discovered IN (0, 1)),
duration_ms INTEGER,
tokens_total INTEGER,
cost_usd REAL,
recorded_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outcomes_model_unit_time
ON llm_task_outcomes (model_id, unit_type, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_outcomes_unit_time
ON llm_task_outcomes (unit_type, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_outcomes_provider_time
ON llm_task_outcomes (provider, recorded_at DESC);

View file

@ -8,6 +8,15 @@
* that need stable product terms. * that need stable product terms.
*/ */
export const WORK_MODES = Object.freeze([
"chat",
"plan",
"build",
"review",
"repair",
"research",
]);
export const RUN_CONTROL_MODES = Object.freeze([ export const RUN_CONTROL_MODES = Object.freeze([
"manual", "manual",
"assisted", "assisted",
@ -21,6 +30,23 @@ export const PERMISSION_PROFILES = Object.freeze([
"unrestricted", "unrestricted",
]); ]);
export const MODEL_MODES = Object.freeze([
"fast",
"smart",
"deep",
]);
/**
* Returns true for a canonical SF work mode.
*
* Purpose: let surfaces reject aliases before they leak into state.
*
* Consumer: command parsers and session state builders.
*/
export function isWorkMode(value) {
return WORK_MODES.includes(value);
}
/** /**
* Returns true for a canonical SF run-control mode. * Returns true for a canonical SF run-control mode.
* *
@ -44,6 +70,28 @@ export function isPermissionProfile(value) {
return PERMISSION_PROFILES.includes(value); return PERMISSION_PROFILES.includes(value);
} }
/**
* Returns true for a canonical SF model mode.
*
* Purpose: let routing and command surfaces reject invalid model mode names.
*
* Consumer: model selection and command parsers.
*/
export function isModelMode(value) {
return MODEL_MODES.includes(value);
}
/**
* Resolve an unknown work mode to the conservative chat mode.
*
* Purpose: fail closed when work mode is absent or misspelled.
*
* Consumer: session state construction and command handlers.
*/
export function resolveWorkMode(value) {
return isWorkMode(value) ? value : "chat";
}
/** /**
* Resolve an unknown run-control value to the conservative manual mode. * Resolve an unknown run-control value to the conservative manual mode.
* *
@ -67,6 +115,17 @@ export function resolvePermissionProfile(value) {
return isPermissionProfile(value) ? value : "restricted"; return isPermissionProfile(value) ? value : "restricted";
} }
/**
* Resolve an unknown model mode to smart.
*
* Purpose: provide a safe default when model mode is absent.
*
* Consumer: model routing and command handlers.
*/
export function resolveModelMode(value) {
return isModelMode(value) ? value : "smart";
}
/** /**
* Derive the UOK run-control mode from the live auto session state. * Derive the UOK run-control mode from the live auto session state.
* *
@ -90,3 +149,50 @@ export function runControlModeForSession(session) {
export function defaultPermissionProfileForRunControl(mode) { export function defaultPermissionProfileForRunControl(mode) {
return resolveRunControlMode(mode) === "manual" ? "restricted" : "normal"; return resolveRunControlMode(mode) === "manual" ? "restricted" : "normal";
} }
/**
* Choose the default model mode for a work mode.
*
* Purpose: guide model routing without replacing explicit model selection.
*
* Consumer: model routing and session initialization.
*/
export function defaultModelModeForWorkMode(workMode) {
switch (resolveWorkMode(workMode)) {
case "plan":
case "review":
case "research":
return "deep";
case "build":
case "repair":
return "smart";
case "chat":
default:
return "fast";
}
}
/**
* Build a canonical mode state object.
*
* Purpose: standardize the five orthogonal axes so every surface stores
* and displays the same shape.
*
* Consumer: session state, TUI badges, command handlers, audit logs.
*/
export function buildModeState({
workMode = "chat",
runControl = "manual",
permissionProfile = "restricted",
modelMode = "smart",
surface = "tui",
} = {}) {
return {
workMode: resolveWorkMode(workMode),
runControl: resolveRunControlMode(runControl),
permissionProfile: resolvePermissionProfile(permissionProfile),
modelMode: resolveModelMode(modelMode),
surface,
updatedAt: new Date().toISOString(),
};
}

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -7,7 +7,6 @@
* Reads the same data sources as `scripts/parallel-monitor.mjs` but * Reads the same data sources as `scripts/parallel-monitor.mjs` but
* renders as a native pi-tui overlay with theme integration. * renders as a native pi-tui overlay with theme integration.
*/ */
import { spawn } from "node:child_process";
import { import {
closeSync, closeSync,
existsSync, existsSync,
@ -18,25 +17,19 @@ import {
statSync, statSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { Key, matchesKey } from "@singularity-forge/pi-tui"; import { Key, matchesKey } from "@singularity-forge/pi-tui";
import { formatDuration } from "../shared/mod.js"; import { formatDuration } from "../shared/mod.js";
import { formattedShortcutPair } from "./shortcut-defs.js"; import { formattedShortcutPair } from "./shortcut-defs.js";
// ─── Async SQLite Helper ────────────────────────────────────────────────── // ─── SQLite Helper ────────────────────────────────────────────────────────
function runSqliteAsync(dbPath, sql) { function queryRows(dbPath, sql, params = []) {
return new Promise((resolve) => { const db = new DatabaseSync(dbPath, { readOnly: true });
const child = spawn("sqlite3", [dbPath, sql], { timeout: 3000 }); try {
const chunks = []; return db.prepare(sql).all(...params).map((row) => ({ ...row }));
child.stdout.on("data", (chunk) => chunks.push(chunk)); } finally {
child.on("close", (code) => { db.close();
if (code !== 0) { }
resolve("");
} else {
resolve(Buffer.concat(chunks).toString("utf-8"));
}
});
child.on("error", () => resolve(""));
});
} }
// ─── Data Helpers ───────────────────────────────────────────────────────── // ─── Data Helpers ─────────────────────────────────────────────────────────
function readJsonSafe(filePath) { function readJsonSafe(filePath) {
@ -98,22 +91,28 @@ function discoverWorkers(basePath) {
} }
return [...mids].sort(); return [...mids].sort();
} }
async function querySliceProgress(basePath, mid) { function querySliceProgress(basePath, mid) {
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db"); const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
if (!existsSync(dbPath)) return []; if (!existsSync(dbPath)) return [];
try { try {
const sql = `SELECT s.id, s.status, COUNT(t.id), SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) FROM slices s LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id WHERE s.milestone_id='${mid}' GROUP BY s.id ORDER BY s.id`; return queryRows(
const out = (await runSqliteAsync(dbPath, sql)).trim(); dbPath,
if (!out) return []; `SELECT s.id AS id,
return out.split("\n").map((line) => { s.status AS status,
const [id, status, total, done] = line.split("|"); COUNT(t.id) AS total,
return { SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
id, FROM slices s
status, LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
total: parseInt(total, 10), WHERE s.milestone_id=?
done: parseInt(done || "0", 10), GROUP BY s.id
}; ORDER BY s.id`,
}); [mid],
).map((row) => ({
id: row.id,
status: row.status,
total: Number(row.total ?? 0),
done: Number(row.done ?? 0),
}));
} catch { } catch {
return []; return [];
} }
@ -141,17 +140,25 @@ function extractCostFromNdjson(basePath, mid) {
return 0; return 0;
} }
} }
async function queryRecentCompletions(basePath, mid) { function queryRecentCompletions(basePath, mid) {
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db"); const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
if (!existsSync(dbPath)) return []; if (!existsSync(dbPath)) return [];
try { try {
const sql = `SELECT id, slice_id, one_liner FROM tasks WHERE milestone_id='${mid}' AND status='complete' AND completed_at IS NOT NULL ORDER BY completed_at DESC LIMIT 5`; return queryRows(
const out = (await runSqliteAsync(dbPath, sql)).trim(); dbPath,
if (!out) return []; `SELECT id AS taskId,
return out.split("\n").map((line) => { slice_id AS sliceId,
const [taskId, sliceId, oneLiner] = line.split("|"); one_liner AS oneLiner
return `${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`; FROM tasks
}); WHERE milestone_id=?
AND status='complete'
AND completed_at IS NOT NULL
ORDER BY completed_at DESC
LIMIT 5`,
[mid],
).map((row) =>
`${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
);
} catch { } catch {
return []; return [];
} }

View file

@ -1,5 +1,5 @@
// SF Database Abstraction Layer // SF Database Abstraction Layer
// Provides a SQLite database via node:sqlite (Node >= 24 built-in). // Provides a SQLite database via node:sqlite (Node >= 26 built-in).
// //
// Exposes a unified sync API for decisions and requirements storage. // Exposes a unified sync API for decisions and requirements storage.
// Schema is initialized on first open with WAL mode for file-backed DBs. // Schema is initialized on first open with WAL mode for file-backed DBs.
@ -29,7 +29,7 @@ let loadAttempted = false;
function loadProvider() { function loadProvider() {
if (loadAttempted) return; if (loadAttempted) return;
loadAttempted = true; loadAttempted = true;
// node:sqlite is built-in in Node >= 24 // node:sqlite is built-in in Node >= 26
} }
function normalizeRow(row) { function normalizeRow(row) {
if (row == null) return undefined; if (row == null) return undefined;
@ -4860,7 +4860,7 @@ export function insertLlmTaskOutcome(input) {
":duration_ms": input.duration_ms ?? null, ":duration_ms": input.duration_ms ?? null,
":tokens_total": input.tokens_total ?? null, ":tokens_total": input.tokens_total ?? null,
":cost_usd": input.cost_usd ?? null, ":cost_usd": input.cost_usd ?? null,
":recorded_at": input.recorded_at, ":recorded_at": input.recorded_at ?? Date.now(),
}); });
return true; return true;
} catch { } catch {

View file

@ -18,7 +18,7 @@ import { dirname, join } from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10); const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10);
const HAS_SQLITE = NODE_VERSION >= 24; const HAS_SQLITE = NODE_VERSION >= 26;
// ─── Helpers ─────────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────────
@ -114,7 +114,7 @@ describe("buildMemoryLLMCall apiKey resolution", () => {
expect(result).toBeNull(); expect(result).toBeNull();
} }
: () => { : () => {
// Skip: requires node:sqlite (Node 24+) // Skip: requires node:sqlite (Node 26+)
}, },
); );
}); });
@ -132,7 +132,7 @@ describe("invalidateAllCaches", () => {
expect(() => invalidateAllCaches()).not.toThrow(); expect(() => invalidateAllCaches()).not.toThrow();
} }
: () => { : () => {
// Skip: requires node:sqlite (Node 24+) // Skip: requires node:sqlite (Node 26+)
}, },
); );
}); });
@ -152,7 +152,7 @@ describe("createMemory", () => {
expect(result).toBeNull(); expect(result).toBeNull();
} }
: () => { : () => {
// Skip: requires node:sqlite (Node 24+) // Skip: requires node:sqlite (Node 26+)
}, },
); );
}); });

View file

@ -1,17 +1,38 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, test } from "vitest"; import { describe, test } from "vitest";
import { import {
buildModeState,
defaultModelModeForWorkMode,
defaultPermissionProfileForRunControl, defaultPermissionProfileForRunControl,
isModelMode,
isPermissionProfile, isPermissionProfile,
isRunControlMode, isRunControlMode,
isWorkMode,
MODEL_MODES,
PERMISSION_PROFILES, PERMISSION_PROFILES,
RUN_CONTROL_MODES, resolveModelMode,
resolvePermissionProfile, resolvePermissionProfile,
resolveRunControlMode, resolveRunControlMode,
resolveWorkMode,
RUN_CONTROL_MODES,
WORK_MODES,
runControlModeForSession, runControlModeForSession,
} from "../operating-model.js"; } from "../operating-model.js";
describe("operating model vocabulary", () => { describe("operating model vocabulary", () => {
test("workModes_are_chat_plan_build_review_repair_research", () => {
assert.deepEqual(WORK_MODES, [
"chat",
"plan",
"build",
"review",
"repair",
"research",
]);
assert.equal(isWorkMode("chat"), true);
assert.equal(isWorkMode("debug"), false);
});
test("runControlModes_are_manual_assisted_autonomous_only", () => { test("runControlModes_are_manual_assisted_autonomous_only", () => {
assert.deepEqual(RUN_CONTROL_MODES, ["manual", "assisted", "autonomous"]); assert.deepEqual(RUN_CONTROL_MODES, ["manual", "assisted", "autonomous"]);
assert.equal(isRunControlMode("auto"), false); assert.equal(isRunControlMode("auto"), false);
@ -28,9 +49,17 @@ describe("operating model vocabulary", () => {
assert.equal(isPermissionProfile("autonomous"), false); assert.equal(isPermissionProfile("autonomous"), false);
}); });
test("modelModes_are_fast_smart_deep", () => {
assert.deepEqual(MODEL_MODES, ["fast", "smart", "deep"]);
assert.equal(isModelMode("smart"), true);
assert.equal(isModelMode("rush"), false);
});
test("resolvers_fail_closed", () => { test("resolvers_fail_closed", () => {
assert.equal(resolveWorkMode("debug"), "chat");
assert.equal(resolveRunControlMode("auto"), "manual"); assert.equal(resolveRunControlMode("auto"), "manual");
assert.equal(resolvePermissionProfile("full"), "restricted"); assert.equal(resolvePermissionProfile("full"), "restricted");
assert.equal(resolveModelMode("rush"), "smart");
}); });
test("session_step_bit_derives_assisted_or_autonomous_run_control", () => { test("session_step_bit_derives_assisted_or_autonomous_run_control", () => {
@ -43,4 +72,52 @@ describe("operating model vocabulary", () => {
assert.equal(defaultPermissionProfileForRunControl("assisted"), "normal"); assert.equal(defaultPermissionProfileForRunControl("assisted"), "normal");
assert.equal(defaultPermissionProfileForRunControl("manual"), "restricted"); assert.equal(defaultPermissionProfileForRunControl("manual"), "restricted");
}); });
test("default_model_mode_matches_work_mode_intent", () => {
assert.equal(defaultModelModeForWorkMode("plan"), "deep");
assert.equal(defaultModelModeForWorkMode("review"), "deep");
assert.equal(defaultModelModeForWorkMode("research"), "deep");
assert.equal(defaultModelModeForWorkMode("build"), "smart");
assert.equal(defaultModelModeForWorkMode("repair"), "smart");
assert.equal(defaultModelModeForWorkMode("chat"), "fast");
assert.equal(defaultModelModeForWorkMode("unknown"), "fast");
});
test("buildModeState_normalizes_all_axes", () => {
const state = buildModeState({
workMode: "build",
runControl: "autonomous",
permissionProfile: "trusted",
modelMode: "smart",
surface: "tui",
});
assert.equal(state.workMode, "build");
assert.equal(state.runControl, "autonomous");
assert.equal(state.permissionProfile, "trusted");
assert.equal(state.modelMode, "smart");
assert.equal(state.surface, "tui");
assert.ok(state.updatedAt);
});
test("buildModeState_fails_closed_on_invalid_values", () => {
const state = buildModeState({
workMode: "debug",
runControl: "auto",
permissionProfile: "full",
modelMode: "rush",
});
assert.equal(state.workMode, "chat");
assert.equal(state.runControl, "manual");
assert.equal(state.permissionProfile, "restricted");
assert.equal(state.modelMode, "smart");
});
test("buildModeState_has_safe_defaults", () => {
const state = buildModeState();
assert.equal(state.workMode, "chat");
assert.equal(state.runControl, "manual");
assert.equal(state.permissionProfile, "restricted");
assert.equal(state.modelMode, "smart");
assert.equal(state.surface, "tui");
});
}); });

View file

@ -0,0 +1,108 @@
/**
* uok-coordination-store.test.mjs SQLite-backed UOK coordination contracts.
*
* Purpose: prove UOK has Redis-like durable primitives without requiring a
* Redis server: TTL keys, append-only streams, and lease-based queues.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
import {
ensureCoordinationSchema,
UokCoordinationStore,
} from "../uok/coordination-store.js";
afterEach(() => {
closeDatabase();
});
function makeStore(now = () => 1_000) {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
return new UokCoordinationStore(db, { now });
}
test("ensureCoordinationSchema_creates_kv_stream_and_queue_tables", () => {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
ensureCoordinationSchema(db);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all()
.map((r) => r.name);
assert.ok(tables.includes("uok_kv"));
assert.ok(tables.includes("uok_stream_entries"));
assert.ok(tables.includes("uok_queue_items"));
});
test("kv_set_get_and_expire_behaves_like_ttl_store", () => {
let now = 1_000;
const store = makeStore(() => now);
store.set("lock:unit", { owner: "agent-a" }, { ttlMs: 100 });
assert.deepEqual(store.get("lock:unit"), { owner: "agent-a" });
now = 1_101;
assert.equal(store.get("lock:unit"), null);
});
test("kv_cleanupExpired_removes_expired_rows", () => {
let now = 1_000;
const store = makeStore(() => now);
store.set("a", 1, { ttlMs: 1 });
store.set("b", 2);
now = 1_002;
assert.equal(store.cleanupExpired(), 1);
assert.equal(store.get("a"), null);
assert.equal(store.get("b"), 2);
});
test("streams_append_and_read_after_id_in_order", () => {
const store = makeStore();
const first = store.xadd("events", "node-start", { id: "a" });
const second = store.xadd("events", "node-complete", { id: "a" });
assert.equal(first, 1);
assert.equal(second, 2);
assert.deepEqual(
store.xread("events", { afterId: 1 }).map((e) => e.type),
["node-complete"],
);
assert.deepEqual(store.xread("events").map((e) => e.payload.id), ["a", "a"]);
});
test("queue_claim_ack_and_release_enforces_owner_leases", () => {
let now = 1_000;
const store = makeStore(() => now);
store.enqueue("work", "task-1", { unitId: "T01" });
const claimed = store.claim("work", "agent-a", { leaseMs: 50 });
assert.equal(claimed.id, "task-1");
assert.equal(claimed.leaseOwner, "agent-a");
assert.equal(store.claim("work", "agent-b"), null);
assert.equal(store.ack("work", "task-1", "agent-b"), false);
assert.equal(store.release("work", "task-1", "agent-a"), true);
const reclaimed = store.claim("work", "agent-b", { leaseMs: 50 });
assert.equal(reclaimed.leaseOwner, "agent-b");
assert.equal(store.ack("work", "task-1", "agent-b"), true);
assert.equal(store.peekQueueItem("work", "task-1").status, "done");
now = 2_000;
assert.equal(store.reapExpiredLeases("work"), 0);
});
test("queue_reapExpiredLeases_returns_stale_leased_work_to_ready", () => {
let now = 1_000;
const store = makeStore(() => now);
store.enqueue("work", "task-1", { unitId: "T01" });
store.claim("work", "agent-a", { leaseMs: 10 });
now = 1_011;
assert.equal(store.reapExpiredLeases("work"), 1);
const claimed = store.claim("work", "agent-b", { leaseMs: 10 });
assert.equal(claimed.leaseOwner, "agent-b");
assert.equal(claimed.attempts, 2);
});

View file

@ -0,0 +1,320 @@
/**
* uok-execution-graph-persist.test.mjs Execution graph persistence contract tests.
*
* Purpose: verify SQLite schema creation, graph/node/progress CRUD, and
* query surfaces for /tasks display.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import {
closeDatabase,
getDatabase,
openDatabase,
} from "../sf-db.js";
import {
ensureExecutionGraphSchema,
getGraphProgressStream,
getGraphStateSummary,
persistFullGraph,
persistGraphNode,
persistGraphSnapshot,
persistProgressEvent,
queryTasksByState,
} from "../uok/execution-graph-persist.js";
afterEach(() => {
closeDatabase();
});
function makeDb() {
assert.equal(openDatabase(":memory:"), true);
return getDatabase();
}
// ─── Schema ────────────────────────────────────────────────────────────────
test("ensureExecutionGraphSchema_creates_tables_and_indexes", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all()
.map((r) => r.name);
assert.ok(tables.includes("uok_execution_graphs"));
assert.ok(tables.includes("uok_graph_nodes"));
assert.ok(tables.includes("uok_graph_progress"));
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index'")
.all()
.map((r) => r.name);
assert.ok(indexes.some((n) => n.includes("idx_graph_nodes_graph")));
assert.ok(indexes.some((n) => n.includes("idx_graph_nodes_status")));
});
// ─── Graph Snapshot ────────────────────────────────────────────────────────
test("persistGraphSnapshot_inserts_new_graph", () => {
const db = makeDb();
persistGraphSnapshot(db, {
id: "g1",
milestoneId: "M001",
sessionId: "sess-1",
phase: "executing",
nodeCount: 5,
createdAt: new Date().toISOString(),
status: "active",
});
const row = db
.prepare("SELECT * FROM uok_execution_graphs WHERE id = :id")
.get({ id: "g1" });
assert.equal(row.id, "g1");
assert.equal(row.milestone_id, "M001");
assert.equal(row.status, "active");
});
test("persistGraphSnapshot_updates_existing_graph", () => {
const db = makeDb();
const id = "g1";
persistGraphSnapshot(db, {
id,
milestoneId: "M001",
phase: "executing",
nodeCount: 5,
createdAt: new Date().toISOString(),
status: "active",
});
persistGraphSnapshot(db, {
id,
milestoneId: "M001",
phase: "completing-milestone",
nodeCount: 5,
createdAt: new Date().toISOString(),
status: "completed",
});
const row = db
.prepare("SELECT * FROM uok_execution_graphs WHERE id = :id")
.get({ id });
assert.equal(row.phase, "completing-milestone");
assert.equal(row.status, "completed");
assert.ok(row.completed_at);
});
// ─── Graph Node ────────────────────────────────────────────────────────────
test("persistGraphNode_inserts_node", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "M001.S01.T01",
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
title: "Implement auth",
dependsOn: ["n0"],
writes: ["src/auth.js"],
state: "todo",
});
const row = db
.prepare("SELECT * FROM uok_graph_nodes WHERE id = :id")
.get({ id: "n1" });
assert.equal(row.id, "n1");
assert.equal(row.unit_type, "execute-task");
assert.equal(row.status, "todo");
assert.equal(JSON.parse(row.depends_on)[0], "n0");
});
test("persistGraphNode_updates_existing_node", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "M001.S01.T01",
state: "todo",
});
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "M001.S01.T01",
state: "done",
workerId: "w-1",
durationMs: 1500,
gateResults: [{ outcome: "pass", gateId: "security" }],
});
const row = db
.prepare("SELECT * FROM uok_graph_nodes WHERE id = :id")
.get({ id: "n1" });
assert.equal(row.status, "done");
assert.equal(row.worker_id, "w-1");
assert.equal(row.duration_ms, 1500);
});
// ─── Full Graph Persistence ────────────────────────────────────────────────
test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => {
const db = makeDb();
const nodes = [
{ id: "n1", kind: "unit", unitType: "execute-task", unitId: "U1", state: "todo" },
{ id: "n2", kind: "unit", unitType: "execute-task", unitId: "U2", state: "todo" },
];
persistFullGraph(db, "g2", nodes, {
milestoneId: "M002",
phase: "executing",
});
const graph = db
.prepare("SELECT * FROM uok_execution_graphs WHERE id = :id")
.get({ id: "g2" });
assert.equal(graph.node_count, 2);
const nodeRows = db
.prepare("SELECT * FROM uok_graph_nodes WHERE graph_id = :id")
.all({ id: "g2" });
assert.equal(nodeRows.length, 2);
});
// ─── Query Tasks ───────────────────────────────────────────────────────────
test("queryTasksByState_filters_by_state", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "done" });
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "in_progress" });
const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] });
assert.equal(todo.length, 1);
assert.equal(todo[0].id, "n1");
const done = queryTasksByState(db, { graphId: "g1", states: ["done"] });
assert.equal(done.length, 1);
assert.equal(done[0].id, "n2");
});
test("queryTasksByState_filters_by_milestone", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", milestoneId: "M001", state: "todo" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", milestoneId: "M002", state: "todo" });
const m1 = queryTasksByState(db, { milestoneId: "M001" });
assert.equal(m1.length, 1);
assert.equal(m1[0].id, "n1");
});
test("queryTasksByState_parses_json_columns", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitId: "U1",
state: "done",
dependsOn: ["n0"],
writes: ["a.js", "b.js"],
gateResults: [{ outcome: "pass", gateId: "g1" }],
});
const tasks = queryTasksByState(db, { graphId: "g1" });
assert.equal(tasks[0].dependsOn.length, 1);
assert.equal(tasks[0].writes.length, 2);
assert.equal(tasks[0].gateResults.length, 1);
});
// ─── State Summary ─────────────────────────────────────────────────────────
test("getGraphStateSummary_computes_counts_and_progress", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "in_progress" });
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "done" });
persistGraphNode(db, "g1", { id: "n4", kind: "unit", unitId: "U4", state: "done" });
const summary = getGraphStateSummary(db, "g1");
assert.equal(summary.total, 4);
assert.equal(summary.terminal, 2);
assert.equal(summary.progress, 50);
assert.equal(summary.isComplete, false);
assert.equal(summary.counts.todo, 1);
assert.equal(summary.counts.in_progress, 1);
assert.equal(summary.counts.done, 2);
});
test("getGraphStateSummary_complete_when_all_terminal", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "done" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "failed" });
const summary = getGraphStateSummary(db, "g1");
assert.equal(summary.progress, 100);
assert.equal(summary.isComplete, true);
});
// ─── Progress Stream ───────────────────────────────────────────────────────
test("persistProgressEvent_inserts_event", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", {
type: "node-start",
nodeId: "n1",
ts: new Date().toISOString(),
});
const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events.length, 1);
assert.equal(events[0].type, "node-start");
assert.equal(events[0].node_id, "n1");
});
test("getGraphProgressStream_returns_recent_first", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", { type: "first", ts: "2024-01-01T00:00:00Z" });
persistProgressEvent(db, "g1", { type: "second", ts: "2024-01-01T00:00:01Z" });
const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events[0].type, "second");
assert.equal(events[1].type, "first");
});
test("getGraphProgressStream_parses_payload_json", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", {
type: "node-complete",
nodeId: "n1",
payload: JSON.stringify({ extra: "data" }),
ts: new Date().toISOString(),
});
const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events[0].payload.extra, "data");
});

View file

@ -0,0 +1,434 @@
/**
* uok-scheduler-v2.test.mjs ExecutionGraphSchedulerV2 contract tests.
*
* Purpose: verify cancellation tokens, progress streaming, worker pool
* enforcement, and task state integration.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
import {
getGraphProgressStream,
getGraphStateSummary,
queryTasksByState,
} from "../uok/execution-graph-persist.js";
import {
CancellationToken,
ExecutionGraphSchedulerV2,
ProgressStream,
WorkerPool,
} from "../uok/scheduler-v2.js";
afterEach(() => {
closeDatabase();
});
// ─── CancellationToken ─────────────────────────────────────────────────────
test("token_initially_not_cancelled", () => {
const token = new CancellationToken();
assert.equal(token.isCancelled, false);
assert.equal(token.reason, null);
});
test("token_cancel_sets_state", () => {
const token = new CancellationToken();
token.cancel("timeout");
assert.equal(token.isCancelled, true);
assert.equal(token.reason, "timeout");
});
test("token_cancel_idempotent", () => {
const token = new CancellationToken();
token.cancel("first");
token.cancel("second");
assert.equal(token.reason, "first");
});
test("token_throwIfCancelled_throws_when_cancelled", () => {
const token = new CancellationToken();
token.cancel("abort");
assert.throws(() => token.throwIfCancelled(), /abort/);
});
test("token_throwIfCancelled_does_not_throw_when_active", () => {
const token = new CancellationToken();
assert.doesNotThrow(() => token.throwIfCancelled());
});
test("token_onCancel_fires_immediately_if_already_cancelled", () => {
const token = new CancellationToken();
token.cancel("done");
let fired = false;
token.onCancel(() => { fired = true; });
assert.equal(fired, true);
});
test("token_onCancel_fires_when_cancelled_later", () => {
const token = new CancellationToken();
let fired = false;
token.onCancel(() => { fired = true; });
assert.equal(fired, false);
token.cancel("later");
assert.equal(fired, true);
});
test("token_onCancel_unsubscribe_works", () => {
const token = new CancellationToken();
let fired = false;
const unsub = token.onCancel(() => { fired = true; });
unsub();
token.cancel("nope");
assert.equal(fired, false);
});
// ─── ProgressStream ────────────────────────────────────────────────────────
test("stream_emit_delivers_to_listener", () => {
const stream = new ProgressStream();
const events = [];
stream.onProgress((ev) => events.push(ev));
stream.emit({ type: "test", data: 1 });
assert.equal(events.length, 1);
assert.equal(events[0].type, "test");
assert.ok(events[0].ts);
});
test("stream_replays_history_to_new_listener", () => {
const stream = new ProgressStream();
stream.emit({ type: "first" });
stream.emit({ type: "second" });
const events = [];
stream.onProgress((ev) => events.push(ev));
assert.equal(events.length, 2);
assert.equal(events[0].type, "first");
assert.equal(events[1].type, "second");
});
test("stream_history_capped_at_max", () => {
const stream = new ProgressStream();
// Private maxHistory = 100; emit 105 events
for (let i = 0; i < 105; i++) {
stream.emit({ type: `ev-${i}` });
}
const events = [];
stream.onProgress((ev) => events.push(ev));
assert.equal(events.length, 100);
assert.equal(events[0].type, "ev-5");
});
// ─── WorkerPool ────────────────────────────────────────────────────────────
test("pool_enforces_max_workers", async () => {
const pool = new WorkerPool(2);
const token1 = new CancellationToken();
const token2 = new CancellationToken();
const token3 = new CancellationToken();
const w1 = await pool.acquire("node-1", token1);
const w2 = await pool.acquire("node-2", token2);
// Third acquire should queue, not resolve immediately
let w3Resolved = false;
const w3Promise = pool.acquire("node-3", token3).then((w) => {
w3Resolved = true;
return w;
});
assert.equal(pool.activeCount, 2);
assert.equal(pool.queuedCount, 1);
assert.equal(w3Resolved, false);
w1.release();
const w3 = await w3Promise;
assert.equal(w3Resolved, true);
assert.equal(pool.activeCount, 2);
w2.release();
w3.release();
assert.equal(pool.activeCount, 0);
});
test("pool_cancel_queued_rejects_queued_acquire", async () => {
const pool = new WorkerPool(1);
const token1 = new CancellationToken();
const token2 = new CancellationToken();
const w1 = await pool.acquire("node-1", token1);
const acquire2 = pool.acquire("node-2", token2);
pool.cancelAll("drain");
await assert.rejects(acquire2, /drain/);
w1.release();
});
test("pool_getActive_returns_current_workers", async () => {
const pool = new WorkerPool(2);
const token = new CancellationToken();
const w = await pool.acquire("node-a", token);
const active = pool.getActive();
assert.equal(active.length, 1);
assert.equal(active[0].nodeId, "node-a");
assert.ok(active[0].durationMs >= 0);
w.release();
});
// ─── ExecutionGraphSchedulerV2 — Serial ────────────────────────────────────
test("scheduler_serial_runs_all_nodes_in_order", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const order = [];
scheduler.registerHandler("unit", async (node) => {
order.push(node.id);
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
{ id: "c", kind: "unit", dependsOn: ["b"], metadata: {} },
];
const result = await scheduler.run(nodes, { parallel: false });
assert.deepEqual(order, ["a", "b", "c"]);
assert.deepEqual(result.order, ["a", "b", "c"]);
});
test("scheduler_serial_respects_dependencies", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const order = [];
scheduler.registerHandler("unit", async (node) => {
order.push(node.id);
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: [], metadata: {} },
{ id: "c", kind: "unit", dependsOn: ["a", "b"], metadata: {} },
];
await scheduler.run(nodes, { parallel: false });
assert.ok(order.indexOf("c") > order.indexOf("a"));
assert.ok(order.indexOf("c") > order.indexOf("b"));
});
test("scheduler_serial_cancellation_stops_execution", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const order = [];
const globalToken = new CancellationToken();
scheduler.registerHandler("unit", async (node, token) => {
if (node.id === "b") globalToken.cancel("stop");
globalToken.throwIfCancelled();
order.push(node.id);
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
{ id: "c", kind: "unit", dependsOn: ["b"], metadata: {} },
];
await assert.rejects(
scheduler.run(nodes, { parallel: false, globalToken }),
/stop/,
);
assert.deepEqual(order, ["a"]);
});
// ─── ExecutionGraphSchedulerV2 — Parallel ──────────────────────────────────
test("scheduler_parallel_runs_independent_nodes_concurrently", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const starts = [];
scheduler.registerHandler("unit", async (node, token, progress) => {
starts.push(node.id);
await new Promise((r) => setTimeout(r, 50));
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: [], metadata: {} },
];
const result = await scheduler.run(nodes, { parallel: true, maxWorkers: 2 });
assert.equal(result.order.length, 2);
assert.ok(result.order.includes("a"));
assert.ok(result.order.includes("b"));
});
test("scheduler_parallel_enforces_max_workers", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
let concurrent = 0;
let maxConcurrent = 0;
scheduler.registerHandler("unit", async (node) => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 50));
concurrent--;
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: [], metadata: {} },
{ id: "c", kind: "unit", dependsOn: [], metadata: {} },
{ id: "d", kind: "unit", dependsOn: [], metadata: {} },
];
await scheduler.run(nodes, { parallel: true, maxWorkers: 2 });
assert.equal(maxConcurrent, 2);
});
test("scheduler_parallel_reports_progress_events", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => {
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
];
const events = [];
scheduler.progress.onProgress((ev) => events.push(ev));
await scheduler.run(nodes, { parallel: true, maxWorkers: 1 });
const startEvent = events.find((e) => e.type === "node-start");
const completeEvent = events.find((e) => e.type === "node-complete");
assert.ok(startEvent);
assert.ok(completeEvent);
assert.equal(completeEvent.nodeId, "a");
assert.equal(completeEvent.state, "done");
});
test("scheduler_parallel_task_records_have_correct_state", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => {
return {
gateResults: [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
],
};
});
const nodes = [
{ id: "task-1", kind: "unit", dependsOn: [], metadata: { unitId: "U1" } },
];
await scheduler.run(nodes, { parallel: true, maxWorkers: 1 });
const task = scheduler.getTask("task-1");
assert.equal(task.state, "done");
assert.equal(task.unitId, "U1");
assert.ok(task.startedAt);
assert.ok(task.endedAt);
});
test("scheduler_parallel_detects_cyclic_dependencies", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
const nodes = [
{ id: "a", kind: "unit", dependsOn: ["b"], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
];
await assert.rejects(
scheduler.run(nodes, { parallel: true }),
/cyclic/,
);
});
test("scheduler_parallel_detects_deadlock", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
// a depends on b, b depends on a — cyclic, but also no nodes with in-degree 0
const nodes = [
{ id: "a", kind: "unit", dependsOn: ["b"], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
];
await assert.rejects(
scheduler.run(nodes, { parallel: true }),
/cyclic/,
);
});
// ─── Progress stream wiring ────────────────────────────────────────────────
test("scheduler_wires_progress_to_parent_stream", async () => {
const parent = new ProgressStream();
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async () => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
const parentEvents = [];
parent.onProgress((ev) => parentEvents.push(ev));
const nodes = [
{ id: "x", kind: "unit", dependsOn: [], metadata: {} },
];
await scheduler.run(nodes, { parallel: false, parentStream: parent });
assert.ok(parentEvents.some((e) => e.type === "node-complete"));
});
// ─── SQLite persistence ───────────────────────────────────────────────────
test("scheduler_when_db_given_persists_graph_tasks_and_progress", async () => {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async () => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
const nodes = [
{
id: "task-1",
kind: "unit",
dependsOn: [],
writes: ["src/a.js"],
metadata: {
unitType: "execute-task",
unitId: "M001/S01/T01",
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
title: "Implement A",
},
},
];
await scheduler.run(nodes, {
parallel: false,
db,
graphId: "graph-persisted",
graphMeta: { milestoneId: "M001", phase: "executing" },
});
const tasks = queryTasksByState(db, { graphId: "graph-persisted" });
assert.equal(tasks.length, 1);
assert.equal(tasks[0].id, "task-1");
assert.equal(tasks[0].status, "done");
assert.deepEqual(tasks[0].writes, ["src/a.js"]);
const summary = getGraphStateSummary(db, "graph-persisted");
assert.equal(summary.total, 1);
assert.equal(summary.progress, 100);
assert.equal(summary.isComplete, true);
const events = getGraphProgressStream(db, "graph-persisted", 10);
assert.ok(events.some((e) => e.type === "node-start"));
assert.ok(events.some((e) => e.type === "node-complete"));
});

View file

@ -0,0 +1,238 @@
/**
* uok-task-state.test.mjs Task state machine contract tests.
*
* Purpose: verify gate-outcome-to-task-state mapping, unit-runtime bridging,
* state transition validity, and aggregate computation.
*/
import assert from "node:assert/strict";
import { test } from "vitest";
import {
TASK_STATES,
TASK_TERMINAL_STATES,
TASK_STATE_TRANSITIONS,
aggregateTaskStates,
buildTaskRecord,
canTransitionTaskState,
gateOutcomesToTaskState,
unitRuntimeToTaskState,
} from "../uok/task-state.js";
// ─── Constants ─────────────────────────────────────────────────────────────
test("TASK_STATES_has_all_orch_states", () => {
assert.deepEqual(TASK_STATES, [
"todo",
"in_progress",
"review",
"done",
"retrying",
"failed",
"cancelled",
]);
});
test("TASK_TERMINAL_STATES_includes_done_failed_cancelled", () => {
assert.equal(TASK_TERMINAL_STATES.has("done"), true);
assert.equal(TASK_TERMINAL_STATES.has("failed"), true);
assert.equal(TASK_TERMINAL_STATES.has("cancelled"), true);
assert.equal(TASK_TERMINAL_STATES.has("todo"), false);
assert.equal(TASK_TERMINAL_STATES.has("in_progress"), false);
});
// ─── Gate outcomes → task state ────────────────────────────────────────────
test("gateOutcomesToTaskState_empty_returns_todo", () => {
assert.equal(gateOutcomesToTaskState([]), "todo");
assert.equal(gateOutcomesToTaskState(null), "todo");
assert.equal(gateOutcomesToTaskState(undefined), "todo");
});
test("gateOutcomesToTaskState_all_pass_returns_done", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
];
assert.equal(gateOutcomesToTaskState(results), "done");
});
test("gateOutcomesToTaskState_any_fail_returns_failed", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "fail", gateId: "cost", failureClass: "policy" },
];
assert.equal(gateOutcomesToTaskState(results), "failed");
});
test("gateOutcomesToTaskState_any_manual_attention_returns_review", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "manual-attention", gateId: "verification" },
];
assert.equal(gateOutcomesToTaskState(results), "review");
});
test("gateOutcomesToTaskState_any_retry_returns_retrying", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "retry", gateId: "execution" },
];
assert.equal(gateOutcomesToTaskState(results), "retrying");
});
test("gateOutcomesToTaskState_mixed_nonterminal_returns_in_progress", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
{ outcome: "pass", gateId: "verification" },
];
// All pass = done, not in_progress
assert.equal(gateOutcomesToTaskState(results), "done");
});
// ─── Unit runtime → task state ─────────────────────────────────────────────
test("unitRuntimeToTaskState_null_returns_todo", () => {
assert.equal(unitRuntimeToTaskState(null), "todo");
});
test("unitRuntimeToTaskState_queued_returns_todo", () => {
assert.equal(unitRuntimeToTaskState({ status: "queued" }), "todo");
});
test("unitRuntimeToTaskState_claimed_returns_todo", () => {
assert.equal(unitRuntimeToTaskState({ status: "claimed" }), "todo");
});
test("unitRuntimeToTaskState_running_returns_in_progress", () => {
assert.equal(unitRuntimeToTaskState({ status: "running" }), "in_progress");
});
test("unitRuntimeToTaskState_progress_returns_in_progress", () => {
assert.equal(unitRuntimeToTaskState({ status: "progress" }), "in_progress");
});
test("unitRuntimeToTaskState_completed_returns_done", () => {
assert.equal(unitRuntimeToTaskState({ status: "completed" }), "done");
});
test("unitRuntimeToTaskState_failed_returns_failed", () => {
assert.equal(unitRuntimeToTaskState({ status: "failed" }), "failed");
});
test("unitRuntimeToTaskState_blocked_returns_review", () => {
assert.equal(unitRuntimeToTaskState({ status: "blocked" }), "review");
});
test("unitRuntimeToTaskState_cancelled_returns_cancelled", () => {
assert.equal(unitRuntimeToTaskState({ status: "cancelled" }), "cancelled");
});
test("unitRuntimeToTaskState_stale_returns_retrying", () => {
assert.equal(unitRuntimeToTaskState({ status: "stale" }), "retrying");
});
test("unitRuntimeToTaskState_runaway_recovered_returns_retrying", () => {
assert.equal(
unitRuntimeToTaskState({ status: "runaway-recovered" }),
"retrying",
);
});
// ─── State transitions ─────────────────────────────────────────────────────
test("canTransitionTaskState_valid_returns_true", () => {
assert.equal(canTransitionTaskState("todo", "in_progress"), true);
assert.equal(canTransitionTaskState("in_progress", "done"), true);
assert.equal(canTransitionTaskState("failed", "retrying"), true);
});
test("canTransitionTaskState_invalid_returns_false", () => {
assert.equal(canTransitionTaskState("todo", "done"), false);
assert.equal(canTransitionTaskState("done", "in_progress"), false);
assert.equal(canTransitionTaskState("cancelled", "todo"), false);
});
test("canTransitionTaskState_self_loop_returns_false", () => {
assert.equal(canTransitionTaskState("todo", "todo"), false);
});
// ─── Aggregate ─────────────────────────────────────────────────────────────
test("aggregateTaskStates_empty_returns_zero_progress", () => {
const agg = aggregateTaskStates([]);
assert.equal(agg.total, 0);
assert.equal(agg.terminal, 0);
assert.equal(agg.progress, 0);
assert.equal(agg.isComplete, false);
});
test("aggregateTaskStates_counts_correctly", () => {
const agg = aggregateTaskStates([
"todo",
"in_progress",
"done",
"done",
"failed",
]);
assert.equal(agg.total, 5);
assert.equal(agg.terminal, 3); // done(2) + failed(1)
assert.equal(agg.progress, 60);
assert.equal(agg.isComplete, false);
assert.equal(agg.counts.todo, 1);
assert.equal(agg.counts.in_progress, 1);
assert.equal(agg.counts.done, 2);
assert.equal(agg.counts.failed, 1);
});
test("aggregateTaskStates_complete_when_all_terminal", () => {
const agg = aggregateTaskStates(["done", "done", "cancelled"]);
assert.equal(agg.progress, 100);
assert.equal(agg.isComplete, true);
});
// ─── Build task record ─────────────────────────────────────────────────────
test("buildTaskRecord_with_gate_results_uses_gate_state", () => {
const task = buildTaskRecord({
nodeId: "execute-task:M001:S01:T01",
unitType: "execute-task",
unitId: "M001.S01.T01",
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
title: "Implement auth",
gateResults: [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
],
});
assert.equal(task.state, "done");
assert.equal(task.unitId, "M001.S01.T01");
assert.equal(task.title, "Implement auth");
assert.ok(task.updatedAt);
});
test("buildTaskRecord_with_runtime_uses_runtime_state", () => {
const task = buildTaskRecord({
nodeId: "execute-task:M001:S01:T01",
unitType: "execute-task",
unitId: "M001.S01.T01",
runtimeRecord: { status: "running" },
gateResults: [],
});
assert.equal(task.state, "in_progress");
});
test("buildTaskRecord_includes_worker_id", () => {
const task = buildTaskRecord({
nodeId: "n1",
unitType: "execute-task",
unitId: "M001.S01.T01",
gateResults: [{ outcome: "pass", gateId: "g1" }],
workerId: "w-3",
durationMs: 1500,
});
assert.equal(task.workerId, "w-3");
assert.equal(task.durationMs, 1500);
});

View file

@ -19,7 +19,7 @@ let loadAttempted = false;
function loadProvider() { function loadProvider() {
if (loadAttempted) return; if (loadAttempted) return;
loadAttempted = true; loadAttempted = true;
// node:sqlite is built-in in Node >= 24 // node:sqlite is built-in in Node >= 26
} }
function normalizeRow(row) { function normalizeRow(row) {
if (row == null) return undefined; if (row == null) return undefined;

View file

@ -0,0 +1,334 @@
/**
* UOK Coordination Store
*
* Purpose: provide Redis-like coordination primitives on top of the project
* SQLite database so UOK can lease work, keep TTL state, append streams, and
* coordinate background tasks without adding a server process.
*
* Consumer: UOK scheduler, /tasks, parallel dispatch, and future background
* work controllers that need durable coordination inside `.sf/sf.db`.
*/
const DEFAULT_NOW = () => Date.now();
/**
* Ensure coordination tables exist in the shared SF database.
*
* Purpose: let callers opt into queue, stream, and TTL primitives without
* coupling them to the main SF schema migration cadence during greenfield UOK
* iteration.
*
* Consumer: UokCoordinationStore constructor and direct tests.
*/
export function ensureCoordinationSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS uok_kv (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
expires_at INTEGER DEFAULT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_uok_kv_expires ON uok_kv(expires_at);
CREATE TABLE IF NOT EXISTS uok_stream_entries (
stream TEXT NOT NULL,
id INTEGER NOT NULL,
type TEXT NOT NULL,
payload_json TEXT NOT NULL,
ts INTEGER NOT NULL,
PRIMARY KEY (stream, id)
);
CREATE INDEX IF NOT EXISTS idx_uok_stream_entries_ts
ON uok_stream_entries(stream, ts);
CREATE TABLE IF NOT EXISTS uok_queue_items (
queue TEXT NOT NULL,
id TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ready',
available_at INTEGER NOT NULL,
lease_owner TEXT DEFAULT NULL,
lease_until INTEGER DEFAULT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (queue, id)
);
CREATE INDEX IF NOT EXISTS idx_uok_queue_ready
ON uok_queue_items(queue, status, available_at, lease_until);
`);
}
function encode(value) {
return JSON.stringify(value ?? null);
}
function decode(text) {
return text ? JSON.parse(text) : null;
}
function normalizeRow(row) {
if (row == null) return null;
return Object.getPrototypeOf(row) === null ? { ...row } : row;
}
/**
* Durable Redis-like coordination primitives backed by SQLite.
*
* Purpose: keep UOK coordination local, inspectable, and serverless while
* preserving the useful parts of Redis: TTL values, append-only streams, and
* lease-based queues.
*
* Consumer: scheduler v2 and future UOK background workers.
*/
export class UokCoordinationStore {
constructor(db, { now = DEFAULT_NOW } = {}) {
this.db = db;
this.now = now;
ensureCoordinationSchema(db);
}
set(key, value, { ttlMs = null } = {}) {
const now = this.now();
const expiresAt = ttlMs == null ? null : now + ttlMs;
this.db
.prepare(
`INSERT INTO uok_kv (key, value_json, expires_at, updated_at)
VALUES (:key, :value, :expiresAt, :now)
ON CONFLICT(key) DO UPDATE SET
value_json = excluded.value_json,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at`,
)
.run({ key, value: encode(value), expiresAt, now });
return true;
}
get(key) {
const now = this.now();
const row = normalizeRow(
this.db
.prepare(
`SELECT value_json, expires_at FROM uok_kv
WHERE key = :key`,
)
.get({ key }),
);
if (!row) return null;
if (row.expires_at != null && row.expires_at <= now) {
this.delete(key);
return null;
}
return decode(row.value_json);
}
delete(key) {
this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key });
}
expire(key, ttlMs) {
const result = this.db
.prepare(
`UPDATE uok_kv
SET expires_at = :expiresAt, updated_at = :now
WHERE key = :key`,
)
.run({ key, expiresAt: this.now() + ttlMs, now: this.now() });
return (result?.changes ?? 0) > 0;
}
cleanupExpired() {
const result = this.db
.prepare(
`DELETE FROM uok_kv
WHERE expires_at IS NOT NULL AND expires_at <= :now`,
)
.run({ now: this.now() });
return result?.changes ?? 0;
}
xadd(stream, type, payload) {
const now = this.now();
const row = normalizeRow(
this.db
.prepare(
`SELECT COALESCE(MAX(id), 0) + 1 AS next_id
FROM uok_stream_entries
WHERE stream = :stream`,
)
.get({ stream }),
);
const id = row?.next_id ?? 1;
this.db
.prepare(
`INSERT INTO uok_stream_entries (stream, id, type, payload_json, ts)
VALUES (:stream, :id, :type, :payload, :ts)`,
)
.run({ stream, id, type, payload: encode(payload), ts: now });
return id;
}
xread(stream, { afterId = 0, limit = 100 } = {}) {
return this.db
.prepare(
`SELECT id, type, payload_json, ts
FROM uok_stream_entries
WHERE stream = :stream AND id > :afterId
ORDER BY id ASC
LIMIT :limit`,
)
.all({ stream, afterId, limit })
.map((row) => ({
id: row.id,
type: row.type,
payload: decode(row.payload_json),
ts: row.ts,
}));
}
enqueue(queue, id, payload, { delayMs = 0 } = {}) {
const now = this.now();
this.db
.prepare(
`INSERT INTO uok_queue_items (
queue, id, payload_json, status, available_at, created_at, updated_at
) VALUES (
:queue, :id, :payload, 'ready', :availableAt, :now, :now
)
ON CONFLICT(queue, id) DO UPDATE SET
payload_json = excluded.payload_json,
status = 'ready',
available_at = excluded.available_at,
lease_owner = NULL,
lease_until = NULL,
updated_at = excluded.updated_at`,
)
.run({
queue,
id,
payload: encode(payload),
availableAt: now + delayMs,
now,
});
return id;
}
claim(queue, owner, { leaseMs = 30_000 } = {}) {
const now = this.now();
const row = normalizeRow(
this.db
.prepare(
`SELECT id
FROM uok_queue_items
WHERE queue = :queue
AND status = 'ready'
AND available_at <= :now
AND (lease_until IS NULL OR lease_until <= :now)
ORDER BY available_at ASC, created_at ASC, id ASC
LIMIT 1`,
)
.get({ queue, now }),
);
if (!row) return null;
const result = this.db
.prepare(
`UPDATE uok_queue_items
SET status = 'leased',
lease_owner = :owner,
lease_until = :leaseUntil,
attempts = attempts + 1,
updated_at = :now
WHERE queue = :queue
AND id = :id
AND status = 'ready'
AND (lease_until IS NULL OR lease_until <= :now)`,
)
.run({
queue,
id: row.id,
owner,
leaseUntil: now + leaseMs,
now,
});
if ((result?.changes ?? 0) !== 1) return null;
return this.peekQueueItem(queue, row.id);
}
ack(queue, id, owner) {
const result = this.db
.prepare(
`UPDATE uok_queue_items
SET status = 'done', updated_at = :now
WHERE queue = :queue
AND id = :id
AND lease_owner = :owner
AND status = 'leased'`,
)
.run({ queue, id, owner, now: this.now() });
return (result?.changes ?? 0) === 1;
}
release(queue, id, owner, { delayMs = 0 } = {}) {
const now = this.now();
const result = this.db
.prepare(
`UPDATE uok_queue_items
SET status = 'ready',
available_at = :availableAt,
lease_owner = NULL,
lease_until = NULL,
updated_at = :now
WHERE queue = :queue
AND id = :id
AND lease_owner = :owner
AND status = 'leased'`,
)
.run({ queue, id, owner, availableAt: now + delayMs, now });
return (result?.changes ?? 0) === 1;
}
reapExpiredLeases(queue = null) {
const now = this.now();
const sql =
queue == null
? `UPDATE uok_queue_items
SET status = 'ready', lease_owner = NULL, lease_until = NULL, updated_at = :now
WHERE status = 'leased' AND lease_until <= :now`
: `UPDATE uok_queue_items
SET status = 'ready', lease_owner = NULL, lease_until = NULL, updated_at = :now
WHERE queue = :queue AND status = 'leased' AND lease_until <= :now`;
const params = queue == null ? { now } : { queue, now };
const result = this.db.prepare(sql).run(params);
return result?.changes ?? 0;
}
peekQueueItem(queue, id) {
const row = normalizeRow(
this.db
.prepare(
`SELECT *
FROM uok_queue_items
WHERE queue = :queue AND id = :id`,
)
.get({ queue, id }),
);
if (!row) return null;
return {
queue: row.queue,
id: row.id,
payload: decode(row.payload_json),
status: row.status,
availableAt: row.available_at,
leaseOwner: row.lease_owner,
leaseUntil: row.lease_until,
attempts: row.attempts,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View file

@ -0,0 +1,374 @@
/**
* UOK Execution Graph Persistence
*
* Purpose: persist execution graph snapshots and task state to SQLite so the
* /tasks surface can query background work without parsing JSON files. Graph
* state is the authority; JSON files under .sf/runtime are read-only
* projections for recovery and external tools.
*
* Consumer: ExecutionGraphSchedulerV2, /tasks command, and UOK kernel
* background-work tracking.
*/
/**
* Ensure the execution graph tables exist.
*
* Purpose: idempotent schema setup so the scheduler can write task state
* without knowing the full DB schema.
*/
export function ensureExecutionGraphSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS uok_execution_graphs (
id TEXT PRIMARY KEY,
milestone_id TEXT,
session_id TEXT,
phase TEXT,
node_count INTEGER,
created_at TEXT NOT NULL,
completed_at TEXT,
status TEXT DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS uok_graph_nodes (
id TEXT NOT NULL,
graph_id TEXT NOT NULL,
kind TEXT NOT NULL,
unit_type TEXT,
unit_id TEXT,
milestone_id TEXT,
slice_id TEXT,
task_id TEXT,
title TEXT,
depends_on TEXT, -- JSON array of node IDs
writes TEXT, -- JSON array of file paths
status TEXT DEFAULT 'todo',
worker_id TEXT,
started_at TEXT,
ended_at TEXT,
duration_ms INTEGER,
cost_usd REAL,
model_id TEXT,
gate_results TEXT, -- JSON array
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (graph_id, id)
);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_graph ON uok_graph_nodes(graph_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_status ON uok_graph_nodes(status);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_unit ON uok_graph_nodes(unit_type, unit_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_milestone ON uok_graph_nodes(milestone_id);
CREATE TABLE IF NOT EXISTS uok_graph_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
graph_id TEXT NOT NULL,
node_id TEXT,
type TEXT NOT NULL,
payload TEXT, -- JSON
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_graph_progress_graph ON uok_graph_progress(graph_id);
`);
}
/**
* Insert or update a graph snapshot.
*/
export function persistGraphSnapshot(db, snapshot) {
ensureExecutionGraphSchema(db);
const {
id,
milestoneId,
sessionId,
phase,
nodeCount,
createdAt,
status = "active",
} = snapshot;
db.prepare(
`
INSERT INTO uok_execution_graphs (id, milestone_id, session_id, phase, node_count, created_at, status)
VALUES (:id, :milestoneId, :sessionId, :phase, :nodeCount, :createdAt, :status)
ON CONFLICT(id) DO UPDATE SET
milestone_id = COALESCE(excluded.milestone_id, milestone_id),
session_id = COALESCE(excluded.session_id, session_id),
phase = COALESCE(excluded.phase, phase),
node_count = COALESCE(excluded.node_count, node_count),
status = excluded.status,
completed_at = CASE
WHEN excluded.status IN ('completed','cancelled','failed') THEN COALESCE(completed_at, datetime('now'))
ELSE completed_at
END
`,
).run({
id,
milestoneId: milestoneId ?? null,
sessionId: sessionId ?? null,
phase: phase ?? null,
nodeCount: nodeCount ?? null,
createdAt,
status,
});
}
/**
* Insert or update a graph node (task record).
*/
export function persistGraphNode(db, graphId, node) {
ensureExecutionGraphSchema(db);
const {
id,
kind = "unit",
unitType,
unitId,
milestoneId,
sliceId,
taskId,
title,
dependsOn = [],
writes = [],
state = "todo",
workerId,
startedAt,
endedAt,
durationMs,
costUsd,
modelId,
gateResults = [],
error,
} = node;
db.prepare(
`
INSERT INTO uok_graph_nodes (
id, graph_id, kind, unit_type, unit_id, milestone_id, slice_id, task_id,
title, depends_on, writes, status, worker_id, started_at, ended_at,
duration_ms, cost_usd, model_id, gate_results, error, updated_at
) VALUES (
:id, :graphId, :kind, :unitType, :unitId, :milestoneId, :sliceId, :taskId,
:title, :dependsOn, :writes, :state, :workerId, :startedAt, :endedAt,
:durationMs, :costUsd, :modelId, :gateResults, :error, datetime('now')
)
ON CONFLICT(graph_id, id) DO UPDATE SET
kind = excluded.kind,
unit_type = COALESCE(excluded.unit_type, unit_type),
unit_id = COALESCE(excluded.unit_id, unit_id),
milestone_id = COALESCE(excluded.milestone_id, milestone_id),
slice_id = COALESCE(excluded.slice_id, slice_id),
task_id = COALESCE(excluded.task_id, task_id),
title = COALESCE(excluded.title, title),
depends_on = excluded.depends_on,
writes = excluded.writes,
status = excluded.status,
worker_id = excluded.worker_id,
started_at = COALESCE(excluded.started_at, started_at),
ended_at = excluded.ended_at,
duration_ms = excluded.duration_ms,
cost_usd = excluded.cost_usd,
model_id = excluded.model_id,
gate_results = excluded.gate_results,
error = excluded.error,
updated_at = excluded.updated_at
`,
).run({
id,
graphId,
kind,
unitType: unitType ?? null,
unitId: unitId ?? null,
milestoneId: milestoneId ?? null,
sliceId: sliceId ?? null,
taskId: taskId ?? null,
title: title ?? null,
dependsOn: JSON.stringify(dependsOn),
writes: JSON.stringify(writes),
state,
workerId: workerId ?? null,
startedAt: startedAt ?? null,
endedAt: endedAt ?? null,
durationMs: durationMs ?? null,
costUsd: costUsd ?? null,
modelId: modelId ?? null,
gateResults: JSON.stringify(gateResults),
error: error ?? null,
});
}
/**
* Record a progress event.
*/
export function persistProgressEvent(db, graphId, event) {
ensureExecutionGraphSchema(db);
let payload = event.payload ?? event;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch {
payload = { text: payload };
}
}
db.prepare(
`
INSERT INTO uok_graph_progress (graph_id, node_id, type, payload, ts)
VALUES (:graphId, :nodeId, :type, :payload, :ts)
`,
).run({
graphId,
nodeId: event.nodeId ?? null,
type: event.type,
payload: JSON.stringify(payload),
ts: event.ts ?? new Date().toISOString(),
});
}
/**
* Query tasks by state for /tasks display.
*/
export function queryTasksByState(db, filters = {}) {
const {
graphId,
milestoneId,
sliceId,
states = [],
limit = 100,
offset = 0,
} = filters;
const conditions = ["1=1"];
const params = {};
if (graphId) {
conditions.push("graph_id = :graphId");
params.graphId = graphId;
}
if (milestoneId) {
conditions.push("milestone_id = :milestoneId");
params.milestoneId = milestoneId;
}
if (sliceId) {
conditions.push("slice_id = :sliceId");
params.sliceId = sliceId;
}
if (states.length > 0) {
conditions.push(`status IN (${states.map((_, i) => `:s${i}`).join(", ")})`);
for (let i = 0; i < states.length; i++) {
params[`s${i}`] = states[i];
}
}
const where = conditions.join(" AND ");
const rows = db
.prepare(
`
SELECT * FROM uok_graph_nodes
WHERE ${where}
ORDER BY updated_at DESC
LIMIT :limit OFFSET :offset
`,
)
.all({ ...params, limit, offset });
return rows.map((r) => ({
...r,
dependsOn: r.depends_on ? JSON.parse(r.depends_on) : [],
writes: r.writes ? JSON.parse(r.writes) : [],
gateResults: r.gate_results ? JSON.parse(r.gate_results) : [],
}));
}
/**
* Get aggregate counts by state for a graph.
*/
export function getGraphStateSummary(db, graphId) {
const rows = db
.prepare(
`
SELECT status, COUNT(*) as count
FROM uok_graph_nodes
WHERE graph_id = :graphId
GROUP BY status
`,
)
.all({ graphId });
const counts = Object.fromEntries(
["todo", "in_progress", "review", "done", "retrying", "failed", "cancelled"].map(
(s) => [s, 0],
),
);
let total = 0;
for (const r of rows) {
counts[r.status] = r.count;
total += r.count;
}
const terminal = ["done", "failed", "cancelled"].reduce(
(sum, s) => sum + (counts[s] ?? 0),
0,
);
return {
graphId,
counts,
total,
terminal,
progress: total > 0 ? Math.round((terminal / total) * 100) : 0,
isComplete: terminal === total && total > 0,
};
}
/**
* Get recent progress events for a graph.
*/
export function getGraphProgressStream(db, graphId, limit = 50) {
return db
.prepare(
`
SELECT * FROM uok_graph_progress
WHERE graph_id = :graphId
ORDER BY ts DESC
LIMIT :limit
`,
)
.all({ graphId, limit })
.map((r) => ({
...r,
payload: r.payload ? JSON.parse(r.payload) : null,
}));
}
/**
* Convenience: persist an entire graph with all nodes in one transaction.
*/
export function persistFullGraph(db, graphId, nodes, meta = {}) {
db.exec("BEGIN IMMEDIATE");
try {
persistGraphSnapshot(db, {
id: graphId,
milestoneId: meta.milestoneId,
sessionId: meta.sessionId,
phase: meta.phase,
nodeCount: nodes.length,
createdAt: new Date().toISOString(),
status: meta.status ?? "active",
});
for (const node of nodes) {
persistGraphNode(db, graphId, node);
}
db.exec("COMMIT");
} catch (err) {
try {
db.exec("ROLLBACK");
} catch {
// Preserve the original persistence failure.
}
throw err;
}
}

View file

@ -0,0 +1,169 @@
/**
* UOK Unified Orchestration Kernel
*
* Purpose: export the complete UOK surface so consumers import from a single
* module instead of reaching into individual files. This is the public API
* contract for all UOK functionality.
*
* Consumer: auto-dispatch, auto-verification, commands, tests, and any
* extension that needs orchestration primitives.
*/
// ─── Contracts & Types ────────────────────────────────────────────────────
export { validateGate } from "./contracts.js";
// ─── Core Kernel ───────────────────────────────────────────────────────────
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js";
// ─── Gate System ───────────────────────────────────────────────────────────
export { UokGateRunner, enrichGateResultWithMemory } from "./gate-runner.js";
// ─── Gates ─────────────────────────────────────────────────────────────────
export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js";
export { CostGuardGate } from "./cost-guard-gate.js";
export { MultiPackageGate } from "./multi-package-gate.js";
export { OutcomeLearningGate } from "./outcome-learning-gate.js";
export { SecurityGate } from "./security-gate.js";
// ─── Flags & Configuration ─────────────────────────────────────────────────
export { resolveUokFlags, loadUokFlags } from "./flags.js";
// ─── Execution Graph ───────────────────────────────────────────────────────
export {
selectConflictFreeBatch,
selectReactiveDispatchBatch,
buildSidecarQueueNodes,
buildExecutionGraphSnapshot,
scheduleSidecarQueue,
ExecutionGraphScheduler,
} from "./execution-graph.js";
// ─── Scheduler v2 (Background Work) ────────────────────────────────────────
export {
CancellationToken,
ProgressStream,
WorkerPool,
ExecutionGraphSchedulerV2,
} from "./scheduler-v2.js";
// ─── Task State Machine ────────────────────────────────────────────────────
export {
TASK_STATES,
TASK_TERMINAL_STATES,
TASK_STATE_TRANSITIONS,
gateOutcomesToTaskState,
unitRuntimeToTaskState,
canTransitionTaskState,
aggregateTaskStates,
buildTaskRecord,
} from "./task-state.js";
// ─── Execution Graph Persistence ───────────────────────────────────────────
export {
ensureExecutionGraphSchema,
persistGraphSnapshot,
persistGraphNode,
persistProgressEvent,
queryTasksByState,
getGraphStateSummary,
getGraphProgressStream,
persistFullGraph,
} from "./execution-graph-persist.js";
// ─── Coordination Store ───────────────────────────────────────────────────
export {
ensureCoordinationSchema,
UokCoordinationStore,
} from "./coordination-store.js";
// ─── Unit Runtime ──────────────────────────────────────────────────────────
export {
UNIT_RUNTIME_STATUSES,
UNIT_RUNTIME_TERMINAL_STATUSES,
UNIT_RUNTIME_TRANSITIONS,
isTerminalUnitRuntimeStatus,
getUnitRuntimeState,
isSyntheticUnitRuntime,
decideUnitRuntimeDispatch,
writeUnitRuntimeRecord,
readUnitRuntimeRecord,
clearUnitRuntimeRecord,
listUnitRuntimeRecords,
recordUnitOutcomeInMemory,
inspectExecuteTaskDurability,
formatExecuteTaskRecoveryStatus,
reconcileStaleCompleteSliceRecords,
reconcileDurableCompleteUnitRuntimeRecords,
} from "./unit-runtime.js";
// ─── Plan v2 ───────────────────────────────────────────────────────────────
export {
EXECUTION_ENTRY_PHASES,
isExecutionEntryPhase,
compileUnitGraphFromState,
hasFinalizedMilestoneContext,
isMissingFinalizedContextResult,
isEmptyPlanV2GraphResult,
ensurePlanV2Graph,
} from "./plan-v2.js";
// ─── Audit & Observability ─────────────────────────────────────────────────
export { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
export { setAuditEnvelopeEnabled, isAuditEnvelopeEnabled } from "./audit-toggle.js";
// ─── Diagnostics ───────────────────────────────────────────────────────────
export {
synthesizeUokDiagnostics,
writeUokDiagnostics,
readUokDiagnostics,
} from "./diagnostic-synthesis.js";
// ─── Parity & Ledger ───────────────────────────────────────────────────────
export {
writeParityReport,
readParityReport,
summarizeParityHealth,
writeParityHeartbeat,
parseParityEvents,
UNMATCHED_RUN_STALE_MS,
signalKernelEnter,
resetParityCommitBlock,
checkAndDrainMissingExit,
} from "./parity-report.js";
export {
signalKernelEnter as signalParityEnter,
} from "./parity-diff-capture.js";
// ─── Message Bus ───────────────────────────────────────────────────────────
export { AgentInbox, MessageBus } from "./message-bus.js";
// ─── Metrics ───────────────────────────────────────────────────────────────
export {
buildMetricsText,
invalidateMetricsCache,
metricsPath,
writeUokMetrics,
readUokMetrics,
} from "./metrics-exposition.js";
// ─── GitOps ────────────────────────────────────────────────────────────────
export {
writeTurnGitTransaction,
writeTurnCloseoutGitRecord,
} from "./gitops.js";
// ─── Writer Token ──────────────────────────────────────────────────────────
export {
acquireWriterToken,
releaseWriterToken,
nextWriteRecord,
} from "./writer.js";
// ─── Model Policy ──────────────────────────────────────────────────────────
export { applyModelPolicyFilter } from "./model-policy.js";
// ─── Loop Adapter ──────────────────────────────────────────────────────────
export { createTurnObserver } from "./loop-adapter.js";
// ─── Dispatch Envelope ─────────────────────────────────────────────────────
export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js";

View file

@ -0,0 +1,620 @@
/**
* UOK Execution Graph Scheduler v2
*
* Purpose: extend the v1 scheduler with cancellation tokens, progress
* streaming, and worker pool enforcement for background /tasks work.
* Maintains backward compatibility with v1's serial/parallel modes.
*
* Consumer: /tasks background work, parallel unit dispatch, and UOK kernel
* when running multiple gates concurrently.
*/
import { debugLog } from "../debug-logger.js";
import { getUnitTimeout } from "../preferences.js";
import {
persistFullGraph,
persistGraphNode,
persistGraphSnapshot,
persistProgressEvent,
} from "./execution-graph-persist.js";
import { buildTaskRecord, gateOutcomesToTaskState } from "./task-state.js";
const DEFAULT_UNIT_TIMEOUT_MS = 300_000; // 5 minutes fallback
const DEFAULT_SCHEDULER_TIMEOUT_MS = 3_600_000; // 1 hour default for background work
/**
* Cancellation token for cooperative task abort.
*/
export class CancellationToken {
#cancelled = false;
#reason = null;
#listeners = new Set();
cancel(reason = "cancelled") {
if (this.#cancelled) return;
this.#cancelled = true;
this.#reason = reason;
for (const fn of this.#listeners) {
try { fn(reason); } catch { /* ignore */ }
}
this.#listeners.clear();
}
get isCancelled() {
return this.#cancelled;
}
get reason() {
return this.#reason;
}
onCancel(fn) {
if (this.#cancelled) {
try { fn(this.#reason); } catch { /* ignore */ }
return () => {};
}
this.#listeners.add(fn);
return () => this.#listeners.delete(fn);
}
throwIfCancelled() {
if (this.#cancelled) {
const err = new Error(this.#reason ?? "cancelled");
err.name = "CancellationError";
throw err;
}
}
}
/**
* Progress stream for reporting task state changes back to parent session.
*/
export class ProgressStream {
#listeners = new Set();
#history = [];
#maxHistory = 100;
emit(event) {
const enriched = {
...event,
ts: new Date().toISOString(),
};
this.#history.push(enriched);
if (this.#history.length > this.#maxHistory) {
this.#history = this.#history.slice(-this.#maxHistory);
}
for (const fn of this.#listeners) {
try { fn(enriched); } catch { /* ignore */ }
}
}
onProgress(fn) {
this.#listeners.add(fn);
// Replay recent history so new listener catches up
for (const ev of this.#history) {
try { fn(ev); } catch { /* ignore */ }
}
return () => this.#listeners.delete(fn);
}
getHistory() {
return [...this.#history];
}
}
/**
* Worker pool with limit enforcement and graceful drain.
*/
export class WorkerPool {
#maxWorkers;
#active = new Map(); // workerId -> { nodeId, startedAt, token }
#queue = [];
#counter = 0;
constructor(maxWorkers = 2) {
this.#maxWorkers = Math.max(1, Math.min(32, maxWorkers));
}
get maxWorkers() {
return this.#maxWorkers;
}
get activeCount() {
return this.#active.size;
}
get queuedCount() {
return this.#queue.length;
}
get isAtCapacity() {
return this.#active.size >= this.#maxWorkers;
}
async acquire(nodeId, token) {
return new Promise((resolve, reject) => {
const tryAcquire = () => {
if (token?.isCancelled) {
reject(new Error(token.reason ?? "cancelled"));
return;
}
if (this.#active.size < this.#maxWorkers) {
this.#counter++;
const workerId = `w-${this.#counter}`;
const startedAt = Date.now();
this.#active.set(workerId, { nodeId, startedAt, token });
resolve({
workerId,
release: () => this.#release(workerId),
});
return;
}
this.#queue.push({ nodeId, token, resolve, reject });
};
tryAcquire();
});
}
#release(workerId) {
this.#active.delete(workerId);
// Drain queue
while (this.#queue.length > 0 && this.#active.size < this.#maxWorkers) {
const next = this.#queue.shift();
if (next.token?.isCancelled) {
next.reject(new Error(next.token.reason ?? "cancelled"));
continue;
}
this.#counter++;
const newWorkerId = `w-${this.#counter}`;
const startedAt = Date.now();
this.#active.set(newWorkerId, {
nodeId: next.nodeId,
startedAt,
token: next.token,
});
next.resolve({
workerId: newWorkerId,
release: () => this.#release(newWorkerId),
});
}
}
cancelAll(reason = "pool-drained") {
// Reject all queued items first
for (const item of this.#queue) {
if (item.token && !item.token.isCancelled) item.token.cancel(reason);
item.reject(new Error(reason));
}
this.#queue = [];
// Then cancel active workers
for (const [, { token }] of this.#active) {
if (token && !token.isCancelled) token.cancel(reason);
}
}
getActive() {
return Array.from(this.#active.entries()).map(([workerId, info]) => ({
workerId,
nodeId: info.nodeId,
durationMs: Date.now() - info.startedAt,
}));
}
}
/**
* v2 Scheduler with cancellation, progress streaming, and worker pool.
*
* Extends v1 semantics:
* - serial mode: unchanged deterministic topological order
* - parallel mode: worker pool enforces max concurrency, cancellation
* stops new work, progress streams report state changes
*/
export class ExecutionGraphSchedulerV2 {
handlers = new Map();
#pool = null;
#progress = new ProgressStream();
#tasks = new Map(); // nodeId -> task record
#db = null;
#graphId = null;
registerHandler(kind, handler) {
this.handlers.set(kind, handler);
}
get progress() {
return this.#progress;
}
getTask(nodeId) {
return this.#tasks.get(nodeId);
}
getAllTasks() {
return Array.from(this.#tasks.values());
}
async run(nodes, options = {}) {
const {
parallel = false,
maxWorkers = 2,
globalToken = new CancellationToken(),
parentStream = null,
timeoutMs = DEFAULT_SCHEDULER_TIMEOUT_MS,
db = null,
graphId = `uok-graph-${Date.now()}`,
graphMeta = {},
} = options;
this.#tasks.clear();
this.#db = db;
this.#graphId = db ? graphId : null;
// Wire progress stream to parent if provided
if (parentStream) {
this.#progress.onProgress((ev) => parentStream.emit(ev));
}
if (this.#db && this.#graphId) {
this.#persistInitialGraph(nodes, graphMeta);
this.#progress.onProgress((ev) => this.#persistProgress(ev));
}
const sorted = this.#topologicalSort(nodes);
const conflicts = this.#detectFileConflicts(nodes);
// Global timeout: auto-cancel if scheduler runs too long
const timeoutToken = new CancellationToken();
const timeoutHandle = setTimeout(() => {
timeoutToken.cancel(`scheduler-timeout-${timeoutMs}ms`);
}, timeoutMs);
// Link timeout to global token
timeoutToken.onCancel((reason) => {
if (globalToken && !globalToken.isCancelled) {
globalToken.cancel(reason);
}
});
try {
if (!parallel) {
const result = await this.#runSerial(sorted, globalToken, conflicts);
this.#persistGraphStatus("completed", sorted.length);
return result;
}
this.#pool = new WorkerPool(maxWorkers);
const result = await this.#runParallel(
sorted,
globalToken,
conflicts,
maxWorkers,
);
this.#persistGraphStatus("completed", sorted.length);
return result;
} finally {
clearTimeout(timeoutHandle);
}
}
async #runSerial(sorted, token, conflicts) {
const order = [];
for (const node of sorted) {
token.throwIfCancelled();
const result = await this.#executeNode(node, token, null);
order.push(result.nodeId);
this.#progress.emit({
type: "node-complete",
nodeId: result.nodeId,
state: result.state,
durationMs: result.durationMs,
});
}
return { order, conflicts, tasks: this.getAllTasks() };
}
async #runParallel(sorted, token, conflicts, maxWorkers) {
const nodeMap = new Map(sorted.map((n) => [n.id, n]));
const done = new Set();
const order = [];
const promises = new Map(); // nodeId -> promise
this.#progress.emit({
type: "scheduler-start",
nodeCount: sorted.length,
maxWorkers,
});
while (done.size < sorted.length) {
token.throwIfCancelled();
const ready = sorted.filter(
(n) =>
!done.has(n.id) &&
!promises.has(n.id) &&
n.dependsOn.every((dep) => done.has(dep)),
);
if (ready.length === 0 && promises.size === 0) {
throw new Error(
"Execution graph deadlock detected: no ready nodes and graph not complete",
);
}
// Start as many ready nodes as pool allows
for (const node of ready) {
if (this.#pool.isAtCapacity) break;
if (promises.has(node.id)) continue;
const nodeToken = new CancellationToken();
// Link to global token
token.onCancel((reason) => nodeToken.cancel(reason));
const acquirePromise = this.#pool
.acquire(node.id, nodeToken)
.then(async ({ workerId, release }) => {
try {
const result = await this.#executeNode(
node,
nodeToken,
workerId,
);
done.add(node.id);
order.push(result.nodeId);
promises.delete(node.id);
this.#progress.emit({
type: "node-complete",
nodeId: result.nodeId,
state: result.state,
durationMs: result.durationMs,
workerId,
});
return result;
} finally {
release();
}
})
.catch((err) => {
if (err.name === "CancellationError") {
this.#progress.emit({
type: "node-cancelled",
nodeId: node.id,
reason: err.message,
});
} else {
this.#progress.emit({
type: "node-error",
nodeId: node.id,
error: err.message,
});
}
promises.delete(node.id);
throw err;
});
promises.set(node.id, acquirePromise);
}
// Wait for at least one node to complete before checking ready again
if (promises.size > 0) {
await Promise.race(promises.values());
}
}
this.#progress.emit({
type: "scheduler-complete",
nodeCount: sorted.length,
order,
});
return { order, conflicts, tasks: this.getAllTasks() };
}
async #executeNode(node, token, workerId) {
const startedAt = Date.now();
this.#progress.emit({
type: "node-start",
nodeId: node.id,
workerId,
});
const handler = this.handlers.get(node.kind);
let gateResults = [];
let error = null;
// Per-node timeout: use unit timeout from preferences or fallback
const unitTimeoutMs = getUnitTimeout() * 1000 || DEFAULT_UNIT_TIMEOUT_MS;
const nodeToken = new CancellationToken();
const nodeTimeoutHandle = setTimeout(() => {
nodeToken.cancel(`unit-timeout-${unitTimeoutMs}ms`);
}, unitTimeoutMs);
// Link node timeout to parent token
token.onCancel((reason) => {
if (!nodeToken.isCancelled) nodeToken.cancel(reason);
});
try {
// Check both tokens
token.throwIfCancelled();
nodeToken.throwIfCancelled();
if (handler) {
const result = await handler(node, nodeToken, this.#progress);
if (result && Array.isArray(result.gateResults)) {
gateResults = result.gateResults;
}
}
} catch (err) {
error = err;
if (err.name === "CancellationError") {
gateResults = [{
gateId: "scheduler",
outcome: "fail",
failureClass: "cancelled",
rationale: err.message,
}];
} else {
gateResults = [{
gateId: "scheduler",
outcome: "fail",
failureClass: "execution",
rationale: err.message,
}];
}
} finally {
clearTimeout(nodeTimeoutHandle);
}
const durationMs = Date.now() - startedAt;
const state = gateOutcomesToTaskState(gateResults);
const task = buildTaskRecord({
nodeId: node.id,
unitType: node.metadata?.unitType ?? node.kind,
unitId: node.metadata?.unitId ?? node.id,
milestoneId: node.metadata?.milestoneId,
sliceId: node.metadata?.sliceId,
taskId: node.metadata?.taskId,
title: node.metadata?.title,
gateResults,
startedAt: new Date(startedAt).toISOString(),
endedAt: new Date().toISOString(),
durationMs,
workerId,
});
this.#tasks.set(node.id, task);
this.#persistTask(node, task, gateResults, error);
if (error && error.name !== "CancellationError") {
this.#persistGraphStatus("failed", this.#tasks.size);
throw error;
}
return { nodeId: node.id, state, durationMs, gateResults };
}
#topologicalSort(nodes) {
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const inDegree = new Map(nodes.map((n) => [n.id, 0]));
for (const node of nodes) {
for (const dep of node.dependsOn) {
if (nodeMap.has(dep)) {
inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1);
}
}
}
const queue = nodes
.filter((n) => (inDegree.get(n.id) ?? 0) === 0)
.sort((a, b) => a.id.localeCompare(b.id));
const ordered = [];
while (queue.length > 0) {
const current = queue.shift();
ordered.push(current);
for (const next of nodes) {
if (!next.dependsOn.includes(current.id)) continue;
const deg = (inDegree.get(next.id) ?? 0) - 1;
inDegree.set(next.id, deg);
if (deg === 0) {
queue.push(next);
queue.sort((a, b) => a.id.localeCompare(b.id));
}
}
}
if (ordered.length !== nodes.length) {
throw new Error("Execution graph has cyclic dependencies");
}
return ordered;
}
#detectFileConflicts(nodes) {
const conflicts = [];
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
const writesA = new Set(a.writes ?? []);
if (writesA.size === 0) continue;
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
for (const file of b.writes ?? []) {
if (writesA.has(file)) {
conflicts.push({ nodeA: a.id, nodeB: b.id, file });
}
}
}
}
return conflicts;
}
#persistInitialGraph(nodes, graphMeta) {
try {
persistFullGraph(this.#db, this.#graphId, nodes, {
...graphMeta,
status: "active",
});
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist initial graph: ${err?.message ?? String(err)}`,
);
}
}
#persistProgress(event) {
if (!this.#db || !this.#graphId) return;
try {
persistProgressEvent(this.#db, this.#graphId, event);
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist progress event: ${err?.message ?? String(err)}`,
);
}
}
#persistTask(node, task, gateResults, error) {
if (!this.#db || !this.#graphId) return;
try {
persistGraphNode(this.#db, this.#graphId, {
id: task.id,
kind: node.kind,
unitType: task.unitType,
unitId: task.unitId,
milestoneId: task.milestoneId,
sliceId: task.sliceId,
taskId: task.taskId,
title: task.title,
dependsOn: node.dependsOn ?? [],
writes: node.writes ?? [],
state: task.state,
workerId: task.workerId,
startedAt: task.startedAt,
endedAt: task.endedAt,
durationMs: task.durationMs,
costUsd: task.costUsd,
modelId: task.modelId,
gateResults,
error: error ? (error.message ?? String(error)) : null,
});
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist task ${task.id}: ${err?.message ?? String(err)}`,
);
}
}
#persistGraphStatus(status, nodeCount) {
if (!this.#db || !this.#graphId) return;
try {
persistGraphSnapshot(this.#db, {
id: this.#graphId,
nodeCount,
createdAt: new Date().toISOString(),
status,
});
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist graph status: ${err?.message ?? String(err)}`,
);
}
}
}

View file

@ -0,0 +1,189 @@
/**
* UOK Task State Machine
*
* Purpose: map ORCH-style task lifecycle states onto UOK gate outcomes so
* the /tasks surface can query and display background work without inventing
* a parallel state system. Task state is a VIEW over gate runs and unit
* runtime records, not a separate authority.
*
* Consumer: /tasks command, execution-graph scheduler, and UOK kernel
* background-work tracking.
*/
import { isDbAvailable } from "../sf-db.js";
import { isTerminalUnitRuntimeStatus, UNIT_RUNTIME_STATUSES } from "./unit-runtime.js";
export const TASK_STATES = [
"todo",
"in_progress",
"review",
"done",
"retrying",
"failed",
"cancelled",
];
export const TASK_TERMINAL_STATES = new Set(["done", "failed", "cancelled"]);
export const TASK_STATE_TRANSITIONS = {
todo: ["in_progress", "cancelled"],
in_progress: ["review", "done", "retrying", "failed", "cancelled"],
review: ["in_progress", "done", "failed", "cancelled"],
retrying: ["in_progress", "failed", "cancelled"],
done: [],
failed: ["retrying", "cancelled"],
cancelled: [],
};
/**
* Derive task state from a collection of gate results.
*
* Purpose: reconcile multiple gate outcomes (security, policy, verification,
* etc.) into a single task-level state for /tasks display.
*
* Consumer: ExecutionGraphScheduler after a node completes all its gates.
*/
export function gateOutcomesToTaskState(gateResults) {
if (!gateResults || gateResults.length === 0) return "todo";
const outcomes = gateResults.map((r) => r.outcome);
if (outcomes.some((o) => o === "manual-attention")) return "review";
if (outcomes.some((o) => o === "retry")) return "retrying";
if (outcomes.every((o) => o === "pass")) return "done";
if (outcomes.some((o) => o === "fail")) return "failed";
return "in_progress";
}
/**
* Derive task state from a unit runtime record.
*
* Purpose: bridge the legacy unit-runtime projection (queued/claimed/running/
* completed/failed/blocked/cancelled/stale) to the ORCH-style task state
* vocabulary so /tasks shows a unified view.
*
* Consumer: /tasks query when no active gate run exists for a unit.
*/
export function unitRuntimeToTaskState(record) {
if (!record) return "todo";
const status = record.status ?? record.phase ?? "queued";
switch (status) {
case "queued":
case "claimed":
return "todo";
case "running":
case "progress":
return "in_progress";
case "completed":
return "done";
case "failed":
return "failed";
case "blocked":
return "review";
case "cancelled":
return "cancelled";
case "stale":
case "runaway-recovered":
return "retrying";
case "notified":
return isTerminalUnitRuntimeStatus(status) ? "done" : "in_progress";
default:
return "in_progress";
}
}
/**
* Check if a task state transition is valid.
*
* Purpose: prevent illegal state jumps in /tasks UI and background scheduler.
*/
export function canTransitionTaskState(from, to) {
const allowed = TASK_STATE_TRANSITIONS[from];
if (!allowed) return false;
return allowed.includes(to);
}
/**
* Compute aggregate task state for a milestone or slice.
*
* Purpose: give /tasks a single-line summary: "3 done, 1 in_progress, 2 todo".
*
* Consumer: /tasks header line and footer summary chips.
*/
export function aggregateTaskStates(taskStates) {
const counts = Object.fromEntries(TASK_STATES.map((s) => [s, 0]));
for (const s of taskStates) {
if (s in counts) counts[s]++;
}
const total = taskStates.length;
const terminal = TASK_STATES.filter((s) => TASK_TERMINAL_STATES.has(s)).reduce(
(sum, s) => sum + counts[s],
0,
);
return {
counts,
total,
terminal,
progress:
total > 0 ? Math.round(((terminal / total) * 100)) : 0,
isComplete: terminal === total && total > 0,
};
}
/**
* Build a Task record from execution graph node + gate results.
*
* Purpose: standardize what /tasks stores and queries so the surface does
* not need to understand UOK internals.
*
* Consumer: ExecutionGraphScheduler after node completion, /tasks command.
*/
export function buildTaskRecord({
nodeId,
unitType,
unitId,
milestoneId,
sliceId,
taskId,
title,
gateResults = [],
runtimeRecord = null,
startedAt = null,
endedAt = null,
durationMs = null,
costUsd = null,
modelId = null,
workerId = null,
}) {
const state = gateResults.length > 0
? gateOutcomesToTaskState(gateResults)
: unitRuntimeToTaskState(runtimeRecord);
return {
id: nodeId,
unitType,
unitId,
milestoneId,
sliceId,
taskId,
title: title ?? unitId,
state,
gateResults: gateResults.map((r) => ({
gateId: r.gateId,
outcome: r.outcome,
failureClass: r.failureClass,
attempt: r.attempt,
durationMs: r.durationMs,
})),
startedAt,
endedAt,
durationMs,
costUsd,
modelId,
workerId,
updatedAt: new Date().toISOString(),
};
}

View file

@ -4,7 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [

View file

@ -13,9 +13,9 @@ function readFile(relativePath: string): string {
// ── Dockerfile.sandbox ── // ── Dockerfile.sandbox ──
test("docker/Dockerfile.sandbox exists and uses Node 24 base", () => { test("docker/Dockerfile.sandbox exists and uses Node 26 base", () => {
const content = readFile("docker/Dockerfile.sandbox"); const content = readFile("docker/Dockerfile.sandbox");
assert.match(content, /FROM node:24/); assert.match(content, /FROM node:26/);
}); });
test("docker/Dockerfile.sandbox installs singularity-forge globally", () => { test("docker/Dockerfile.sandbox installs singularity-forge globally", () => {

View file

@ -142,7 +142,7 @@ test("bridge-service workspace-index subprocess uses compiled JS when under node
bridgeSource, bridgeSource,
/resolveSubprocessModule/, /resolveSubprocessModule/,
"bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " + "bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " +
"hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v24 (see #2279)", "hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v26 (see #2279)",
); );
}); });
@ -166,7 +166,7 @@ test("all web service files use resolveSubprocessModule instead of hardcoded .ts
source, source,
/resolveSubprocessModule/, /resolveSubprocessModule/,
`${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` + `${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` +
"subprocess .ts paths will fail under node_modules/ on Node v24 (#2279)", "subprocess .ts paths will fail under node_modules/ on Node v26 (#2279)",
); );
} }
} }

View file

@ -127,9 +127,9 @@ describe("batch directory discovery", () => {
// ─── Node.js compile cache ────────────────────────────────────────────────── // ─── Node.js compile cache ──────────────────────────────────────────────────
describe("Node.js compile cache env setup", () => { describe("Node.js compile cache env setup", () => {
it("NODE_COMPILE_CACHE is settable on Node 24+", () => { it("NODE_COMPILE_CACHE is settable on Node 26+", () => {
const nodeVersion = parseInt(process.versions.node, 10); const nodeVersion = parseInt(process.versions.node, 10);
if (nodeVersion >= 24) { if (nodeVersion >= 26) {
// Verify the env var mechanism works (does not throw) // Verify the env var mechanism works (does not throw)
const original = process.env.NODE_COMPILE_CACHE; const original = process.env.NODE_COMPILE_CACHE;
try { try {

View file

@ -4,14 +4,14 @@ import { join } from "node:path";
/** /**
* Returns the correct Node.js type-stripping flag for subprocess spawning. * Returns the correct Node.js type-stripping flag for subprocess spawning.
* *
* Node v24 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files * Node v26 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files
* resolved under `node_modules/`. When SF is installed globally via npm, * resolved under `node_modules/`. When SF is installed globally via npm,
* all source files live under `node_modules/sf-run/src/...`, so * all source files live under `node_modules/sf-run/src/...`, so
* `--experimental-strip-types` fails deterministically. * `--experimental-strip-types` fails deterministically.
* *
* `--experimental-transform-types` applies a full TypeScript transform that * `--experimental-transform-types` applies a full TypeScript transform that
* works regardless of whether the file is under `node_modules/`. SF requires * works regardless of whether the file is under `node_modules/`. SF requires
* Node 24+, so this flag is always available. * Node 26+, so this flag is always available.
*/ */
export function resolveTypeStrippingFlag(packageRoot: string): string { export function resolveTypeStrippingFlag(packageRoot: string): string {
return isUnderNodeModules(packageRoot) return isUnderNodeModules(packageRoot)
@ -39,7 +39,7 @@ export interface SubprocessModuleResolution {
* Resolves a subprocess module path, preferring compiled `dist/*.js` when the * Resolves a subprocess module path, preferring compiled `dist/*.js` when the
* package root is under `node_modules/`. * package root is under `node_modules/`.
* *
* Node v24 unconditionally refuses `.ts` files under `node_modules/` even * Node v26 unconditionally refuses `.ts` files under `node_modules/` even
* with `--experimental-transform-types`. When SF is installed globally via * with `--experimental-transform-types`. When SF is installed globally via
* npm, every subprocess that loads a `.ts` extension module crashes with * npm, every subprocess that loads a `.ts` extension module crashes with
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.

View file

@ -1,4 +1,4 @@
FROM node:24-bookworm FROM node:26-bookworm
WORKDIR /test WORKDIR /test

View file

@ -166,6 +166,7 @@ export default defineConfig({
"src/tests/**/*.test.mjs", "src/tests/**/*.test.mjs",
"src/resources/extensions/sf/tests/**/*.test.ts", "src/resources/extensions/sf/tests/**/*.test.ts",
"src/resources/extensions/sf/tests/**/*.test.mjs", "src/resources/extensions/sf/tests/**/*.test.mjs",
"src/resources/extensions/sf/learning/*.test.mjs",
"src/resources/extensions/sf-permissions/tests/**/*.test.ts", "src/resources/extensions/sf-permissions/tests/**/*.test.ts",
"src/resources/extensions/shared/tests/**/*.test.ts", "src/resources/extensions/shared/tests/**/*.test.ts",
"src/resources/extensions/claude-code-cli/tests/**/*.test.ts", "src/resources/extensions/claude-code-cli/tests/**/*.test.ts",

View file

@ -33,7 +33,7 @@
"workspace" "workspace"
], ],
"engines": { "engines": {
"node": ">=24.15.0", "node": ">=26.1.0",
"vscode": "^1.95.0" "vscode": "^1.95.0"
}, },
"categories": [ "categories": [

24350
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=26.1.0"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@ -78,7 +78,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@types/node": "^24", "@types/node": "^25.6.2",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",