Skip to content

Commit 8894a91

Browse files
committed
Merge branch 'main' of github.com:nodetool-ai/nodetool
2 parents 0abcd19 + 06c2594 commit 8894a91

27 files changed

Lines changed: 659 additions & 108 deletions

docs/project-resource-inventory.md

Lines changed: 20 additions & 15 deletions
Large diffs are not rendered by default.

docs/project-scoping/prd.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ These findings came from source inspection, not a live visual walkthrough.
151151

152152
| ID | Finding | Source |
153153
| --- | --- | --- |
154-
| F1 | Project opening adds all project documents to a shared tab list and selects the overview. | [WorkspaceTabsStore](https://github.com/nodetool-ai/nodetool/blob/main/web/src/stores/WorkspaceTabsStore.ts), [useProjects](https://github.com/nodetool-ai/nodetool/blob/main/web/src/hooks/useProjects.ts) |
155-
| F2 | The entity library query searches assets without a project filter, while the overview has a project-specific entity section. | [useEntities](https://github.com/nodetool-ai/nodetool/blob/main/web/src/serverState/useEntities.ts), [ProjectEntitiesSection](https://github.com/nodetool-ai/nodetool/blob/main/web/src/components/projects/ProjectEntitiesSection.tsx) |
156-
| F3 | Assets carry a project field, but the inspected upload payload does not carry project context. | [AssetStore](https://github.com/nodetool-ai/nodetool/blob/main/web/src/stores/AssetStore.ts), [asset schema](https://github.com/nodetool-ai/nodetool/blob/main/packages/models/src/schema/assets.ts) |
157-
| F4 | The project document summary union omits workflows, and project metadata has one agent-thread pointer. | [project-summary](https://github.com/nodetool-ai/nodetool/blob/main/packages/models/src/project-summary.ts), [project schema](https://github.com/nodetool-ai/nodetool/blob/main/packages/models/src/schema/projects.ts) |
154+
| F1 | Project opening adds all project documents to a shared tab list and selects the overview. | <a href="https://github.com/nodetool-ai/nodetool/blob/main/web/src/stores/WorkspaceTabsStore.ts">WorkspaceTabsStore</a>, <a href="https://github.com/nodetool-ai/nodetool/blob/main/web/src/hooks/useProjects.ts">useProjects</a> |
155+
| F2 | The entity library query searches assets without a project filter, while the overview has a project-specific entity section. | <a href="https://github.com/nodetool-ai/nodetool/blob/main/web/src/serverState/useEntities.ts">useEntities</a>, <a href="https://github.com/nodetool-ai/nodetool/blob/main/web/src/components/projects/ProjectEntitiesSection.tsx">ProjectEntitiesSection</a> |
156+
| F3 | Assets carry a project field, but the inspected upload payload does not carry project context. | <a href="https://github.com/nodetool-ai/nodetool/blob/main/web/src/stores/AssetStore.ts">AssetStore</a>, <a href="https://github.com/nodetool-ai/nodetool/blob/main/packages/models/src/schema/assets.ts">asset schema</a> |
157+
| F4 | The project document summary union omits workflows, and project metadata has one agent-thread pointer. | <a href="https://github.com/nodetool-ai/nodetool/blob/main/packages/models/src/project-summary.ts">project-summary</a>, <a href="https://github.com/nodetool-ai/nodetool/blob/main/packages/models/src/schema/projects.ts">project schema</a> |

packages/models/src/job.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,15 +321,17 @@ export class Job extends DBModel {
321321
limit?: number;
322322
status?: JobStatus;
323323
workflowId?: string;
324+
projectId?: string;
324325
} = {}
325326
): Promise<[Job[], string]> {
326-
const { limit = 50, status, workflowId } = opts;
327+
const { limit = 50, status, workflowId, projectId } = opts;
327328
const startKey = opts.startKey ?? opts.cursor;
328329
const db = getDb();
329330

330331
const conditions = [eq(jobs.user_id, userId)];
331332
if (status) conditions.push(eq(jobs.status, status));
332333
if (workflowId) conditions.push(eq(jobs.workflow_id, workflowId));
334+
if (projectId !== undefined) conditions.push(eq(jobs.project_id, projectId));
333335
if (startKey) {
334336
const cursorRow = await Job.get<Job>(startKey);
335337
if (cursorRow && cursorRow.user_id === userId) {

packages/models/src/thread.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,18 @@ export class Thread extends DBModel {
5555
startKey?: string;
5656
reverse?: boolean;
5757
workflowId?: string;
58+
projectId?: string;
5859
} = {}
5960
): Promise<[Thread[], string]> {
60-
const { limit = 50, reverse = true, workflowId, startKey } = opts;
61+
const { limit = 50, reverse = true, workflowId, projectId, startKey } = opts;
6162
const db = getDb();
6263
const conditions = [eq(threads.user_id, userId)];
6364
if (workflowId !== undefined) {
6465
conditions.push(eq(threads.workflow_id, workflowId));
6566
}
67+
if (projectId !== undefined) {
68+
conditions.push(eq(threads.project_id, projectId));
69+
}
6670
// Seek past the cursor row so following `next` advances the page. Without
6771
// this the same first page was returned forever while still advertising a
6872
// cursor.

packages/models/src/workflow.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,13 +268,17 @@ export class Workflow extends DBModel {
268268
access?: AccessLevel;
269269
runMode?: WorkflowRunMode | string;
270270
tag?: string;
271+
projectId?: string;
271272
startKey?: string;
272273
} = {}
273274
): Promise<[Workflow[], string]> {
274-
const { limit = 50, access, runMode, tag, startKey } = opts;
275+
const { limit = 50, access, runMode, tag, projectId, startKey } = opts;
275276
const db = getDb();
276277

277278
const conditions = [eq(workflows.user_id, userId)];
279+
if (projectId !== undefined) {
280+
conditions.push(eq(workflows.project_id, projectId));
281+
}
278282
if (access) conditions.push(eq(workflows.access, access));
279283
if (runMode) {
280284
conditions.push(eq(workflows.run_mode, runMode));
@@ -336,11 +340,14 @@ export class Workflow extends DBModel {
336340
*/
337341
static async paginateSummaries(
338342
userId: string,
339-
opts: { limit?: number; startKey?: string } = {}
343+
opts: { limit?: number; projectId?: string; startKey?: string } = {}
340344
): Promise<[WorkflowSummary[], string]> {
341-
const { limit = 50, startKey } = opts;
345+
const { limit = 50, projectId, startKey } = opts;
342346
const db = getDb();
343347
const conditions = [eq(workflows.user_id, userId)];
348+
if (projectId !== undefined) {
349+
conditions.push(eq(workflows.project_id, projectId));
350+
}
344351

345352
if (startKey) {
346353
const [cursor] = await db
@@ -444,14 +451,17 @@ export class Workflow extends DBModel {
444451

445452
static async paginateTools(
446453
userId: string,
447-
opts: { limit?: number; startKey?: string } = {}
454+
opts: { limit?: number; projectId?: string; startKey?: string } = {}
448455
): Promise<[Workflow[], string]> {
449-
const { limit = 50, startKey } = opts;
456+
const { limit = 50, projectId, startKey } = opts;
450457
const db = getDb();
451458
const conditions = [
452459
eq(workflows.user_id, userId),
453460
eq(workflows.run_mode, "tool")
454461
];
462+
if (projectId !== undefined) {
463+
conditions.push(eq(workflows.project_id, projectId));
464+
}
455465
if (startKey) {
456466
const cursor = await Workflow.get<Workflow>(startKey);
457467
if (cursor && cursor.user_id === userId) {

packages/models/src/workspace.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,18 @@ export class Workspace extends DBModel {
9090

9191
static async paginate(
9292
userId: string,
93-
opts: { limit?: number; startKey?: string } = {}
93+
opts: { limit?: number; projectId?: string; startKey?: string } = {}
9494
): Promise<[Workspace[], string]> {
95-
const { limit = 50 } = opts;
95+
const { limit = 50, projectId } = opts;
9696
const db = getDb();
97+
const conditions = [eq(workspaces.user_id, userId)];
98+
if (projectId !== undefined) {
99+
conditions.push(eq(workspaces.project_id, projectId));
100+
}
97101
const rows = await db
98102
.select()
99103
.from(workspaces)
100-
.where(eq(workspaces.user_id, userId))
104+
.where(and(...conditions))
101105
.limit(limit + 1);
102106

103107
const items = rows.map((r: Record<string, unknown>) => new Workspace(r));

packages/models/tests/migrations.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,15 @@ describe("Built-in migrations", () => {
530530
expect(await adapter.tableExists("nodetool_workflow_shares")).toBe(true);
531531
expect(await adapter.tableExists("projects")).toBe(true);
532532

533+
for (const table of [
534+
"nodetool_workflows",
535+
"nodetool_threads",
536+
"nodetool_jobs",
537+
"nodetool_workspaces"
538+
]) {
539+
expect(await adapter.columnExists(table, "project_id")).toBe(true);
540+
}
541+
533542
// Ledger rows carry the project (and document) they were spent on.
534543
expect(
535544
await adapter.columnExists("nodetool_predictions", "project_id")

packages/models/tests/models.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Workflow } from "../src/workflow.js";
66
import { Asset } from "../src/asset.js";
77
import { Message } from "../src/message.js";
88
import { Thread } from "../src/thread.js";
9+
import { Workspace } from "../src/workspace.js";
910

1011
// ── Setup ────────────────────────────────────────────────────────────
1112

@@ -341,6 +342,69 @@ describe("Workflow model", () => {
341342
);
342343
});
343344

345+
it("scopes workflow pages by project without changing the legacy default", async () => {
346+
await Workflow.create<Workflow>({
347+
user_id: "u1",
348+
project_id: "p1",
349+
name: "One",
350+
graph: { nodes: [], edges: [] }
351+
});
352+
await Workflow.create<Workflow>({
353+
user_id: "u1",
354+
project_id: "p2",
355+
name: "Two",
356+
graph: { nodes: [], edges: [] }
357+
});
358+
359+
const [all] = await Workflow.paginate("u1");
360+
const [one] = await Workflow.paginate("u1", { projectId: "p1" });
361+
const [summaries] = await Workflow.paginateSummaries("u1", {
362+
projectId: "p1"
363+
});
364+
const [tools] = await Workflow.paginateTools("u1", {
365+
projectId: "p1"
366+
});
367+
368+
expect(all).toHaveLength(2);
369+
expect(one.map((workflow) => workflow.project_id)).toEqual(["p1"]);
370+
expect(summaries.map((workflow) => workflow.id)).toEqual([one[0].id]);
371+
expect(tools).toEqual([]);
372+
});
373+
374+
it("scopes job and thread pages by project", async () => {
375+
await Job.create<Job>({ user_id: "u1", workflow_id: "w1", project_id: "p1" });
376+
await Job.create<Job>({ user_id: "u1", workflow_id: "w2", project_id: "p2" });
377+
await Thread.create<Thread>({ user_id: "u1", project_id: "p1", title: "One" });
378+
await Thread.create<Thread>({ user_id: "u1", project_id: "p2", title: "Two" });
379+
380+
const [jobs] = await Job.paginate("u1", { projectId: "p1" });
381+
const [threads] = await Thread.paginate("u1", { projectId: "p1" });
382+
383+
expect(jobs.map((job) => job.project_id)).toEqual(["p1"]);
384+
expect(threads.map((thread) => thread.project_id)).toEqual(["p1"]);
385+
});
386+
387+
it("scopes workspace pages by project", async () => {
388+
await Workspace.create<Workspace>({
389+
user_id: "u1",
390+
project_id: "p1",
391+
name: "One",
392+
path: "/tmp/one"
393+
});
394+
await Workspace.create<Workspace>({
395+
user_id: "u1",
396+
project_id: "p2",
397+
name: "Two",
398+
path: "/tmp/two"
399+
});
400+
401+
const [workspaces] = await Workspace.paginate("u1", {
402+
projectId: "p2"
403+
});
404+
405+
expect(workspaces.map((workspace) => workspace.project_id)).toEqual(["p2"]);
406+
});
407+
344408
it("paginate with access filter", async () => {
345409
await Workflow.create<Workflow>({
346410
user_id: "u1",

packages/protocol/src/api-schemas/jobs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from "zod";
77
export const jobResponse = z.object({
88
id: z.string(),
99
user_id: z.string(),
10+
project_id: z.string().optional(),
1011
job_type: z.literal("workflow"),
1112
status: z.string(),
1213
name: z.string().nullable(),
@@ -24,6 +25,7 @@ export type JobResponse = z.infer<typeof jobResponse>;
2425
// `/running/all` and the cancel endpoint response.
2526
export const backgroundJobResponse = z.object({
2627
job_id: z.string(),
28+
project_id: z.string().optional(),
2729
status: z.string(),
2830
workflow_id: z.string(),
2931
created_at: z.string().nullable(),
@@ -36,6 +38,7 @@ export type BackgroundJobResponse = z.infer<typeof backgroundJobResponse>;
3638
export const listInput = z.object({
3739
limit: z.number().int().min(1).max(500).default(100),
3840
workflow_id: z.string().optional(),
41+
project_id: z.string().optional(),
3942
include_outputs: z.boolean().default(false),
4043
// Cursor from a previous response's next_start_key. Without this the returned
4144
// cursor could never be submitted, so pagination past the first page was

packages/protocol/src/api-schemas/threads.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { z } from "zod";
55
export const threadResponse = z.object({
66
id: z.string(),
77
user_id: z.string(),
8+
project_id: z.string().optional(),
89
workflow_id: z.string().nullable().optional(),
910
title: z.string().nullable(),
1011
created_at: z.string(),
@@ -20,7 +21,8 @@ export const listInput = z.object({
2021
limit: z.number().int().min(1).max(500).default(10),
2122
cursor: z.string().optional(),
2223
reverse: z.boolean().optional(),
23-
workflow_id: z.string().optional()
24+
workflow_id: z.string().optional(),
25+
project_id: z.string().optional()
2426
});
2527
export type ListInput = z.infer<typeof listInput>;
2628

@@ -40,7 +42,8 @@ export type GetInput = z.infer<typeof getInput>;
4042
// `title` is optional; defaults to "New Thread" server-side.
4143
export const createInput = z.object({
4244
title: z.string().optional(),
43-
workflow_id: z.string().nullable().optional()
45+
workflow_id: z.string().nullable().optional(),
46+
project_id: z.string().default("default")
4447
});
4548
export type CreateInput = z.infer<typeof createInput>;
4649

0 commit comments

Comments
 (0)