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:
|
attributes:
|
||||||
label: Node.js version
|
label: Node.js version
|
||||||
description: Run `node --version`.
|
description: Run `node --version`.
|
||||||
placeholder: "e.g. v24.14.0"
|
placeholder: "e.g. v26.1.0"
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: os
|
id: os
|
||||||
|
|
|
||||||
2
.github/workflows/build-native.yml
vendored
2
.github/workflows/build-native.yml
vendored
|
|
@ -106,7 +106,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
|
|
||||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -105,7 +105,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
|
|
||||||
- name: Validate skill references
|
- name: Validate skill references
|
||||||
run: node scripts/check-skill-references.mjs
|
run: node scripts/check-skill-references.mjs
|
||||||
|
|
@ -129,7 +129,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
@ -181,7 +181,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
@ -225,7 +225,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
@ -273,7 +273,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
|
||||||
2
.github/workflows/cleanup-dev-versions.yml
vendored
2
.github/workflows/cleanup-dev-versions.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: Unpublish old dev versions
|
- name: Unpublish old dev versions
|
||||||
|
|
|
||||||
4
.github/workflows/dev-publish.yml
vendored
4
.github/workflows/dev-publish.yml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/next-publish.yml
vendored
4
.github/workflows/next-publish.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
|
||||||
6
.github/workflows/pipeline.yml
vendored
6
.github/workflows/pipeline.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/pr-risk.yml
vendored
2
.github/workflows/pr-risk.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
|
|
||||||
# Use the GitHub API to get changed files — no fork code is executed.
|
# Use the GitHub API to get changed files — no fork code is executed.
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
|
|
|
||||||
2
.github/workflows/prod-release.yml
vendored
2
.github/workflows/prod-release.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.15'
|
node-version: '26.1'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
|
|
||||||
2
.mise.toml
Normal file
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 .
|
devcontainer up --workspace-folder .
|
||||||
```
|
```
|
||||||
|
|
||||||
The container includes Node 24, Rust, GitHub CLI, Docker-in-Docker, and recommended VS Code extensions.
|
The container includes Node 26, Rust, GitHub CLI, Docker-in-Docker, and recommended VS Code extensions.
|
||||||
|
|
||||||
## Dependency Updates
|
## Dependency Updates
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
# Image: ghcr.io/singularity-ng/singularity-foundry
|
# Image: ghcr.io/singularity-ng/singularity-foundry
|
||||||
# Used by: end users via docker run
|
# Used by: end users via docker run
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
FROM node:24.15-slim AS runtime
|
FROM node:26.1-slim AS runtime
|
||||||
|
|
||||||
# Git is required for SF's git operations
|
# Git is required for SF's git operations
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Sources checked 2026-05-08:
|
||||||
<https://github.com/features/copilot/cli>
|
<https://github.com/features/copilot/cli>
|
||||||
- GitHub Changelog, "GitHub Copilot CLI is now generally available"
|
- GitHub Changelog, "GitHub Copilot CLI is now generally available"
|
||||||
<https://github.blog/changelog/2026-02-25-github-copilot-cli-is-now-generally-available/>
|
<https://github.blog/changelog/2026-02-25-github-copilot-cli-is-now-generally-available/>
|
||||||
- GitHub Changelog, "GitHub Copilot CLI now supports BYOK and local models"
|
- GitHub Changelog, "Copilot CLI now supports BYOK and local models"
|
||||||
<https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/>
|
<https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/>
|
||||||
- Factory Droid, "Autonomy Level"
|
- Factory Droid, "Autonomy Level"
|
||||||
<https://docs.factory.ai/cli/user-guides/auto-run>
|
<https://docs.factory.ai/cli/user-guides/auto-run>
|
||||||
|
|
@ -102,6 +102,8 @@ modelMode: fast | smart | deep
|
||||||
surface: tui | web | headless | rpc
|
surface: tui | web | headless | rpc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: `repair` is a `workMode`, not a separate subsystem. The `/doctor` command is the diagnostic engine; `/repair` switches `workMode` to `repair`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -295,9 +297,9 @@ Required surfaces:
|
||||||
|
|
||||||
This should not use `/sf`.
|
This should not use `/sf`.
|
||||||
|
|
||||||
## Repair Mode
|
## Repair Work Mode
|
||||||
|
|
||||||
`repair` is the right name for doctor-like work.
|
`repair` is a `workMode`, not a separate subsystem.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
|
||||||
|
|
@ -434,7 +436,7 @@ Direct command:
|
||||||
|
|
||||||
It should show:
|
It should show:
|
||||||
|
|
||||||
- autonomous units
|
- autonomous units (durable state: todo | in_progress | review | done | retrying | failed | cancelled)
|
||||||
- parallel workers
|
- parallel workers
|
||||||
- scheduled autonomous dispatches
|
- scheduled autonomous dispatches
|
||||||
- background shell sessions
|
- background shell sessions
|
||||||
|
|
@ -443,10 +445,12 @@ It should show:
|
||||||
- current cost/budget state
|
- current cost/budget state
|
||||||
- last checkpoint and next action
|
- last checkpoint and next action
|
||||||
|
|
||||||
|
Task lifecycle uses ORCH-style states. `todo` means ready to run, not "queued."
|
||||||
|
|
||||||
This complements, not replaces:
|
This complements, not replaces:
|
||||||
|
|
||||||
- `/status`
|
- `/status`
|
||||||
- `/queue`
|
- `/queue` (milestone dispatch order, not task state)
|
||||||
- `/parallel status`
|
- `/parallel status`
|
||||||
- `/session-report`
|
- `/session-report`
|
||||||
- `/logs`
|
- `/logs`
|
||||||
|
|
@ -1040,3 +1044,176 @@ If Node 26 passes those gates, SF should run itself on Node 26 internally even
|
||||||
before raising public `engines.node`. Once stable, raise the repo baseline and
|
before raising public `engines.node`. Once stable, raise the repo baseline and
|
||||||
start replacing fragile `Date`/millisecond logic with Temporal in the schedule,
|
start replacing fragile `Date`/millisecond logic with Temporal in the schedule,
|
||||||
lease, journal, and background task surfaces.
|
lease, journal, and background task surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Related Source Files
|
||||||
|
|
||||||
|
This section maps the concepts in this document to actual code in the repo.
|
||||||
|
|
||||||
|
### A.1 Operating Model (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/operating-model.js`
|
||||||
|
|
||||||
|
Already exports canonical vocabulary:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const RUN_CONTROL_MODES = ["manual", "assisted", "autonomous"];
|
||||||
|
export const PERMISSION_PROFILES = ["restricted", "normal", "trusted", "unrestricted"];
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests: `src/resources/extensions/sf/tests/operating-model.test.mjs`
|
||||||
|
|
||||||
|
**Gap:** No `workMode` or `modelMode` constants yet. Add to this file.
|
||||||
|
|
||||||
|
### A.2 Execution Policy (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/execution-policy.js`
|
||||||
|
|
||||||
|
Maps permission profiles to concrete tool restrictions:
|
||||||
|
|
||||||
|
```js
|
||||||
|
EXECUTION_POLICY_PROFILES = {
|
||||||
|
restricted: { filesystem: "read-mostly", network: "read-only", git: "read-only", mutation: "planning-artifacts-only" },
|
||||||
|
normal: { filesystem: "workspace-write", network: "allowed", git: "normal", mutation: "workspace" },
|
||||||
|
trusted: { filesystem: "workspace-write", network: "allowed", git: "normal", mutation: "workspace" },
|
||||||
|
unrestricted: { filesystem: "danger-full-access", network: "allowed", git: "dangerous", mutation: "host" }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap:** Not yet wired to tool-call boundaries. Enforcement is in `write-gate.js` and `destructive-guard.js` but not unified.
|
||||||
|
|
||||||
|
### A.3 Auto Session State (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/auto/session.js`
|
||||||
|
|
||||||
|
`AutoSession` class holds:
|
||||||
|
- `active`, `paused`, `stepMode`, `canAskUser`
|
||||||
|
- `currentUnit`, `currentMilestoneId`
|
||||||
|
- `autoModeStartModel`, `currentUnitModel`
|
||||||
|
|
||||||
|
**Gap:** No `workMode` property. Add to `AutoSession` and `reset()`.
|
||||||
|
|
||||||
|
### A.4 Command Registration (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/commands/index.js`
|
||||||
|
|
||||||
|
Registers direct commands via `pi.registerCommand()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
for (const command of DIRECT_SF_COMMANDS) {
|
||||||
|
pi.registerCommand(command.cmd, { ... });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/commands/catalog.js`
|
||||||
|
|
||||||
|
Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`.
|
||||||
|
|
||||||
|
**Gap:** Commands still use `/sf` prefix in user-facing strings. `SF_COMMAND_DESCRIPTION` lists `/sf help|start|...`.
|
||||||
|
|
||||||
|
### A.5 TUI Extension (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf-tui/index.js`
|
||||||
|
|
||||||
|
Registers shortcuts:
|
||||||
|
- `Ctrl+Alt+H` — prompt history
|
||||||
|
- `Ctrl+Shift+H` — prompt history fallback
|
||||||
|
- `Ctrl+Alt+M` — marketplace
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf-tui/header.js`
|
||||||
|
|
||||||
|
Renders header with project name, branch, model. No mode badge yet.
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf-tui/footer.js`
|
||||||
|
|
||||||
|
Renders footer with git status, cost, context usage. No mode badge yet.
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf-tui/extension-manifest.json`
|
||||||
|
|
||||||
|
Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`.
|
||||||
|
|
||||||
|
**Gap:** No mode badge rendering. No mode-switching shortcuts. Header hidden during auto mode.
|
||||||
|
|
||||||
|
### A.6 UOK Parity Report (Already Uses runControl)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/tests/uok-parity-report.test.mjs`
|
||||||
|
|
||||||
|
Tests verify `runControl` and `permissionProfile` in UOK events:
|
||||||
|
|
||||||
|
```js
|
||||||
|
assert.equal(events[0].runControl, "autonomous");
|
||||||
|
assert.equal(events[0].permissionProfile, "normal");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap:** No `workMode` in UOK events yet.
|
||||||
|
|
||||||
|
### A.7 Routing History (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/routing-history.js`
|
||||||
|
|
||||||
|
Tracks model tier success/failure per task pattern.
|
||||||
|
|
||||||
|
**Gap:** Not yet connected to `modelMode` (`fast`/`smart`/`deep`). Currently uses `light`/`standard`/`heavy` tiers.
|
||||||
|
|
||||||
|
### A.8 Doctor System (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/doctor.js`
|
||||||
|
**File:** `src/resources/extensions/sf/doctor-proactive.js`
|
||||||
|
**File:** `src/resources/extensions/sf/doctor-checks.js`
|
||||||
|
|
||||||
|
Health checks, auto-fix, proactive monitoring.
|
||||||
|
|
||||||
|
**Gap:** No `repair` work mode. Doctor is diagnostic-only, not a workflow.
|
||||||
|
|
||||||
|
### A.9 Self-Feedback (Already Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/self-feedback.js`
|
||||||
|
|
||||||
|
Records anomalies, blocking entries, version-bump resolution.
|
||||||
|
|
||||||
|
**Gap:** Not connected to `workMode` transitions.
|
||||||
|
|
||||||
|
### A.10 Skills (Partially Exists)
|
||||||
|
|
||||||
|
**File:** `src/resources/extensions/sf/skill-discovery.js`
|
||||||
|
**File:** `src/resources/extensions/sf/skill-health.js`
|
||||||
|
**File:** `src/resources/extensions/sf/skill-telemetry.js`
|
||||||
|
|
||||||
|
Skill loading, health monitoring, telemetry.
|
||||||
|
|
||||||
|
**Gap:** No `.agents/skills/` directory structure. No YAML frontmatter. No auto-creation flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Implementation Priority
|
||||||
|
|
||||||
|
| Priority | Item | Files to Touch | Effort |
|
||||||
|
|----------|------|----------------|--------|
|
||||||
|
| P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small |
|
||||||
|
| P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small |
|
||||||
|
| P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small |
|
||||||
|
| P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small |
|
||||||
|
| P0 | Deprecate `/sf` prefix in commands | `commands/catalog.js`, `commands/index.js` | Medium |
|
||||||
|
| P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium |
|
||||||
|
| P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/write-gate.js`, `safety/destructive-guard.js` | Medium |
|
||||||
|
| P1 | Add `/tasks` background work surface | New: `tasks-overlay.js`, `tasks-db.js` | Large |
|
||||||
|
| P1 | Make `repair` first-class work mode | `commands/handlers/core.js`, `doctor.js` | Medium |
|
||||||
|
| P2 | Add `.agents/skills/` structure | New: `skills-directory.js`, skill templates | Large |
|
||||||
|
| P2 | Add skill YAML frontmatter parser | New: `skill-frontmatter.js` | Medium |
|
||||||
|
| P2 | Add skill eval harness | New: `skill-eval.js`, eval templates | Large |
|
||||||
|
| P2 | Adopt Temporal in `sf schedule` | `schedule/*.js` | Medium |
|
||||||
|
| P2 | Node 26 canary | `package.json`, CI | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C: Open Questions
|
||||||
|
|
||||||
|
1. Should paused autonomous show previous badge dimmed, or `[P]` for paused?
|
||||||
|
2. Should mode be per-session or per-project? (Current: per-session)
|
||||||
|
3. Should badge appear in tmux/terminal window titles?
|
||||||
|
4. Should mode transitions have sound/notification?
|
||||||
|
5. Should `repair` auto-transition be `ask` by default for new projects?
|
||||||
|
6. Should skill eval cases run in CI or only on-demand?
|
||||||
|
7. Should `/tasks` be a TUI overlay or a separate scrollable panel?
|
||||||
|
8. Should `modelMode` replace or supplement the existing tier system (`light`/`standard`/`heavy`)?
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
# Image: ghcr.io/sf-build/sf-ci-builder
|
# Image: ghcr.io/sf-build/sf-ci-builder
|
||||||
# Used by: pipeline.yml Dev stage
|
# Used by: pipeline.yml Dev stage
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
FROM node:24-bookworm
|
FROM node:26-bookworm
|
||||||
|
|
||||||
# Rust toolchain (stable, minimal profile)
|
# Rust toolchain (stable, minimal profile)
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
# Purpose: Isolated environment for SF auto mode
|
# Purpose: Isolated environment for SF auto mode
|
||||||
# Usage: docker sandbox create --template ./docker
|
# Usage: docker sandbox create --template ./docker
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
FROM node:24-bookworm-slim
|
FROM node:26-bookworm-slim
|
||||||
|
|
||||||
# System dependencies required by SF
|
# System dependencies required by SF
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ Persist learning: when a unit produces a gotcha or anti-pattern, write to sf's m
|
||||||
| Dependency down | Behaviour |
|
| Dependency down | Behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Native engine (`forge_engine.node`) | Fall back to JS implementations; log degraded mode. Never silently proceed without confirming fallback path is wired. |
|
| Native engine (`forge_engine.node`) | Fall back to JS implementations; log degraded mode. Never silently proceed without confirming fallback path is wired. |
|
||||||
| `node:sqlite` and `better-sqlite3` both unavailable | Block DB-owned operations; there is no normal no-DB planning mode. Read files only as human evidence. |
|
| `node:sqlite` unavailable | Block DB-owned operations; there is no normal no-DB planning mode or alternate SQLite engine fallback. Read files only as human evidence. |
|
||||||
| LLM provider | Try next allowed provider per `~/.sf/preferences.md`; if exhausted, halt unit with `ErrModelUnavailable` (no silent skip). |
|
| LLM provider | Try next allowed provider per `~/.sf/preferences.md`; if exhausted, halt unit with `ErrModelUnavailable` (no silent skip). |
|
||||||
| SOPS unavailable | Use already-exported env vars; log that secret refresh is unavailable. Block secret-touching commands. |
|
| SOPS unavailable | Use already-exported env vars; log that secret refresh is unavailable. Block secret-touching commands. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ UOK Kernel (executes units)
|
||||||
↓ records outcomes via
|
↓ records outcomes via
|
||||||
Unit Runtime (recordUnitOutcomeInMemory)
|
Unit Runtime (recordUnitOutcomeInMemory)
|
||||||
↓ stores patterns in
|
↓ stores patterns in
|
||||||
Memory System (SQLite, Node 24 native)
|
Memory System (SQLite, Node 26 native)
|
||||||
↓ queried by
|
↓ queried by
|
||||||
Dispatch (enhanceUnitRankingWithMemory)
|
Dispatch (enhanceUnitRankingWithMemory)
|
||||||
↓ boosts scores for matching patterns
|
↓ boosts scores for matching patterns
|
||||||
|
|
@ -70,7 +70,7 @@ Dispatch (enhanceUnitRankingWithMemory)
|
||||||
1. **Maximize kernel + DB** — Single UOK kernel, memory as DB layer, no multiplication
|
1. **Maximize kernel + DB** — Single UOK kernel, memory as DB layer, no multiplication
|
||||||
2. **Fire-and-forget async** — Memory never blocks critical path; safe degradation
|
2. **Fire-and-forget async** — Memory never blocks critical path; safe degradation
|
||||||
3. **Existing infrastructure** — SF already has 10 memory modules; no duplication
|
3. **Existing infrastructure** — SF already has 10 memory modules; no duplication
|
||||||
4. **Node 24 native SQLite** — No external dependencies; efficient storage
|
4. **Node 26 native SQLite** — No external dependencies; efficient storage
|
||||||
5. **Confidence scoring** — Learned patterns inform but don't dominate decisions
|
5. **Confidence scoring** — Learned patterns inform but don't dominate decisions
|
||||||
6. **Pure diagnostic gates** — Gate failures become learning opportunities, not gate logic change
|
6. **Pure diagnostic gates** — Gate failures become learning opportunities, not gate logic change
|
||||||
|
|
||||||
|
|
@ -119,7 +119,7 @@ All memory operations fail silently without blocking:
|
||||||
- Category assignment
|
- Category assignment
|
||||||
- Unit type extraction
|
- Unit type extraction
|
||||||
|
|
||||||
**Phase 2 Tests:** 21 test cases (syntax correct, require Node 24.15)
|
**Phase 2 Tests:** 21 test cases (syntax correct, require Node 26.1)
|
||||||
- Memory-enhanced ranking
|
- Memory-enhanced ranking
|
||||||
- Embedding computation
|
- Embedding computation
|
||||||
- Score boosting formula
|
- Score boosting formula
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Singularity-forge includes a **complete autonomous memory system** built on SQLite (Node 24 native) with no external dependencies. The memory system enables SF to:
|
Singularity-forge includes a **complete autonomous memory system** built on SQLite (Node 26 native) with no external dependencies. The memory system enables SF to:
|
||||||
|
|
||||||
- **Learn** from unit execution patterns and outcomes
|
- **Learn** from unit execution patterns and outcomes
|
||||||
- **Recall** similar past situations for context-aware decisions
|
- **Recall** similar past situations for context-aware decisions
|
||||||
|
|
@ -286,7 +286,7 @@ memory_sources (
|
||||||
### **sf-db.js** (SQLite Backend)
|
### **sf-db.js** (SQLite Backend)
|
||||||
**Location:** `src/resources/extensions/sf/sf-db.js`
|
**Location:** `src/resources/extensions/sf/sf-db.js`
|
||||||
|
|
||||||
**Purpose:** Core SQLite database abstraction (Node 24 native, no external deps).
|
**Purpose:** Core SQLite database abstraction (Node 26 native, no external deps).
|
||||||
|
|
||||||
**Tables:**
|
**Tables:**
|
||||||
- `memories` — Memory entries
|
- `memories` — Memory entries
|
||||||
|
|
@ -295,7 +295,7 @@ memory_sources (
|
||||||
- `memory_sources` — Source tracking
|
- `memory_sources` — Source tracking
|
||||||
- Plus other SF tables (uok, env, etc.)
|
- Plus other SF tables (uok, env, etc.)
|
||||||
|
|
||||||
**Key Advantage:** Node 24.15+ has native SQLite support (`node:sqlite`)
|
**Key Advantage:** Node 26.1+ has native SQLite support (`node:sqlite`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -443,7 +443,7 @@ const gotchas = await getRelevantMemoriesRanked(
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ ┌─────────────────────────────┐ │
|
│ ┌─────────────────────────────┐ │
|
||||||
│ │ SQLite (sf-db.js) │ │
|
│ │ SQLite (sf-db.js) │ │
|
||||||
│ │ Node 24 native sqlite │ │
|
│ │ Node 26 native sqlite │ │
|
||||||
│ └─────────────────────────────┘ │
|
│ └─────────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
@ -517,7 +517,7 @@ All memory operations follow the **fire-and-forget pattern**:
|
||||||
**Memory is NOT exposed as MCP server.**
|
**Memory is NOT exposed as MCP server.**
|
||||||
|
|
||||||
- **SF is an MCP *client* only** — SF consumes MCP tools from external services
|
- **SF is an MCP *client* only** — SF consumes MCP tools from external services
|
||||||
- **Memory is internal SF infrastructure** — uses SQLite (Node 24 native)
|
- **Memory is internal SF infrastructure** — uses SQLite (Node 26 native)
|
||||||
- **Memory exported as SF tools** — LLM agents within SF call memory functions
|
- **Memory exported as SF tools** — LLM agents within SF call memory functions
|
||||||
- **No external exposure** — Memory system is not a service; it's SF's autonomous learning mechanism
|
- **No external exposure** — Memory system is not a service; it's SF's autonomous learning mechanism
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ all.forEach(m => console.log(`${m.id}: ${m.hit_count} hits`));
|
||||||
## Architecture: Memory is Internal
|
## Architecture: Memory is Internal
|
||||||
|
|
||||||
- **No MCP server** — memory stays inside SF
|
- **No MCP server** — memory stays inside SF
|
||||||
- **SQLite only** — Node 24 native (no external deps)
|
- **SQLite only** — Node 26 native (no external deps)
|
||||||
- **Fire-and-forget** — never blocks dispatch
|
- **Fire-and-forget** — never blocks dispatch
|
||||||
- **Private learning** — autonomous pattern extraction
|
- **Private learning** — autonomous pattern extraction
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,312 +1,46 @@
|
||||||
# SQLite Migration Guide for Model Learning
|
# SQLite Runtime Baseline
|
||||||
|
|
||||||
**Status**: Planned for Node 24.15.0 upgrade
|
**Status:** complete for the Node 26.1 runtime baseline.
|
||||||
**Current**: JSON-based storage (model-learner.js, self-report-fixer.js)
|
|
||||||
**Target**: Native `node:sqlite` integration
|
|
||||||
|
|
||||||
## Why SQLite?
|
SF uses built-in `node:sqlite` and the project-local `.sf/sf.db` as the
|
||||||
|
canonical structured store for planning state, UOK execution state, learning
|
||||||
|
outcomes, schedules, memory extraction queues, and generated projections.
|
||||||
|
|
||||||
1. **Zero dependencies**: Node 24+ has built-in `node:sqlite` (no package install)
|
## Runtime Rule
|
||||||
2. **Queryable**: SQL joins with UOK's `llm_task_outcomes` table for unified learning database
|
|
||||||
3. **Transactional**: Atomic outcome recording prevents partial state corruption
|
|
||||||
4. **Performant**: Indexes on (task_type, model_id) for per-task-type ranking queries
|
|
||||||
5. **Durable**: WAL mode ensures data survives crashes
|
|
||||||
|
|
||||||
## Current State (Node 20)
|
- **Node baseline:** 26.1+
|
||||||
|
- **Canonical database:** `.sf/sf.db`
|
||||||
|
- **SQLite binding:** built-in `node:sqlite`
|
||||||
|
- **Sidecar stores:** not allowed for active SF state
|
||||||
|
|
||||||
### JSON-Based Storage
|
Runtime code must not introduce `sql.js`, `better-sqlite3`, `sqlite3` CLI
|
||||||
- `model-learner.js`: `.sf/model-performance.json` (nested object hierarchy)
|
shell-outs, or JSON fallback databases for DB-owned state. If a feature needs
|
||||||
```json
|
ordering, validation, joins, leases, TTLs, or history, put it in `.sf/sf.db`.
|
||||||
{
|
|
||||||
"execute-task": {
|
|
||||||
"gpt-4o": {
|
|
||||||
"successes": 42,
|
|
||||||
"failures": 3,
|
|
||||||
"successRate": 0.93
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `self-report-fixer.js`: Stateless (no persistent storage)
|
|
||||||
- `triage-self-feedback.js`: Reads/writes `REQUIREMENTS.md`, `ARCHITECTURE.md`
|
|
||||||
|
|
||||||
### Pain Points
|
## Current Surfaces
|
||||||
- Entire file read/write on every outcome (O(n) latency)
|
|
||||||
- No queryable schema (must load all data, filter in-memory)
|
|
||||||
- No transactions (partial failures possible)
|
|
||||||
- No natural joins with UOK database
|
|
||||||
|
|
||||||
## SQLite Schema (Target)
|
- **Learning outcomes:** `llm_task_outcomes`
|
||||||
|
- **UOK execution graphs:** `uok_execution_graphs`, `uok_graph_nodes`,
|
||||||
|
`uok_graph_progress`
|
||||||
|
- **UOK coordination:** `uok_kv`, `uok_stream_entries`, `uok_queue_items`
|
||||||
|
- **Memory extraction:** `threads`, `stage1_outputs`, `jobs`
|
||||||
|
- **Schedules:** `schedule_entries`
|
||||||
|
|
||||||
### Table 1: model_outcomes
|
## Development Rules
|
||||||
Raw event log for every model outcome.
|
|
||||||
|
|
||||||
```sql
|
1. Use SF query/writer helpers when operating inside the SF extension.
|
||||||
CREATE TABLE model_outcomes (
|
2. Use `node:sqlite` directly only for isolated tooling or package code that
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
cannot import the SF extension runtime.
|
||||||
task_type TEXT NOT NULL, -- "execute-task", "plan-slice", etc.
|
3. Prefer read-only SQLite handles for monitors and inspection overlays.
|
||||||
model_id TEXT NOT NULL, -- "gpt-4o", "claude-opus", etc.
|
4. Do not keep compatibility code for retired JSON or sidecar DB stores unless
|
||||||
success INTEGER NOT NULL, -- 1 = success, 0 = failure
|
an explicit migration command owns it.
|
||||||
timeout INTEGER NOT NULL DEFAULT 0, -- 1 = timed out, 0 = normal
|
5. Add behavior tests before changing persistence semantics.
|
||||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
||||||
timestamp TEXT NOT NULL, -- ISO 8601
|
|
||||||
FOREIGN KEY (task_type, model_id) REFERENCES model_stats(task_type, model_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_outcomes_task_model ON model_outcomes(task_type, model_id);
|
## Verification
|
||||||
CREATE INDEX idx_outcomes_timestamp ON model_outcomes(timestamp DESC);
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck:extensions
|
||||||
|
npx vitest run --config vitest.config.ts \
|
||||||
|
src/resources/extensions/sf/learning/*.test.mjs \
|
||||||
|
src/resources/extensions/sf/tests/uok-*.test.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Table 2: model_stats
|
|
||||||
Aggregated per-task-per-model statistics (updated atomically with each outcome).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE model_stats (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_type TEXT NOT NULL,
|
|
||||||
model_id TEXT NOT NULL,
|
|
||||||
successes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
failures INTEGER NOT NULL DEFAULT 0,
|
|
||||||
timeouts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_cost REAL NOT NULL DEFAULT 0.0,
|
|
||||||
last_used TEXT, -- ISO 8601 timestamp of last outcome
|
|
||||||
UNIQUE(task_type, model_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_stats_task_model ON model_stats(task_type, model_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Steps
|
|
||||||
|
|
||||||
### Phase 1: Refactor `ModelPerformanceTracker` (model-learner.js)
|
|
||||||
|
|
||||||
**Before** (JSON):
|
|
||||||
```javascript
|
|
||||||
recordOutcome(taskType, modelId, outcome) {
|
|
||||||
if (!this.data[taskType]) this.data[taskType] = {};
|
|
||||||
if (!this.data[taskType][modelId]) {
|
|
||||||
this.data[taskType][modelId] = { successes: 0, failures: 0, ... };
|
|
||||||
}
|
|
||||||
const stats = this.data[taskType][modelId];
|
|
||||||
if (outcome.success) stats.successes += 1;
|
|
||||||
else stats.failures += 1;
|
|
||||||
this._save(); // Entire file rewrite
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After** (SQLite):
|
|
||||||
```javascript
|
|
||||||
recordOutcome(taskType, modelId, outcome) {
|
|
||||||
this.db.exec("BEGIN");
|
|
||||||
|
|
||||||
// Insert event
|
|
||||||
const insertStmt = this.db.prepare(`
|
|
||||||
INSERT INTO model_outcomes (task_type, model_id, success, timeout, ...)
|
|
||||||
VALUES (?, ?, ?, ?, ...)
|
|
||||||
`);
|
|
||||||
insertStmt.run(taskType, modelId, outcome.success ? 1 : 0, ...);
|
|
||||||
|
|
||||||
// Upsert stats
|
|
||||||
const updateStmt = this.db.prepare(`
|
|
||||||
INSERT INTO model_stats (task_type, model_id, successes, ...)
|
|
||||||
VALUES (?, ?, ?, ...)
|
|
||||||
ON CONFLICT(task_type, model_id) DO UPDATE SET
|
|
||||||
successes = successes + ?,
|
|
||||||
failures = failures + ?,
|
|
||||||
...
|
|
||||||
`);
|
|
||||||
updateStmt.run(...);
|
|
||||||
|
|
||||||
this.db.exec("COMMIT");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- O(1) outcome recording (single INSERT)
|
|
||||||
- Atomic transaction (both tables updated together)
|
|
||||||
- No full-file rewrite
|
|
||||||
|
|
||||||
### Phase 2: Update Query Methods
|
|
||||||
|
|
||||||
**getRankedModels** → SQL SELECT with ORDER BY
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
getRankedModels(taskType, minSamples = 3) {
|
|
||||||
const query = this.db.prepare(`
|
|
||||||
SELECT model_id, successes, failures, total_tokens, total_cost, last_used
|
|
||||||
FROM model_stats
|
|
||||||
WHERE task_type = ? AND (successes + failures) >= ?
|
|
||||||
ORDER BY (CAST(successes AS FLOAT) / (successes + failures)) DESC
|
|
||||||
`);
|
|
||||||
return query.all(taskType, minSamples).map(row => ({
|
|
||||||
modelId: row.model_id,
|
|
||||||
successRate: row.successes / (row.successes + row.failures),
|
|
||||||
...
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: Integrate with UOK Database (Optional)
|
|
||||||
|
|
||||||
If UOK stores outcomes in its database, consider a **federated schema**:
|
|
||||||
- Keep model_learner SQLite database separate (`.sf/model-performance.db`)
|
|
||||||
- OR: Create view in UOK database that joins with UOK's `llm_task_outcomes`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- In UOK database:
|
|
||||||
CREATE VIEW model_performance AS
|
|
||||||
SELECT
|
|
||||||
outcome.task_type,
|
|
||||||
outcome.model_id,
|
|
||||||
COUNT(CASE WHEN outcome.success = 1 THEN 1 END) as successes,
|
|
||||||
COUNT(CASE WHEN outcome.success = 0 THEN 1 END) as failures,
|
|
||||||
SUM(outcome.tokens_used) as total_tokens,
|
|
||||||
SUM(outcome.cost_usd) as total_cost
|
|
||||||
FROM llm_task_outcomes outcome
|
|
||||||
GROUP BY outcome.task_type, outcome.model_id;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: Data Migration (JSON → SQLite)
|
|
||||||
|
|
||||||
Create migration function in constructor:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
_initDb() {
|
|
||||||
const db = new DatabaseSync(this.dbPath);
|
|
||||||
// ... create tables ...
|
|
||||||
|
|
||||||
// Migrate existing JSON data
|
|
||||||
if (existsSync(this.oldJsonPath)) {
|
|
||||||
const jsonData = JSON.parse(readFileSync(this.oldJsonPath, 'utf-8'));
|
|
||||||
this._migrateFromJson(db, jsonData);
|
|
||||||
// After migration: delete old JSON or archive
|
|
||||||
}
|
|
||||||
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
_migrateFromJson(db, jsonData) {
|
|
||||||
db.exec("BEGIN");
|
|
||||||
|
|
||||||
for (const [taskType, models] of Object.entries(jsonData)) {
|
|
||||||
for (const [modelId, stats] of Object.entries(models)) {
|
|
||||||
const insertStmt = db.prepare(`
|
|
||||||
INSERT INTO model_stats
|
|
||||||
(task_type, model_id, successes, failures, timeouts, total_tokens, total_cost, last_used)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
insertStmt.run(
|
|
||||||
taskType, modelId,
|
|
||||||
stats.successes, stats.failures, stats.timeouts || 0,
|
|
||||||
stats.totalTokens, stats.totalCost, stats.lastUsed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec("COMMIT");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests (No Changes Needed)
|
|
||||||
Existing tests in `model-learner.test.ts` should pass unchanged:
|
|
||||||
- `recordOutcome()` API remains the same
|
|
||||||
- `getRankedModels()` returns same shape
|
|
||||||
- `shouldDemote()`, `getABTestCandidates()` unchanged
|
|
||||||
|
|
||||||
### Integration Tests (Add SQLite-Specific)
|
|
||||||
```typescript
|
|
||||||
test("persists to SQLite database", () => {
|
|
||||||
const learner = new ModelLearner(basePath);
|
|
||||||
learner.recordOutcome("execute-task", "gpt-4o", { success: true, tokensUsed: 100 });
|
|
||||||
|
|
||||||
// Verify record in model_outcomes table
|
|
||||||
const query = learner.tracker.db.prepare(`
|
|
||||||
SELECT COUNT(*) as count FROM model_outcomes
|
|
||||||
WHERE task_type = ? AND model_id = ?
|
|
||||||
`);
|
|
||||||
const result = query.get("execute-task", "gpt-4o");
|
|
||||||
expect(result.count).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("transactions are atomic", () => {
|
|
||||||
// Simulate failure during upsert
|
|
||||||
// Verify both INSERT and UPDATE succeed or both rollback
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
1. **When Node 24.15.0 becomes standard** (6-8 weeks)
|
|
||||||
- Update `.nvmrc`, `package.json` engines
|
|
||||||
- Enable snap to run Node 24
|
|
||||||
|
|
||||||
2. **Migration PR** (2 days of work)
|
|
||||||
- Refactor `ModelPerformanceTracker` class
|
|
||||||
- Add migration function
|
|
||||||
- Test with existing unit tests
|
|
||||||
|
|
||||||
3. **Rollout** (1 day)
|
|
||||||
- Deploy with backward-compatible JSON→SQLite auto-migration
|
|
||||||
- Monitor for edge cases
|
|
||||||
- Archive old JSON files after 1 week
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
- **Auto-migrate**: On first run with Node 24, detect `.sf/model-performance.json` and import to SQLite
|
|
||||||
- **Keep JSON**: Don't delete old JSON file immediately (keep for 1 week as backup)
|
|
||||||
- **Graceful fallback**: If SQLite init fails, log error and fall back to JSON (degraded mode)
|
|
||||||
|
|
||||||
## Future Opportunities
|
|
||||||
|
|
||||||
Once SQLite is in place:
|
|
||||||
|
|
||||||
1. **Dashboard**: Query performance metrics
|
|
||||||
```sql
|
|
||||||
SELECT model_id,
|
|
||||||
ROUND(100.0 * successes / (successes + failures), 1) as success_rate,
|
|
||||||
total_tokens, total_cost
|
|
||||||
FROM model_stats
|
|
||||||
WHERE task_type = ?
|
|
||||||
ORDER BY success_rate DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Trend analysis**: Model performance over time
|
|
||||||
```sql
|
|
||||||
SELECT DATE(timestamp) as day, model_id, COUNT(*) as attempts,
|
|
||||||
SUM(success) as wins,
|
|
||||||
ROUND(100.0 * SUM(success) / COUNT(*), 1) as daily_success_rate
|
|
||||||
FROM model_outcomes
|
|
||||||
WHERE task_type = ? AND timestamp > date('now', '-30 days')
|
|
||||||
GROUP BY day, model_id
|
|
||||||
ORDER BY day DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **A/B testing**: Compare challenger vs incumbent in detail
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
model_id,
|
|
||||||
COUNT(*) as trials,
|
|
||||||
SUM(success) as wins,
|
|
||||||
ROUND(AVG(tokens_used), 0) as avg_tokens,
|
|
||||||
ROUND(AVG(cost_usd), 4) as avg_cost
|
|
||||||
FROM model_outcomes
|
|
||||||
WHERE task_type = ? AND timestamp > ?
|
|
||||||
GROUP BY model_id;
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Federated learning**: Export performance data for cross-project analysis
|
|
||||||
```sql
|
|
||||||
SELECT * FROM model_stats
|
|
||||||
WHERE successes + failures >= 10 -- High-confidence entries only
|
|
||||||
ORDER BY success_rate DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Node.js `node:sqlite` docs: https://nodejs.org/api/sqlite.html
|
|
||||||
- UOK `llm_task_outcomes` schema: See `docs/dev/UOK-SELF-EVOLUTION.md`
|
|
||||||
- SQLite WAL mode: https://www.sqlite.org/wal.html
|
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,8 @@ For `@dev` or `@next` rollbacks, the next successful merge will overwrite the ta
|
||||||
|
|
||||||
| Image | Base | Purpose | Tags |
|
| Image | Base | Purpose | Tags |
|
||||||
|-------|------|---------|------|
|
|-------|------|---------|------|
|
||||||
| `ghcr.io/singularity-forge/sf-ci-builder` | `node:24-bookworm` | CI build environment with Rust toolchain | `:latest`, `:<date>` |
|
| `ghcr.io/singularity-forge/sf-ci-builder` | `node:26-bookworm` | CI build environment with Rust toolchain | `:latest`, `:<date>` |
|
||||||
| `ghcr.io/singularity-forge/sf-run` | `node:24-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` |
|
| `ghcr.io/singularity-forge/sf-run` | `node:26-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` |
|
||||||
|
|
||||||
The CI builder image is rebuilt automatically when the `Dockerfile` changes. It eliminates ~3-5 min of toolchain setup per CI run.
|
The CI builder image is rebuilt automatically when the `Dockerfile` changes. It eliminates ~3-5 min of toolchain setup per CI run.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,194 +1,35 @@
|
||||||
## M001-6377a4: Consolidate Memory Systems into Unified node:sqlite Store
|
## M001-6377a4: Consolidate Memory State into `.sf/sf.db`
|
||||||
|
|
||||||
**Gathered:** 2026-05-07
|
**Gathered:** 2026-05-07
|
||||||
**Status:** Promoted plan
|
**Status:** Implemented baseline, remaining work is product integration.
|
||||||
**Source:** Promoted from `.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md`
|
|
||||||
|
|
||||||
## Project Description
|
## Purpose
|
||||||
|
|
||||||
Replace three fragmented memory systems with a single unified store backed by `node:sqlite`. All memory ingestion, querying, and prompt injection flows through one canonical database table in `sf.db`.
|
Memory state must follow the same rule as the rest of SF state: structured,
|
||||||
|
queryable, project-owned data lives in `.sf/sf.db`. Runtime memory code should
|
||||||
|
not write global sidecar databases or introduce a second SQLite engine.
|
||||||
|
|
||||||
**Three systems being consolidated:**
|
## Current Baseline
|
||||||
|
|
||||||
1. **`memory-store.js`** (SF, `src/resources/extensions/sf/memory-store.js`) — function-based API backed by `sf-db.js` → `node:sqlite` → `sf.db`. Already uses `node:sqlite`. Exports: `createMemory`, `updateMemoryContent`, `reinforceMemory`, `supersedeMemory`, `getActiveMemoriesRanked`, `getRelevantMemoriesRanked`, `formatMemoriesForPrompt`. Tables: `memories`, `memory_embeddings`, `memory_relations`, `memory_processed_units`.
|
- SF memory APIs use `node:sqlite` through the project database.
|
||||||
|
- The Pi memory extraction queue now opens `<cwd>/.sf/sf.db`.
|
||||||
|
- Extracted memory artifacts are written under `<cwd>/.sf/memory`.
|
||||||
|
- Runtime package dependencies no longer include a WASM SQLite engine for the
|
||||||
|
memory pipeline.
|
||||||
|
|
||||||
2. **Memory extension** (`packages/pi-coding-agent/src/resources/extensions/memory/`) — LLM-based session transcript extraction that writes to `agent.db` via `sql.js` (WASM SQLite). Pipeline: scan → filter → phase1 LLM extraction → phase2 consolidation → `MEMORY.md` output.
|
## Remaining Product Work
|
||||||
|
|
||||||
3. **`knowledge-injector.js`** (SF, `src/resources/extensions/sf/knowledge-injector.js`) — parses markdown knowledge entries and injects into prompts via semantic similarity matching. Called by prompt assembly before agent start.
|
- Make `/memory view` prefer DB-backed memory rows over generated markdown when
|
||||||
|
both exist.
|
||||||
|
- Connect extracted stage outputs to the existing `memories` table instead of
|
||||||
|
only producing markdown summaries.
|
||||||
|
- Add a migration command only if we decide old user data should be imported.
|
||||||
|
Do not keep passive compatibility code in startup paths.
|
||||||
|
|
||||||
## Why This Milestone
|
## Acceptance
|
||||||
|
|
||||||
**What problem this solves:** Three parallel memory systems create maintenance fragmentation, competing injection paths into system prompts, and two SQLite implementations (`node:sqlite` in SF + `sql.js` WASM in pi-coding-agent). Adding a `source` column and wiring all paths to `sf.db` eliminates the duplication and provides a single canonical store.
|
- `/memory rebuild` records extraction queue state in `.sf/sf.db`.
|
||||||
|
- `/memory view` reads the same project memory source that UOK and prompt
|
||||||
**Why now:** The existing `memory-store.js` is already well-designed. The migration and wiring work is tractable. Post-consolidation, future memory features (embedding reranking, relation boosting) have one place to land.
|
assembly use.
|
||||||
|
- Grep finds no runtime memory import of alternate SQLite engines.
|
||||||
## User-Visible Outcome
|
- Tests cover the DB path, rebuild path, and clear path.
|
||||||
|
|
||||||
### When this milestone is complete, the user can:
|
|
||||||
|
|
||||||
- Run `/memory view` and see memories from `sf.db` (not from `agent.db` or `MEMORY.md`)
|
|
||||||
- Trigger `/memory rebuild` and watch extraction write directly to `sf.db`
|
|
||||||
- Invoke the `capture_thought` tool and see it persist to `sf.db` with a source tag
|
|
||||||
- Query memories via `memory_query` and receive ranked results via cosine + relation boost
|
|
||||||
|
|
||||||
### Entry point / environment
|
|
||||||
|
|
||||||
- Entry point: `sf` CLI, `/memory` command, `capture_thought` and `memory_query` tool calls
|
|
||||||
- Environment: local dev, CI, production (single-user, per-project sf.db)
|
|
||||||
- Live dependencies: LLM provider (for extraction), `node:sqlite` (built-in Node >= 24)
|
|
||||||
|
|
||||||
## Completion Class
|
|
||||||
|
|
||||||
- **Contract complete** means: `sf.db` `memories` table passes CRUD + ranking tests; `capture_thought` and `memory_query` are registered native tools with schema validation; migration script has dry-run + backup modes.
|
|
||||||
- **Integration complete** means: session transcript pipeline writes to `sf.db`; `/memory` command reads from `sf.db`; all three legacy paths are removed or no-op'd.
|
|
||||||
- **Operational complete** means: WAL contention does not block session startup (extraction is fire-and-forget); no memory-related background processes leak resources.
|
|
||||||
|
|
||||||
## Final Integrated Acceptance
|
|
||||||
|
|
||||||
To call this milestone complete, we must prove:
|
|
||||||
|
|
||||||
- **Behavioral regression test passes:** A Playwright or shell test starts a session, triggers extraction, and verifies `/memory view` shows entries from `sf.db` — not `agent.db` or `MEMORY.md`.
|
|
||||||
- **`grep` verification passes:** `grep -r "sql.js|better-sqlite3" src/ packages/ --include="*.ts" --include="*.js" | grep -v "test\|spec\|deprecated"` returns zero matches in memory-related code paths.
|
|
||||||
- **`capture_thought`/`memory_query` are native tools:** Registered with proper TypeBox schema, validated in tool registry tests.
|
|
||||||
|
|
||||||
## Architectural Decisions
|
|
||||||
|
|
||||||
### Use function-based API, not a class wrapper
|
|
||||||
|
|
||||||
**Decision:** Extend the existing `memory-store.js` function-based API rather than wrapping it in a `MemoryStore` class.
|
|
||||||
|
|
||||||
**Rationale:** The existing functions (`createMemory`, `getRelevantMemoriesRanked`, etc.) are already the right abstraction. Adding a class wrapper introduces churn with no clear benefit — the pipeline can call functions directly. This minimizes risk during consolidation.
|
|
||||||
|
|
||||||
**Alternatives Considered:**
|
|
||||||
- Class wrapper (`MemoryStore` class) — higher churn, no functional benefit; rejected.
|
|
||||||
|
|
||||||
### Add `source` column to `memories` table
|
|
||||||
|
|
||||||
**Decision:** Add a `source` column (`'capture' | 'extracted' | 'migrated' | 'manual'`) to distinguish ingestion paths.
|
|
||||||
|
|
||||||
**Rationale:** Different sources have different confidence defaults and maintenance semantics. `capture_thought` entries start at confidence 0.8; extracted memories start at 0.7; migrated entries preserve original confidence. The column enables source-filtered queries and targeted deduplication.
|
|
||||||
|
|
||||||
### Register `capture_thought` and `memory_query` as native pi tools
|
|
||||||
|
|
||||||
**Decision:** Register `capture_thought` and `memory_query` as native pi tools (like `vectordrive_store`) with TypeBox parameter schemas, rather than relying solely on LLM tool-call convention in prompts.
|
|
||||||
|
|
||||||
**Rationale:** Native tool registration provides: (1) proper schema validation, (2) tool descriptions surfaced to the LLM, (3) consistent error handling. The current approach (LLM calls named tools in prompts) is fragile — the tool isn't actually registered, so errors are silently dropped.
|
|
||||||
|
|
||||||
**Alternatives Considered:**
|
|
||||||
- LLM tool-call convention only — already works but fragile; no schema validation; rejected.
|
|
||||||
|
|
||||||
### Keep `memory_embeddings` table as-is
|
|
||||||
|
|
||||||
**Decision:** Leave the existing `memory_embeddings` table in `sf.db` (BLOB storage for vectors) and the associated `memory-embeddings.js` / `memory-embeddings-llm-gateway.js` modules unchanged.
|
|
||||||
|
|
||||||
**Rationale:** The embedding infrastructure is pre-existing and functional. The consolidation goal is storage/unification, not embedding redesign. Wiring to VectorDrive is a future optimization, not required for this milestone.
|
|
||||||
|
|
||||||
**Alternatives Considered:**
|
|
||||||
- Wire embeddings to VectorDrive — VectorDrive has Rust SQLite vector support, but it is a separate system; adds complexity; deferred to a future milestone.
|
|
||||||
- Pure JS vector similarity — viable for small scale, but the existing infrastructure is sufficient.
|
|
||||||
|
|
||||||
### Migrate `agent.db` in S03, delete after import
|
|
||||||
|
|
||||||
**Decision:** S03 migration script reads `agent.db` stage1_outputs, imports memories to `sf.db` with `source='extracted'`, then deletes `agent.db`.
|
|
||||||
|
|
||||||
**Rationale:** Deleting after successful import is the cleanest cutover. Keeping the file around creates dual-write risk and user confusion. Dry-run mode + automatic `sf.db` backup mitigate migration risk.
|
|
||||||
|
|
||||||
**Alternatives Considered:**
|
|
||||||
- Delete at end of S04 — leaves dual-write window open longer; rejected.
|
|
||||||
- Leave orphaned (don't delete) — leaves cruft; rejected.
|
|
||||||
|
|
||||||
### Full scope: SF + pi-coding-agent
|
|
||||||
|
|
||||||
**Decision:** Consolidate both SF's `memory-store.js`/`knowledge-injector.js` AND pi-coding-agent's memory extension into `sf.db`.
|
|
||||||
|
|
||||||
**Rationale:** The memory extension's extraction pipeline is the primary source of extracted memories. If it still writes to `agent.db`, the consolidation is incomplete. Porting it to write to `sf.db` via `MemoryStore` is the correct scope.
|
|
||||||
|
|
||||||
## Error Handling Strategy
|
|
||||||
|
|
||||||
- **DB unavailable:** All `memory-store.js` functions degrade gracefully — return `[]` / `null` / `false` instead of throwing. `capture_thought` tool returns an error message, not a crash.
|
|
||||||
- **Migration failures:** S03 script skips corrupted records with a warning, continues processing remaining entries, and reports final counts. Never partially migrates without reporting.
|
|
||||||
- **LLM extraction failures:** Session startup extraction runs fire-and-forget; errors are caught and logged but do not block dispatch.
|
|
||||||
- **Token budget overflow:** `formatMemoriesForPrompt` respects `tokenBudget` parameter (~4 chars/token) and truncates at budget. Category grouping preserves priority order (gotcha → convention → architecture → pattern → environment → preference).
|
|
||||||
|
|
||||||
## Risks and Unknowns
|
|
||||||
|
|
||||||
- **Data loss during migration** — Users may have valuable accumulated memories in `agent.db` and `KNOWLEDGE.md` that would be lost if migration fails. **Mitigation:** Dry-run mode reports counts without modifying DB; automatic backup of `sf.db` before migration; skip-on-error with warning for corrupted records.
|
|
||||||
- **WAL contention on `sf.db`** — The `sf.db` already has a single-writer invariant. Adding memory extraction writes during session startup could create lock contention. **Mitigation:** Extraction runs fire-and-forget (does not block dispatch). If contention occurs, the single-writer invariant ensures serialized writes.
|
|
||||||
- **Breaking memory extension API contract** — The memory extension is a Pi extension with hooks and commands. Changing its storage backend changes observable behavior for external consumers. **Mitigation:** The `/memory` command output format is preserved; migration script ensures no data loss.
|
|
||||||
- **`capture_thought`/`memory_query` registration scope** — These tools should be registered in the pi-agent-core tool registry. The registration point needs to be identified before S01 implementation.
|
|
||||||
- **Node.js version requirement** — `node:sqlite` (DatabaseSync) requires Node >= 24. The project currently documents this as a minimum version. No change needed.
|
|
||||||
|
|
||||||
## Existing Codebase / Prior Art
|
|
||||||
|
|
||||||
- `src/resources/extensions/sf/memory-store.js` — Source of truth for the existing function-based API; already uses `node:sqlite` via `sf-db.js`. **Not to be rewritten; extended.**
|
|
||||||
- `src/resources/extensions/sf/sf-db.js` — Single-writer SQLite adapter using `node:sqlite` DatabaseSync. **Already correct; no changes needed.**
|
|
||||||
- `src/resources/extensions/sf/memory-embeddings.js` — LLM gateway for embedding computation. **Pre-existing; out of scope.**
|
|
||||||
- `src/resources/extensions/sf/memory-embeddings-llm-gateway.js` — Cross-encoder reranking. **Pre-existing; out of scope.**
|
|
||||||
- `packages/pi-coding-agent/src/resources/extensions/memory/storage.ts` — `sql.js`-based `MemoryStorage` class. **Replaced in S02.**
|
|
||||||
- `packages/pi-coding-agent/src/resources/extensions/memory/pipeline.ts` — Two-phase extraction pipeline. **Ported to `sf.db` in S02.**
|
|
||||||
- `src/resources/extensions/vectordrive/` — Rust N-API vector database. **Pre-existing; embedding integration deferred to future milestone.**
|
|
||||||
- `src/resources/extensions/sf/knowledge-injector.js` — Markdown knowledge parser and semantic similarity. **Removed or no-op'd in S03.**
|
|
||||||
|
|
||||||
## Relevant Requirements
|
|
||||||
|
|
||||||
- **Unified memory storage** — Covered: all three systems consolidate into `sf.db`.
|
|
||||||
- **Semantic search** — Covered: `getRelevantMemoriesRanked` with cosine + relation boost + optional rerank.
|
|
||||||
- **Session-based learning** — Covered: extraction pipeline ports to `sf.db` in S02.
|
|
||||||
- **Cross-session context persistence** — Partially covered: memories survive across sessions via `sf.db`. Multi-project sharing deferred.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In Scope
|
|
||||||
|
|
||||||
- Add `source` column to `memories` table in `sf.db`
|
|
||||||
- Register `capture_thought` and `memory_query` as native pi tools with TypeBox schemas
|
|
||||||
- Port memory extension extraction pipeline from `sql.js`/`agent.db` to `sf.db` via `memory-store.js` functions
|
|
||||||
- Migration script: `KNOWLEDGE.md` → `sf.db` and `agent.db` → `sf.db`
|
|
||||||
- Behavioral regression test (shell/Playwright) for end-to-end verification
|
|
||||||
- Remove or no-op `knowledge-injector.js` after migration
|
|
||||||
- Remove `sql.js` dependency from `packages/pi-coding-agent`
|
|
||||||
- Remove `memory_embeddings` table and embedding code **NOT in scope** — pre-existing, functional
|
|
||||||
|
|
||||||
### Out of Scope / Non-Goals
|
|
||||||
|
|
||||||
- Redesigning the embedding infrastructure (VectorDrive wiring, pure-JS vectors) — deferred to future milestone
|
|
||||||
- Multi-project memory sharing or cloud sync
|
|
||||||
- Changing the `memory-embeddings.js` / `memory-embeddings-llm-gateway.js` modules
|
|
||||||
- Changing `sf-db.js` schema initialization logic
|
|
||||||
- Supporting Node < 24
|
|
||||||
|
|
||||||
## Technical Constraints
|
|
||||||
|
|
||||||
- **Node >= 24 required** — `node:sqlite` DatabaseSync is built-in since Node 24. Earlier versions would need a polyfill or different approach.
|
|
||||||
- **Single-writer invariant on `sf.db`** — `sf-db.js` is the only writer. Memory functions must go through the adapter, not direct SQL.
|
|
||||||
- **`sql.js` WASM bundle** — Currently in `packages/pi-coding-agent/package.json`. Removing it requires updating the build output and verifying no other packages depend on it.
|
|
||||||
|
|
||||||
## Integration Points
|
|
||||||
|
|
||||||
- **LLM provider** — Extraction pipeline calls `completeSimple` for phase 1 (memory extraction) and phase 2 (consolidation). No API key changes needed.
|
|
||||||
- **`sf.db`** — Canonical store. Schema already has `memories` table; only needs `source` column added.
|
|
||||||
- **`agent.db`** — Legacy store. Migrated in S03, then deleted.
|
|
||||||
- **`KNOWLEDGE.md`** — Legacy file. Migrated in S03, then read-only fallback (removed from injection path).
|
|
||||||
- **pi-coding-agent package** — Owns the extraction pipeline and `/memory` command. S02 rewires it to `sf.db`.
|
|
||||||
- **VectorDrive** — Pre-existing vector DB. Embedding integration deferred.
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
- **Unit tests (S01):** CRUD operations on `memories` table, ranking formula (`confidence * (1 + hit_count * 0.1)`), source filtering, graceful degradation when DB unavailable, `formatMemoriesForPrompt` truncation and category grouping.
|
|
||||||
- **Contract tests (S02):** Pipeline writes to `sf.db` with correct `source` value; `/memory view` reads from `sf.db`; fire-and-forget does not block dispatch.
|
|
||||||
- **Migration tests (S03):** Dry-run reports correct counts; backup created before migration; `KNOWLEDGE.md` entries imported with `source='migrated'`; `agent.db` stage1_outputs imported with `source='extracted'`; skip-on-error for corrupted records.
|
|
||||||
- **Behavioral regression test (S04):** Playwright or shell test that starts a session, triggers extraction, and asserts `/memory view` output contains entries from `sf.db`.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. `sf.db` `memories` table has `source` column; all `memory-store.js` functions accept/return `source` field.
|
|
||||||
2. `capture_thought` and `memory_query` are registered native pi tools with TypeBox schemas and are called without errors.
|
|
||||||
3. Session extraction pipeline writes to `sf.db` with `source='extracted'`; `/memory view` reads from `sf.db`.
|
|
||||||
4. S03 migration script: dry-run mode reports correct counts; backup created; `agent.db` and `KNOWLEDGE.md` entries imported; old files removed.
|
|
||||||
5. `grep` finds zero `sql.js` or `better-sqlite3` imports in memory-related code paths.
|
|
||||||
6. Behavioral regression test passes: `/memory view` output originates from `sf.db`.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- **`capture_thought`/`memory_query` registration point** — These tools should be registered in `pi-agent-core`'s tool registry or the sf-run bootstrap. The exact registration module needs to be identified before S01 implementation. Current hypothesis: `src/resources/extensions/sf/` bootstrap or a new `memory-tools.js` module. **TBD: investigate `sf-run` tool registration flow.**
|
|
||||||
- **S04 behavioral test format** — Playwright (requires browser) or shell script (requires `sf` binary)? Shell script with `--print` output parsing is simpler and faster in CI. **Decision needed: test framework for behavioral regression.**
|
|
||||||
|
|
|
||||||
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
|
```bash
|
||||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
|
||||||
source ~/.bashrc # or ~/.zshrc
|
source ~/.bashrc # or ~/.zshrc
|
||||||
nvm install 24
|
nvm install 26
|
||||||
nvm use 24
|
nvm use 26
|
||||||
```
|
```
|
||||||
|
|
||||||
#### All distros: Steps 2-7
|
#### All distros: Steps 2-7
|
||||||
|
|
@ -64,7 +64,7 @@ nvm use 24
|
||||||
**Step 2 — Verify dependencies are installed:**
|
**Step 2 — Verify dependencies are installed:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --version # should print v24.x or higher
|
node --version # should print v26.x or higher
|
||||||
git --version # should print 2.20+
|
git --version # should print 2.20+
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -309,7 +309,7 @@ Or from within a session:
|
||||||
| `sf` runs `git svn dcommit` | oh-my-zsh conflict — `unalias sf` or use `sf-cli` |
|
| `sf` runs `git svn dcommit` | oh-my-zsh conflict — `unalias sf` or use `sf-cli` |
|
||||||
| Permission errors on `npm install -g` | Fix npm prefix (see Linux notes) or use nvm |
|
| Permission errors on `npm install -g` | Fix npm prefix (see Linux notes) or use nvm |
|
||||||
| Can't connect to LLM | Check API key with `sf config`, verify network access |
|
| Can't connect to LLM | Check API key with `sf config`, verify network access |
|
||||||
| `sf` hangs on start | Check Node.js version: `node --version` (need 24+) |
|
| `sf` hangs on start | Check Node.js version: `node --version` (need 26+) |
|
||||||
|
|
||||||
For more, see [Troubleshooting](./troubleshooting.md).
|
For more, see [Troubleshooting](./troubleshooting.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,11 +151,11 @@ rm -rf "$(dirname .sf)/.sf.lock"
|
||||||
- If the error persists, close tools that may be holding the file open and then retry.
|
- If the error persists, close tools that may be holding the file open and then retry.
|
||||||
- If repeated failures continue, run `/doctor` to confirm the repo state is still healthy and report the exact path + error code.
|
- If repeated failures continue, run `/doctor` to confirm the repo state is still healthy and report the exact path + error code.
|
||||||
|
|
||||||
### Node v24 web boot failure
|
### Node v26 web boot failure
|
||||||
|
|
||||||
**Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v24.
|
**Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v26.
|
||||||
|
|
||||||
**Cause:** Node v24 changed type-stripping behavior for `node_modules`, breaking the Next.js web build.
|
**Cause:** Node v26 changed type-stripping behavior for `node_modules`, breaking the Next.js web build.
|
||||||
|
|
||||||
**Fix:** Fixed in v2.42.0+ (#1864). Upgrade to the latest version.
|
**Fix:** Fixed in v2.42.0+ (#1864). Upgrade to the latest version.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ The web server binds to `localhost:3000` by default. Use `--host`, `--port`, and
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `SF_WEB_PROJECT_CWD` | Default project path when `?project=` is not specified |
|
| `SF_WEB_PROJECT_CWD` | Default project path when `?project=` is not specified |
|
||||||
|
|
||||||
## Node v24 Compatibility
|
## Node v26 Compatibility
|
||||||
|
|
||||||
Node v24 introduced breaking changes to type stripping that caused `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on web boot. This is fixed in v2.42.0+ (#1864). If you encounter this error, upgrade SF.
|
Node v26 introduced breaking changes to type stripping that caused `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on web boot. This is fixed in v2.42.0+ (#1864). If you encounter this error, upgrade SF.
|
||||||
|
|
||||||
## Auth Token Persistence
|
## Auth Token Persistence
|
||||||
|
|
||||||
|
|
|
||||||
28059
package-lock.json
generated
28059
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -38,7 +38,7 @@
|
||||||
"configDir": ".sf"
|
"configDir": ".sf"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"packageManager": "npm@11.13.0",
|
"packageManager": "npm@11.13.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -148,7 +148,6 @@
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
"sql.js": "^1.14.1",
|
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"undici": "^7.24.2",
|
"undici": "^7.24.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
|
@ -159,7 +158,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^25.6.2",
|
||||||
"@types/picomatch": "^4.0.2",
|
"@types/picomatch": "^4.0.2",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,11 @@
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^25.6.2",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|
|
||||||
|
|
@ -81,22 +81,22 @@ describe("generatePlist", () => {
|
||||||
|
|
||||||
it("uses the absolute node path from opts", () => {
|
it("uses the absolute node path from opts", () => {
|
||||||
const opts = basePlistOpts({
|
const opts = basePlistOpts({
|
||||||
nodePath: "/home/user/.nvm/versions/node/v24.0.0/bin/node",
|
nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node",
|
||||||
});
|
});
|
||||||
const xml = generatePlist(opts);
|
const xml = generatePlist(opts);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
xml.includes(
|
xml.includes(
|
||||||
"<string>/home/user/.nvm/versions/node/v24.0.0/bin/node</string>",
|
"<string>/home/user/.nvm/versions/node/v26.1.0/bin/node</string>",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes NVM bin directory in PATH", () => {
|
it("includes NVM bin directory in PATH", () => {
|
||||||
const opts = basePlistOpts({
|
const opts = basePlistOpts({
|
||||||
nodePath: "/home/user/.nvm/versions/node/v24.0.0/bin/node",
|
nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node",
|
||||||
});
|
});
|
||||||
const xml = generatePlist(opts);
|
const xml = generatePlist(opts);
|
||||||
assert.ok(xml.includes("/home/user/.nvm/versions/node/v24.0.0/bin"));
|
assert.ok(xml.includes("/home/user/.nvm/versions/node/v26.1.0/bin"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets KeepAlive with SuccessfulExit false", () => {
|
it("sets KeepAlive with SuccessfulExit false", () => {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@singularity-forge/engine-darwin-arm64": ">=2.75.0",
|
"@singularity-forge/engine-darwin-arm64": ">=2.75.0",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* for Node.js module resolution (ESM/CJS compatibility).
|
* for Node.js module resolution (ESM/CJS compatibility).
|
||||||
*
|
*
|
||||||
* Regression test for #2861: "type": "module" + "import"-only export
|
* Regression test for #2861: "type": "module" + "import"-only export
|
||||||
* conditions caused crashes on Node.js v24 when the parent package also
|
* conditions caused crashes on Node.js v26 when the parent package also
|
||||||
* declared "type": "module" and strict ESM resolution was enforced.
|
* declared "type": "module" and strict ESM resolution was enforced.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
|
||||||
assert.notEqual(
|
assert.notEqual(
|
||||||
pkg.type,
|
pkg.type,
|
||||||
"module",
|
"module",
|
||||||
'package.json must not set "type": "module" — this causes crashes on Node.js v24 ' +
|
'package.json must not set "type": "module" — this causes crashes on Node.js v26 ' +
|
||||||
"when the parent package also declares ESM (see #2861)",
|
"when the parent package also declares ESM (see #2861)",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -55,7 +55,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!conditions.import || conditions.default,
|
!conditions.import || conditions.default,
|
||||||
`exports["${subpath}"] uses "import" condition without "default" — ` +
|
`exports["${subpath}"] uses "import" condition without "default" — ` +
|
||||||
`this breaks CJS consumers and Node.js v24 strict resolution`,
|
`this breaks CJS consumers and Node.js v26 strict resolution`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,6 @@
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,6 @@
|
||||||
"@smithy/node-http-handler": "^4.5.0"
|
"@smithy/node-http-handler": "^4.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,18 +33,16 @@
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"undici": "^7.24.2",
|
"undici": "^7.24.2",
|
||||||
"sql.js": "^1.14.1",
|
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/sql.js": "^1.4.9",
|
|
||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
"@types/hosted-git-info": "^3.0.5",
|
"@types/hosted-git-info": "^3.0.5",
|
||||||
"@types/proper-lockfile": "^4.1.4",
|
"@types/proper-lockfile": "^4.1.4",
|
||||||
"@types/express": "^4.17.21"
|
"@types/express": "^4.17.21"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
* - /memory command: view, clear, rebuild, stats
|
* - /memory command: view, clear, rebuild, stats
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { completeSimple } from "@singularity-forge/pi-ai";
|
import { completeSimple } from "@singularity-forge/pi-ai";
|
||||||
|
|
@ -23,26 +22,25 @@ import {
|
||||||
import { getFullMemory, getMemorySummary, runStartup } from "./pipeline.js";
|
import { getFullMemory, getMemorySummary, runStartup } from "./pipeline.js";
|
||||||
import { MemoryStorage } from "./storage.js";
|
import { MemoryStorage } from "./storage.js";
|
||||||
|
|
||||||
/** Encode cwd to a filesystem-safe directory name */
|
|
||||||
function encodeCwd(cwd: string): string {
|
|
||||||
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the memory directory for a project */
|
/** Get the memory directory for a project */
|
||||||
function getMemoryDir(cwd: string): string {
|
function getMemoryDir(cwd: string): string {
|
||||||
return join(getAgentDir(), "memories", encodeCwd(cwd));
|
return join(cwd, ".sf", "memory");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the database path */
|
/** Get the database path */
|
||||||
function getDbPath(): string {
|
function getDbPath(cwd: string): string {
|
||||||
return join(getAgentDir(), "agent.db");
|
return join(cwd, ".sf", "sf.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
let storageInstance: MemoryStorage | null = null;
|
let storageInstance: MemoryStorage | null = null;
|
||||||
|
let storageDbPath: string | null = null;
|
||||||
|
|
||||||
async function getStorage(): Promise<MemoryStorage> {
|
async function getStorage(cwd: string): Promise<MemoryStorage> {
|
||||||
if (!storageInstance) {
|
const dbPath = getDbPath(cwd);
|
||||||
storageInstance = await MemoryStorage.create(getDbPath());
|
if (!storageInstance || storageDbPath !== dbPath) {
|
||||||
|
storageInstance?.close();
|
||||||
|
storageInstance = await MemoryStorage.create(dbPath);
|
||||||
|
storageDbPath = dbPath;
|
||||||
}
|
}
|
||||||
return storageInstance;
|
return storageInstance;
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +128,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
||||||
|
|
||||||
// Fire and forget
|
// Fire and forget
|
||||||
runStartup(
|
runStartup(
|
||||||
await getStorage(),
|
await getStorage(cwd),
|
||||||
{
|
{
|
||||||
sessionsDir,
|
sessionsDir,
|
||||||
memoryDir,
|
memoryDir,
|
||||||
|
|
@ -212,7 +210,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
||||||
"Delete all extracted memories for this project?",
|
"Delete all extracted memories for this project?",
|
||||||
);
|
);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
(await getStorage()).clearForCwd(ctx.cwd);
|
(await getStorage(ctx.cwd)).clearForCwd(ctx.cwd);
|
||||||
if (existsSync(projectMemoryDir)) {
|
if (existsSync(projectMemoryDir)) {
|
||||||
rmSync(projectMemoryDir, { recursive: true, force: true });
|
rmSync(projectMemoryDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +225,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
||||||
"Re-extract all memories from session history? This may take a while.",
|
"Re-extract all memories from session history? This may take a while.",
|
||||||
);
|
);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
(await getStorage()).resetAllForCwd(ctx.cwd);
|
(await getStorage(ctx.cwd)).resetAllForCwd(ctx.cwd);
|
||||||
if (existsSync(projectMemoryDir)) {
|
if (existsSync(projectMemoryDir)) {
|
||||||
rmSync(projectMemoryDir, { recursive: true, force: true });
|
rmSync(projectMemoryDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +238,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "stats": {
|
case "stats": {
|
||||||
const stats = (await getStorage()).getStats();
|
const stats = (await getStorage(ctx.cwd)).getStats();
|
||||||
const statsText = [
|
const statsText = [
|
||||||
"Memory Pipeline Statistics:",
|
"Memory Pipeline Statistics:",
|
||||||
` Total sessions tracked: ${stats.totalThreads}`,
|
` Total sessions tracked: ${stats.totalThreads}`,
|
||||||
|
|
@ -274,6 +272,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
|
||||||
if (storageInstance) {
|
if (storageInstance) {
|
||||||
storageInstance.close();
|
storageInstance.close();
|
||||||
storageInstance = null;
|
storageInstance = null;
|
||||||
|
storageDbPath = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, it } from "vitest";
|
import { afterEach, describe, it } from "vitest";
|
||||||
|
|
@ -10,11 +10,7 @@ function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "sf-memory-storage-test-"));
|
return mkdtempSync(join(tmpdir(), "sf-memory-storage-test-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
describe("MemoryStorage node:sqlite persistence", () => {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MemoryStorage debounced persistence", () => {
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -23,14 +19,11 @@ describe("MemoryStorage debounced persistence", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multiple rapid mutations only trigger one persist write", async () => {
|
it("multiple rapid mutations are immediately queryable", async () => {
|
||||||
dir = makeTmpDir();
|
dir = makeTmpDir();
|
||||||
const dbPath = join(dir, "test.db");
|
const dbPath = join(dir, "test.db");
|
||||||
const storage = await MemoryStorage.create(dbPath);
|
const storage = await MemoryStorage.create(dbPath);
|
||||||
|
|
||||||
const initialStat = readFileSync(dbPath);
|
|
||||||
const _initialMtime = initialStat.length;
|
|
||||||
|
|
||||||
storage.upsertThreads([
|
storage.upsertThreads([
|
||||||
{
|
{
|
||||||
threadId: "t1",
|
threadId: "t1",
|
||||||
|
|
@ -59,35 +52,18 @@ describe("MemoryStorage debounced persistence", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const afterMutationsBuf = readFileSync(dbPath);
|
assert.equal(existsSync(dbPath), true);
|
||||||
assert.deepEqual(
|
|
||||||
afterMutationsBuf,
|
|
||||||
initialStat,
|
|
||||||
"File should not have been written yet (debounce window has not elapsed)",
|
|
||||||
);
|
|
||||||
|
|
||||||
await wait(700);
|
|
||||||
|
|
||||||
const afterDebounceBuf = readFileSync(dbPath);
|
|
||||||
assert.notDeepEqual(
|
|
||||||
afterDebounceBuf,
|
|
||||||
initialStat,
|
|
||||||
"File should have been written after debounce window elapsed",
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = storage.getStats();
|
const stats = storage.getStats();
|
||||||
assert.equal(stats.totalThreads, 3);
|
assert.equal(stats.totalThreads, 3);
|
||||||
|
|
||||||
storage.close();
|
storage.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("close() flushes pending changes immediately without waiting for debounce", async () => {
|
it("close() releases the database and persisted rows reopen", async () => {
|
||||||
dir = makeTmpDir();
|
dir = makeTmpDir();
|
||||||
const dbPath = join(dir, "test.db");
|
const dbPath = join(dir, "test.db");
|
||||||
const storage = await MemoryStorage.create(dbPath);
|
const storage = await MemoryStorage.create(dbPath);
|
||||||
|
|
||||||
const initialBuf = readFileSync(dbPath);
|
|
||||||
|
|
||||||
storage.upsertThreads([
|
storage.upsertThreads([
|
||||||
{
|
{
|
||||||
threadId: "t1",
|
threadId: "t1",
|
||||||
|
|
@ -98,22 +74,8 @@ describe("MemoryStorage debounced persistence", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const beforeCloseBuf = readFileSync(dbPath);
|
|
||||||
assert.deepEqual(
|
|
||||||
beforeCloseBuf,
|
|
||||||
initialBuf,
|
|
||||||
"File should not have been written yet (debounce window has not elapsed)",
|
|
||||||
);
|
|
||||||
|
|
||||||
storage.close();
|
storage.close();
|
||||||
|
|
||||||
const afterCloseBuf = readFileSync(dbPath);
|
|
||||||
assert.notDeepEqual(
|
|
||||||
afterCloseBuf,
|
|
||||||
initialBuf,
|
|
||||||
"File should have been written immediately on close()",
|
|
||||||
);
|
|
||||||
|
|
||||||
const reopened = await MemoryStorage.create(dbPath);
|
const reopened = await MemoryStorage.create(dbPath);
|
||||||
const stats = reopened.getStats();
|
const stats = reopened.getStats();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import initSqlJs, { type Database as SqlJsDatabase } from "sql.js";
|
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
||||||
|
|
||||||
export interface ThreadRow {
|
export interface ThreadRow {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
|
|
@ -44,13 +44,10 @@ export interface JobRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemoryStorage {
|
export class MemoryStorage {
|
||||||
private db: SqlJsDatabase;
|
private db: DatabaseSync;
|
||||||
private dbPath: string;
|
|
||||||
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
private constructor(db: SqlJsDatabase, dbPath: string) {
|
private constructor(db: DatabaseSync) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.dbPath = dbPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(dbPath: string): Promise<MemoryStorage> {
|
static async create(dbPath: string): Promise<MemoryStorage> {
|
||||||
|
|
@ -59,36 +56,23 @@ export class MemoryStorage {
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const SQL = await initSqlJs();
|
const db = new DatabaseSync(dbPath);
|
||||||
const buffer = existsSync(dbPath) ? readFileSync(dbPath) : undefined;
|
|
||||||
const db = buffer ? new SQL.Database(buffer) : new SQL.Database();
|
|
||||||
|
|
||||||
db.run("PRAGMA journal_mode = WAL");
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
db.run("PRAGMA synchronous = NORMAL");
|
db.exec("PRAGMA synchronous = NORMAL");
|
||||||
db.run("PRAGMA busy_timeout = 5000");
|
db.exec("PRAGMA busy_timeout = 5000");
|
||||||
|
|
||||||
const storage = new MemoryStorage(db, dbPath);
|
const storage = new MemoryStorage(db);
|
||||||
storage.initSchema();
|
storage.initSchema();
|
||||||
return storage;
|
return storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private persist(): void {
|
private run(sql: string, params: unknown[] = []): void {
|
||||||
const data = this.db.export();
|
this.db.prepare(sql).run(...(params as SQLInputValue[]));
|
||||||
writeFileSync(this.dbPath, Buffer.from(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
private schedulePersist(): void {
|
|
||||||
if (this.persistTimer) {
|
|
||||||
clearTimeout(this.persistTimer);
|
|
||||||
}
|
|
||||||
this.persistTimer = setTimeout(() => {
|
|
||||||
this.persistTimer = null;
|
|
||||||
this.persist();
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private initSchema(): void {
|
private initSchema(): void {
|
||||||
this.db.run(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS threads (
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
thread_id TEXT PRIMARY KEY,
|
thread_id TEXT PRIMARY KEY,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
|
|
@ -101,7 +85,7 @@ export class MemoryStorage {
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
this.db.run(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS stage1_outputs (
|
CREATE TABLE IF NOT EXISTS stage1_outputs (
|
||||||
thread_id TEXT PRIMARY KEY,
|
thread_id TEXT PRIMARY KEY,
|
||||||
extraction_json TEXT NOT NULL,
|
extraction_json TEXT NOT NULL,
|
||||||
|
|
@ -109,7 +93,7 @@ export class MemoryStorage {
|
||||||
FOREIGN KEY (thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE
|
FOREIGN KEY (thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
this.db.run(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
phase TEXT NOT NULL,
|
phase TEXT NOT NULL,
|
||||||
|
|
@ -123,30 +107,23 @@ export class MemoryStorage {
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
this.db.run(
|
this.db.exec(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_phase_status ON jobs(phase, status)",
|
"CREATE INDEX IF NOT EXISTS idx_jobs_phase_status ON jobs(phase, status)",
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.db.exec(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status)",
|
"CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status)",
|
||||||
);
|
);
|
||||||
this.db.run("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)");
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)");
|
||||||
this.persist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private queryAll<T>(sql: string, params: unknown[] = []): T[] {
|
private queryAll<T>(sql: string, params: unknown[] = []): T[] {
|
||||||
const stmt = this.db.prepare(sql);
|
return this.db.prepare(sql).all(...(params as SQLInputValue[])) as T[];
|
||||||
stmt.bind(params as (string | number | null | Uint8Array)[]);
|
|
||||||
const rows: T[] = [];
|
|
||||||
while (stmt.step()) {
|
|
||||||
rows.push(stmt.getAsObject() as T);
|
|
||||||
}
|
|
||||||
stmt.free();
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
|
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
|
||||||
const rows = this.queryAll<T>(sql, params);
|
return this.db.prepare(sql).get(...(params as SQLInputValue[])) as
|
||||||
return rows[0];
|
| T
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -177,11 +154,11 @@ export class MemoryStorage {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
this.db.run(
|
this.run(
|
||||||
"INSERT INTO threads (thread_id, file_path, file_size, file_mtime, cwd, status) VALUES (?, ?, ?, ?, ?, 'pending')",
|
"INSERT INTO threads (thread_id, file_path, file_size, file_mtime, cwd, status) VALUES (?, ?, ?, ?, ?, 'pending')",
|
||||||
[t.threadId, t.filePath, t.fileSize, t.fileMtime, t.cwd],
|
[t.threadId, t.filePath, t.fileSize, t.fileMtime, t.cwd],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
||||||
[randomUUID(), t.threadId],
|
[randomUUID(), t.threadId],
|
||||||
);
|
);
|
||||||
|
|
@ -190,12 +167,12 @@ export class MemoryStorage {
|
||||||
existing.file_size !== t.fileSize ||
|
existing.file_size !== t.fileSize ||
|
||||||
existing.file_mtime !== t.fileMtime
|
existing.file_mtime !== t.fileMtime
|
||||||
) {
|
) {
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE threads SET file_path = ?, file_size = ?, file_mtime = ?, cwd = ?, status = 'pending', updated_at = datetime('now') WHERE thread_id = ?",
|
"UPDATE threads SET file_path = ?, file_size = ?, file_mtime = ?, cwd = ?, status = 'pending', updated_at = datetime('now') WHERE thread_id = ?",
|
||||||
[t.filePath, t.fileSize, t.fileMtime, t.cwd, t.threadId],
|
[t.filePath, t.fileSize, t.fileMtime, t.cwd, t.threadId],
|
||||||
);
|
);
|
||||||
if (existing.status === "done" || existing.status === "error") {
|
if (existing.status === "done" || existing.status === "error") {
|
||||||
this.db.run(
|
this.run(
|
||||||
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
||||||
[randomUUID(), t.threadId],
|
[randomUUID(), t.threadId],
|
||||||
);
|
);
|
||||||
|
|
@ -206,7 +183,6 @@ export class MemoryStorage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.schedulePersist();
|
|
||||||
return { inserted, updated, skipped };
|
return { inserted, updated, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +198,7 @@ export class MemoryStorage {
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
|
const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
|
||||||
|
|
||||||
this.db.run(
|
this.run(
|
||||||
`UPDATE jobs SET
|
`UPDATE jobs SET
|
||||||
status = 'claimed',
|
status = 'claimed',
|
||||||
worker_id = ?,
|
worker_id = ?,
|
||||||
|
|
@ -243,8 +219,6 @@ export class MemoryStorage {
|
||||||
[token],
|
[token],
|
||||||
);
|
);
|
||||||
|
|
||||||
this.schedulePersist();
|
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
jobId: r.id,
|
jobId: r.id,
|
||||||
threadId: r.thread_id,
|
threadId: r.thread_id,
|
||||||
|
|
@ -256,34 +230,32 @@ export class MemoryStorage {
|
||||||
* Mark a stage1 job as complete and store the extraction output.
|
* Mark a stage1 job as complete and store the extraction output.
|
||||||
*/
|
*/
|
||||||
completeStage1Job(threadId: string, output: string): void {
|
completeStage1Job(threadId: string, output: string): void {
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
|
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
|
||||||
[threadId],
|
[threadId],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"INSERT OR REPLACE INTO stage1_outputs (thread_id, extraction_json, created_at) VALUES (?, ?, datetime('now'))",
|
"INSERT OR REPLACE INTO stage1_outputs (thread_id, extraction_json, created_at) VALUES (?, ?, datetime('now'))",
|
||||||
[threadId, output],
|
[threadId, output],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?",
|
"UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?",
|
||||||
[threadId],
|
[threadId],
|
||||||
);
|
);
|
||||||
this.schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a stage1 job as errored.
|
* Mark a stage1 job as errored.
|
||||||
*/
|
*/
|
||||||
failStage1Job(threadId: string, errorMessage: string): void {
|
failStage1Job(threadId: string, errorMessage: string): void {
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
|
"UPDATE jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
|
||||||
[errorMessage, threadId],
|
[errorMessage, threadId],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?",
|
"UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?",
|
||||||
[errorMessage, threadId],
|
[errorMessage, threadId],
|
||||||
);
|
);
|
||||||
this.schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -322,12 +294,11 @@ export class MemoryStorage {
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
this.db.run(
|
this.run(
|
||||||
"INSERT INTO jobs (id, phase, status, worker_id, ownership_token, lease_expires_at) VALUES (?, 'stage2', 'claimed', ?, ?, ?)",
|
"INSERT INTO jobs (id, phase, status, worker_id, ownership_token, lease_expires_at) VALUES (?, 'stage2', 'claimed', ?, ?, ?)",
|
||||||
[jobId, workerId, token, expiresAt],
|
[jobId, workerId, token, expiresAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
this.schedulePersist();
|
|
||||||
return { jobId, ownershipToken: token };
|
return { jobId, ownershipToken: token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,11 +306,10 @@ export class MemoryStorage {
|
||||||
* Complete the phase 2 consolidation job.
|
* Complete the phase 2 consolidation job.
|
||||||
*/
|
*/
|
||||||
completePhase2Job(jobId: string): void {
|
completePhase2Job(jobId: string): void {
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'",
|
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'",
|
||||||
[jobId],
|
[jobId],
|
||||||
);
|
);
|
||||||
this.schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -432,41 +402,39 @@ export class MemoryStorage {
|
||||||
* Clear all data (for /memory clear).
|
* Clear all data (for /memory clear).
|
||||||
*/
|
*/
|
||||||
clearAll(): void {
|
clearAll(): void {
|
||||||
this.db.run("DELETE FROM stage1_outputs");
|
this.db.exec("DELETE FROM stage1_outputs");
|
||||||
this.db.run("DELETE FROM jobs");
|
this.db.exec("DELETE FROM jobs");
|
||||||
this.db.run("DELETE FROM threads");
|
this.db.exec("DELETE FROM threads");
|
||||||
this.schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear data for a specific cwd (for /memory clear in project scope).
|
* Clear data for a specific cwd (for /memory clear in project scope).
|
||||||
*/
|
*/
|
||||||
clearForCwd(cwd: string): void {
|
clearForCwd(cwd: string): void {
|
||||||
this.db.run(
|
this.run(
|
||||||
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||||
[cwd],
|
[cwd],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||||
[cwd],
|
[cwd],
|
||||||
);
|
);
|
||||||
this.db.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
|
this.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
|
||||||
this.schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset all threads to pending (for /memory rebuild).
|
* Reset all threads to pending (for /memory rebuild).
|
||||||
*/
|
*/
|
||||||
resetAllForCwd(cwd: string): void {
|
resetAllForCwd(cwd: string): void {
|
||||||
this.db.run(
|
this.run(
|
||||||
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||||
[cwd],
|
[cwd],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
|
||||||
[cwd],
|
[cwd],
|
||||||
);
|
);
|
||||||
this.db.run(
|
this.run(
|
||||||
"UPDATE threads SET status = 'pending', updated_at = datetime('now') WHERE cwd = ?",
|
"UPDATE threads SET status = 'pending', updated_at = datetime('now') WHERE cwd = ?",
|
||||||
[cwd],
|
[cwd],
|
||||||
);
|
);
|
||||||
|
|
@ -477,20 +445,14 @@ export class MemoryStorage {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const t of threads) {
|
for (const t of threads) {
|
||||||
this.db.run(
|
this.run(
|
||||||
"INSERT INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
"INSERT INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
|
||||||
[randomUUID(), t.thread_id],
|
[randomUUID(), t.thread_id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.persistTimer) {
|
|
||||||
clearTimeout(this.persistTimer);
|
|
||||||
this.persistTimer = null;
|
|
||||||
}
|
|
||||||
this.persist();
|
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,32 +26,6 @@ declare module "proper-lockfile" {
|
||||||
export default lockfile;
|
export default lockfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "sql.js" {
|
|
||||||
export interface Statement {
|
|
||||||
bind(values: (string | number | null | Uint8Array)[]): void;
|
|
||||||
step(): boolean;
|
|
||||||
getAsObject(): Record<string, unknown>;
|
|
||||||
free(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Database {
|
|
||||||
run(sql: string, params?: unknown[]): void;
|
|
||||||
prepare(sql: string): Statement;
|
|
||||||
export(): Uint8Array;
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SqlJsStatic {
|
|
||||||
Database: new (data?: Uint8Array | ArrayBuffer | Buffer) => Database;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SqlJsConfig {
|
|
||||||
locateFile?: (file: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function initSqlJs(config?: SqlJsConfig): Promise<SqlJsStatic>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "hosted-git-info" {
|
declare module "hosted-git-info" {
|
||||||
export interface HostedGitInfo {
|
export interface HostedGitInfo {
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@
|
||||||
"koffi": "^2.9.0"
|
"koffi": "^2.9.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,6 @@
|
||||||
"test": "node --test dist/rpc-client.test.js"
|
"test": "node --test dist/rpc-client.test.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "sf",
|
"name": "sf",
|
||||||
"version": "2.75.3",
|
"version": "2.75.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"piConfig": {
|
"piConfig": {
|
||||||
"name": "sf",
|
"name": "sf",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
* Data sources:
|
* Data sources:
|
||||||
* .sf/parallel/M0xx.status.json — heartbeat, cost, state (written by orchestrator)
|
* .sf/parallel/M0xx.status.json — heartbeat, cost, state (written by orchestrator)
|
||||||
* .sf/worktrees/M0xx/.sf/auto.lock — current unit type + ID (written by worker)
|
* .sf/worktrees/M0xx/.sf/auto.lock — current unit type + ID (written by worker)
|
||||||
* .sf/worktrees/M0xx/.sf/sf.db — task/slice completion (SQLite, queried via cli)
|
* .sf/worktrees/M0xx/.sf/sf.db — task/slice completion (read-only node:sqlite query)
|
||||||
* .sf/parallel/M0xx.stdout.log — NDJSON events (cost extraction, notify messages)
|
* .sf/parallel/M0xx.stdout.log — NDJSON events (cost extraction, notify messages)
|
||||||
* .sf/parallel/M0xx.stderr.log — error surfacing
|
* .sf/parallel/M0xx.stderr.log — error surfacing
|
||||||
*
|
*
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
import { execSync, spawn, spawnSync } from "node:child_process";
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -175,26 +176,38 @@ function readAutoLock(mid) {
|
||||||
return readJsonSafe(lockPath);
|
return readJsonSafe(lockPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queryRows(dbPath, sql, params = []) {
|
||||||
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||||
|
try {
|
||||||
|
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function querySliceProgress(mid) {
|
function querySliceProgress(mid) {
|
||||||
const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`);
|
const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`);
|
||||||
if (!fs.existsSync(dbPath)) return [];
|
if (!fs.existsSync(dbPath)) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sql = `SELECT s.id, s.status, COUNT(t.id), SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) FROM slices s LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id WHERE s.milestone_id='${mid}' GROUP BY s.id ORDER BY s.id`;
|
return queryRows(
|
||||||
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, {
|
dbPath,
|
||||||
timeout: 3000,
|
`SELECT s.id AS id,
|
||||||
encoding: "utf-8",
|
s.status AS status,
|
||||||
}).trim();
|
COUNT(t.id) AS total,
|
||||||
if (!out) return [];
|
SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
|
||||||
return out.split("\n").map((line) => {
|
FROM slices s
|
||||||
const [id, status, total, done] = line.split("|");
|
LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
|
||||||
return {
|
WHERE s.milestone_id=?
|
||||||
id,
|
GROUP BY s.id
|
||||||
status,
|
ORDER BY s.id`,
|
||||||
total: parseInt(total, 10),
|
[mid],
|
||||||
done: parseInt(done || "0", 10),
|
).map((row) => ({
|
||||||
};
|
id: row.id,
|
||||||
});
|
status: row.status,
|
||||||
|
total: Number(row.total ?? 0),
|
||||||
|
done: Number(row.done ?? 0),
|
||||||
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -631,17 +644,23 @@ function queryRecentCompletions(mid) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Completed tasks with timestamps, most recent first
|
// Completed tasks with timestamps, most recent first
|
||||||
const sql = `SELECT id, slice_id, one_liner, completed_at FROM tasks WHERE milestone_id='${mid}' AND status='complete' AND completed_at IS NOT NULL ORDER BY completed_at DESC LIMIT 5`;
|
return queryRows(
|
||||||
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, {
|
dbPath,
|
||||||
timeout: 3000,
|
`SELECT id AS taskId,
|
||||||
encoding: "utf-8",
|
slice_id AS sliceId,
|
||||||
}).trim();
|
one_liner AS oneLiner,
|
||||||
if (!out) return [];
|
completed_at AS completedAt
|
||||||
return out.split("\n").map((line) => {
|
FROM tasks
|
||||||
const [taskId, sliceId, oneLiner, completedAt] = line.split("|");
|
WHERE milestone_id=?
|
||||||
|
AND status='complete'
|
||||||
|
AND completed_at IS NOT NULL
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
[mid],
|
||||||
|
).map((row) => {
|
||||||
return {
|
return {
|
||||||
ts: completedAt ? new Date(completedAt).getTime() : Date.now(),
|
ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(),
|
||||||
msg: `✓ ${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`,
|
msg: `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
||||||
mid,
|
mid,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import { stopWebMode } from "./web-mode.js";
|
||||||
import { loadStoredEnvKeys } from "./wizard.js";
|
import { loadStoredEnvKeys } from "./wizard.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// V8 compile cache — Node 24+ can cache compiled bytecode across runs,
|
// V8 compile cache — Node 26+ can cache compiled bytecode across runs,
|
||||||
// eliminating repeated parse/compile overhead for unchanged modules.
|
// eliminating repeated parse/compile overhead for unchanged modules.
|
||||||
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
|
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ if (
|
||||||
// package.json (already parsed above) and verifies git is available.
|
// package.json (already parsed above) and verifies git is available.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
{
|
{
|
||||||
const MIN_NODE_MAJOR = 24;
|
const MIN_NODE_MAJOR = 26;
|
||||||
const red = "\x1b[31m";
|
const red = "\x1b[31m";
|
||||||
const bold = "\x1b[1m";
|
const bold = "\x1b[1m";
|
||||||
const dim = "\x1b[2m";
|
const dim = "\x1b[2m";
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/*.test.mjs"
|
"test": "node --test tests/*.test.mjs"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "cmux integration library — used by other extensions, not an extension itself",
|
"description": "cmux integration library — used by other extensions, not an extension itself",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {}
|
"pi": {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,7 @@ export function syncProjectRootToWorktree(
|
||||||
}
|
}
|
||||||
// Always clean up WAL/SHM sidecar files when the main DB was deleted
|
// Always clean up WAL/SHM sidecar files when the main DB was deleted
|
||||||
// or is already missing. Orphaned WAL/SHM files cause SQLite WAL
|
// or is already missing. Orphaned WAL/SHM files cause SQLite WAL
|
||||||
// recovery on next open, which triggers a CPU spin on Node 24's
|
// recovery on next open, which triggers a CPU spin on Node 26's
|
||||||
// node:sqlite DatabaseSync implementation (#2478).
|
// node:sqlite DatabaseSync implementation (#2478).
|
||||||
if (deleteSidecars) {
|
if (deleteSidecars) {
|
||||||
for (const suffix of ["-wal", "-shm"]) {
|
for (const suffix of ["-wal", "-shm"]) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@
|
||||||
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
|
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
|
||||||
* `let` or `var` declarations.
|
* `let` or `var` declarations.
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
buildModeState,
|
||||||
|
resolveModelMode,
|
||||||
|
resolvePermissionProfile,
|
||||||
|
resolveRunControlMode,
|
||||||
|
resolveWorkMode,
|
||||||
|
} from "../operating-model.js";
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
export const MAX_UNIT_DISPATCHES = 3;
|
export const MAX_UNIT_DISPATCHES = 3;
|
||||||
export const STUB_RECOVERY_THRESHOLD = 2;
|
export const STUB_RECOVERY_THRESHOLD = 2;
|
||||||
|
|
@ -48,6 +56,31 @@ export class AutoSession {
|
||||||
activeEngineId = null;
|
activeEngineId = null;
|
||||||
activeRunDir = null;
|
activeRunDir = null;
|
||||||
cmdCtx = null;
|
cmdCtx = null;
|
||||||
|
// ── Mode state ───────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Current work mode: chat | plan | build | review | repair | research.
|
||||||
|
* Defaults to "chat" for new sessions.
|
||||||
|
*/
|
||||||
|
workMode = "chat";
|
||||||
|
/**
|
||||||
|
* Current permission profile: restricted | normal | trusted | unrestricted.
|
||||||
|
* Defaults to "restricted" for safety.
|
||||||
|
*/
|
||||||
|
permissionProfile = "restricted";
|
||||||
|
/**
|
||||||
|
* Current model mode: fast | smart | deep.
|
||||||
|
* Defaults to "smart".
|
||||||
|
*/
|
||||||
|
modelMode = "smart";
|
||||||
|
/**
|
||||||
|
* Surface identifier: tui | web | headless | rpc.
|
||||||
|
* Defaults to "tui".
|
||||||
|
*/
|
||||||
|
surface = "tui";
|
||||||
|
/**
|
||||||
|
* ISO timestamp of last mode transition.
|
||||||
|
*/
|
||||||
|
modeUpdatedAt = null;
|
||||||
// ── Paths ────────────────────────────────────────────────────────────────
|
// ── Paths ────────────────────────────────────────────────────────────────
|
||||||
basePath = "";
|
basePath = "";
|
||||||
originalBasePath = "";
|
originalBasePath = "";
|
||||||
|
|
@ -224,6 +257,12 @@ export class AutoSession {
|
||||||
this.activeEngineId = null;
|
this.activeEngineId = null;
|
||||||
this.activeRunDir = null;
|
this.activeRunDir = null;
|
||||||
this.cmdCtx = null;
|
this.cmdCtx = null;
|
||||||
|
// Mode state
|
||||||
|
this.workMode = "chat";
|
||||||
|
this.permissionProfile = "restricted";
|
||||||
|
this.modelMode = "smart";
|
||||||
|
this.surface = "tui";
|
||||||
|
this.modeUpdatedAt = null;
|
||||||
// Paths
|
// Paths
|
||||||
this.basePath = "";
|
this.basePath = "";
|
||||||
this.originalBasePath = "";
|
this.originalBasePath = "";
|
||||||
|
|
@ -296,6 +335,47 @@ export class AutoSession {
|
||||||
this.sigtermHandler = null;
|
this.sigtermHandler = null;
|
||||||
// Loop promise state lives in auto-loop.ts module scope
|
// Loop promise state lives in auto-loop.ts module scope
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Update mode state with validation and timestamp.
|
||||||
|
*
|
||||||
|
* Purpose: centralize mode transitions so every change is logged and
|
||||||
|
* validated against canonical vocabulary.
|
||||||
|
*
|
||||||
|
* Consumer: command handlers and auto health gates.
|
||||||
|
*/
|
||||||
|
setMode({
|
||||||
|
workMode,
|
||||||
|
runControl,
|
||||||
|
permissionProfile,
|
||||||
|
modelMode,
|
||||||
|
surface,
|
||||||
|
} = {}) {
|
||||||
|
const prev = this.getMode();
|
||||||
|
if (workMode !== undefined) this.workMode = resolveWorkMode(workMode);
|
||||||
|
if (runControl !== undefined) {
|
||||||
|
const mode = resolveRunControlMode(runControl);
|
||||||
|
this.stepMode = mode === "assisted";
|
||||||
|
}
|
||||||
|
if (permissionProfile !== undefined) {
|
||||||
|
this.permissionProfile = resolvePermissionProfile(permissionProfile);
|
||||||
|
}
|
||||||
|
if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode);
|
||||||
|
if (surface !== undefined) this.surface = surface;
|
||||||
|
this.modeUpdatedAt = new Date().toISOString();
|
||||||
|
return { from: prev, to: this.getMode() };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get current mode state as a canonical object.
|
||||||
|
*/
|
||||||
|
getMode() {
|
||||||
|
return buildModeState({
|
||||||
|
workMode: this.workMode,
|
||||||
|
runControl: this.stepMode ? "assisted" : this.active ? "autonomous" : "manual",
|
||||||
|
permissionProfile: this.permissionProfile,
|
||||||
|
modelMode: this.modelMode,
|
||||||
|
surface: this.surface,
|
||||||
|
});
|
||||||
|
}
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
active: this.active,
|
active: this.active,
|
||||||
|
|
@ -307,6 +387,7 @@ export class AutoSession {
|
||||||
currentMilestoneId: this.currentMilestoneId,
|
currentMilestoneId: this.currentMilestoneId,
|
||||||
currentUnit: this.currentUnit,
|
currentUnit: this.currentUnit,
|
||||||
unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
|
unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
|
||||||
|
mode: this.getMode(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,7 @@ test("writeFallbackChains warns via log when project-level .sf/agent/settings.js
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writeFallbackChains emits Kimi K2.6 through the Kimi Code wire route", () => {
|
test("writeFallbackChains emits canonical Kimi K2.6 on the Kimi Code provider", () => {
|
||||||
const { dir, settingsPath } = makeTempSettingsDir();
|
const { dir, settingsPath } = makeTempSettingsDir();
|
||||||
try {
|
try {
|
||||||
// Deps deliberately minimal — no overrides, no enabledModels — so
|
// Deps deliberately minimal — no overrides, no enabledModels — so
|
||||||
|
|
@ -334,8 +334,7 @@ test("writeFallbackChains emits Kimi K2.6 through the Kimi Code wire route", ()
|
||||||
assert.equal(mainChain.length, 1, "main chain has exactly 1 direct entry");
|
assert.equal(mainChain.length, 1, "main chain has exactly 1 direct entry");
|
||||||
|
|
||||||
assert.equal(mainChain[0].provider, "kimi-coding");
|
assert.equal(mainChain[0].provider, "kimi-coding");
|
||||||
// Provider wire ID for Kimi K2.6.
|
assert.equal(mainChain[0].model, "kimi-k2.6");
|
||||||
assert.equal(mainChain[0].model, "kimi-for-coding");
|
|
||||||
assert.equal(mainChain[0].priority, 0);
|
assert.equal(mainChain[0].priority, 0);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -379,7 +378,7 @@ test("hardcoded main chain coexists with blender-computed per-unit-type chains",
|
||||||
assert.ok(Array.isArray(chains.main), "main chain present");
|
assert.ok(Array.isArray(chains.main), "main chain present");
|
||||||
assert.equal(chains.main.length, 1);
|
assert.equal(chains.main.length, 1);
|
||||||
assert.equal(chains.main[0].provider, "kimi-coding");
|
assert.equal(chains.main[0].provider, "kimi-coding");
|
||||||
assert.equal(chains.main[0].model, "kimi-for-coding");
|
assert.equal(chains.main[0].model, "kimi-k2.6");
|
||||||
|
|
||||||
// Blender-computed per-unit-type chain also present
|
// Blender-computed per-unit-type chain also present
|
||||||
assert.ok(Array.isArray(chains.planning), "planning chain present");
|
assert.ok(Array.isArray(chains.planning), "planning chain present");
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@ test("registerRoutingHook: registers handler + reload command and routes a simul
|
||||||
|
|
||||||
const pi = makeFakePi();
|
const pi = makeFakePi();
|
||||||
// Route the DB to a non-existent path so the lazy open returns null and
|
// Route the DB to a non-existent path so the lazy open returns null and
|
||||||
// the handler runs in priors-only mode (no better-sqlite3 dependency).
|
// the handler runs in priors-only mode (no persisted DB dependency).
|
||||||
registerRoutingHook(pi, {
|
registerRoutingHook(pi, {
|
||||||
dbPath: "/tmp/sf-learning-test-nonexistent.db",
|
dbPath: "/tmp/sf-learning-test-nonexistent.db",
|
||||||
notify: true,
|
notify: true,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Wires together the four S01-S04 modules into a single registerable plugin:
|
* Wires together the four S01-S04 modules into a single registerable plugin:
|
||||||
*
|
*
|
||||||
* loadCapabilityOverrides → priors (per (unit_type, model))
|
* loadCapabilityOverrides → priors (per (unit_type, model))
|
||||||
* outcome-recorder → write llm_task_outcomes rows
|
* sf-db outcome writer → write llm_task_outcomes rows
|
||||||
* outcome-aggregator → rolling-window observed stats
|
* outcome-aggregator → rolling-window observed stats
|
||||||
* bayesian-blender → α · prior + (1-α) · observed + UCB1
|
* bayesian-blender → α · prior + (1-α) · observed + UCB1
|
||||||
* hook-handler → translates the above into a before_model_select handler
|
* hook-handler → translates the above into a before_model_select handler
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
*
|
*
|
||||||
* import { init } from "./index.mjs";
|
* import { init } from "./index.mjs";
|
||||||
* const plugin = await init(pi, {
|
* const plugin = await init(pi, {
|
||||||
* dbPath: "~/.sf/sf-learning.db",
|
|
||||||
* priorsPath: "./src/data/model-benchmarks.json",
|
* priorsPath: "./src/data/model-benchmarks.json",
|
||||||
* weightsPath: "./src/data/unit-weights.json",
|
* weightsPath: "./src/data/unit-weights.json",
|
||||||
* nPrior: 10,
|
* nPrior: 10,
|
||||||
|
|
@ -25,23 +24,27 @@
|
||||||
* // plugin.unregister() on tear down
|
* // plugin.unregister() on tear down
|
||||||
*
|
*
|
||||||
* ## Side effects
|
* ## Side effects
|
||||||
* - Opens (or creates) a SQLite database at the resolved dbPath
|
* - Uses the already-open `.sf/sf.db`, or opens `<basePath>/.sf/sf.db`
|
||||||
* - Bootstraps the schema if absent
|
* - Relies on the shared SF DB bootstrap for `llm_task_outcomes`
|
||||||
* - Registers a hook on the supplied pi instance
|
* - Registers a hook on the supplied pi instance
|
||||||
*
|
*
|
||||||
* ## Errors
|
* ## Errors
|
||||||
* - Init failures are wrapped with a stage label so callers can see where
|
* - Init failures are wrapped with a stage label so callers can see where
|
||||||
* things broke ("loading priors", "opening db", "applying schema",
|
* things broke ("loading priors", "opening db", "registering hook")
|
||||||
* "registering hook")
|
|
||||||
* - Once init succeeds, the running handler is fire-and-forget — it cannot
|
* - Once init succeeds, the running handler is fire-and-forget — it cannot
|
||||||
* crash the dispatch path
|
* crash the dispatch path
|
||||||
*
|
*
|
||||||
* @module sf-learning
|
* @module sf-learning
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import {
|
||||||
|
getDatabase,
|
||||||
|
insertLlmTaskOutcome,
|
||||||
|
openDatabase as openSfDatabase,
|
||||||
|
} from "../sf-db.js";
|
||||||
import { writeFallbackChains } from "./fallback-chain-writer.mjs";
|
import { writeFallbackChains } from "./fallback-chain-writer.mjs";
|
||||||
import {
|
import {
|
||||||
createBeforeModelSelectHandler,
|
createBeforeModelSelectHandler,
|
||||||
|
|
@ -49,26 +52,24 @@ import {
|
||||||
} from "./hook-handler.mjs";
|
} from "./hook-handler.mjs";
|
||||||
import { loadCapabilityOverrides } from "./loadCapabilityOverrides.mjs";
|
import { loadCapabilityOverrides } from "./loadCapabilityOverrides.mjs";
|
||||||
import { aggregateAllForUnitType } from "./outcome-aggregator.mjs";
|
import { aggregateAllForUnitType } from "./outcome-aggregator.mjs";
|
||||||
import { ensureSchema, recordOutcome } from "./outcome-recorder.mjs";
|
|
||||||
|
|
||||||
const MODULE_DIRECTORY = import.meta.dirname;
|
|
||||||
const SCHEMA_PATH = resolve(MODULE_DIRECTORY, "outcome-schema.sql");
|
|
||||||
const DEFAULT_DB_PATH = "~/.sf/sf-learning.db";
|
|
||||||
const DEFAULT_N_PRIOR = 10;
|
const DEFAULT_N_PRIOR = 10;
|
||||||
const DEFAULT_ROLLING_DAYS = 30;
|
const DEFAULT_ROLLING_DAYS = 30;
|
||||||
const DEFAULT_EXPLORATION_C = 1.4;
|
const DEFAULT_EXPLORATION_C = 1.4;
|
||||||
|
const DEFAULT_DB_SUBPATH = [".sf", "sf.db"];
|
||||||
const HOME_REGEX = /^~(?=$|\/)/;
|
const HOME_REGEX = /^~(?=$|\/)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PluginConfig
|
* @typedef {Object} PluginConfig
|
||||||
* @property {string} [dbPath] - default: ~/.sf/sf-learning.db
|
* @property {string} [basePath] - default: process.cwd()
|
||||||
|
* @property {string} [dbPath] - default: <basePath>/.sf/sf.db
|
||||||
* @property {string} [priorsPath] - default: <plugin>/data/model-benchmarks.json
|
* @property {string} [priorsPath] - default: <plugin>/data/model-benchmarks.json
|
||||||
* @property {string} [weightsPath] - default: <plugin>/data/unit-weights.json
|
* @property {string} [weightsPath] - default: <plugin>/data/unit-weights.json
|
||||||
* @property {number} [nPrior=10]
|
* @property {number} [nPrior=10]
|
||||||
* @property {number} [rollingDays=30]
|
* @property {number} [rollingDays=30]
|
||||||
* @property {number} [explorationC=1.4]
|
* @property {number} [explorationC=1.4]
|
||||||
* @property {boolean} [explorationEnabled=true]
|
* @property {boolean} [explorationEnabled=true]
|
||||||
* @property {Object} [db] - pre-opened db handle (overrides dbPath)
|
* @property {Object} [db] - pre-opened db handle for hook reads
|
||||||
* @property {(msg: string) => void} [log]
|
* @property {(msg: string) => void} [log]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -92,93 +93,45 @@ function expandPath(path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the outcome-schema SQL file. Read once at init time; cheap.
|
* Resolve the shared SF database path for learning state.
|
||||||
*
|
*
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function loadSchemaSql() {
|
function defaultDbPath(config) {
|
||||||
return readFileSync(SCHEMA_PATH, "utf8");
|
return join(config.basePath ?? process.cwd(), ...DEFAULT_DB_SUBPATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether we're running under Bun. better-sqlite3 is a Node native
|
* Resolve the learning outcomes database from SF's shared database handle.
|
||||||
* addon and Bun has not shipped compatibility yet (tracked upstream in
|
|
||||||
* https://github.com/oven-sh/bun/issues/4290), so under Bun we use the
|
|
||||||
* built-in `bun:sqlite` module instead — its Statement API (`prepare`,
|
|
||||||
* `run`, `get`, `all`, `exec`, `transaction`) is a drop-in superset of the
|
|
||||||
* surface this plugin consumes.
|
|
||||||
*
|
*
|
||||||
* @returns {boolean}
|
* Purpose: keep UOK outcome learning, model stats, and learned routing on one
|
||||||
*/
|
* `.sf/sf.db` ledger instead of splitting feedback into a sidecar database.
|
||||||
function isBunRuntime() {
|
|
||||||
return typeof globalThis.Bun !== "undefined";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically import bun's built-in sqlite module. Only callable under Bun —
|
|
||||||
* the import specifier `bun:sqlite` throws under Node.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Function|null>}
|
|
||||||
*/
|
|
||||||
async function tryImportBunSqlite() {
|
|
||||||
try {
|
|
||||||
const mod = await import("bun:sqlite");
|
|
||||||
return mod.Database ?? mod.default ?? null;
|
|
||||||
} catch (_err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically import better-sqlite3. Returns null if the package is not
|
|
||||||
* installed so we can produce a clear error rather than an opaque module
|
|
||||||
* resolution failure.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Function|null>} the better-sqlite3 default export, or null
|
|
||||||
*/
|
|
||||||
async function tryImportBetterSqlite() {
|
|
||||||
try {
|
|
||||||
const mod = await import("better-sqlite3");
|
|
||||||
return mod.default ?? mod;
|
|
||||||
} catch (_err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a database handle, either from the caller-supplied one or by
|
|
||||||
* dynamically loading a sqlite binding. Prefers `bun:sqlite` when running
|
|
||||||
* under Bun (better-sqlite3 is a Node native addon that Bun can't load),
|
|
||||||
* and falls back to `better-sqlite3` everywhere else.
|
|
||||||
*
|
*
|
||||||
* @param {PluginConfig} config
|
* @param {PluginConfig} config
|
||||||
* @returns {Promise<Object>} duck-typed sqlite handle
|
* @returns {Object} duck-typed sqlite handle
|
||||||
*/
|
*/
|
||||||
async function openDatabase(config) {
|
function openDatabase(config) {
|
||||||
if (config.db) {
|
if (config.db) {
|
||||||
return config.db;
|
return config.db;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = expandPath(config.dbPath ?? DEFAULT_DB_PATH);
|
const activeDb = getDatabase();
|
||||||
|
if (activeDb) {
|
||||||
if (isBunRuntime()) {
|
return activeDb;
|
||||||
const BunDatabase = await tryImportBunSqlite();
|
|
||||||
if (!BunDatabase) {
|
|
||||||
throw new Error(
|
|
||||||
"sf-learning is running under Bun but failed to import `bun:sqlite`. This module ships with Bun itself — if this fails the Bun install is broken.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return new BunDatabase(dbPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Database = await tryImportBetterSqlite();
|
const dbPath = expandPath(config.dbPath ?? defaultDbPath(config));
|
||||||
if (!Database) {
|
if (dbPath !== ":memory:") {
|
||||||
throw new Error(
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
"sf-learning needs better-sqlite3 to open the outcomes database. Install it with `npm install better-sqlite3` or `bun add better-sqlite3`, or pass a pre-opened db handle via config.db.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (!openSfDatabase(dbPath)) {
|
||||||
return new Database(dbPath);
|
throw new Error(`failed to open shared SF database at ${dbPath}`);
|
||||||
|
}
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db) {
|
||||||
|
throw new Error(`shared SF database did not become available at ${dbPath}`);
|
||||||
|
}
|
||||||
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -225,7 +178,7 @@ function wrapInitError(stage, err) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the plugin: load priors, open db, bootstrap schema, register hook.
|
* Initialize the plugin: load priors, resolve shared db, register hook.
|
||||||
*
|
*
|
||||||
* @param {Object} pi
|
* @param {Object} pi
|
||||||
* @param {PluginConfig} [config={}]
|
* @param {PluginConfig} [config={}]
|
||||||
|
|
@ -249,13 +202,6 @@ export async function init(pi, config = {}) {
|
||||||
throw wrapInitError("opening db", err);
|
throw wrapInitError("opening db", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const schemaSql = loadSchemaSql();
|
|
||||||
ensureSchema(db, schemaSql);
|
|
||||||
} catch (err) {
|
|
||||||
throw wrapInitError("applying schema", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deps = buildHookDeps(db, priors, config);
|
const deps = buildHookDeps(db, priors, config);
|
||||||
|
|
||||||
let unregister;
|
let unregister;
|
||||||
|
|
@ -293,7 +239,7 @@ export async function init(pi, config = {}) {
|
||||||
return {
|
return {
|
||||||
unregister,
|
unregister,
|
||||||
fallbackWriteSummary,
|
fallbackWriteSummary,
|
||||||
recordOutcome: (outcome) => recordOutcome(db, outcome),
|
recordOutcome: (outcome) => insertLlmTaskOutcome(outcome),
|
||||||
reloadPriors: async () => {
|
reloadPriors: async () => {
|
||||||
const fresh = await loadCapabilityOverrides({
|
const fresh = await loadCapabilityOverrides({
|
||||||
benchmarksPath: config.priorsPath,
|
benchmarksPath: config.priorsPath,
|
||||||
|
|
|
||||||
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";
|
} from "./hook-handler.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake in-memory db that mimics enough of better-sqlite3 for
|
* Fake in-memory db that mimics enough of node:sqlite DatabaseSync for
|
||||||
* outcome-recorder + outcome-aggregator to operate against array-backed rows.
|
* outcome-recorder + outcome-aggregator to operate against array-backed rows.
|
||||||
*
|
*
|
||||||
* The aggregator runs SELECT ... GROUP BY model_id; rather than implementing a
|
* The aggregator runs SELECT ... GROUP BY model_id; rather than implementing a
|
||||||
* SQL parser, we recognize each statement by regex and compute the aggregate
|
* SQL parser, we recognize each statement by regex and compute the aggregate
|
||||||
* in JavaScript. This is sufficient for these tests and isolates them from a
|
* in JavaScript. This is sufficient for these tests and isolates them from a
|
||||||
* real native dependency.
|
* real file-backed database.
|
||||||
*/
|
*/
|
||||||
function createFakeDb() {
|
function createFakeDb() {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
*
|
*
|
||||||
* ## Dependencies
|
* ## Dependencies
|
||||||
* - Duck-typed SQLite handle exposing `prepare(sql).get(...params)` and
|
* - Duck-typed SQLite handle exposing `prepare(sql).get(...params)` and
|
||||||
* `prepare(sql).all(...params)`. Compatible with `better-sqlite3`.
|
* `prepare(sql).all(...params)`. Compatible with Node's `node:sqlite`
|
||||||
|
* DatabaseSync.
|
||||||
*
|
*
|
||||||
* ## Contract
|
* ## Contract
|
||||||
* - All SQL is parameterized — no string interpolation of caller input.
|
* - All SQL is parameterized — no string interpolation of caller input.
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,3 @@ export declare function recordOutcomeBatch(
|
||||||
db: unknown,
|
db: unknown,
|
||||||
outcomes: Array<Record<string, unknown>>,
|
outcomes: Array<Record<string, unknown>>,
|
||||||
): number;
|
): number;
|
||||||
export declare function ensureSchema(db: unknown, schemaSql?: string): void;
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
* ## Responsibilities
|
* ## Responsibilities
|
||||||
* - Validate outcome shape before insertion
|
* - Validate outcome shape before insertion
|
||||||
* - Insert one or many outcomes via parameterized SQL
|
* - Insert one or many outcomes via parameterized SQL
|
||||||
* - Bootstrap the schema on a fresh database
|
|
||||||
*
|
*
|
||||||
* ## Contract — fire-and-forget
|
* ## Contract — fire-and-forget
|
||||||
* `recordOutcome` and `recordOutcomeBatch` must NEVER throw. They catch
|
* `recordOutcome` and `recordOutcomeBatch` must NEVER throw. They catch
|
||||||
|
|
@ -17,7 +16,7 @@
|
||||||
* ## Dependencies
|
* ## Dependencies
|
||||||
* - Duck-typed SQLite handle exposing `prepare(sql).run(...params)`,
|
* - Duck-typed SQLite handle exposing `prepare(sql).run(...params)`,
|
||||||
* `prepare(sql).get(...params)`, `prepare(sql).all(...params)` and
|
* `prepare(sql).get(...params)`, `prepare(sql).all(...params)` and
|
||||||
* ideally `exec(sql)`. Compatible with `better-sqlite3`.
|
* ideally `exec(sql)`. Compatible with Node's `node:sqlite` DatabaseSync.
|
||||||
* - No hard import of any SQLite library — keeps this module standalone
|
* - No hard import of any SQLite library — keeps this module standalone
|
||||||
* and unit-testable with an in-memory fake.
|
* and unit-testable with an in-memory fake.
|
||||||
*
|
*
|
||||||
|
|
@ -233,8 +232,9 @@ export function recordOutcome(db, outcome) {
|
||||||
* Record many outcomes in a single transaction. Fire-and-forget — never throws.
|
* Record many outcomes in a single transaction. Fire-and-forget — never throws.
|
||||||
*
|
*
|
||||||
* Invalid rows are skipped and counted; valid rows are inserted. If the
|
* Invalid rows are skipped and counted; valid rows are inserted. If the
|
||||||
* database supports `transaction()` (better-sqlite3 style), the inserts run
|
* database supports `transaction()`, the inserts run inside it. With
|
||||||
* inside it; otherwise they run sequentially.
|
* `node:sqlite`, batches are wrapped in an explicit SQL transaction to avoid
|
||||||
|
* repeated writer-lock churn.
|
||||||
*
|
*
|
||||||
* @param {object} db Duck-typed SQLite handle
|
* @param {object} db Duck-typed SQLite handle
|
||||||
* @param {Outcome[]} outcomes
|
* @param {Outcome[]} outcomes
|
||||||
|
|
@ -273,6 +273,23 @@ export function recordOutcomeBatch(db, outcomes) {
|
||||||
if (typeof db.transaction === "function") {
|
if (typeof db.transaction === "function") {
|
||||||
const txn = db.transaction(insertAll);
|
const txn = db.transaction(insertAll);
|
||||||
txn();
|
txn();
|
||||||
|
} else if (typeof db.exec === "function") {
|
||||||
|
let began = false;
|
||||||
|
try {
|
||||||
|
db.exec("BEGIN IMMEDIATE");
|
||||||
|
began = true;
|
||||||
|
insertAll();
|
||||||
|
db.exec("COMMIT");
|
||||||
|
} catch (err) {
|
||||||
|
if (began) {
|
||||||
|
try {
|
||||||
|
db.exec("ROLLBACK");
|
||||||
|
} catch (_rollbackErr) {
|
||||||
|
// Preserve the original failure for the outer catch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
insertAll();
|
insertAll();
|
||||||
}
|
}
|
||||||
|
|
@ -284,43 +301,3 @@ export function recordOutcomeBatch(db, outcomes) {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap the schema on a fresh database. Fire-and-forget — never throws.
|
|
||||||
*
|
|
||||||
* Uses `db.exec(sql)` if available (better-sqlite3 style) so multi-statement
|
|
||||||
* DDL works in one call. Otherwise splits on `;` and runs each statement
|
|
||||||
* via `db.prepare(stmt).run()`.
|
|
||||||
*
|
|
||||||
* @param {object} db Duck-typed SQLite handle
|
|
||||||
* @param {string} schemaSql Raw schema SQL (CREATE TABLE / CREATE INDEX ...)
|
|
||||||
* @returns {boolean} true if schema applied, false on error
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* import {readFileSync} from "node:fs";
|
|
||||||
* const sql = readFileSync(new URL("./outcome-schema.sql", import.meta.url), "utf8");
|
|
||||||
* ensureSchema(db, sql);
|
|
||||||
*/
|
|
||||||
export function ensureSchema(db, schemaSql) {
|
|
||||||
if (typeof schemaSql !== "string" || schemaSql.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (typeof db.exec === "function") {
|
|
||||||
db.exec(schemaSql);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statements = schemaSql
|
|
||||||
.split(";")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0 && !s.startsWith("--"));
|
|
||||||
|
|
||||||
for (const stmt of statements) {
|
|
||||||
db.prepare(stmt).run();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (_err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* sf-learning: outcome-recorder + outcome-aggregator tests
|
* sf-learning: outcome-recorder + outcome-aggregator tests
|
||||||
*
|
*
|
||||||
* Uses node:test with a minimal in-memory fake `db` that mimics the
|
* Uses node:test with a minimal in-memory fake `db` that mimics the
|
||||||
* better-sqlite3 surface (`prepare(sql).run/get/all`, `exec`,
|
* node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
|
||||||
* `transaction`). The fake parses just enough SQL to verify the
|
* `transaction`). The fake parses just enough SQL to verify the
|
||||||
* insert and aggregate semantics without spinning up real SQLite.
|
* insert and aggregate semantics without spinning up real SQLite.
|
||||||
*/
|
*/
|
||||||
|
|
@ -16,14 +16,13 @@ import {
|
||||||
totalSamples,
|
totalSamples,
|
||||||
} from "./outcome-aggregator.mjs";
|
} from "./outcome-aggregator.mjs";
|
||||||
import {
|
import {
|
||||||
ensureSchema,
|
|
||||||
recordOutcome,
|
recordOutcome,
|
||||||
recordOutcomeBatch,
|
recordOutcomeBatch,
|
||||||
validateOutcome,
|
validateOutcome,
|
||||||
} from "./outcome-recorder.mjs";
|
} from "./outcome-recorder.mjs";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Minimal in-memory fake of better-sqlite3
|
// Minimal in-memory fake of the SQLite surface consumed by sf-learning.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const INSERT_COLUMNS = [
|
const INSERT_COLUMNS = [
|
||||||
|
|
@ -42,8 +41,9 @@ const INSERT_COLUMNS = [
|
||||||
"recorded_at",
|
"recorded_at",
|
||||||
];
|
];
|
||||||
|
|
||||||
function createFakeDb({ throwOnPrepare = false } = {}) {
|
function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
const execSql = [];
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
function prepare(sql) {
|
function prepare(sql) {
|
||||||
|
|
@ -92,7 +92,6 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// CREATE TABLE / CREATE INDEX from ensureSchema fallback path
|
|
||||||
if (
|
if (
|
||||||
normalized.startsWith("create table") ||
|
normalized.startsWith("create table") ||
|
||||||
normalized.startsWith("create index")
|
normalized.startsWith("create index")
|
||||||
|
|
@ -107,7 +106,8 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
|
||||||
throw new Error(`fake db: unsupported sql: ${normalized.slice(0, 80)}`);
|
throw new Error(`fake db: unsupported sql: ${normalized.slice(0, 80)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exec(_sql) {
|
function exec(sql) {
|
||||||
|
execSql.push(sql);
|
||||||
// no-op — schema bootstrap success path
|
// no-op — schema bootstrap success path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,12 +117,16 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const db = {
|
||||||
prepare,
|
prepare,
|
||||||
exec,
|
exec,
|
||||||
transaction,
|
|
||||||
_rows: rows,
|
_rows: rows,
|
||||||
|
_execSql: execSql,
|
||||||
};
|
};
|
||||||
|
if (includeTransaction) {
|
||||||
|
db.transaction = transaction;
|
||||||
|
}
|
||||||
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAggregate(sql, params, rows) {
|
function runAggregate(sql, params, rows) {
|
||||||
|
|
@ -363,30 +367,14 @@ test("recordOutcomeBatch handles empty array", () => {
|
||||||
assert.deepEqual(result, { inserted: 0, skipped: 0 });
|
assert.deepEqual(result, { inserted: 0, skipped: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
test("recordOutcomeBatch_when_no_transaction_helper_wraps_batch_with_sql_transaction", () => {
|
||||||
// ensureSchema
|
const db = createFakeDb({ includeTransaction: false });
|
||||||
// ---------------------------------------------------------------------------
|
const result = recordOutcomeBatch(db, [
|
||||||
|
minimalOutcome({ unitId: "T01" }),
|
||||||
test("ensureSchema returns true via db.exec path", () => {
|
minimalOutcome({ unitId: "T02" }),
|
||||||
const db = createFakeDb();
|
]);
|
||||||
const ok = ensureSchema(db, "CREATE TABLE foo (x INTEGER);");
|
assert.deepEqual(result, { inserted: 2, skipped: 0 });
|
||||||
assert.equal(ok, true);
|
assert.deepEqual(db._execSql, ["BEGIN IMMEDIATE", "COMMIT"]);
|
||||||
});
|
|
||||||
|
|
||||||
test("ensureSchema returns false on empty input", () => {
|
|
||||||
const db = createFakeDb();
|
|
||||||
assert.equal(ensureSchema(db, ""), false);
|
|
||||||
assert.equal(ensureSchema(db, null), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ensureSchema falls back to per-statement prepare when no exec()", () => {
|
|
||||||
const db = createFakeDb();
|
|
||||||
delete db.exec;
|
|
||||||
const ok = ensureSchema(
|
|
||||||
db,
|
|
||||||
"CREATE TABLE foo (x INTEGER); CREATE INDEX idx_foo ON foo(x);",
|
|
||||||
);
|
|
||||||
assert.equal(ok, true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* that need stable product terms.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export const WORK_MODES = Object.freeze([
|
||||||
|
"chat",
|
||||||
|
"plan",
|
||||||
|
"build",
|
||||||
|
"review",
|
||||||
|
"repair",
|
||||||
|
"research",
|
||||||
|
]);
|
||||||
|
|
||||||
export const RUN_CONTROL_MODES = Object.freeze([
|
export const RUN_CONTROL_MODES = Object.freeze([
|
||||||
"manual",
|
"manual",
|
||||||
"assisted",
|
"assisted",
|
||||||
|
|
@ -21,6 +30,23 @@ export const PERMISSION_PROFILES = Object.freeze([
|
||||||
"unrestricted",
|
"unrestricted",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const MODEL_MODES = Object.freeze([
|
||||||
|
"fast",
|
||||||
|
"smart",
|
||||||
|
"deep",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true for a canonical SF work mode.
|
||||||
|
*
|
||||||
|
* Purpose: let surfaces reject aliases before they leak into state.
|
||||||
|
*
|
||||||
|
* Consumer: command parsers and session state builders.
|
||||||
|
*/
|
||||||
|
export function isWorkMode(value) {
|
||||||
|
return WORK_MODES.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true for a canonical SF run-control mode.
|
* Returns true for a canonical SF run-control mode.
|
||||||
*
|
*
|
||||||
|
|
@ -44,6 +70,28 @@ export function isPermissionProfile(value) {
|
||||||
return PERMISSION_PROFILES.includes(value);
|
return PERMISSION_PROFILES.includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true for a canonical SF model mode.
|
||||||
|
*
|
||||||
|
* Purpose: let routing and command surfaces reject invalid model mode names.
|
||||||
|
*
|
||||||
|
* Consumer: model selection and command parsers.
|
||||||
|
*/
|
||||||
|
export function isModelMode(value) {
|
||||||
|
return MODEL_MODES.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an unknown work mode to the conservative chat mode.
|
||||||
|
*
|
||||||
|
* Purpose: fail closed when work mode is absent or misspelled.
|
||||||
|
*
|
||||||
|
* Consumer: session state construction and command handlers.
|
||||||
|
*/
|
||||||
|
export function resolveWorkMode(value) {
|
||||||
|
return isWorkMode(value) ? value : "chat";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an unknown run-control value to the conservative manual mode.
|
* Resolve an unknown run-control value to the conservative manual mode.
|
||||||
*
|
*
|
||||||
|
|
@ -67,6 +115,17 @@ export function resolvePermissionProfile(value) {
|
||||||
return isPermissionProfile(value) ? value : "restricted";
|
return isPermissionProfile(value) ? value : "restricted";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an unknown model mode to smart.
|
||||||
|
*
|
||||||
|
* Purpose: provide a safe default when model mode is absent.
|
||||||
|
*
|
||||||
|
* Consumer: model routing and command handlers.
|
||||||
|
*/
|
||||||
|
export function resolveModelMode(value) {
|
||||||
|
return isModelMode(value) ? value : "smart";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the UOK run-control mode from the live auto session state.
|
* Derive the UOK run-control mode from the live auto session state.
|
||||||
*
|
*
|
||||||
|
|
@ -90,3 +149,50 @@ export function runControlModeForSession(session) {
|
||||||
export function defaultPermissionProfileForRunControl(mode) {
|
export function defaultPermissionProfileForRunControl(mode) {
|
||||||
return resolveRunControlMode(mode) === "manual" ? "restricted" : "normal";
|
return resolveRunControlMode(mode) === "manual" ? "restricted" : "normal";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the default model mode for a work mode.
|
||||||
|
*
|
||||||
|
* Purpose: guide model routing without replacing explicit model selection.
|
||||||
|
*
|
||||||
|
* Consumer: model routing and session initialization.
|
||||||
|
*/
|
||||||
|
export function defaultModelModeForWorkMode(workMode) {
|
||||||
|
switch (resolveWorkMode(workMode)) {
|
||||||
|
case "plan":
|
||||||
|
case "review":
|
||||||
|
case "research":
|
||||||
|
return "deep";
|
||||||
|
case "build":
|
||||||
|
case "repair":
|
||||||
|
return "smart";
|
||||||
|
case "chat":
|
||||||
|
default:
|
||||||
|
return "fast";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a canonical mode state object.
|
||||||
|
*
|
||||||
|
* Purpose: standardize the five orthogonal axes so every surface stores
|
||||||
|
* and displays the same shape.
|
||||||
|
*
|
||||||
|
* Consumer: session state, TUI badges, command handlers, audit logs.
|
||||||
|
*/
|
||||||
|
export function buildModeState({
|
||||||
|
workMode = "chat",
|
||||||
|
runControl = "manual",
|
||||||
|
permissionProfile = "restricted",
|
||||||
|
modelMode = "smart",
|
||||||
|
surface = "tui",
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
workMode: resolveWorkMode(workMode),
|
||||||
|
runControl: resolveRunControlMode(runControl),
|
||||||
|
permissionProfile: resolvePermissionProfile(permissionProfile),
|
||||||
|
modelMode: resolveModelMode(modelMode),
|
||||||
|
surface,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
* Reads the same data sources as `scripts/parallel-monitor.mjs` but
|
* Reads the same data sources as `scripts/parallel-monitor.mjs` but
|
||||||
* renders as a native pi-tui overlay with theme integration.
|
* renders as a native pi-tui overlay with theme integration.
|
||||||
*/
|
*/
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import {
|
import {
|
||||||
closeSync,
|
closeSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
|
|
@ -18,25 +17,19 @@ import {
|
||||||
statSync,
|
statSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { Key, matchesKey } from "@singularity-forge/pi-tui";
|
import { Key, matchesKey } from "@singularity-forge/pi-tui";
|
||||||
import { formatDuration } from "../shared/mod.js";
|
import { formatDuration } from "../shared/mod.js";
|
||||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||||
|
|
||||||
// ─── Async SQLite Helper ──────────────────────────────────────────────────
|
// ─── SQLite Helper ────────────────────────────────────────────────────────
|
||||||
function runSqliteAsync(dbPath, sql) {
|
function queryRows(dbPath, sql, params = []) {
|
||||||
return new Promise((resolve) => {
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||||
const child = spawn("sqlite3", [dbPath, sql], { timeout: 3000 });
|
try {
|
||||||
const chunks = [];
|
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
|
||||||
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
} finally {
|
||||||
child.on("close", (code) => {
|
db.close();
|
||||||
if (code !== 0) {
|
}
|
||||||
resolve("");
|
|
||||||
} else {
|
|
||||||
resolve(Buffer.concat(chunks).toString("utf-8"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
child.on("error", () => resolve(""));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// ─── Data Helpers ─────────────────────────────────────────────────────────
|
// ─── Data Helpers ─────────────────────────────────────────────────────────
|
||||||
function readJsonSafe(filePath) {
|
function readJsonSafe(filePath) {
|
||||||
|
|
@ -98,22 +91,28 @@ function discoverWorkers(basePath) {
|
||||||
}
|
}
|
||||||
return [...mids].sort();
|
return [...mids].sort();
|
||||||
}
|
}
|
||||||
async function querySliceProgress(basePath, mid) {
|
function querySliceProgress(basePath, mid) {
|
||||||
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
||||||
if (!existsSync(dbPath)) return [];
|
if (!existsSync(dbPath)) return [];
|
||||||
try {
|
try {
|
||||||
const sql = `SELECT s.id, s.status, COUNT(t.id), SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) FROM slices s LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id WHERE s.milestone_id='${mid}' GROUP BY s.id ORDER BY s.id`;
|
return queryRows(
|
||||||
const out = (await runSqliteAsync(dbPath, sql)).trim();
|
dbPath,
|
||||||
if (!out) return [];
|
`SELECT s.id AS id,
|
||||||
return out.split("\n").map((line) => {
|
s.status AS status,
|
||||||
const [id, status, total, done] = line.split("|");
|
COUNT(t.id) AS total,
|
||||||
return {
|
SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
|
||||||
id,
|
FROM slices s
|
||||||
status,
|
LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
|
||||||
total: parseInt(total, 10),
|
WHERE s.milestone_id=?
|
||||||
done: parseInt(done || "0", 10),
|
GROUP BY s.id
|
||||||
};
|
ORDER BY s.id`,
|
||||||
});
|
[mid],
|
||||||
|
).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
total: Number(row.total ?? 0),
|
||||||
|
done: Number(row.done ?? 0),
|
||||||
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -141,17 +140,25 @@ function extractCostFromNdjson(basePath, mid) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function queryRecentCompletions(basePath, mid) {
|
function queryRecentCompletions(basePath, mid) {
|
||||||
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
||||||
if (!existsSync(dbPath)) return [];
|
if (!existsSync(dbPath)) return [];
|
||||||
try {
|
try {
|
||||||
const sql = `SELECT id, slice_id, one_liner FROM tasks WHERE milestone_id='${mid}' AND status='complete' AND completed_at IS NOT NULL ORDER BY completed_at DESC LIMIT 5`;
|
return queryRows(
|
||||||
const out = (await runSqliteAsync(dbPath, sql)).trim();
|
dbPath,
|
||||||
if (!out) return [];
|
`SELECT id AS taskId,
|
||||||
return out.split("\n").map((line) => {
|
slice_id AS sliceId,
|
||||||
const [taskId, sliceId, oneLiner] = line.split("|");
|
one_liner AS oneLiner
|
||||||
return `✓ ${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`;
|
FROM tasks
|
||||||
});
|
WHERE milestone_id=?
|
||||||
|
AND status='complete'
|
||||||
|
AND completed_at IS NOT NULL
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
[mid],
|
||||||
|
).map((row) =>
|
||||||
|
`✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// SF Database Abstraction Layer
|
// SF Database Abstraction Layer
|
||||||
// Provides a SQLite database via node:sqlite (Node >= 24 built-in).
|
// Provides a SQLite database via node:sqlite (Node >= 26 built-in).
|
||||||
//
|
//
|
||||||
// Exposes a unified sync API for decisions and requirements storage.
|
// Exposes a unified sync API for decisions and requirements storage.
|
||||||
// Schema is initialized on first open with WAL mode for file-backed DBs.
|
// Schema is initialized on first open with WAL mode for file-backed DBs.
|
||||||
|
|
@ -29,7 +29,7 @@ let loadAttempted = false;
|
||||||
function loadProvider() {
|
function loadProvider() {
|
||||||
if (loadAttempted) return;
|
if (loadAttempted) return;
|
||||||
loadAttempted = true;
|
loadAttempted = true;
|
||||||
// node:sqlite is built-in in Node >= 24
|
// node:sqlite is built-in in Node >= 26
|
||||||
}
|
}
|
||||||
function normalizeRow(row) {
|
function normalizeRow(row) {
|
||||||
if (row == null) return undefined;
|
if (row == null) return undefined;
|
||||||
|
|
@ -4860,7 +4860,7 @@ export function insertLlmTaskOutcome(input) {
|
||||||
":duration_ms": input.duration_ms ?? null,
|
":duration_ms": input.duration_ms ?? null,
|
||||||
":tokens_total": input.tokens_total ?? null,
|
":tokens_total": input.tokens_total ?? null,
|
||||||
":cost_usd": input.cost_usd ?? null,
|
":cost_usd": input.cost_usd ?? null,
|
||||||
":recorded_at": input.recorded_at,
|
":recorded_at": input.recorded_at ?? Date.now(),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { dirname, join } from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10);
|
const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10);
|
||||||
const HAS_SQLITE = NODE_VERSION >= 24;
|
const HAS_SQLITE = NODE_VERSION >= 26;
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -114,7 +114,7 @@ describe("buildMemoryLLMCall apiKey resolution", () => {
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
}
|
}
|
||||||
: () => {
|
: () => {
|
||||||
// Skip: requires node:sqlite (Node 24+)
|
// Skip: requires node:sqlite (Node 26+)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +132,7 @@ describe("invalidateAllCaches", () => {
|
||||||
expect(() => invalidateAllCaches()).not.toThrow();
|
expect(() => invalidateAllCaches()).not.toThrow();
|
||||||
}
|
}
|
||||||
: () => {
|
: () => {
|
||||||
// Skip: requires node:sqlite (Node 24+)
|
// Skip: requires node:sqlite (Node 26+)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -152,7 +152,7 @@ describe("createMemory", () => {
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
}
|
}
|
||||||
: () => {
|
: () => {
|
||||||
// Skip: requires node:sqlite (Node 24+)
|
// Skip: requires node:sqlite (Node 26+)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,38 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildModeState,
|
||||||
|
defaultModelModeForWorkMode,
|
||||||
defaultPermissionProfileForRunControl,
|
defaultPermissionProfileForRunControl,
|
||||||
|
isModelMode,
|
||||||
isPermissionProfile,
|
isPermissionProfile,
|
||||||
isRunControlMode,
|
isRunControlMode,
|
||||||
|
isWorkMode,
|
||||||
|
MODEL_MODES,
|
||||||
PERMISSION_PROFILES,
|
PERMISSION_PROFILES,
|
||||||
RUN_CONTROL_MODES,
|
resolveModelMode,
|
||||||
resolvePermissionProfile,
|
resolvePermissionProfile,
|
||||||
resolveRunControlMode,
|
resolveRunControlMode,
|
||||||
|
resolveWorkMode,
|
||||||
|
RUN_CONTROL_MODES,
|
||||||
|
WORK_MODES,
|
||||||
runControlModeForSession,
|
runControlModeForSession,
|
||||||
} from "../operating-model.js";
|
} from "../operating-model.js";
|
||||||
|
|
||||||
describe("operating model vocabulary", () => {
|
describe("operating model vocabulary", () => {
|
||||||
|
test("workModes_are_chat_plan_build_review_repair_research", () => {
|
||||||
|
assert.deepEqual(WORK_MODES, [
|
||||||
|
"chat",
|
||||||
|
"plan",
|
||||||
|
"build",
|
||||||
|
"review",
|
||||||
|
"repair",
|
||||||
|
"research",
|
||||||
|
]);
|
||||||
|
assert.equal(isWorkMode("chat"), true);
|
||||||
|
assert.equal(isWorkMode("debug"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("runControlModes_are_manual_assisted_autonomous_only", () => {
|
test("runControlModes_are_manual_assisted_autonomous_only", () => {
|
||||||
assert.deepEqual(RUN_CONTROL_MODES, ["manual", "assisted", "autonomous"]);
|
assert.deepEqual(RUN_CONTROL_MODES, ["manual", "assisted", "autonomous"]);
|
||||||
assert.equal(isRunControlMode("auto"), false);
|
assert.equal(isRunControlMode("auto"), false);
|
||||||
|
|
@ -28,9 +49,17 @@ describe("operating model vocabulary", () => {
|
||||||
assert.equal(isPermissionProfile("autonomous"), false);
|
assert.equal(isPermissionProfile("autonomous"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("modelModes_are_fast_smart_deep", () => {
|
||||||
|
assert.deepEqual(MODEL_MODES, ["fast", "smart", "deep"]);
|
||||||
|
assert.equal(isModelMode("smart"), true);
|
||||||
|
assert.equal(isModelMode("rush"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("resolvers_fail_closed", () => {
|
test("resolvers_fail_closed", () => {
|
||||||
|
assert.equal(resolveWorkMode("debug"), "chat");
|
||||||
assert.equal(resolveRunControlMode("auto"), "manual");
|
assert.equal(resolveRunControlMode("auto"), "manual");
|
||||||
assert.equal(resolvePermissionProfile("full"), "restricted");
|
assert.equal(resolvePermissionProfile("full"), "restricted");
|
||||||
|
assert.equal(resolveModelMode("rush"), "smart");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("session_step_bit_derives_assisted_or_autonomous_run_control", () => {
|
test("session_step_bit_derives_assisted_or_autonomous_run_control", () => {
|
||||||
|
|
@ -43,4 +72,52 @@ describe("operating model vocabulary", () => {
|
||||||
assert.equal(defaultPermissionProfileForRunControl("assisted"), "normal");
|
assert.equal(defaultPermissionProfileForRunControl("assisted"), "normal");
|
||||||
assert.equal(defaultPermissionProfileForRunControl("manual"), "restricted");
|
assert.equal(defaultPermissionProfileForRunControl("manual"), "restricted");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("default_model_mode_matches_work_mode_intent", () => {
|
||||||
|
assert.equal(defaultModelModeForWorkMode("plan"), "deep");
|
||||||
|
assert.equal(defaultModelModeForWorkMode("review"), "deep");
|
||||||
|
assert.equal(defaultModelModeForWorkMode("research"), "deep");
|
||||||
|
assert.equal(defaultModelModeForWorkMode("build"), "smart");
|
||||||
|
assert.equal(defaultModelModeForWorkMode("repair"), "smart");
|
||||||
|
assert.equal(defaultModelModeForWorkMode("chat"), "fast");
|
||||||
|
assert.equal(defaultModelModeForWorkMode("unknown"), "fast");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildModeState_normalizes_all_axes", () => {
|
||||||
|
const state = buildModeState({
|
||||||
|
workMode: "build",
|
||||||
|
runControl: "autonomous",
|
||||||
|
permissionProfile: "trusted",
|
||||||
|
modelMode: "smart",
|
||||||
|
surface: "tui",
|
||||||
|
});
|
||||||
|
assert.equal(state.workMode, "build");
|
||||||
|
assert.equal(state.runControl, "autonomous");
|
||||||
|
assert.equal(state.permissionProfile, "trusted");
|
||||||
|
assert.equal(state.modelMode, "smart");
|
||||||
|
assert.equal(state.surface, "tui");
|
||||||
|
assert.ok(state.updatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildModeState_fails_closed_on_invalid_values", () => {
|
||||||
|
const state = buildModeState({
|
||||||
|
workMode: "debug",
|
||||||
|
runControl: "auto",
|
||||||
|
permissionProfile: "full",
|
||||||
|
modelMode: "rush",
|
||||||
|
});
|
||||||
|
assert.equal(state.workMode, "chat");
|
||||||
|
assert.equal(state.runControl, "manual");
|
||||||
|
assert.equal(state.permissionProfile, "restricted");
|
||||||
|
assert.equal(state.modelMode, "smart");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildModeState_has_safe_defaults", () => {
|
||||||
|
const state = buildModeState();
|
||||||
|
assert.equal(state.workMode, "chat");
|
||||||
|
assert.equal(state.runControl, "manual");
|
||||||
|
assert.equal(state.permissionProfile, "restricted");
|
||||||
|
assert.equal(state.modelMode, "smart");
|
||||||
|
assert.equal(state.surface, "tui");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
function loadProvider() {
|
||||||
if (loadAttempted) return;
|
if (loadAttempted) return;
|
||||||
loadAttempted = true;
|
loadAttempted = true;
|
||||||
// node:sqlite is built-in in Node >= 24
|
// node:sqlite is built-in in Node >= 26
|
||||||
}
|
}
|
||||||
function normalizeRow(row) {
|
function normalizeRow(row) {
|
||||||
if (row == null) return undefined;
|
if (row == null) return undefined;
|
||||||
|
|
|
||||||
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",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ function readFile(relativePath: string): string {
|
||||||
|
|
||||||
// ── Dockerfile.sandbox ──
|
// ── Dockerfile.sandbox ──
|
||||||
|
|
||||||
test("docker/Dockerfile.sandbox exists and uses Node 24 base", () => {
|
test("docker/Dockerfile.sandbox exists and uses Node 26 base", () => {
|
||||||
const content = readFile("docker/Dockerfile.sandbox");
|
const content = readFile("docker/Dockerfile.sandbox");
|
||||||
assert.match(content, /FROM node:24/);
|
assert.match(content, /FROM node:26/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("docker/Dockerfile.sandbox installs singularity-forge globally", () => {
|
test("docker/Dockerfile.sandbox installs singularity-forge globally", () => {
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ test("bridge-service workspace-index subprocess uses compiled JS when under node
|
||||||
bridgeSource,
|
bridgeSource,
|
||||||
/resolveSubprocessModule/,
|
/resolveSubprocessModule/,
|
||||||
"bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " +
|
"bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " +
|
||||||
"hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v24 (see #2279)",
|
"hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v26 (see #2279)",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -166,7 +166,7 @@ test("all web service files use resolveSubprocessModule instead of hardcoded .ts
|
||||||
source,
|
source,
|
||||||
/resolveSubprocessModule/,
|
/resolveSubprocessModule/,
|
||||||
`${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` +
|
`${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` +
|
||||||
"subprocess .ts paths will fail under node_modules/ on Node v24 (#2279)",
|
"subprocess .ts paths will fail under node_modules/ on Node v26 (#2279)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,9 @@ describe("batch directory discovery", () => {
|
||||||
// ─── Node.js compile cache ──────────────────────────────────────────────────
|
// ─── Node.js compile cache ──────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("Node.js compile cache env setup", () => {
|
describe("Node.js compile cache env setup", () => {
|
||||||
it("NODE_COMPILE_CACHE is settable on Node 24+", () => {
|
it("NODE_COMPILE_CACHE is settable on Node 26+", () => {
|
||||||
const nodeVersion = parseInt(process.versions.node, 10);
|
const nodeVersion = parseInt(process.versions.node, 10);
|
||||||
if (nodeVersion >= 24) {
|
if (nodeVersion >= 26) {
|
||||||
// Verify the env var mechanism works (does not throw)
|
// Verify the env var mechanism works (does not throw)
|
||||||
const original = process.env.NODE_COMPILE_CACHE;
|
const original = process.env.NODE_COMPILE_CACHE;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { join } from "node:path";
|
||||||
/**
|
/**
|
||||||
* Returns the correct Node.js type-stripping flag for subprocess spawning.
|
* Returns the correct Node.js type-stripping flag for subprocess spawning.
|
||||||
*
|
*
|
||||||
* Node v24 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files
|
* Node v26 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files
|
||||||
* resolved under `node_modules/`. When SF is installed globally via npm,
|
* resolved under `node_modules/`. When SF is installed globally via npm,
|
||||||
* all source files live under `node_modules/sf-run/src/...`, so
|
* all source files live under `node_modules/sf-run/src/...`, so
|
||||||
* `--experimental-strip-types` fails deterministically.
|
* `--experimental-strip-types` fails deterministically.
|
||||||
*
|
*
|
||||||
* `--experimental-transform-types` applies a full TypeScript transform that
|
* `--experimental-transform-types` applies a full TypeScript transform that
|
||||||
* works regardless of whether the file is under `node_modules/`. SF requires
|
* works regardless of whether the file is under `node_modules/`. SF requires
|
||||||
* Node 24+, so this flag is always available.
|
* Node 26+, so this flag is always available.
|
||||||
*/
|
*/
|
||||||
export function resolveTypeStrippingFlag(packageRoot: string): string {
|
export function resolveTypeStrippingFlag(packageRoot: string): string {
|
||||||
return isUnderNodeModules(packageRoot)
|
return isUnderNodeModules(packageRoot)
|
||||||
|
|
@ -39,7 +39,7 @@ export interface SubprocessModuleResolution {
|
||||||
* Resolves a subprocess module path, preferring compiled `dist/*.js` when the
|
* Resolves a subprocess module path, preferring compiled `dist/*.js` when the
|
||||||
* package root is under `node_modules/`.
|
* package root is under `node_modules/`.
|
||||||
*
|
*
|
||||||
* Node v24 unconditionally refuses `.ts` files under `node_modules/` — even
|
* Node v26 unconditionally refuses `.ts` files under `node_modules/` — even
|
||||||
* with `--experimental-transform-types`. When SF is installed globally via
|
* with `--experimental-transform-types`. When SF is installed globally via
|
||||||
* npm, every subprocess that loads a `.ts` extension module crashes with
|
* npm, every subprocess that loads a `.ts` extension module crashes with
|
||||||
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
|
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:24-bookworm
|
FROM node:26-bookworm
|
||||||
|
|
||||||
WORKDIR /test
|
WORKDIR /test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export default defineConfig({
|
||||||
"src/tests/**/*.test.mjs",
|
"src/tests/**/*.test.mjs",
|
||||||
"src/resources/extensions/sf/tests/**/*.test.ts",
|
"src/resources/extensions/sf/tests/**/*.test.ts",
|
||||||
"src/resources/extensions/sf/tests/**/*.test.mjs",
|
"src/resources/extensions/sf/tests/**/*.test.mjs",
|
||||||
|
"src/resources/extensions/sf/learning/*.test.mjs",
|
||||||
"src/resources/extensions/sf-permissions/tests/**/*.test.ts",
|
"src/resources/extensions/sf-permissions/tests/**/*.test.ts",
|
||||||
"src/resources/extensions/shared/tests/**/*.test.ts",
|
"src/resources/extensions/shared/tests/**/*.test.ts",
|
||||||
"src/resources/extensions/claude-code-cli/tests/**/*.test.ts",
|
"src/resources/extensions/claude-code-cli/tests/**/*.test.ts",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"workspace"
|
"workspace"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0",
|
"node": ">=26.1.0",
|
||||||
"vscode": "^1.95.0"
|
"vscode": "^1.95.0"
|
||||||
},
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
|
|
|
||||||
24350
web/package-lock.json
generated
24350
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=26.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.2.0",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"@types/node": "^24",
|
"@types/node": "^25.6.2",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue