Skip to content

Commit a05d30e

Browse files
georgiSpriteclaude
authored
T3 — Bind creation and execution to the originating project (#5707)
* [T-20260910-0003] Bind execution to originating project * Fix workflow chat project scope lookup * Bind media and imports to originating project * docs(marketing): rename category to Agent-First Creative Workspace (#5711) * docs(marketing): rename category to Agent-First Creative Workspace Replace "Creative AI Workspace" wording across marketing pages, docs pages, and READMEs with "Agent-First Creative Workspace", regenerating the derived public/llms.txt and public/index.md from the updated generator script. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCzsABL9a7Ztit2E4o8FAB * docs: fix broken source links in project-scoping PRD HTML-Proofer resolves these against the built Jekyll site, not the repo tree, so relative paths into web/src and packages/models 404. Match the doc's own GitHub-blob-URL convention used elsewhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCzsABL9a7Ztit2E4o8FAB --------- Co-authored-by: Claude <noreply@anthropic.com> * Fix reference-to-video review findings (#5710) * fix(storyboard): widen the board cast to its shots' entities on load (#5712) * feat(chat): add reference-to-video mode and agent capability (#5713) * fix(compute): honor the full WorkerSpec in the Vast.ai provider (#5714) * fix(release): bump electron-builder to 26.16.1 The macOS release job has failed on every run since 2026-09-06 in "Build Electron (macOS/Linux)": security set-key-partition-list ... -k *** <tmp>.keychain security: SecKeychainUnlock: The user name or passphrase you entered is not correct. electron-builder creates its own temp keychain with a random password, imports the p12, then passed the *p12* password to set-key-partition-list instead of the keychain's own unlock password. That mismatch was tolerated by macOS 26.5.2 (runner image 20260728) and is rejected by 26.6.2 (image 20260831) — the last green mac build ran on the older image, and nothing in this repo changed. 26.16.1 fixes it upstream ("use keychain password for set-key-partition-list"); no other version in the 26.15.x line carries the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MuPJ6656wv9MxF6UZGteg3 * docs: refresh project resource inventory (#5717) Co-authored-by: Sprite <noreply@sprites.dev> * [T-20260910-0002] Expose project ownership on legacy resource APIs (#5718) * feat: expose project ownership on legacy resource APIs * fix: declare workspace project ownership input * fix: satisfy workspace payload lint rule --------- Co-authored-by: Sprite <noreply@sprites.dev> * Fix project session focus restoration (#5708) * Persist separate project workspace sessions * Fix workspace tab test isolation * Fix active tab focus after project tab closes * Fix project session focus restoration --------- Co-authored-by: Sprite <noreply@sprites.dev> * Fix project-scoped chat media execution * [T-20260910-0003] Bind execution to originating project * Fix workflow chat project scope lookup * Bind media and imports to originating project * Fix project-scoped chat media execution * fix: remove duplicate project scope declarations * fix: preserve omitted workflow project scope on update * test: cover project-scoped workflow creation * fix: skip database lookup for inline workflow runs --------- Co-authored-by: Sprite <noreply@sprites.dev> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8894a91 commit a05d30e

25 files changed

Lines changed: 236 additions & 35 deletions

packages/agents/src/capabilities/workflows.specs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export const CREATE_WORKFLOW_SCHEMA: JsonSchema = {
5555
type: "string",
5656
enum: ["private", "public"],
5757
default: "private"
58+
},
59+
project_id: {
60+
type: "string",
61+
description: "Project to own the workflow. Defaults to this agent run's project."
5862
}
5963
},
6064
required: ["name", "graph"]

packages/agents/src/capabilities/workflows.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import {
117117
MAX_VERSION_LIMIT
118118
} from "./workflows.specs.js";
119119
import { isNumber, isObjectLike, isString } from "../utils/type-guards.js";
120+
import { resolveProjectId } from "./project-scope.js";
120121

121122
/** The run environment this run can execute a workflow in, or null. */
122123
function runEnvironmentOf(run: CapabilityRun) {
@@ -129,7 +130,8 @@ async function listUserWorkflows(
129130
): Promise<unknown> {
130131
const { Workflow } = await import("@nodetool-ai/models");
131132
const [workflows, next] = await Workflow.paginate(userIdOf(run.context), {
132-
limit
133+
limit,
134+
projectId: run.projectId
133135
});
134136
return lightWorkflowList({
135137
workflows: workflows.map((w) => workflowRecord(w)),
@@ -195,14 +197,24 @@ const getWorkflow: CapabilityExport = {
195197
const workflowId = String(params["workflow_id"]);
196198
const workflow = await Workflow.find(userIdOf(run.context), workflowId);
197199
if (!workflow) return { error: `Workflow ${workflowId} was not found.` };
200+
if (run.projectId !== undefined && workflow.project_id !== run.projectId) {
201+
return { error: `Workflow ${workflowId} was not found.` };
202+
}
198203
return workflowRecord(workflow);
199204
}
200205
};
201206

202207
const createWorkflow: CapabilityExport = {
203208
spec: createWorkflowSpec,
204209
impl: async (run, params) => {
205-
const { Workflow } = await import("@nodetool-ai/models");
210+
const { Project, Workflow } = await import("@nodetool-ai/models");
211+
const projectId = resolveProjectId(run, params);
212+
if (
213+
projectId !== "default" &&
214+
!(await Project.findOwned(userIdOf(run.context), projectId))
215+
) {
216+
return { error: "Project not found." };
217+
}
206218
// Declare before normalizing, so the handle is on the node the editor,
207219
// the validator and every later run read. Without a registry this is the
208220
// identity function and the graph is stored exactly as it arrived.
@@ -230,7 +242,8 @@ const createWorkflow: CapabilityExport = {
230242
tags: Array.isArray(params["tags"]) ? (params["tags"] as string[]) : [],
231243
access: params["access"] === "public" ? "public" : "private",
232244
graph: graph as WorkflowRow["graph"],
233-
run_mode: "workflow"
245+
run_mode: "workflow",
246+
project_id: projectId
234247
})) as WorkflowRow;
235248
return workflowRecord(created);
236249
}
@@ -251,6 +264,9 @@ async function findOwnedWorkflow(
251264
const { Workflow } = await import("@nodetool-ai/models");
252265
const wf = (await Workflow.get(id)) as WorkflowRow | null;
253266
if (!wf || wf.user_id !== userIdOf(run.context)) return null;
267+
if (run.projectId !== undefined && wf.project_id !== run.projectId) {
268+
return null;
269+
}
254270
return wf;
255271
}
256272

packages/agents/tests/mcp-tools.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import type { BaseProvider, ProcessingContext } from "@nodetool-ai/runtime";
33
import { ACTIVE_MODEL_CONTEXT_KEY } from "@nodetool-ai/runtime";
4-
import { Asset, Job, Workflow, initTestDb } from "@nodetool-ai/models";
4+
import { Asset, Job, Project, Workflow, initTestDb } from "@nodetool-ai/models";
55
import {
66
debugSessions,
77
InteractiveEscalationHandle
@@ -460,6 +460,21 @@ describe("create_workflow", () => {
460460
expect(stored?.access).toBe("private");
461461
});
462462

463+
it("persists the workflow under the requested project", async () => {
464+
const project = await Project.create<Project>({
465+
user_id: USER,
466+
name: "Project workflow"
467+
});
468+
const result = (await tool.process(ctx, {
469+
name: "Project WF",
470+
project_id: project.id,
471+
graph: { nodes: [], edges: [] }
472+
})) as Record<string, unknown>;
473+
474+
const stored = await Workflow.find(USER, String(result.id));
475+
expect(stored?.project_id).toBe(project.id);
476+
});
477+
463478
it("normalizes an agent-friendly keyed graph", async () => {
464479
const result = await tool.process(ctx, {
465480
name: "Daily News",

packages/cli/src/harness/capability-table.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const CAPABILITY_COVERAGE: readonly CapabilityCoverageEntry[] = [
4848
name: "create_workflow",
4949
module: "workflows",
5050
impl: "packages/agents/src/capabilities/workflows.ts",
51-
contract: "efc501df79a2",
51+
contract: "4c2f2cf8e5bb",
5252
selfcheck: "capability-suites",
5353
suites: [
5454
"packages/agents/tests/capabilities-dispatcher.test.ts",

packages/execution/src/service/workflow-run.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import { createLogger } from "@nodetool-ai/config";
1616
import { BoundedHandle, WorkflowRunner } from "@nodetool-ai/kernel";
17-
import { Job, Workflow, getSecret } from "@nodetool-ai/models";
17+
import { Job, Project, Workflow, getSecret } from "@nodetool-ai/models";
1818
import {
1919
hydrateGraphNodeFlags,
2020
propertyTypesForMetadata,
@@ -512,8 +512,12 @@ export async function runWorkflow(
512512
// (Comment/Group/Reroute) pruned, and edges typed from `edge_type` or the
513513
// legacy `type`.
514514
let runnableGraph: ReturnType<typeof normalizeGraph>;
515+
let workflowProjectId = options.projectId ?? null;
515516
if (options.graph) {
516517
runnableGraph = normalizeGraph(options.graph);
518+
if (workflowProjectId && workflowProjectId !== "default") {
519+
await Project.requireOwned(userId, workflowProjectId);
520+
}
517521
} else {
518522
const workflow = await Workflow.find(userId, workflowId);
519523
if (!workflow) {
@@ -528,6 +532,18 @@ export async function runWorkflow(
528532
detail: `Workflow run mode "${runMode}" is not supported by the standalone backend`
529533
};
530534
}
535+
workflowProjectId = workflow.project_id;
536+
if (
537+
options.projectId !== undefined &&
538+
options.projectId !== null &&
539+
options.projectId !== workflow.project_id
540+
) {
541+
return {
542+
kind: "error",
543+
status: 400,
544+
detail: "Workflow is owned by another project"
545+
};
546+
}
531547
runnableGraph = normalizeGraph(workflow.getGraph());
532548
}
533549

@@ -573,7 +589,8 @@ export async function runWorkflow(
573589
status: "running",
574590
name: options.jobName ?? "",
575591
params,
576-
graph: runnableGraph
592+
graph: runnableGraph,
593+
project_id: workflowProjectId ?? "default"
577594
})) as Job;
578595

579596
// Everything after the row exists must finalize it. Workspace resolution,
@@ -631,6 +648,7 @@ export async function runWorkflow(
631648
const executionContext = buildWorkspaceExecutionContext({
632649
jobId: job.id,
633650
workflowId,
651+
projectId: workflowProjectId,
634652
userId,
635653
workspace,
636654
storage: environment.storage ?? null,
@@ -656,7 +674,7 @@ export async function runWorkflow(
656674
attachRunCostLedger(executionContext, {
657675
userId,
658676
workflowId,
659-
projectId: options.projectId ?? null,
677+
projectId: workflowProjectId,
660678
documentId: options.documentId ?? null,
661679
nodeType: nodeTypeLookup(runnableGraph.nodes),
662680
resolveSecret: (key) => executionContext.getSecret(key)

packages/execution/src/service/workflow-workspace.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ export async function resolveWorkflowWorkspace(
104104
const workflow = await Workflow.find(userId, workflowId);
105105
if (workflow?.workspace_id) {
106106
const row = await WorkspaceRow.find(userId, workflow.workspace_id);
107-
if (row?.isAccessible()) {
107+
if (
108+
row?.isAccessible() &&
109+
row.project_id === workflow.project_id
110+
) {
108111
const workspace = workspaceFromRow(row);
109112
if (workspace) return workspace;
110113
}
@@ -172,6 +175,7 @@ export function usesCloudWorkspaces(): boolean {
172175
export function buildWorkspaceExecutionContext(opts: {
173176
jobId: string;
174177
workflowId?: string | null;
178+
projectId?: string | null;
175179
userId: string;
176180
workspace: Workspace | null;
177181
/** Overrides the per-user DB lookup (tests, a host with its own store). */
@@ -187,6 +191,7 @@ export function buildWorkspaceExecutionContext(opts: {
187191
const context = new ProcessingContext({
188192
jobId: opts.jobId,
189193
workflowId: opts.workflowId ?? null,
194+
projectId: opts.projectId ?? null,
190195
userId: opts.userId,
191196
workspace: opts.workspace,
192197
storage: opts.storage ?? null,

packages/models/src/project.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ export class Project extends DBModel {
184184
return row && row.user_id === userId ? row : null;
185185
}
186186

187+
/** Resolve a caller-supplied project without permitting cross-user writes. */
188+
static async requireOwned(userId: string, id: string): Promise<Project> {
189+
const project = await Project.findOwned(userId, id);
190+
if (!project) throw new Error("Project not found");
191+
return project;
192+
}
193+
187194
/**
188195
* The project whose agent thread this is. A chat turn knows its thread, so
189196
* this is how a run learns which project the documents it creates belong to.

packages/models/src/workflow.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type WorkflowUpdateFields = Partial<{
6363
path: string | null;
6464
run_mode: string | null;
6565
workspace_id: string | null;
66+
project_id: string;
6667
html_app: string | null;
6768
app_doc: Record<string, unknown> | null;
6869
receive_clipboard: boolean | null;
@@ -280,6 +281,7 @@ export class Workflow extends DBModel {
280281
conditions.push(eq(workflows.project_id, projectId));
281282
}
282283
if (access) conditions.push(eq(workflows.access, access));
284+
if (projectId) conditions.push(eq(workflows.project_id, projectId));
283285
if (runMode) {
284286
conditions.push(eq(workflows.run_mode, runMode));
285287
} else {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const createInput = z.object({
6464
job_id: z.string().nullable().optional(),
6565
timeline_id: z.string().nullable().optional(),
6666
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
67+
project_id: z.string().min(1).optional(),
6768
sketch_document_id: z.string().nullable().optional(),
6869
size: z.number().nullable().optional()
6970
});
@@ -97,7 +98,8 @@ export const createUploadInput = z.object({
9798
node_id: z.string().nullable().optional(),
9899
job_id: z.string().nullable().optional(),
99100
timeline_id: z.string().nullable().optional(),
100-
metadata: z.record(z.string(), z.unknown()).nullable().optional()
101+
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
102+
project_id: z.string().min(1).optional()
101103
});
102104
export type CreateUploadInput = z.infer<typeof createUploadInput>;
103105

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ export type GetInput = z.infer<typeof getInput>;
280280

281281
export const workflowBody = z.object({
282282
name: z.string().min(1),
283-
project_id: z.string().default("default"),
283+
project_id: z.string().optional(),
284284
tool_name: z.string().nullable().optional(),
285285
package_name: z.string().nullable().optional(),
286286
path: z.string().nullable().optional(),
@@ -307,6 +307,7 @@ export type WorkflowBody = z.infer<typeof workflowBody>;
307307
// ── create (POST /api/workflows) ─────────────────────────────────────────────
308308

309309
export const createInput = workflowBody.extend({
310+
project_id: z.string().default("default"),
310311
// Optional query params for example-seeding
311312
from_example_package: z.string().optional(),
312313
from_example_name: z.string().optional()

0 commit comments

Comments
 (0)