feat(FN-3544): add project-scoped auth storage and docs updates

- Add project auth storage model and persistence wiring in core DB/store/types
- Expand core test coverage for DB migration, store behavior, and project auth store flows
- Update heartbeat agent, settings, dashboard, and storage documentation for the new behavior
- Replace hardcoded mobile touch-target sizing in dependency graph styles with token-based sizing

Fusion-Task-Id: FN-3544
This commit is contained in:
Fusion
2026-05-06 04:42:28 -07:00
committed by gsxdsm
parent 8f812e2f89
commit 4502e15fd0
18 changed files with 492 additions and 33 deletions

View File

@@ -237,6 +237,7 @@ The `runtimeConfig` field on agents supports the following options:
| `autoClaimRelevantTasks` | `boolean` | `true` | During no-task heartbeats, opportunistically claim unowned relevant todo tasks that align with the agent's role/soul |
| `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) |
| `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent |
| `runMissedHeartbeatOnStartup` | `boolean` | `false` | When enabled, if the server was down across this agent's scheduled heartbeat tick, fire one catch-up heartbeat at startup (only when `lastHeartbeatAt` is older than the resolved interval) |
| `allowParallelExecution` | `boolean` | `true` (when unset) | Permanent agents only. When `false`, heartbeat and executor paths serialize symmetrically: a heartbeat will not start while the agent's bound task has an active executor session, and an executor session will not start while the agent has an active heartbeat run |
| `messageResponseMode` | `"immediate" \| "on-heartbeat"` | `"immediate"` | Whether agent wakes immediately on message (immediate) or processes during heartbeat (on-heartbeat). See [Heartbeat Run Mailbox Checking](#heartbeat-run-mailbox-checking) |
| `selfImproveEnabled` | `boolean` | `true` | Enable periodic self-improvement reflection prompts during heartbeat runs |
@@ -249,7 +250,9 @@ The `runtimeConfig` field on agents supports the following options:
Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms).
Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat intervals globally; per-agent `heartbeatIntervalMs` remains the base interval before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed".
`allowParallelExecution` defaults to `true` when unset; setting it to `false` is serialized explicitly so operators can enforce non-parallel heartbeat/executor behavior for that permanent agent.
`runMissedHeartbeatOnStartup` defaults to `false` and is configured in **Agent Detail → Settings → Heartbeat Settings → Run Missed Heartbeat On Startup**.
`allowParallelExecution` defaults to `true` when unset; setting it to `false` is serialized explicitly so operators can enforce non-parallel heartbeat/executor behavior for that permanent agent. Configure it in **Agent Detail → Settings → Heartbeat Settings → Allow Parallel Execution**.
### No-task auto-claim behavior
@@ -549,6 +552,19 @@ Expected behavior for both manual and automatic triggers:
Messages remain an important input signal, but they do not replace the heartbeat procedure.
### Heartbeat/Executor Separation (Current Behavior)
For permanent agents, heartbeat runs now continue as an ambient coordination loop even when the currently bound task is blocked from normal task progress.
- **Heartbeat path**: coordination, wake processing, mailbox/delegation/memory/task-creation actions, and lightweight ambient follow-through.
- **Executor path**: task-body implementation work from task steps/prompts.
When `allowParallelExecution` is set to `false` on a permanent agent, the two paths serialize symmetrically:
- Heartbeat does not start while the bound task has an active executor session.
- Executor does not start while the agent has an active heartbeat run.
When `allowParallelExecution` is `true` (default), both paths may run concurrently.
## Heartbeat Run Mailbox Checking
When messaging tools are enabled for an agent, heartbeat runs check for unread mailbox messages during execution regardless of the trigger type. This ensures agents can see and respond to incoming messages without needing an explicit wake-on-message trigger.

View File

@@ -288,6 +288,8 @@ The **Logs** tab includes an **Agent Log** subview designed for debugging long-r
- The initial load fetches a recent page, then **Load More** progressively prepends older history.
- Live streaming appends new entries in chronological order while preserving your scroll position when loading older pages.
- The **Markdown / Plain** toggle lets you switch between formatted markdown and literal/raw text rendering.
- The **Tools: On/Off** toggle shows or hides tool-call rows (`tool`, `tool_result`, `tool_error`) so you can focus on narrative/thinking output when needed.
- Both display preferences persist across sessions via local storage (`fn-agent-log-markdown` and `fn-agent-log-tool-output`).
The **Routing** tab shows:
- effective node

View File

@@ -556,6 +556,8 @@ fn plugin install ./plugins/fusion-plugin-openclaw-runtime
```
> 💡 In the dashboard, go to **Settings → Plugins → Fusion Plugins**. The **Bundled Plugins** section surfaces Agent Browser, Hermes, Paperclip, OpenClaw, Droid, and Dependency Graph directly from shipped manifests, shows install status, and provides one-click install actions for plugins that are not yet installed.
>
> Bundled runtime plugins (`fusion-plugin-paperclip-runtime`, `fusion-plugin-hermes-runtime`, `fusion-plugin-openclaw-runtime`) are also auto-installed on the first settings save for that bundled plugin card (lazy install on first `PUT /api/plugins/:id/settings`). They are **not** auto-installed at app boot or npm install time.
2. Create agents with the appropriate `runtimeConfig`:
@@ -757,12 +759,14 @@ Common heartbeat/runtime keys on `runtimeConfig` include:
| `heartbeatTimeoutMs` | `number` | Per-agent heartbeat timeout |
| `maxConcurrentRuns` | `number` | Per-agent concurrent heartbeat limit |
| `messageResponseMode` | `"immediate" \| "on-heartbeat"` | Wake on message immediately or process during periodic heartbeat |
| `runMissedHeartbeatOnStartup` | `boolean` | Fire one startup catch-up heartbeat when the last tick was missed while the server was down (default `false`) |
| `allowParallelExecution` | `boolean` | Permanent agents only. Default `true` when unset. Set `false` to serialize heartbeat and executor sessions symmetrically (heartbeat won't start while executor is active, and executor won't start while heartbeat is active); `false` is explicitly persisted while unset/`true` keeps parallel behavior |
| `runMissedHeartbeatOnStartup` | `boolean` | Default `false`. When enabled, startup triggers one catch-up heartbeat if the agent's `lastHeartbeatAt` is older than its resolved heartbeat interval (server was down across a scheduled tick). |
| `allowParallelExecution` | `boolean` | Permanent agents only. Default `true` when unset. Set `false` to serialize heartbeat and executor sessions symmetrically (heartbeat won't start while executor is active, and executor won't start while heartbeat is active); `false` is explicitly persisted while unset/`true` keeps parallel behavior. |
| `selfImproveEnabled` | `boolean` | Enables periodic self-improvement prompts |
| `selfImproveIntervalMs` | `number` | Delay between self-improvement cycles (default 4h, minimum 1h) |
| `lastSelfImproveAt` | `string` | Last self-improvement checkpoint timestamp (managed by heartbeat monitor) |
Configure these per agent in **Agents → Agent Detail → Settings → Heartbeat Settings** (dashboard), or by updating agent `runtimeConfig` via the Agents API/CLI config flows.
These examples show agents configured to use Paperclip, Hermes, and OpenClaw runtime hints:
```json

View File

@@ -6,7 +6,7 @@
- **Backend settings keys defined in `@fusion/core`:** **78** total
- **Global settings:** 17 (`GlobalSettings`)
- **Project settings:** 61 (`ProjectSettings`)
- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **39** (including migration-created tables)
- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **43** (including migration-created tables)
- **Issues identified:** **9**
- High: 2
- Medium: 5
@@ -195,6 +195,10 @@ Additional backend notes:
| `project_insight_run_events` | Append-only per-run lifecycle trail (`seq`, `type`, `message`, optional `status`/`classification`/`metadata`) used by cancel/retry/timeout auditing and API inspection. |
| `todo_lists` | Project-scoped todo list metadata (`projectId`, title, created/updated timestamps). |
| `todo_items` | Todo list items (`listId` FK) with completion state, completion timestamp, and deterministic `sortOrder`. |
| `project_auth_users` | Project-scoped user identities (email/display name/active state) used for membership and session relationships. |
| `project_auth_memberships` | Project-scoped membership records linking users to fixed v1 roles (`owner`, `admin`, `editor`, `viewer`). |
| `project_auth_providers` | Per-project external auth-provider links for users (provider + external user ID + metadata). |
| `project_auth_sessions` | Project-scoped auth sessions tied to a user + membership with expiry and revocation timestamps. |
| `ai_sessions` *(migration-created)* | Persisted AI interactive sessions (planning/interview/subtask) with status and conversation history. |
| `messages` *(migration-created)* | Inter-agent/user message mailbox storage. |
| `agentRatings` *(migration-created)* | Agent performance ratings (1-5), optional reviewer metadata, and run/task attribution. |
@@ -213,6 +217,8 @@ Additional backend notes:
| `eval_task_results` | Per-task eval outcomes linked to runs (`runId` FK cascade), including durable task snapshots, category scores, evidence references, deterministic/AI signal payloads, rationale, and follow-up suggestions. |
| `eval_run_events` | Append-only eval run event trail (`runId` FK cascade, ordered by `seq`) for orchestration/debug auditing and downstream API/UI drill-down. |
Scope boundary note: the `project_auth_*` tables are strictly project-database membership/auth domain data. They do **not** replace or migrate global remote-access credentials/tokens, daemon auth, or model-provider credential settings (which remain in their existing global/project settings stores).
---
## 5) Issues Found

View File

@@ -112,6 +112,10 @@ describe("Database", () => {
expect(tableNames).toContain("roadmap_features");
// Verification cache (migration 61)
expect(tableNames).toContain("verification_cache");
expect(tableNames).toContain("project_auth_users");
expect(tableNames).toContain("project_auth_memberships");
expect(tableNames).toContain("project_auth_providers");
expect(tableNames).toContain("project_auth_sessions");
});
it("creates all expected indexes", () => {
@@ -157,12 +161,18 @@ describe("Database", () => {
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
// Verification cache index (migration 61)
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
expect(indexNames).toContain("idxProjectAuthUsersEmail");
expect(indexNames).toContain("idxProjectAuthMembershipsUserId");
expect(indexNames).toContain("idxProjectAuthMembershipsRole");
expect(indexNames).toContain("idxProjectAuthProvidersUserId");
expect(indexNames).toContain("idxProjectAuthSessionsUserId");
expect(indexNames).toContain("idxProjectAuthSessionsMembershipId");
expect(indexNames).toContain("idxProjectAuthSessionsExpiry");
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
expect(ts).toBeGreaterThan(0);
@@ -183,9 +193,8 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
db.prepare("UPDATE config SET nextId = 42 WHERE id = 1").run();
@@ -957,7 +966,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -982,11 +991,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
db.close();
});
@@ -1021,7 +1030,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1062,7 +1071,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1131,7 +1140,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1234,7 +1243,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1308,7 +1317,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1332,7 +1341,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1436,7 +1445,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1905,7 +1914,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2018,3 +2027,33 @@ describe("TaskStore — verification cache", () => {
expect(hit!.taskId).toBe("FN-020");
});
});
describe("migration v63 project auth tables", () => {
it("migrates from v62 and creates project auth tables", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const localDb = new Database(fusion);
localDb.init();
localDb.prepare("UPDATE __meta SET value = '62' WHERE key = 'schemaVersion'").run();
localDb.prepare("DROP TABLE IF EXISTS project_auth_sessions").run();
localDb.prepare("DROP TABLE IF EXISTS project_auth_providers").run();
localDb.prepare("DROP TABLE IF EXISTS project_auth_memberships").run();
localDb.prepare("DROP TABLE IF EXISTS project_auth_users").run();
localDb.close();
const migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(63);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%' ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((t) => t.name)).toEqual([
"project_auth_memberships",
"project_auth_providers",
"project_auth_sessions",
"project_auth_users",
]);
migrated.close();
rmSync(temp, { recursive: true, force: true });
});
});

View File

@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(62);
expect(db1.getSchemaVersion()).toBe(63);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(62);
expect(db3.getSchemaVersion()).toBe(63);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(62);
expect(db1.getSchemaVersion()).toBe(63);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(62);
expect(db2.getSchemaVersion()).toBe(63);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(62);
expect(db1.getSchemaVersion()).toBe(63);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
});
it("mission_features table has loop state columns", () => {

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database } from "../db.js";
import { ProjectAuthStore } from "../project-auth-store.js";
describe("ProjectAuthStore", () => {
let tmpDir: string;
let db: Database;
let store: ProjectAuthStore;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-project-auth-"));
db = new Database(join(tmpDir, ".fusion"));
db.init();
store = new ProjectAuthStore(db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("persists users memberships providers and active sessions", () => {
const user = store.createUser({ email: "owner@example.com", displayName: "Owner" });
const membership = store.createMembership({ userId: user.id, role: "owner" });
const provider = store.createProvider({ userId: user.id, provider: "github", providerUserId: "123", metadata: { login: "owner" } });
const session = store.createSession({ userId: user.id, membershipId: membership.id, sessionToken: "tok_1", expiresAt: "2099-01-01T00:00:00.000Z" });
expect(store.getUser(user.id)?.email).toBe("owner@example.com");
expect(store.listMembershipsByUser(user.id)[0]?.role).toBe("owner");
expect(store.listProvidersByUser(user.id)[0]?.provider).toBe("github");
expect(store.resolveActiveSessionByToken(session.sessionToken)?.id).toBe(session.id);
expect(provider.metadata).toEqual({ login: "owner" });
});
it("treats revoked and expired sessions as inactive", () => {
const user = store.createUser({ email: "viewer@example.com" });
const membership = store.createMembership({ userId: user.id, role: "viewer" });
const active = store.createSession({ userId: user.id, membershipId: membership.id, sessionToken: "tok_active", expiresAt: "2099-01-01T00:00:00.000Z" });
const expired = store.createSession({ userId: user.id, membershipId: membership.id, sessionToken: "tok_expired", expiresAt: "2000-01-01T00:00:00.000Z" });
store.revokeSession(active.id);
expect(store.resolveActiveSessionByToken("tok_active")).toBeUndefined();
expect(store.resolveActiveSessionByToken("tok_expired")).toBeUndefined();
expect(expired.expiresAt).toBe("2000-01-01T00:00:00.000Z");
});
});

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
});
});
});

View File

@@ -11295,4 +11295,42 @@ describe("RunMutationContext", () => {
expect(cReloaded.executionStartBranch).toBe("fusion/still-alive");
});
});
describe("project auth store getter", () => {
it("lazily returns a stable ProjectAuthStore instance", () => {
const authStoreA = store.getProjectAuthStore();
const authStoreB = store.getProjectAuthStore();
expect(authStoreA).toBe(authStoreB);
const user = authStoreA.createUser({ email: "store-getter@example.com" });
expect(authStoreB.getUser(user.id)?.email).toBe("store-getter@example.com");
});
it("does not regress task CRUD behavior after auth store initialization", async () => {
const authStore = store.getProjectAuthStore();
const user = authStore.createUser({ email: "compat@example.com" });
expect(user.id).toMatch(/^PAU-/);
const task = await store.createTask({ description: "auth-compat task", assigneeUserId: "user:dashboard" });
expect(task.assigneeUserId).toBe("user:dashboard");
const updated = await store.updateTask(task.id, { title: "updated" });
expect(updated?.title).toBe("updated");
const movedToTodo = await store.moveTask(task.id, "todo");
expect(movedToTodo?.column).toBe("todo");
});
it("persists project auth records across TaskStore reinitialization", async () => {
store = new TaskStore(rootDir, globalDir);
const authStore = store.getProjectAuthStore();
const user = authStore.createUser({ email: "persist@example.com" });
store.close();
store = new TaskStore(rootDir, globalDir);
const reloaded = store.getProjectAuthStore().getUser(user.id);
expect(reloaded?.email).toBe("persist@example.com");
});
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(62);
expect(db.getSchemaVersion()).toBe(63);
const index = db
.prepare(

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 62;
const SCHEMA_VERSION = 63;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -793,6 +793,59 @@ CREATE TABLE IF NOT EXISTS todo_items (
CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId);
CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId);
CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder);
-- Project-scoped auth domain tables (FN-3515)
CREATE TABLE IF NOT EXISTS project_auth_users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
displayName TEXT,
active INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_auth_memberships (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
role TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_auth_providers (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
provider TEXT NOT NULL,
providerUserId TEXT NOT NULL,
metadata TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE,
UNIQUE(provider, providerUserId)
);
CREATE TABLE IF NOT EXISTS project_auth_sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
membershipId TEXT NOT NULL,
sessionToken TEXT NOT NULL UNIQUE,
expiresAt TEXT NOT NULL,
revokedAt TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE,
FOREIGN KEY (membershipId) REFERENCES project_auth_memberships(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectAuthUsersEmail ON project_auth_users(email);
CREATE INDEX IF NOT EXISTS idxProjectAuthMembershipsUserId ON project_auth_memberships(userId);
CREATE INDEX IF NOT EXISTS idxProjectAuthMembershipsRole ON project_auth_memberships(role);
CREATE INDEX IF NOT EXISTS idxProjectAuthProvidersUserId ON project_auth_providers(userId);
CREATE INDEX IF NOT EXISTS idxProjectAuthSessionsUserId ON project_auth_sessions(userId);
CREATE INDEX IF NOT EXISTS idxProjectAuthSessionsMembershipId ON project_auth_sessions(membershipId);
CREATE INDEX IF NOT EXISTS idxProjectAuthSessionsExpiry ON project_auth_sessions(expiresAt);
`;
// ── Database Class ───────────────────────────────────────────────────
@@ -2577,6 +2630,66 @@ export class Database {
});
}
if (version < 63) {
this.applyMigration(63, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_auth_users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
displayName TEXT,
active INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_auth_memberships (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
role TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_auth_providers (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
provider TEXT NOT NULL,
providerUserId TEXT NOT NULL,
metadata TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE,
UNIQUE(provider, providerUserId)
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_auth_sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
membershipId TEXT NOT NULL,
sessionToken TEXT NOT NULL UNIQUE,
expiresAt TEXT NOT NULL,
revokedAt TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE,
FOREIGN KEY (membershipId) REFERENCES project_auth_memberships(id) ON DELETE CASCADE
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthUsersEmail ON project_auth_users(email)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthMembershipsUserId ON project_auth_memberships(userId)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthMembershipsRole ON project_auth_memberships(role)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthProvidersUserId ON project_auth_providers(userId)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthSessionsUserId ON project_auth_sessions(userId)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthSessionsMembershipId ON project_auth_sessions(membershipId)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxProjectAuthSessionsExpiry ON project_auth_sessions(expiresAt)`);
});
}
}
/**

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, PROJECT_AUTH_ROLES } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export * from "./mesh-replication-protocol.js";
export {
@@ -46,6 +46,7 @@ export type { ReflectionStoreEvents } from "./reflection-store.js";
export { MessageStore } from "./message-store.js";
export type { MessageStoreEvents } from "./message-store.js";
export { TaskStore } from "./store.js";
export { ProjectAuthStore } from "./project-auth-store.js";
export { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db.js";
export type { Statement } from "./db.js";
export { ArchiveDatabase } from "./archive-db.js";

View File

@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import type {
ProjectAuthMembership,
ProjectAuthMembershipCreateInput,
ProjectAuthProvider,
ProjectAuthProviderCreateInput,
ProjectAuthRole,
ProjectAuthSession,
ProjectAuthSessionCreateInput,
ProjectAuthUser,
ProjectAuthUserCreateInput,
} from "./types.js";
import { PROJECT_AUTH_ROLES } from "./types.js";
interface ProjectAuthUserRow { id: string; email: string; displayName: string | null; active: number; createdAt: string; updatedAt: string; }
interface ProjectAuthMembershipRow { id: string; userId: string; role: ProjectAuthRole; active: number; createdAt: string; updatedAt: string; }
interface ProjectAuthProviderRow { id: string; userId: string; provider: string; providerUserId: string; metadata: string | null; createdAt: string; updatedAt: string; }
interface ProjectAuthSessionRow { id: string; userId: string; membershipId: string; sessionToken: string; expiresAt: string; revokedAt: string | null; createdAt: string; updatedAt: string; }
export class ProjectAuthStore extends EventEmitter {
constructor(private db: Database) { super(); }
private makeId(prefix: string): string { return `${prefix}-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`; }
private now(): string { return new Date().toISOString(); }
private rowToUser(row: ProjectAuthUserRow): ProjectAuthUser { return { ...row, active: row.active === 1 }; }
private rowToMembership(row: ProjectAuthMembershipRow): ProjectAuthMembership { return { ...row, active: row.active === 1 }; }
private rowToProvider(row: ProjectAuthProviderRow): ProjectAuthProvider { return { ...row, metadata: fromJson<Record<string, unknown>>(row.metadata) }; }
private rowToSession(row: ProjectAuthSessionRow): ProjectAuthSession { return { ...row }; }
createUser(input: ProjectAuthUserCreateInput): ProjectAuthUser {
const now = this.now();
const user: ProjectAuthUser = { id: this.makeId("PAU"), email: input.email, displayName: input.displayName ?? null, active: input.active ?? true, createdAt: now, updatedAt: now };
this.db.prepare("INSERT INTO project_auth_users (id,email,displayName,active,createdAt,updatedAt) VALUES (?, ?, ?, ?, ?, ?)").run(user.id, user.email, user.displayName, user.active ? 1 : 0, now, now);
this.db.bumpLastModified();
return user;
}
getUser(id: string): ProjectAuthUser | undefined {
const row = this.db.prepare("SELECT * FROM project_auth_users WHERE id = ?").get(id) as ProjectAuthUserRow | undefined;
return row ? this.rowToUser(row) : undefined;
}
listUsers(): ProjectAuthUser[] {
return (this.db.prepare("SELECT * FROM project_auth_users ORDER BY createdAt ASC, id ASC").all() as ProjectAuthUserRow[]).map((row) => this.rowToUser(row));
}
createMembership(input: ProjectAuthMembershipCreateInput): ProjectAuthMembership {
if (!PROJECT_AUTH_ROLES.includes(input.role)) throw new Error(`Invalid role: ${input.role}`);
const now = this.now();
const membership: ProjectAuthMembership = { id: this.makeId("PAM"), userId: input.userId, role: input.role, active: input.active ?? true, createdAt: now, updatedAt: now };
this.db.prepare("INSERT INTO project_auth_memberships (id,userId,role,active,createdAt,updatedAt) VALUES (?, ?, ?, ?, ?, ?)").run(membership.id, membership.userId, membership.role, membership.active ? 1 : 0, now, now);
this.db.bumpLastModified();
return membership;
}
listMembershipsByUser(userId: string): ProjectAuthMembership[] {
return (this.db.prepare("SELECT * FROM project_auth_memberships WHERE userId = ? ORDER BY createdAt ASC, id ASC").all(userId) as ProjectAuthMembershipRow[]).map((row) => this.rowToMembership(row));
}
getMembership(id: string): ProjectAuthMembership | undefined {
const row = this.db.prepare("SELECT * FROM project_auth_memberships WHERE id = ?").get(id) as ProjectAuthMembershipRow | undefined;
return row ? this.rowToMembership(row) : undefined;
}
createProvider(input: ProjectAuthProviderCreateInput): ProjectAuthProvider {
const now = this.now();
const provider: ProjectAuthProvider = { id: this.makeId("PAP"), userId: input.userId, provider: input.provider, providerUserId: input.providerUserId, metadata: input.metadata, createdAt: now, updatedAt: now };
this.db.prepare("INSERT INTO project_auth_providers (id,userId,provider,providerUserId,metadata,createdAt,updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(provider.id, provider.userId, provider.provider, provider.providerUserId, toJsonNullable(provider.metadata), now, now);
this.db.bumpLastModified();
return provider;
}
listProvidersByUser(userId: string): ProjectAuthProvider[] {
return (this.db.prepare("SELECT * FROM project_auth_providers WHERE userId = ? ORDER BY createdAt ASC, id ASC").all(userId) as ProjectAuthProviderRow[]).map((row) => this.rowToProvider(row));
}
createSession(input: ProjectAuthSessionCreateInput): ProjectAuthSession {
const now = this.now();
const session: ProjectAuthSession = { id: this.makeId("PAS"), userId: input.userId, membershipId: input.membershipId, sessionToken: input.sessionToken, expiresAt: input.expiresAt, revokedAt: null, createdAt: now, updatedAt: now };
this.db.prepare("INSERT INTO project_auth_sessions (id,userId,membershipId,sessionToken,expiresAt,revokedAt,createdAt,updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(session.id, session.userId, session.membershipId, session.sessionToken, session.expiresAt, session.revokedAt, now, now);
this.db.bumpLastModified();
return session;
}
revokeSession(id: string): ProjectAuthSession | undefined {
const now = this.now();
this.db.prepare("UPDATE project_auth_sessions SET revokedAt = ?, updatedAt = ? WHERE id = ?").run(now, now, id);
this.db.bumpLastModified();
return this.getSession(id);
}
getSession(id: string): ProjectAuthSession | undefined {
const row = this.db.prepare("SELECT * FROM project_auth_sessions WHERE id = ?").get(id) as ProjectAuthSessionRow | undefined;
return row ? this.rowToSession(row) : undefined;
}
resolveActiveSessionByToken(sessionToken: string, nowIso: string = this.now()): ProjectAuthSession | undefined {
const row = this.db.prepare("SELECT * FROM project_auth_sessions WHERE sessionToken = ? AND revokedAt IS NULL AND expiresAt > ?").get(sessionToken, nowIso) as ProjectAuthSessionRow | undefined;
return row ? this.rowToSession(row) : undefined;
}
}

View File

@@ -17,6 +17,7 @@ import { InsightStore } from "./insight-store.js";
import { ResearchStore } from "./research-store.js";
import { TodoStore } from "./todo-store.js";
import { EvalStore } from "./eval-store.js";
import { ProjectAuthStore } from "./project-auth-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker } from "./task-merge.js";
@@ -516,6 +517,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private todoStore: TodoStore | null = null;
/** Cached EvalStore instance */
private evalStore: EvalStore | null = null;
/** Cached ProjectAuthStore instance */
private projectAuthStore: ProjectAuthStore | null = null;
/** Buffer for batching agent log writes to reduce WAL pressure. */
private agentLogBuffer: Array<{
@@ -6637,6 +6640,17 @@ ${notificationsSection}`;
return this.evalStore;
}
/**
* Get the ProjectAuthStore instance for project-scoped auth domain operations.
* Lazily initializes the ProjectAuthStore on first access.
*/
getProjectAuthStore(): ProjectAuthStore {
if (!this.projectAuthStore) {
this.projectAuthStore = new ProjectAuthStore(this.db);
}
return this.projectAuthStore;
}
// ── Verification Cache ────────────────────────────────────────────────────
/**

View File

@@ -1179,6 +1179,76 @@ export interface TodoListWithItems extends TodoList {
items: TodoItem[];
}
// ── Project Auth Types ───────────────────────────────────────────────────
export const PROJECT_AUTH_ROLES = ["owner", "admin", "editor", "viewer"] as const;
export type ProjectAuthRole = (typeof PROJECT_AUTH_ROLES)[number];
export interface ProjectAuthUser {
id: string;
email: string;
displayName: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface ProjectAuthMembership {
id: string;
userId: string;
role: ProjectAuthRole;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface ProjectAuthProvider {
id: string;
userId: string;
provider: string;
providerUserId: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface ProjectAuthSession {
id: string;
userId: string;
membershipId: string;
sessionToken: string;
expiresAt: string;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ProjectAuthUserCreateInput {
email: string;
displayName?: string | null;
active?: boolean;
}
export interface ProjectAuthMembershipCreateInput {
userId: string;
role: ProjectAuthRole;
active?: boolean;
}
export interface ProjectAuthProviderCreateInput {
userId: string;
provider: string;
providerUserId: string;
metadata?: Record<string, unknown>;
}
export interface ProjectAuthSessionCreateInput {
userId: string;
membershipId: string;
sessionToken: string;
expiresAt: string;
}
// ── Settings Scope Types ────────────────────────────────────────────────
//
// Settings are split into two scopes:

View File

@@ -121,8 +121,8 @@
}
.dependency-graph-controls .btn {
min-height: 44px;
min-width: 44px;
min-height: calc(var(--space-xs) * 11);
min-width: calc(var(--space-xs) * 11);
}
.dependency-graph-canvas {