fix: resume distributed task ID counter past existing IDs; extract plugin view types
The dashboard task-create route now uses the distributed task ID allocator (FN-3450). On projects whose tasks had been allocated through the legacy config.nextId counter, the allocator's `ensureStateRow` was seeding a fresh prefix at sequence 1, so new tasks restarted at FN-001 even when FN-3700 already existed. ensureStateRow now seeds past: - the legacy config.nextId counter (when configured taskPrefix matches), and - one past the highest numeric suffix on any existing tasks/archivedTasks row for the prefix. A regression test seeds FN-3700 in a fresh DB and asserts the next reservation returns FN-3701, not FN-001. Plugin dashboard view contracts are now exposed via a slim type-only module (`@fusion/dashboard/app/plugins/types`). External plugin tsc builds previously imported `pluginViewRegistry`, transitively pulling in dashboard runtime sources (React components, CSS, lucide-react). The dependency-graph plugin's import + path mapping is updated to use the new module. Schema housekeeping: drop unused scaffolding tables left over from an earlier migration via a new idempotent migration (v67). Fresh DBs see no change; existing DBs that ran the older migration get the orphan tables dropped on next init. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.changeset/distributed-task-id-resume.md
Normal file
9
.changeset/distributed-task-id-resume.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix task ID counter resetting to `001` on first mesh-routed task creation.
|
||||
|
||||
When the dashboard's task-create route was migrated to the distributed task ID allocator, projects whose tasks had been allocated through the legacy counter (e.g. `FN-3700`) saw new tasks restart at `FN-001`, colliding with historical IDs. The allocator now seeds its sequence past any existing task for the prefix (live or archived) and past the legacy counter, so new task IDs always continue forward.
|
||||
|
||||
Internal: extracted a slim type-only module for plugin dashboard view contracts so external plugin builds no longer pull in dashboard runtime sources, and dropped unused scaffolding tables (added by a previous schema migration) via an idempotent migration.
|
||||
@@ -195,10 +195,6 @@ 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. |
|
||||
@@ -217,8 +213,6 @@ Additional backend notes:
|
||||
| `eval_task_results` | Per-task eval outcomes linked to runs (`runId` FK cascade), including durable task snapshots and structured score payloads. `categoryScores[]` stores canonical per-category fields (`category`, `deterministicScore`, `aiScore`, `finalScore`, `weight`, `band`, `rationale`, `evidence[]`), plus `overallScore` derived from category finals. Also stores deterministic/AI signal payloads, summary rationale, structured follow-up suggestions (`suggestionId`, `dedupeKey`, recommendation, lifecycle state, suppression fields, optional `createdTaskId` linkage), and a bounded `TaskEvaluationEvidenceBundle` (fixed source-order groups, capped entry counts, max 500-char excerpts with truncation marker) embedded in result metadata for backward-compatible persistence. |
|
||||
| `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
|
||||
|
||||
@@ -112,10 +112,6 @@ 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");
|
||||
expect(tableNames).toContain("distributed_task_id_state");
|
||||
expect(tableNames).toContain("distributed_task_id_reservations");
|
||||
});
|
||||
@@ -165,17 +161,10 @@ 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(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
});
|
||||
it("seeds lastModified", () => {
|
||||
const ts = db.getLastModified();
|
||||
@@ -197,7 +186,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -970,7 +959,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -995,11 +984,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1034,7 +1023,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1075,7 +1064,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1144,7 +1133,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1247,7 +1236,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1321,7 +1310,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
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" }]);
|
||||
@@ -1345,7 +1334,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
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" }]);
|
||||
@@ -1449,7 +1438,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1918,7 +1907,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2032,32 +2021,45 @@ describe("TaskStore — verification cache", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration v63 project auth tables", () => {
|
||||
it("migrates from v62 and creates project auth tables", () => {
|
||||
describe("migration v67 drops orphan project auth tables", () => {
|
||||
it("drops project_auth_* tables left over from the removed pluggable auth feature", () => {
|
||||
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();
|
||||
// Simulate a user who ran the old migration 63 (schema version 63–66) and
|
||||
// therefore has the orphan project_auth_* tables sitting in their DB. We
|
||||
// recreate them by hand and roll the schemaVersion back so the new
|
||||
// migration runs on the next init.
|
||||
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_users (id TEXT PRIMARY KEY)`);
|
||||
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_memberships (id TEXT PRIMARY KEY, userId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE)`);
|
||||
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_providers (id TEXT PRIMARY KEY, userId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE)`);
|
||||
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_sessions (id TEXT PRIMARY KEY, userId TEXT, membershipId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE, FOREIGN KEY (membershipId) REFERENCES project_auth_memberships(id) ON DELETE CASCADE)`);
|
||||
localDb.prepare("UPDATE __meta SET value = '66' WHERE key = 'schemaVersion'").run();
|
||||
localDb.close();
|
||||
|
||||
const migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(66);
|
||||
expect(migrated.getSchemaVersion()).toBe(67);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%' ORDER BY name")
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables.map((t) => t.name)).toEqual([
|
||||
"project_auth_memberships",
|
||||
"project_auth_providers",
|
||||
"project_auth_sessions",
|
||||
"project_auth_users",
|
||||
]);
|
||||
expect(tables).toEqual([]);
|
||||
migrated.close();
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is a no-op on fresh DBs that never had the auth tables", () => {
|
||||
const temp = makeTmpDir();
|
||||
const fusion = join(temp, ".fusion");
|
||||
const fresh = new Database(fusion);
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(67);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([]);
|
||||
fresh.close();
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,30 @@ describe("distributed-task-id allocator", () => {
|
||||
expect(state.committedClusterTaskCount).toBe(0);
|
||||
});
|
||||
|
||||
it("seeds nextSequence past existing tasks for the configured prefix", async () => {
|
||||
// Regression: FN-3450 wired the dashboard task-create route to the
|
||||
// distributed allocator. On databases whose tasks were originally
|
||||
// allocated through TaskStore.allocateId() (config.nextId), the first
|
||||
// mesh-routed reservation used to restart at 1 and produce FN-001 even
|
||||
// when FN-3700 already existed. The allocator must now resume past any
|
||||
// existing task ID for the prefix.
|
||||
const db = new Database("/tmp/fusion-test", { inMemory: true });
|
||||
db.init();
|
||||
db.prepare("UPDATE config SET nextId = 3701, settings = ? WHERE id = 1").run(
|
||||
JSON.stringify({ taskPrefix: "FN" }),
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
|
||||
).run("FN-3700", new Date().toISOString(), new Date().toISOString());
|
||||
const allocator = createDistributedTaskIdAllocator(db);
|
||||
|
||||
const reservation = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-a" });
|
||||
expect(reservation.taskId).toBe("FN-3701");
|
||||
|
||||
const state = await allocator.getDistributedTaskIdState({ prefix: "FN" });
|
||||
expect(state.nextSequence).toBe(3702);
|
||||
});
|
||||
|
||||
it("state reports committed count independently from nextSequence", async () => {
|
||||
const { allocator } = createAllocator();
|
||||
const first = await allocator.reserveDistributedTaskId({ prefix: "FN", nodeId: "node-a" });
|
||||
|
||||
@@ -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(66);
|
||||
expect(db1.getSchemaVersion()).toBe(67);
|
||||
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(66);
|
||||
expect(db3.getSchemaVersion()).toBe(67);
|
||||
|
||||
// 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(66);
|
||||
expect(db1.getSchemaVersion()).toBe(67);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(66);
|
||||
expect(db2.getSchemaVersion()).toBe(67);
|
||||
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(66);
|
||||
expect(db1.getSchemaVersion()).toBe(67);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 40 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11536,44 +11536,6 @@ describe("RunMutationContext", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared mesh snapshots", () => {
|
||||
it("exports and reapplies task/activity/audit snapshots deterministically", async () => {
|
||||
const task = await store.createTask({ description: "snapshot task" });
|
||||
|
||||
@@ -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(66);
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 66;
|
||||
const SCHEMA_VERSION = 67;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -825,59 +825,6 @@ 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 ───────────────────────────────────────────────────
|
||||
@@ -2674,66 +2621,6 @@ 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)`);
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 64) {
|
||||
this.applyMigration(64, () => {
|
||||
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idxEvalTaskResultsRunTaskUnique ON eval_task_results(runId, taskId)`);
|
||||
@@ -2782,6 +2669,20 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 67) {
|
||||
// Drop the project_auth_* tables introduced by the old migration 63
|
||||
// (FN-3544). The pluggable project-auth feature was removed before any
|
||||
// production usage; these tables are orphaned on DBs that ran the old
|
||||
// migration. Drop sessions/providers/memberships before users so the
|
||||
// foreign-key cascade order is honored.
|
||||
this.applyMigration(67, () => {
|
||||
this.db.exec(`DROP TABLE IF EXISTS project_auth_sessions`);
|
||||
this.db.exec(`DROP TABLE IF EXISTS project_auth_providers`);
|
||||
this.db.exec(`DROP TABLE IF EXISTS project_auth_memberships`);
|
||||
this.db.exec(`DROP TABLE IF EXISTS project_auth_users`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,11 +82,60 @@ export function createDistributedTaskIdAllocator(db: Database): DistributedTaskI
|
||||
};
|
||||
|
||||
const ensureStateRow = (prefix: string): void => {
|
||||
// Seed nextSequence past any pre-existing task ID for this prefix. Without
|
||||
// this, projects whose tasks were originally allocated through
|
||||
// TaskStore.allocateId() (config.nextId) would have mesh-routed task
|
||||
// creates restart at 1 and collide with historical FN-001 / FN-002 / …
|
||||
// IDs (regression introduced when the dashboard task-create route was
|
||||
// wired to reserveDistributedTaskId in FN-3450).
|
||||
//
|
||||
// We take the max of:
|
||||
// - 1 (historical default)
|
||||
// - the legacy config.nextId counter, when the configured taskPrefix
|
||||
// matches `prefix`
|
||||
// - one past the highest numeric suffix on any existing task for this
|
||||
// prefix (live tasks + archived), to handle DBs where config.nextId
|
||||
// ever drifted below the real high-water mark
|
||||
let seedSequence = 1;
|
||||
try {
|
||||
const configRow = db
|
||||
.prepare("SELECT nextId, settings FROM config WHERE id = 1")
|
||||
.get() as { nextId: number | null; settings: string | null } | undefined;
|
||||
if (configRow) {
|
||||
const settings = configRow.settings ? (JSON.parse(configRow.settings) as { taskPrefix?: string }) : null;
|
||||
const configuredPrefix = (settings?.taskPrefix ?? "KB").trim().toUpperCase();
|
||||
if (configuredPrefix === prefix && typeof configRow.nextId === "number" && configRow.nextId > seedSequence) {
|
||||
seedSequence = configRow.nextId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: if the config row/column is missing (fresh test DB) we
|
||||
// fall back to the historical default of 1.
|
||||
}
|
||||
const idPattern = `${prefix}-%`;
|
||||
const probeTable = (table: string): void => {
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(CAST(substr(id, ${prefix.length + 2}) AS INTEGER)) AS maxSeq
|
||||
FROM ${table}
|
||||
WHERE id LIKE ? AND substr(id, ${prefix.length + 2}) GLOB '[0-9]*'`,
|
||||
)
|
||||
.get(idPattern) as { maxSeq: number | null } | undefined;
|
||||
if (row && typeof row.maxSeq === "number" && row.maxSeq + 1 > seedSequence) {
|
||||
seedSequence = row.maxSeq + 1;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist (tests, isolated DBs); ignore.
|
||||
}
|
||||
};
|
||||
probeTable("tasks");
|
||||
probeTable("archivedTasks");
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO distributed_task_id_state (
|
||||
prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt
|
||||
) VALUES (?, 1, 0, NULL, ?)`
|
||||
).run(prefix, new Date().toISOString());
|
||||
) VALUES (?, ?, 0, NULL, ?)`
|
||||
).run(prefix, seedSequence, new Date().toISOString());
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, 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, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, ProjectAuthRole, ProjectAuthUser, ProjectAuthMembership, ProjectAuthProvider, ProjectAuthSession, ProjectAuthUserCreateInput, ProjectAuthMembershipCreateInput, ProjectAuthProviderCreateInput, ProjectAuthSessionCreateInput, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, 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, DEFAULT_COLUMN, isColumn, normalizeColumn, 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, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, 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 * from "./mesh-task-replication.js";
|
||||
@@ -54,7 +54,6 @@ export {
|
||||
DistributedTaskIdError,
|
||||
} from "./distributed-task-id.js";
|
||||
export type { DistributedTaskIdAllocator } from "./distributed-task-id.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";
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ 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";
|
||||
@@ -522,8 +521,6 @@ 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;
|
||||
/** Cached distributed task-id allocator instance. */
|
||||
private distributedTaskIdAllocator: DistributedTaskIdAllocator | null = null;
|
||||
|
||||
@@ -6842,17 +6839,6 @@ ${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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1215,76 +1215,6 @@ 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:
|
||||
|
||||
27
packages/dashboard/app/plugins/types.ts
Normal file
27
packages/dashboard/app/plugins/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Slim types-only module for plugin dashboard view contracts.
|
||||
*
|
||||
* External plugins (and their `tsc` builds) import `PluginDashboardViewContext`
|
||||
* from here so they don't transitively pull in dashboard runtime sources
|
||||
* (React components, CSS, lucide-react, etc.) through `pluginViewRegistry.tsx`.
|
||||
*
|
||||
* Keep imports here limited to type-only references from `@fusion/core`
|
||||
* and `react`. Do NOT import dashboard components, hooks, or CSS here.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
|
||||
/** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */
|
||||
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow";
|
||||
|
||||
/** Runtime context passed to a plugin dashboard view component. */
|
||||
export interface PluginDashboardViewContext {
|
||||
projectId?: string;
|
||||
tasks: Task[];
|
||||
workflowSteps: WorkflowStep[];
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
|
||||
}
|
||||
|
||||
/** Composite view ID format: `plugin:{pluginId}:{viewId}`. */
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
@@ -22,6 +22,22 @@
|
||||
"./planning": {
|
||||
"types": "./src/planning.ts",
|
||||
"import": "./src/planning.ts"
|
||||
},
|
||||
"./app/components/TaskCard": {
|
||||
"types": "./app/components/TaskCard.tsx",
|
||||
"import": "./app/components/TaskCard.tsx"
|
||||
},
|
||||
"./app/utils/taskStuck": {
|
||||
"types": "./app/utils/taskStuck.ts",
|
||||
"import": "./app/utils/taskStuck.ts"
|
||||
},
|
||||
"./app/plugins/pluginViewRegistry": {
|
||||
"types": "./app/plugins/pluginViewRegistry.tsx",
|
||||
"import": "./app/plugins/pluginViewRegistry.tsx"
|
||||
},
|
||||
"./app/plugins/types": {
|
||||
"types": "./app/plugins/types.ts",
|
||||
"import": "./app/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/pluginViewRegistry";
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { createElement } from "react";
|
||||
import { DependencyGraph } from "./DependencyGraph";
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["react"]
|
||||
"types": ["react"],
|
||||
"paths": {
|
||||
"@fusion/dashboard/app/components/TaskCard": ["./src/dashboard-interop.d.ts"],
|
||||
"@fusion/dashboard/app/utils/taskStuck": ["./src/dashboard-interop.d.ts"],
|
||||
"@fusion/dashboard/app/plugins/types": ["../../packages/dashboard/app/plugins/types.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
||||
"exclude": ["src/__tests__/**"]
|
||||
|
||||
Reference in New Issue
Block a user