feat(sf): align node sqlite uok runtime
This commit is contained in:
parent
760564dbfb
commit
19bfc3d3f6
93 changed files with 30516 additions and 27240 deletions
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -77,7 +77,7 @@ body:
|
|||
attributes:
|
||||
label: Node.js version
|
||||
description: Run `node --version`.
|
||||
placeholder: "e.g. v24.14.0"
|
||||
placeholder: "e.g. v26.1.0"
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
|
|
|
|||
2
.github/workflows/build-native.yml
vendored
2
.github/workflows/build-native.yml
vendored
|
|
@ -106,7 +106,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "npm"
|
||||
|
||||
|
|
|
|||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -105,7 +105,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
|
||||
- name: Validate skill references
|
||||
run: node scripts/check-skill-references.mjs
|
||||
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
@ -181,7 +181,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
@ -225,7 +225,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
@ -273,7 +273,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
2
.github/workflows/cleanup-dev-versions.yml
vendored
2
.github/workflows/cleanup-dev-versions.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Unpublish old dev versions
|
||||
|
|
|
|||
4
.github/workflows/dev-publish.yml
vendored
4
.github/workflows/dev-publish.yml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
|
|||
4
.github/workflows/next-publish.yml
vendored
4
.github/workflows/next-publish.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
|
|||
6
.github/workflows/pipeline.yml
vendored
6
.github/workflows/pipeline.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
|
|||
2
.github/workflows/pr-risk.yml
vendored
2
.github/workflows/pr-risk.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
|
||||
# Use the GitHub API to get changed files — no fork code is executed.
|
||||
- name: Get changed files
|
||||
|
|
|
|||
2
.github/workflows/prod-release.yml
vendored
2
.github/workflows/prod-release.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15'
|
||||
node-version: '26.1'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'npm'
|
||||
|
||||
|
|
|
|||
2
.mise.toml
Normal file
2
.mise.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[tools]
|
||||
node = "26.1.0"
|
||||
|
|
@ -1 +1 @@
|
|||
24.15.0
|
||||
26.1.0
|
||||
|
|
|
|||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
24.15.0
|
||||
26.1.0
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ Open the repository in VS Code with the Dev Containers extension, or run:
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Image: ghcr.io/singularity-ng/singularity-foundry
|
||||
# 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
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Sources checked 2026-05-08:
|
|||
<https://github.com/features/copilot/cli>
|
||||
- GitHub Changelog, "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/>
|
||||
- Factory Droid, "Autonomy Level"
|
||||
<https://docs.factory.ai/cli/user-guides/auto-run>
|
||||
|
|
@ -102,6 +102,8 @@ modelMode: fast | smart | deep
|
|||
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:
|
||||
|
||||
```text
|
||||
|
|
@ -295,9 +297,9 @@ Required surfaces:
|
|||
|
||||
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:
|
||||
|
||||
|
|
@ -434,7 +436,7 @@ Direct command:
|
|||
|
||||
It should show:
|
||||
|
||||
- autonomous units
|
||||
- autonomous units (durable state: todo | in_progress | review | done | retrying | failed | cancelled)
|
||||
- parallel workers
|
||||
- scheduled autonomous dispatches
|
||||
- background shell sessions
|
||||
|
|
@ -443,10 +445,12 @@ It should show:
|
|||
- current cost/budget state
|
||||
- last checkpoint and next action
|
||||
|
||||
Task lifecycle uses ORCH-style states. `todo` means ready to run, not "queued."
|
||||
|
||||
This complements, not replaces:
|
||||
|
||||
- `/status`
|
||||
- `/queue`
|
||||
- `/queue` (milestone dispatch order, not task state)
|
||||
- `/parallel status`
|
||||
- `/session-report`
|
||||
- `/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
|
||||
start replacing fragile `Date`/millisecond logic with Temporal in the schedule,
|
||||
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`)?
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Image: ghcr.io/sf-build/sf-ci-builder
|
||||
# Used by: pipeline.yml Dev stage
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:24-bookworm
|
||||
FROM node:26-bookworm
|
||||
|
||||
# Rust toolchain (stable, minimal profile)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# Purpose: Isolated environment for SF auto mode
|
||||
# Usage: docker sandbox create --template ./docker
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:24-bookworm-slim
|
||||
FROM node:26-bookworm-slim
|
||||
|
||||
# System dependencies required by SF
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ Persist learning: when a unit produces a gotcha or anti-pattern, write to sf's m
|
|||
| 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. |
|
||||
| `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). |
|
||||
| SOPS unavailable | Use already-exported env vars; log that secret refresh is unavailable. Block secret-touching commands. |
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ UOK Kernel (executes units)
|
|||
↓ records outcomes via
|
||||
Unit Runtime (recordUnitOutcomeInMemory)
|
||||
↓ stores patterns in
|
||||
Memory System (SQLite, Node 24 native)
|
||||
Memory System (SQLite, Node 26 native)
|
||||
↓ queried by
|
||||
Dispatch (enhanceUnitRankingWithMemory)
|
||||
↓ boosts scores for matching patterns
|
||||
|
|
@ -70,7 +70,7 @@ Dispatch (enhanceUnitRankingWithMemory)
|
|||
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
|
||||
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
|
||||
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
|
||||
- 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
|
||||
- Embedding computation
|
||||
- Score boosting formula
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 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
|
||||
- **Recall** similar past situations for context-aware decisions
|
||||
|
|
@ -286,7 +286,7 @@ memory_sources (
|
|||
### **sf-db.js** (SQLite Backend)
|
||||
**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:**
|
||||
- `memories` — Memory entries
|
||||
|
|
@ -295,7 +295,7 @@ memory_sources (
|
|||
- `memory_sources` — Source tracking
|
||||
- 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) │ │
|
||||
│ │ 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.**
|
||||
|
||||
- **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
|
||||
- **No external exposure** — Memory system is not a service; it's SF's autonomous learning mechanism
|
||||
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ all.forEach(m => console.log(`${m.id}: ${m.hit_count} hits`));
|
|||
## Architecture: Memory is Internal
|
||||
|
||||
- **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
|
||||
- **Private learning** — autonomous pattern extraction
|
||||
|
||||
|
|
|
|||
|
|
@ -1,312 +1,46 @@
|
|||
# SQLite Migration Guide for Model Learning
|
||||
# SQLite Runtime Baseline
|
||||
|
||||
**Status**: Planned for Node 24.15.0 upgrade
|
||||
**Current**: JSON-based storage (model-learner.js, self-report-fixer.js)
|
||||
**Target**: Native `node:sqlite` integration
|
||||
**Status:** complete for the Node 26.1 runtime baseline.
|
||||
|
||||
## 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)
|
||||
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
|
||||
## Runtime Rule
|
||||
|
||||
## 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
|
||||
- `model-learner.js`: `.sf/model-performance.json` (nested object hierarchy)
|
||||
```json
|
||||
{
|
||||
"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`
|
||||
Runtime code must not introduce `sql.js`, `better-sqlite3`, `sqlite3` CLI
|
||||
shell-outs, or JSON fallback databases for DB-owned state. If a feature needs
|
||||
ordering, validation, joins, leases, TTLs, or history, put it in `.sf/sf.db`.
|
||||
|
||||
### Pain Points
|
||||
- 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
|
||||
## Current Surfaces
|
||||
|
||||
## 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
|
||||
Raw event log for every model outcome.
|
||||
## Development Rules
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_outcomes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_type TEXT NOT NULL, -- "execute-task", "plan-slice", etc.
|
||||
model_id TEXT NOT NULL, -- "gpt-4o", "claude-opus", etc.
|
||||
success INTEGER NOT NULL, -- 1 = success, 0 = failure
|
||||
timeout INTEGER NOT NULL DEFAULT 0, -- 1 = timed out, 0 = normal
|
||||
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)
|
||||
);
|
||||
1. Use SF query/writer helpers when operating inside the SF extension.
|
||||
2. Use `node:sqlite` directly only for isolated tooling or package code that
|
||||
cannot import the SF extension runtime.
|
||||
3. Prefer read-only SQLite handles for monitors and inspection overlays.
|
||||
4. Do not keep compatibility code for retired JSON or sidecar DB stores unless
|
||||
an explicit migration command owns it.
|
||||
5. Add behavior tests before changing persistence semantics.
|
||||
|
||||
CREATE INDEX idx_outcomes_task_model ON model_outcomes(task_type, model_id);
|
||||
CREATE INDEX idx_outcomes_timestamp ON model_outcomes(timestamp DESC);
|
||||
## Verification
|
||||
|
||||
```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
|
||||
|
|
|
|||
|
|
@ -153,8 +153,8 @@ For `@dev` or `@next` rollbacks, the next successful merge will overwrite the ta
|
|||
|
||||
| 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-run` | `node:24-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` |
|
||||
| `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: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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
**Status:** Promoted plan
|
||||
**Source:** Promoted from `.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md`
|
||||
**Status:** Implemented baseline, remaining work is product integration.
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
## User-Visible Outcome
|
||||
|
||||
### 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.**
|
||||
- `/memory rebuild` records extraction queue state in `.sf/sf.db`.
|
||||
- `/memory view` reads the same project memory source that UOK and prompt
|
||||
assembly use.
|
||||
- Grep finds no runtime memory import of alternate SQLite engines.
|
||||
- Tests cover the DB path, rebuild path, and clear path.
|
||||
|
|
|
|||
623
docs/specs/agent-mode-system.md
Normal file
623
docs/specs/agent-mode-system.md
Normal 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>
|
||||
|
|
@ -55,8 +55,8 @@ sudo pacman -S nodejs npm git
|
|||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
|
||||
source ~/.bashrc # or ~/.zshrc
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
nvm install 26
|
||||
nvm use 26
|
||||
```
|
||||
|
||||
#### All distros: Steps 2-7
|
||||
|
|
@ -64,7 +64,7 @@ nvm use 24
|
|||
**Step 2 — Verify dependencies are installed:**
|
||||
|
||||
```bash
|
||||
node --version # should print v24.x or higher
|
||||
node --version # should print v26.x or higher
|
||||
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` |
|
||||
| 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 |
|
||||
| `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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
147
package-lock.json
generated
147
package-lock.json
generated
|
|
@ -50,7 +50,6 @@
|
|||
"remark-parse": "^11.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
"sql.js": "^1.14.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"undici": "^7.24.2",
|
||||
"unified": "^11.0.5",
|
||||
|
|
@ -66,8 +65,8 @@
|
|||
"sf-server": "packages/daemon/dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.13",
|
||||
"@types/node": "^24.12.0",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
|
|
@ -78,7 +77,7 @@
|
|||
"vitest": "^4.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
|
|
@ -1181,9 +1180,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz",
|
||||
"integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
|
||||
"integrity": "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -1197,20 +1196,20 @@
|
|||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.13",
|
||||
"@biomejs/cli-darwin-x64": "2.4.13",
|
||||
"@biomejs/cli-linux-arm64": "2.4.13",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.13",
|
||||
"@biomejs/cli-linux-x64": "2.4.13",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.13",
|
||||
"@biomejs/cli-win32-arm64": "2.4.13",
|
||||
"@biomejs/cli-win32-x64": "2.4.13"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.14",
|
||||
"@biomejs/cli-darwin-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.14",
|
||||
"@biomejs/cli-linux-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.14",
|
||||
"@biomejs/cli-win32-arm64": "2.4.14",
|
||||
"@biomejs/cli-win32-x64": "2.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz",
|
||||
"integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1225,9 +1224,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz",
|
||||
"integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1242,13 +1241,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz",
|
||||
"integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1259,13 +1261,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz",
|
||||
"integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1276,13 +1281,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz",
|
||||
"integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1293,13 +1301,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz",
|
||||
"integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1310,9 +1321,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz",
|
||||
"integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1327,9 +1338,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz",
|
||||
"integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -6314,13 +6325,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -6415,12 +6419,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/normalize-package-data": {
|
||||
|
|
@ -6518,17 +6522,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sql.js": {
|
||||
"version": "1.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.9.tgz",
|
||||
"integrity": "sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
|
|
@ -12341,12 +12334,6 @@
|
|||
"integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
|
|
@ -12895,9 +12882,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
|
|
@ -13602,11 +13589,11 @@
|
|||
"sf-server": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
},
|
||||
"packages/daemon/node_modules/zod": {
|
||||
|
|
@ -13623,7 +13610,7 @@
|
|||
"version": "2.75.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@singularity-forge/engine-darwin-arm64": ">=2.75.0",
|
||||
|
|
@ -13637,7 +13624,7 @@
|
|||
"name": "@singularity-forge/pi-agent-core",
|
||||
"version": "2.75.3",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
},
|
||||
"packages/pi-ai": {
|
||||
|
|
@ -13665,7 +13652,7 @@
|
|||
"@smithy/node-http-handler": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
},
|
||||
"packages/pi-ai/node_modules/@smithy/node-http-handler": {
|
||||
|
|
@ -13701,7 +13688,6 @@
|
|||
"marked": "^15.0.12",
|
||||
"minimatch": "^10.2.3",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"sql.js": "^1.14.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"undici": "^7.24.2",
|
||||
"yaml": "^2.8.2"
|
||||
|
|
@ -13710,11 +13696,10 @@
|
|||
"@types/diff": "^7.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/hosted-git-info": "^3.0.5",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/sql.js": "^1.4.9"
|
||||
"@types/proper-lockfile": "^4.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
},
|
||||
"packages/pi-coding-agent/node_modules/accepts": {
|
||||
|
|
@ -14021,7 +14006,7 @@
|
|||
"@types/mime-types": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"koffi": "^2.9.0"
|
||||
|
|
@ -14032,7 +14017,7 @@
|
|||
"version": "2.75.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"configDir": ".sf"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"packageManager": "npm@11.13.0",
|
||||
"scripts": {
|
||||
|
|
@ -148,7 +148,6 @@
|
|||
"remark-parse": "^11.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
"sql.js": "^1.14.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"undici": "^7.24.2",
|
||||
"unified": "^11.0.5",
|
||||
|
|
@ -159,7 +158,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@
|
|||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
|
|
|||
|
|
@ -81,22 +81,22 @@ describe("generatePlist", () => {
|
|||
|
||||
it("uses the absolute node path from opts", () => {
|
||||
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);
|
||||
assert.ok(
|
||||
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", () => {
|
||||
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);
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@
|
|||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@singularity-forge/engine-darwin-arm64": ">=2.75.0",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* for Node.js module resolution (ESM/CJS compatibility).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
|
|||
assert.notEqual(
|
||||
pkg.type,
|
||||
"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)",
|
||||
);
|
||||
});
|
||||
|
|
@ -55,7 +55,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
|
|||
assert.ok(
|
||||
!conditions.import || conditions.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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,6 @@
|
|||
},
|
||||
"dependencies": {},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,6 @@
|
|||
"@smithy/node-http-handler": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,18 +33,16 @@
|
|||
"proper-lockfile": "^4.1.2",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"undici": "^7.24.2",
|
||||
"sql.js": "^1.14.1",
|
||||
"yaml": "^2.8.2",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/hosted-git-info": "^3.0.5",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/express": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
* - /memory command: view, clear, rebuild, stats
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { completeSimple } from "@singularity-forge/pi-ai";
|
||||
|
|
@ -23,26 +22,25 @@ import {
|
|||
import { getFullMemory, getMemorySummary, runStartup } from "./pipeline.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 */
|
||||
function getMemoryDir(cwd: string): string {
|
||||
return join(getAgentDir(), "memories", encodeCwd(cwd));
|
||||
return join(cwd, ".sf", "memory");
|
||||
}
|
||||
|
||||
/** Get the database path */
|
||||
function getDbPath(): string {
|
||||
return join(getAgentDir(), "agent.db");
|
||||
function getDbPath(cwd: string): string {
|
||||
return join(cwd, ".sf", "sf.db");
|
||||
}
|
||||
|
||||
let storageInstance: MemoryStorage | null = null;
|
||||
let storageDbPath: string | null = null;
|
||||
|
||||
async function getStorage(): Promise<MemoryStorage> {
|
||||
if (!storageInstance) {
|
||||
storageInstance = await MemoryStorage.create(getDbPath());
|
||||
async function getStorage(cwd: string): Promise<MemoryStorage> {
|
||||
const dbPath = getDbPath(cwd);
|
||||
if (!storageInstance || storageDbPath !== dbPath) {
|
||||
storageInstance?.close();
|
||||
storageInstance = await MemoryStorage.create(dbPath);
|
||||
storageDbPath = dbPath;
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
|
|
@ -130,7 +128,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
|||
|
||||
// Fire and forget
|
||||
runStartup(
|
||||
await getStorage(),
|
||||
await getStorage(cwd),
|
||||
{
|
||||
sessionsDir,
|
||||
memoryDir,
|
||||
|
|
@ -212,7 +210,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
|||
"Delete all extracted memories for this project?",
|
||||
);
|
||||
if (confirmed) {
|
||||
(await getStorage()).clearForCwd(ctx.cwd);
|
||||
(await getStorage(ctx.cwd)).clearForCwd(ctx.cwd);
|
||||
if (existsSync(projectMemoryDir)) {
|
||||
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.",
|
||||
);
|
||||
if (confirmed) {
|
||||
(await getStorage()).resetAllForCwd(ctx.cwd);
|
||||
(await getStorage(ctx.cwd)).resetAllForCwd(ctx.cwd);
|
||||
if (existsSync(projectMemoryDir)) {
|
||||
rmSync(projectMemoryDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -240,7 +238,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
|||
}
|
||||
|
||||
case "stats": {
|
||||
const stats = (await getStorage()).getStats();
|
||||
const stats = (await getStorage(ctx.cwd)).getStats();
|
||||
const statsText = [
|
||||
"Memory Pipeline Statistics:",
|
||||
` Total sessions tracked: ${stats.totalThreads}`,
|
||||
|
|
@ -274,6 +272,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
|||
if (storageInstance) {
|
||||
storageInstance.close();
|
||||
storageInstance = null;
|
||||
storageDbPath = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { join } from "node:path";
|
||||
import { afterEach, describe, it } from "vitest";
|
||||
|
|
@ -10,11 +10,7 @@ function makeTmpDir(): string {
|
|||
return mkdtempSync(join(tmpdir(), "sf-memory-storage-test-"));
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("MemoryStorage debounced persistence", () => {
|
||||
describe("MemoryStorage node:sqlite persistence", () => {
|
||||
let dir: string;
|
||||
|
||||
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();
|
||||
const dbPath = join(dir, "test.db");
|
||||
const storage = await MemoryStorage.create(dbPath);
|
||||
|
||||
const initialStat = readFileSync(dbPath);
|
||||
const _initialMtime = initialStat.length;
|
||||
|
||||
storage.upsertThreads([
|
||||
{
|
||||
threadId: "t1",
|
||||
|
|
@ -59,35 +52,18 @@ describe("MemoryStorage debounced persistence", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
const afterMutationsBuf = readFileSync(dbPath);
|
||||
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",
|
||||
);
|
||||
|
||||
assert.equal(existsSync(dbPath), true);
|
||||
const stats = storage.getStats();
|
||||
assert.equal(stats.totalThreads, 3);
|
||||
|
||||
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();
|
||||
const dbPath = join(dir, "test.db");
|
||||
const storage = await MemoryStorage.create(dbPath);
|
||||
|
||||
const initialBuf = readFileSync(dbPath);
|
||||
|
||||
storage.upsertThreads([
|
||||
{
|
||||
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();
|
||||
|
||||
const afterCloseBuf = readFileSync(dbPath);
|
||||
assert.notDeepEqual(
|
||||
afterCloseBuf,
|
||||
initialBuf,
|
||||
"File should have been written immediately on close()",
|
||||
);
|
||||
|
||||
const reopened = await MemoryStorage.create(dbPath);
|
||||
const stats = reopened.getStats();
|
||||
assert.equal(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
*/
|
||||
|
||||
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 initSqlJs, { type Database as SqlJsDatabase } from "sql.js";
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
||||
|
||||
export interface ThreadRow {
|
||||
thread_id: string;
|
||||
|
|
@ -44,13 +44,10 @@ export interface JobRow {
|
|||
}
|
||||
|
||||
export class MemoryStorage {
|
||||
private db: SqlJsDatabase;
|
||||
private dbPath: string;
|
||||
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private db: DatabaseSync;
|
||||
|
||||
private constructor(db: SqlJsDatabase, dbPath: string) {
|
||||
private constructor(db: DatabaseSync) {
|
||||
this.db = db;
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
static async create(dbPath: string): Promise<MemoryStorage> {
|
||||
|
|
@ -59,36 +56,23 @@ export class MemoryStorage {
|
|||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs();
|
||||
const buffer = existsSync(dbPath) ? readFileSync(dbPath) : undefined;
|
||||
const db = buffer ? new SQL.Database(buffer) : new SQL.Database();
|
||||
const db = new DatabaseSync(dbPath);
|
||||
|
||||
db.run("PRAGMA journal_mode = WAL");
|
||||
db.run("PRAGMA synchronous = NORMAL");
|
||||
db.run("PRAGMA busy_timeout = 5000");
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA synchronous = NORMAL");
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
|
||||
const storage = new MemoryStorage(db, dbPath);
|
||||
const storage = new MemoryStorage(db);
|
||||
storage.initSchema();
|
||||
return storage;
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
const data = this.db.export();
|
||||
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 run(sql: string, params: unknown[] = []): void {
|
||||
this.db.prepare(sql).run(...(params as SQLInputValue[]));
|
||||
}
|
||||
|
||||
private initSchema(): void {
|
||||
this.db.run(`
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
file_path TEXT NOT NULL,
|
||||
|
|
@ -101,7 +85,7 @@ export class MemoryStorage {
|
|||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
this.db.run(`
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stage1_outputs (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
extraction_json TEXT NOT NULL,
|
||||
|
|
@ -109,7 +93,7 @@ export class MemoryStorage {
|
|||
FOREIGN KEY (thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.run(`
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
phase TEXT NOT NULL,
|
||||
|
|
@ -123,30 +107,23 @@ export class MemoryStorage {
|
|||
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)",
|
||||
);
|
||||
this.db.run(
|
||||
this.db.exec(
|
||||
"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.persist();
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)");
|
||||
}
|
||||
|
||||
private queryAll<T>(sql: string, params: unknown[] = []): T[] {
|
||||
const stmt = this.db.prepare(sql);
|
||||
stmt.bind(params as (string | number | null | Uint8Array)[]);
|
||||
const rows: T[] = [];
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject() as T);
|
||||
}
|
||||
stmt.free();
|
||||
return rows;
|
||||
return this.db.prepare(sql).all(...(params as SQLInputValue[])) as T[];
|
||||
}
|
||||
|
||||
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
|
||||
const rows = this.queryAll<T>(sql, params);
|
||||
return rows[0];
|
||||
return this.db.prepare(sql).get(...(params as SQLInputValue[])) as
|
||||
| T
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -177,11 +154,11 @@ export class MemoryStorage {
|
|||
);
|
||||
|
||||
if (!existing) {
|
||||
this.db.run(
|
||||
this.run(
|
||||
"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],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
||||
[randomUUID(), t.threadId],
|
||||
);
|
||||
|
|
@ -190,12 +167,12 @@ export class MemoryStorage {
|
|||
existing.file_size !== t.fileSize ||
|
||||
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 = ?",
|
||||
[t.filePath, t.fileSize, t.fileMtime, t.cwd, t.threadId],
|
||||
);
|
||||
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')",
|
||||
[randomUUID(), t.threadId],
|
||||
);
|
||||
|
|
@ -206,7 +183,6 @@ export class MemoryStorage {
|
|||
}
|
||||
}
|
||||
|
||||
this.schedulePersist();
|
||||
return { inserted, updated, skipped };
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +198,7 @@ export class MemoryStorage {
|
|||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
|
||||
|
||||
this.db.run(
|
||||
this.run(
|
||||
`UPDATE jobs SET
|
||||
status = 'claimed',
|
||||
worker_id = ?,
|
||||
|
|
@ -243,8 +219,6 @@ export class MemoryStorage {
|
|||
[token],
|
||||
);
|
||||
|
||||
this.schedulePersist();
|
||||
|
||||
return rows.map((r) => ({
|
||||
jobId: r.id,
|
||||
threadId: r.thread_id,
|
||||
|
|
@ -256,34 +230,32 @@ export class MemoryStorage {
|
|||
* Mark a stage1 job as complete and store the extraction output.
|
||||
*/
|
||||
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'",
|
||||
[threadId],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"INSERT OR REPLACE INTO stage1_outputs (thread_id, extraction_json, created_at) VALUES (?, ?, datetime('now'))",
|
||||
[threadId, output],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?",
|
||||
[threadId],
|
||||
);
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage1 job as errored.
|
||||
*/
|
||||
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'",
|
||||
[errorMessage, threadId],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?",
|
||||
[errorMessage, threadId],
|
||||
);
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -322,12 +294,11 @@ export class MemoryStorage {
|
|||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
this.db.run(
|
||||
this.run(
|
||||
"INSERT INTO jobs (id, phase, status, worker_id, ownership_token, lease_expires_at) VALUES (?, 'stage2', 'claimed', ?, ?, ?)",
|
||||
[jobId, workerId, token, expiresAt],
|
||||
);
|
||||
|
||||
this.schedulePersist();
|
||||
return { jobId, ownershipToken: token };
|
||||
}
|
||||
|
||||
|
|
@ -335,11 +306,10 @@ export class MemoryStorage {
|
|||
* Complete the phase 2 consolidation job.
|
||||
*/
|
||||
completePhase2Job(jobId: string): void {
|
||||
this.db.run(
|
||||
this.run(
|
||||
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'",
|
||||
[jobId],
|
||||
);
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -432,41 +402,39 @@ export class MemoryStorage {
|
|||
* Clear all data (for /memory clear).
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.db.run("DELETE FROM stage1_outputs");
|
||||
this.db.run("DELETE FROM jobs");
|
||||
this.db.run("DELETE FROM threads");
|
||||
this.schedulePersist();
|
||||
this.db.exec("DELETE FROM stage1_outputs");
|
||||
this.db.exec("DELETE FROM jobs");
|
||||
this.db.exec("DELETE FROM threads");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear data for a specific cwd (for /memory clear in project scope).
|
||||
*/
|
||||
clearForCwd(cwd: string): void {
|
||||
this.db.run(
|
||||
this.run(
|
||||
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||
[cwd],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||
[cwd],
|
||||
);
|
||||
this.db.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
|
||||
this.schedulePersist();
|
||||
this.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all threads to pending (for /memory rebuild).
|
||||
*/
|
||||
resetAllForCwd(cwd: string): void {
|
||||
this.db.run(
|
||||
this.run(
|
||||
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||
[cwd],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||
[cwd],
|
||||
);
|
||||
this.db.run(
|
||||
this.run(
|
||||
"UPDATE threads SET status = 'pending', updated_at = datetime('now') WHERE cwd = ?",
|
||||
[cwd],
|
||||
);
|
||||
|
|
@ -477,20 +445,14 @@ export class MemoryStorage {
|
|||
);
|
||||
|
||||
for (const t of threads) {
|
||||
this.db.run(
|
||||
this.run(
|
||||
"INSERT INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
||||
[randomUUID(), t.thread_id],
|
||||
);
|
||||
}
|
||||
this.schedulePersist();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
this.persist();
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,32 +26,6 @@ declare module "proper-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" {
|
||||
export interface HostedGitInfo {
|
||||
domain?: string;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@
|
|||
"koffi": "^2.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,6 @@
|
|||
"test": "node --test dist/rpc-client.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "sf",
|
||||
"version": "2.75.3",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"piConfig": {
|
||||
"name": "sf",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
* Data sources:
|
||||
* .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/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.stderr.log — error surfacing
|
||||
*
|
||||
|
|
@ -44,6 +44,7 @@
|
|||
import { execSync, spawn, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -175,26 +176,38 @@ function readAutoLock(mid) {
|
|||
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) {
|
||||
const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`);
|
||||
if (!fs.existsSync(dbPath)) return [];
|
||||
|
||||
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`;
|
||||
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, {
|
||||
timeout: 3000,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
if (!out) return [];
|
||||
return out.split("\n").map((line) => {
|
||||
const [id, status, total, done] = line.split("|");
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
total: parseInt(total, 10),
|
||||
done: parseInt(done || "0", 10),
|
||||
};
|
||||
});
|
||||
return queryRows(
|
||||
dbPath,
|
||||
`SELECT s.id AS id,
|
||||
s.status AS status,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
|
||||
FROM slices s
|
||||
LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
|
||||
WHERE s.milestone_id=?
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -631,17 +644,23 @@ function queryRecentCompletions(mid) {
|
|||
|
||||
try {
|
||||
// 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`;
|
||||
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, {
|
||||
timeout: 3000,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
if (!out) return [];
|
||||
return out.split("\n").map((line) => {
|
||||
const [taskId, sliceId, oneLiner, completedAt] = line.split("|");
|
||||
return queryRows(
|
||||
dbPath,
|
||||
`SELECT id AS taskId,
|
||||
slice_id AS sliceId,
|
||||
one_liner AS oneLiner,
|
||||
completed_at AS completedAt
|
||||
FROM tasks
|
||||
WHERE milestone_id=?
|
||||
AND status='complete'
|
||||
AND completed_at IS NOT NULL
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT 5`,
|
||||
[mid],
|
||||
).map((row) => {
|
||||
return {
|
||||
ts: completedAt ? new Date(completedAt).getTime() : Date.now(),
|
||||
msg: `✓ ${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`,
|
||||
ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(),
|
||||
msg: `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
||||
mid,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import { stopWebMode } from "./web-mode.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.
|
||||
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ if (
|
|||
// 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 bold = "\x1b[1m";
|
||||
const dim = "\x1b[2m";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "module",
|
||||
"description": "cmux integration library — used by other extensions, not an extension itself",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ export function syncProjectRootToWorktree(
|
|||
}
|
||||
// Always clean up WAL/SHM sidecar files when the main DB was deleted
|
||||
// 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).
|
||||
if (deleteSidecars) {
|
||||
for (const suffix of ["-wal", "-shm"]) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@
|
|||
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
|
||||
* `let` or `var` declarations.
|
||||
*/
|
||||
import {
|
||||
buildModeState,
|
||||
resolveModelMode,
|
||||
resolvePermissionProfile,
|
||||
resolveRunControlMode,
|
||||
resolveWorkMode,
|
||||
} from "../operating-model.js";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
export const MAX_UNIT_DISPATCHES = 3;
|
||||
export const STUB_RECOVERY_THRESHOLD = 2;
|
||||
|
|
@ -48,6 +56,31 @@ export class AutoSession {
|
|||
activeEngineId = null;
|
||||
activeRunDir = 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 ────────────────────────────────────────────────────────────────
|
||||
basePath = "";
|
||||
originalBasePath = "";
|
||||
|
|
@ -224,6 +257,12 @@ export class AutoSession {
|
|||
this.activeEngineId = null;
|
||||
this.activeRunDir = null;
|
||||
this.cmdCtx = null;
|
||||
// Mode state
|
||||
this.workMode = "chat";
|
||||
this.permissionProfile = "restricted";
|
||||
this.modelMode = "smart";
|
||||
this.surface = "tui";
|
||||
this.modeUpdatedAt = null;
|
||||
// Paths
|
||||
this.basePath = "";
|
||||
this.originalBasePath = "";
|
||||
|
|
@ -296,6 +335,47 @@ export class AutoSession {
|
|||
this.sigtermHandler = null;
|
||||
// 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() {
|
||||
return {
|
||||
active: this.active,
|
||||
|
|
@ -307,6 +387,7 @@ export class AutoSession {
|
|||
currentMilestoneId: this.currentMilestoneId,
|
||||
currentUnit: this.currentUnit,
|
||||
unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
|
||||
mode: this.getMode(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
try {
|
||||
// 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[0].provider, "kimi-coding");
|
||||
// Provider wire ID for Kimi K2.6.
|
||||
assert.equal(mainChain[0].model, "kimi-for-coding");
|
||||
assert.equal(mainChain[0].model, "kimi-k2.6");
|
||||
assert.equal(mainChain[0].priority, 0);
|
||||
|
||||
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.equal(chains.main.length, 1);
|
||||
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
|
||||
assert.ok(Array.isArray(chains.planning), "planning chain present");
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ test("registerRoutingHook: registers handler + reload command and routes a simul
|
|||
|
||||
const pi = makeFakePi();
|
||||
// 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, {
|
||||
dbPath: "/tmp/sf-learning-test-nonexistent.db",
|
||||
notify: true,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Wires together the four S01-S04 modules into a single registerable plugin:
|
||||
*
|
||||
* 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
|
||||
* bayesian-blender → α · prior + (1-α) · observed + UCB1
|
||||
* hook-handler → translates the above into a before_model_select handler
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
*
|
||||
* import { init } from "./index.mjs";
|
||||
* const plugin = await init(pi, {
|
||||
* dbPath: "~/.sf/sf-learning.db",
|
||||
* priorsPath: "./src/data/model-benchmarks.json",
|
||||
* weightsPath: "./src/data/unit-weights.json",
|
||||
* nPrior: 10,
|
||||
|
|
@ -25,23 +24,27 @@
|
|||
* // plugin.unregister() on tear down
|
||||
*
|
||||
* ## Side effects
|
||||
* - Opens (or creates) a SQLite database at the resolved dbPath
|
||||
* - Bootstraps the schema if absent
|
||||
* - Uses the already-open `.sf/sf.db`, or opens `<basePath>/.sf/sf.db`
|
||||
* - Relies on the shared SF DB bootstrap for `llm_task_outcomes`
|
||||
* - Registers a hook on the supplied pi instance
|
||||
*
|
||||
* ## Errors
|
||||
* - Init failures are wrapped with a stage label so callers can see where
|
||||
* things broke ("loading priors", "opening db", "applying schema",
|
||||
* "registering hook")
|
||||
* things broke ("loading priors", "opening db", "registering hook")
|
||||
* - Once init succeeds, the running handler is fire-and-forget — it cannot
|
||||
* crash the dispatch path
|
||||
*
|
||||
* @module sf-learning
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdirSync } from "node:fs";
|
||||
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 {
|
||||
createBeforeModelSelectHandler,
|
||||
|
|
@ -49,26 +52,24 @@ import {
|
|||
} from "./hook-handler.mjs";
|
||||
import { loadCapabilityOverrides } from "./loadCapabilityOverrides.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_ROLLING_DAYS = 30;
|
||||
const DEFAULT_EXPLORATION_C = 1.4;
|
||||
const DEFAULT_DB_SUBPATH = [".sf", "sf.db"];
|
||||
const HOME_REGEX = /^~(?=$|\/)/;
|
||||
|
||||
/**
|
||||
* @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} [weightsPath] - default: <plugin>/data/unit-weights.json
|
||||
* @property {number} [nPrior=10]
|
||||
* @property {number} [rollingDays=30]
|
||||
* @property {number} [explorationC=1.4]
|
||||
* @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]
|
||||
*/
|
||||
|
||||
|
|
@ -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}
|
||||
*/
|
||||
function loadSchemaSql() {
|
||||
return readFileSync(SCHEMA_PATH, "utf8");
|
||||
function defaultDbPath(config) {
|
||||
return join(config.basePath ?? process.cwd(), ...DEFAULT_DB_SUBPATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether we're running under Bun. better-sqlite3 is a Node native
|
||||
* 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.
|
||||
* Resolve the learning outcomes database from SF's shared database handle.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
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.
|
||||
* Purpose: keep UOK outcome learning, model stats, and learned routing on one
|
||||
* `.sf/sf.db` ledger instead of splitting feedback into a sidecar database.
|
||||
*
|
||||
* @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) {
|
||||
return config.db;
|
||||
}
|
||||
|
||||
const dbPath = expandPath(config.dbPath ?? DEFAULT_DB_PATH);
|
||||
|
||||
if (isBunRuntime()) {
|
||||
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 activeDb = getDatabase();
|
||||
if (activeDb) {
|
||||
return activeDb;
|
||||
}
|
||||
|
||||
const Database = await tryImportBetterSqlite();
|
||||
if (!Database) {
|
||||
throw new Error(
|
||||
"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.",
|
||||
);
|
||||
const dbPath = expandPath(config.dbPath ?? defaultDbPath(config));
|
||||
if (dbPath !== ":memory:") {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
return new Database(dbPath);
|
||||
if (!openSfDatabase(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 {PluginConfig} [config={}]
|
||||
|
|
@ -249,13 +202,6 @@ export async function init(pi, config = {}) {
|
|||
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);
|
||||
|
||||
let unregister;
|
||||
|
|
@ -293,7 +239,7 @@ export async function init(pi, config = {}) {
|
|||
return {
|
||||
unregister,
|
||||
fallbackWriteSummary,
|
||||
recordOutcome: (outcome) => recordOutcome(db, outcome),
|
||||
recordOutcome: (outcome) => insertLlmTaskOutcome(outcome),
|
||||
reloadPriors: async () => {
|
||||
const fresh = await loadCapabilityOverrides({
|
||||
benchmarksPath: config.priorsPath,
|
||||
|
|
|
|||
63
src/resources/extensions/sf/learning/index.test.mjs
Normal file
63
src/resources/extensions/sf/learning/index.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -19,13 +19,13 @@ import {
|
|||
} 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.
|
||||
*
|
||||
* The aggregator runs SELECT ... GROUP BY model_id; rather than implementing a
|
||||
* 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
|
||||
* real native dependency.
|
||||
* real file-backed database.
|
||||
*/
|
||||
function createFakeDb() {
|
||||
const rows = [];
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
*
|
||||
* ## Dependencies
|
||||
* - 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
|
||||
* - All SQL is parameterized — no string interpolation of caller input.
|
||||
|
|
|
|||
|
|
@ -10,4 +10,3 @@ export declare function recordOutcomeBatch(
|
|||
db: unknown,
|
||||
outcomes: Array<Record<string, unknown>>,
|
||||
): number;
|
||||
export declare function ensureSchema(db: unknown, schemaSql?: string): void;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
* ## Responsibilities
|
||||
* - Validate outcome shape before insertion
|
||||
* - Insert one or many outcomes via parameterized SQL
|
||||
* - Bootstrap the schema on a fresh database
|
||||
*
|
||||
* ## Contract — fire-and-forget
|
||||
* `recordOutcome` and `recordOutcomeBatch` must NEVER throw. They catch
|
||||
|
|
@ -17,7 +16,7 @@
|
|||
* ## Dependencies
|
||||
* - Duck-typed SQLite handle exposing `prepare(sql).run(...params)`,
|
||||
* `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
|
||||
* 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.
|
||||
*
|
||||
* Invalid rows are skipped and counted; valid rows are inserted. If the
|
||||
* database supports `transaction()` (better-sqlite3 style), the inserts run
|
||||
* inside it; otherwise they run sequentially.
|
||||
* database supports `transaction()`, the inserts run inside it. With
|
||||
* `node:sqlite`, batches are wrapped in an explicit SQL transaction to avoid
|
||||
* repeated writer-lock churn.
|
||||
*
|
||||
* @param {object} db Duck-typed SQLite handle
|
||||
* @param {Outcome[]} outcomes
|
||||
|
|
@ -273,6 +273,23 @@ export function recordOutcomeBatch(db, outcomes) {
|
|||
if (typeof db.transaction === "function") {
|
||||
const txn = db.transaction(insertAll);
|
||||
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 {
|
||||
insertAll();
|
||||
}
|
||||
|
|
@ -284,43 +301,3 @@ export function recordOutcomeBatch(db, outcomes) {
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* sf-learning: outcome-recorder + outcome-aggregator tests
|
||||
*
|
||||
* 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
|
||||
* insert and aggregate semantics without spinning up real SQLite.
|
||||
*/
|
||||
|
|
@ -16,14 +16,13 @@ import {
|
|||
totalSamples,
|
||||
} from "./outcome-aggregator.mjs";
|
||||
import {
|
||||
ensureSchema,
|
||||
recordOutcome,
|
||||
recordOutcomeBatch,
|
||||
validateOutcome,
|
||||
} 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 = [
|
||||
|
|
@ -42,8 +41,9 @@ const INSERT_COLUMNS = [
|
|||
"recorded_at",
|
||||
];
|
||||
|
||||
function createFakeDb({ throwOnPrepare = false } = {}) {
|
||||
function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) {
|
||||
const rows = [];
|
||||
const execSql = [];
|
||||
let nextId = 1;
|
||||
|
||||
function prepare(sql) {
|
||||
|
|
@ -92,7 +92,6 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
// CREATE TABLE / CREATE INDEX from ensureSchema fallback path
|
||||
if (
|
||||
normalized.startsWith("create table") ||
|
||||
normalized.startsWith("create index")
|
||||
|
|
@ -107,7 +106,8 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -117,12 +117,16 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
return {
|
||||
const db = {
|
||||
prepare,
|
||||
exec,
|
||||
transaction,
|
||||
_rows: rows,
|
||||
_execSql: execSql,
|
||||
};
|
||||
if (includeTransaction) {
|
||||
db.transaction = transaction;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function runAggregate(sql, params, rows) {
|
||||
|
|
@ -363,30 +367,14 @@ test("recordOutcomeBatch handles empty array", () => {
|
|||
assert.deepEqual(result, { inserted: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ensureSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("ensureSchema returns true via db.exec path", () => {
|
||||
const db = createFakeDb();
|
||||
const ok = ensureSchema(db, "CREATE TABLE foo (x INTEGER);");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
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);
|
||||
test("recordOutcomeBatch_when_no_transaction_helper_wraps_batch_with_sql_transaction", () => {
|
||||
const db = createFakeDb({ includeTransaction: false });
|
||||
const result = recordOutcomeBatch(db, [
|
||||
minimalOutcome({ unitId: "T01" }),
|
||||
minimalOutcome({ unitId: "T02" }),
|
||||
]);
|
||||
assert.deepEqual(result, { inserted: 2, skipped: 0 });
|
||||
assert.deepEqual(db._execSql, ["BEGIN IMMEDIATE", "COMMIT"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -8,6 +8,15 @@
|
|||
* that need stable product terms.
|
||||
*/
|
||||
|
||||
export const WORK_MODES = Object.freeze([
|
||||
"chat",
|
||||
"plan",
|
||||
"build",
|
||||
"review",
|
||||
"repair",
|
||||
"research",
|
||||
]);
|
||||
|
||||
export const RUN_CONTROL_MODES = Object.freeze([
|
||||
"manual",
|
||||
"assisted",
|
||||
|
|
@ -21,6 +30,23 @@ export const PERMISSION_PROFILES = Object.freeze([
|
|||
"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.
|
||||
*
|
||||
|
|
@ -44,6 +70,28 @@ export function isPermissionProfile(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.
|
||||
*
|
||||
|
|
@ -67,6 +115,17 @@ export function resolvePermissionProfile(value) {
|
|||
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.
|
||||
*
|
||||
|
|
@ -90,3 +149,50 @@ export function runControlModeForSession(session) {
|
|||
export function defaultPermissionProfileForRunControl(mode) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
* Reads the same data sources as `scripts/parallel-monitor.mjs` but
|
||||
* renders as a native pi-tui overlay with theme integration.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
|
|
@ -18,25 +17,19 @@ import {
|
|||
statSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { Key, matchesKey } from "@singularity-forge/pi-tui";
|
||||
import { formatDuration } from "../shared/mod.js";
|
||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
|
||||
// ─── Async SQLite Helper ──────────────────────────────────────────────────
|
||||
function runSqliteAsync(dbPath, sql) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("sqlite3", [dbPath, sql], { timeout: 3000 });
|
||||
const chunks = [];
|
||||
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
resolve("");
|
||||
} else {
|
||||
resolve(Buffer.concat(chunks).toString("utf-8"));
|
||||
// ─── SQLite Helper ────────────────────────────────────────────────────────
|
||||
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();
|
||||
}
|
||||
});
|
||||
child.on("error", () => resolve(""));
|
||||
});
|
||||
}
|
||||
// ─── Data Helpers ─────────────────────────────────────────────────────────
|
||||
function readJsonSafe(filePath) {
|
||||
|
|
@ -98,22 +91,28 @@ function discoverWorkers(basePath) {
|
|||
}
|
||||
return [...mids].sort();
|
||||
}
|
||||
async function querySliceProgress(basePath, mid) {
|
||||
function querySliceProgress(basePath, mid) {
|
||||
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
||||
if (!existsSync(dbPath)) return [];
|
||||
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`;
|
||||
const out = (await runSqliteAsync(dbPath, sql)).trim();
|
||||
if (!out) return [];
|
||||
return out.split("\n").map((line) => {
|
||||
const [id, status, total, done] = line.split("|");
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
total: parseInt(total, 10),
|
||||
done: parseInt(done || "0", 10),
|
||||
};
|
||||
});
|
||||
return queryRows(
|
||||
dbPath,
|
||||
`SELECT s.id AS id,
|
||||
s.status AS status,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
|
||||
FROM slices s
|
||||
LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
|
||||
WHERE s.milestone_id=?
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -141,17 +140,25 @@ function extractCostFromNdjson(basePath, mid) {
|
|||
return 0;
|
||||
}
|
||||
}
|
||||
async function queryRecentCompletions(basePath, mid) {
|
||||
function queryRecentCompletions(basePath, mid) {
|
||||
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
||||
if (!existsSync(dbPath)) return [];
|
||||
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`;
|
||||
const out = (await runSqliteAsync(dbPath, sql)).trim();
|
||||
if (!out) return [];
|
||||
return out.split("\n").map((line) => {
|
||||
const [taskId, sliceId, oneLiner] = line.split("|");
|
||||
return `✓ ${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`;
|
||||
});
|
||||
return queryRows(
|
||||
dbPath,
|
||||
`SELECT id AS taskId,
|
||||
slice_id AS sliceId,
|
||||
one_liner AS 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 {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// 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.
|
||||
// Schema is initialized on first open with WAL mode for file-backed DBs.
|
||||
|
|
@ -29,7 +29,7 @@ let loadAttempted = false;
|
|||
function loadProvider() {
|
||||
if (loadAttempted) return;
|
||||
loadAttempted = true;
|
||||
// node:sqlite is built-in in Node >= 24
|
||||
// node:sqlite is built-in in Node >= 26
|
||||
}
|
||||
function normalizeRow(row) {
|
||||
if (row == null) return undefined;
|
||||
|
|
@ -4860,7 +4860,7 @@ export function insertLlmTaskOutcome(input) {
|
|||
":duration_ms": input.duration_ms ?? null,
|
||||
":tokens_total": input.tokens_total ?? null,
|
||||
":cost_usd": input.cost_usd ?? null,
|
||||
":recorded_at": input.recorded_at,
|
||||
":recorded_at": input.recorded_at ?? Date.now(),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { dirname, join } from "node:path";
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10);
|
||||
const HAS_SQLITE = NODE_VERSION >= 24;
|
||||
const HAS_SQLITE = NODE_VERSION >= 26;
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ describe("buildMemoryLLMCall apiKey resolution", () => {
|
|||
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();
|
||||
}
|
||||
: () => {
|
||||
// Skip: requires node:sqlite (Node 24+)
|
||||
// Skip: requires node:sqlite (Node 26+)
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -152,7 +152,7 @@ describe("createMemory", () => {
|
|||
expect(result).toBeNull();
|
||||
}
|
||||
: () => {
|
||||
// Skip: requires node:sqlite (Node 24+)
|
||||
// Skip: requires node:sqlite (Node 26+)
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,38 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
buildModeState,
|
||||
defaultModelModeForWorkMode,
|
||||
defaultPermissionProfileForRunControl,
|
||||
isModelMode,
|
||||
isPermissionProfile,
|
||||
isRunControlMode,
|
||||
isWorkMode,
|
||||
MODEL_MODES,
|
||||
PERMISSION_PROFILES,
|
||||
RUN_CONTROL_MODES,
|
||||
resolveModelMode,
|
||||
resolvePermissionProfile,
|
||||
resolveRunControlMode,
|
||||
resolveWorkMode,
|
||||
RUN_CONTROL_MODES,
|
||||
WORK_MODES,
|
||||
runControlModeForSession,
|
||||
} from "../operating-model.js";
|
||||
|
||||
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", () => {
|
||||
assert.deepEqual(RUN_CONTROL_MODES, ["manual", "assisted", "autonomous"]);
|
||||
assert.equal(isRunControlMode("auto"), false);
|
||||
|
|
@ -28,9 +49,17 @@ describe("operating model vocabulary", () => {
|
|||
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", () => {
|
||||
assert.equal(resolveWorkMode("debug"), "chat");
|
||||
assert.equal(resolveRunControlMode("auto"), "manual");
|
||||
assert.equal(resolvePermissionProfile("full"), "restricted");
|
||||
assert.equal(resolveModelMode("rush"), "smart");
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
434
src/resources/extensions/sf/tests/uok-scheduler-v2.test.mjs
Normal file
434
src/resources/extensions/sf/tests/uok-scheduler-v2.test.mjs
Normal 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"));
|
||||
});
|
||||
238
src/resources/extensions/sf/tests/uok-task-state.test.mjs
Normal file
238
src/resources/extensions/sf/tests/uok-task-state.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ let loadAttempted = false;
|
|||
function loadProvider() {
|
||||
if (loadAttempted) return;
|
||||
loadAttempted = true;
|
||||
// node:sqlite is built-in in Node >= 24
|
||||
// node:sqlite is built-in in Node >= 26
|
||||
}
|
||||
function normalizeRow(row) {
|
||||
if (row == null) return undefined;
|
||||
|
|
|
|||
334
src/resources/extensions/sf/uok/coordination-store.js
Normal file
334
src/resources/extensions/sf/uok/coordination-store.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
374
src/resources/extensions/sf/uok/execution-graph-persist.js
Normal file
374
src/resources/extensions/sf/uok/execution-graph-persist.js
Normal 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;
|
||||
}
|
||||
}
|
||||
169
src/resources/extensions/sf/uok/index.js
Normal file
169
src/resources/extensions/sf/uok/index.js
Normal 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";
|
||||
620
src/resources/extensions/sf/uok/scheduler-v2.js
Normal file
620
src/resources/extensions/sf/uok/scheduler-v2.js
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/resources/extensions/sf/uok/task-state.js
Normal file
189
src/resources/extensions/sf/uok/task-state.js
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ function readFile(relativePath: string): string {
|
|||
|
||||
// ── 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");
|
||||
assert.match(content, /FROM node:24/);
|
||||
assert.match(content, /FROM node:26/);
|
||||
});
|
||||
|
||||
test("docker/Dockerfile.sandbox installs singularity-forge globally", () => {
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ test("bridge-service workspace-index subprocess uses compiled JS when under node
|
|||
bridgeSource,
|
||||
/resolveSubprocessModule/,
|
||||
"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,
|
||||
/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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,9 +127,9 @@ describe("batch directory discovery", () => {
|
|||
// ─── Node.js compile cache ──────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
if (nodeVersion >= 24) {
|
||||
if (nodeVersion >= 26) {
|
||||
// Verify the env var mechanism works (does not throw)
|
||||
const original = process.env.NODE_COMPILE_CACHE;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { join } from "node:path";
|
|||
/**
|
||||
* 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,
|
||||
* all source files live under `node_modules/sf-run/src/...`, so
|
||||
* `--experimental-strip-types` fails deterministically.
|
||||
*
|
||||
* `--experimental-transform-types` applies a full TypeScript transform that
|
||||
* 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 {
|
||||
return isUnderNodeModules(packageRoot)
|
||||
|
|
@ -39,7 +39,7 @@ export interface SubprocessModuleResolution {
|
|||
* Resolves a subprocess module path, preferring compiled `dist/*.js` when the
|
||||
* 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
|
||||
* npm, every subprocess that loads a `.ts` extension module crashes with
|
||||
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:24-bookworm
|
||||
FROM node:26-bookworm
|
||||
|
||||
WORKDIR /test
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export default defineConfig({
|
|||
"src/tests/**/*.test.mjs",
|
||||
"src/resources/extensions/sf/tests/**/*.test.ts",
|
||||
"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/shared/tests/**/*.test.ts",
|
||||
"src/resources/extensions/claude-code-cli/tests/**/*.test.ts",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
"workspace"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24.15.0",
|
||||
"node": ">=26.1.0",
|
||||
"vscode": "^1.95.0"
|
||||
},
|
||||
"categories": [
|
||||
|
|
|
|||
18
web/package-lock.json
generated
18
web/package-lock.json
generated
|
|
@ -72,7 +72,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/node": "^24",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"esbuild": "^0.27.4",
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
"typescript": "5.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
"node": ">=26.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -4652,13 +4652,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
|
|
@ -11705,9 +11705,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
"node": ">=26.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/node": "^24",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"esbuild": "^0.27.4",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue