@fieldwangai/agentflow 0.1.101 → 0.1.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,6 +84,11 @@ import {
84
84
  import { runNodeScript } from "./pipeline-scripts.mjs";
85
85
  import { computeNextRunAt, readFlowSchedule, writeFlowSchedule } from "./schedule-config.mjs";
86
86
  import { listScheduleStatuses } from "./scheduler.mjs";
87
+ import {
88
+ mergeWorkspaceGraphs,
89
+ workspaceDesignRevision,
90
+ workspaceRuntimeRevision,
91
+ } from "./workspace-graph-merge.mjs";
87
92
  import {
88
93
  deleteMarketplaceFlowSnippetPackage,
89
94
  deleteMarketplaceNodePackage,
@@ -129,6 +134,20 @@ import {
129
134
  readWorkspaceRunLogEvents,
130
135
  } from "./workspace-run-logs.mjs";
131
136
  import { createWorkspaceRunController } from "./workspace-run-controller.mjs";
137
+ import {
138
+ acceptWorkspaceCollaborationInvite,
139
+ addWorkspaceCollaborationMember,
140
+ deleteWorkspaceCollaborationById,
141
+ deleteWorkspaceCollaborationForFlow,
142
+ ensureWorkspaceCollaboration,
143
+ getWorkspaceCollaborationByFlow,
144
+ getWorkspaceCollaborationForProject,
145
+ listWorkspaceCollaborationsForUser,
146
+ removeWorkspaceCollaborationMember,
147
+ updateWorkspaceCollaborationFlow,
148
+ workspaceCollaborationAccess,
149
+ workspaceCollaborationSummary,
150
+ } from "./workspace-collaboration.mjs";
132
151
 
133
152
  const MIME = {
134
153
  ".html": "text/html; charset=utf-8",
@@ -1668,6 +1687,13 @@ function workspaceGraphPath(workspaceRoot) {
1668
1687
  return path.join(path.resolve(workspaceRoot), WORKSPACE_GRAPH_FILENAME);
1669
1688
  }
1670
1689
 
1690
+ function writeWorkspaceGraphAtomic(graphPath, graph) {
1691
+ fs.mkdirSync(path.dirname(graphPath), { recursive: true });
1692
+ const tmp = `${graphPath}.${process.pid}.${Date.now()}.tmp`;
1693
+ fs.writeFileSync(tmp, JSON.stringify(graph, null, 2) + "\n", "utf-8");
1694
+ fs.renameSync(tmp, graphPath);
1695
+ }
1696
+
1671
1697
  function emptyWorkspaceGraph() {
1672
1698
  return { version: 1, instances: {}, edges: [], ui: { nodePositions: {} } };
1673
1699
  }
@@ -3079,11 +3105,93 @@ function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
3079
3105
  if (!isValidFlowSourceRead(flowSource)) {
3080
3106
  return { root: "", error: "Invalid flowSource" };
3081
3107
  }
3082
- const result = getPipelineFiles(workspaceRoot, flowId, flowSource, archived, opts);
3108
+ const workspaceId = String(params.workspaceId || "").trim();
3109
+ let collaboration = getWorkspaceCollaborationForProject({
3110
+ workspaceId,
3111
+ flowId,
3112
+ flowSource,
3113
+ archived,
3114
+ ownerId: workspaceId ? "" : opts.userId,
3115
+ });
3116
+ if (!collaboration && !workspaceId) {
3117
+ collaboration = listWorkspaceCollaborationsForUser(opts.userId).find((record) => (
3118
+ record.flowId === flowId
3119
+ && record.archived === archived
3120
+ && (record.projectSource || record.flowSource || "workspace") === flowSource
3121
+ )) || null;
3122
+ }
3123
+ if (!collaboration && flowSource === "workspace") {
3124
+ collaboration = getWorkspaceCollaborationByFlow(flowId, archived);
3125
+ }
3126
+ if (collaboration) {
3127
+ const access = workspaceCollaborationAccess(collaboration, opts.userId);
3128
+ if (!access.allowed) {
3129
+ return { root: "", error: "Workspace collaboration permission denied", status: 403 };
3130
+ }
3131
+ }
3132
+ const physicalFlowSource = collaboration?.projectSource || collaboration?.flowSource || flowSource;
3133
+ const physicalOpts = collaboration && physicalFlowSource === "user"
3134
+ ? { ...opts, userId: collaboration.ownerId }
3135
+ : opts;
3136
+ const result = getPipelineFiles(workspaceRoot, flowId, physicalFlowSource, archived, physicalOpts);
3083
3137
  if (result.error || !result.path) {
3084
3138
  return { root: "", error: result.error || "Pipeline workspace not found" };
3085
3139
  }
3086
- return { root: path.resolve(result.path), flowId, flowSource, archived };
3140
+ return {
3141
+ root: path.resolve(result.path),
3142
+ flowId,
3143
+ flowSource: physicalFlowSource,
3144
+ requestedFlowSource: flowSource,
3145
+ workspaceId: collaboration?.id || workspaceId,
3146
+ archived,
3147
+ collaboration,
3148
+ collaborationAccess: workspaceCollaborationAccess(collaboration, opts.userId),
3149
+ };
3150
+ }
3151
+
3152
+ function workspaceFlowCollaborationGuard(flowId, flowSource, archived, userCtx = {}, capability = "read") {
3153
+ if (flowSource !== "workspace") return null;
3154
+ const collaboration = getWorkspaceCollaborationByFlow(flowId, archived === true);
3155
+ if (!collaboration) return null;
3156
+ const access = workspaceCollaborationAccess(collaboration, userCtx.userId);
3157
+ if (!access.allowed) return { error: "Workspace collaboration permission denied", status: 403 };
3158
+ if (capability === "write" && !access.writable) {
3159
+ return { error: "Workspace collaboration edit permission denied", status: 403 };
3160
+ }
3161
+ if (capability === "run" && !access.runnable) {
3162
+ return { error: "Workspace collaboration run permission denied", status: 403 };
3163
+ }
3164
+ if (capability === "owner" && access.role !== "owner") {
3165
+ return { error: "Only the workspace owner can manage this workflow", status: 403 };
3166
+ }
3167
+ return null;
3168
+ }
3169
+
3170
+ function findWorkspaceShareUser(username) {
3171
+ const query = String(username || "").trim().toLowerCase();
3172
+ if (!query) return null;
3173
+ const users = readAuthUsers();
3174
+ for (const [userId, user] of Object.entries(users)) {
3175
+ const storedUsername = String(user?.username || userId).trim();
3176
+ if (String(userId).toLowerCase() === query || storedUsername.toLowerCase() === query) {
3177
+ return { userId: String(userId), username: storedUsername };
3178
+ }
3179
+ }
3180
+ return null;
3181
+ }
3182
+
3183
+ function workspaceCollaborationSummaryWithUsers(record, userId) {
3184
+ const summary = workspaceCollaborationSummary(record, userId);
3185
+ if (!summary) return null;
3186
+ const users = readAuthUsers();
3187
+ return {
3188
+ ...summary,
3189
+ ownerUsername: String(users[summary.ownerId]?.username || summary.ownerId),
3190
+ members: (summary.members || []).map((member) => ({
3191
+ ...member,
3192
+ username: String(users[member.userId]?.username || member.userId),
3193
+ })),
3194
+ };
3087
3195
  }
3088
3196
 
3089
3197
  function workspaceConversationsPath(scopedRoot) {
@@ -7069,7 +7177,8 @@ const flowEditorSyncSubscribers = new Map();
7069
7177
  const flowEditorSyncVersions = new Map();
7070
7178
 
7071
7179
  function flowEditorSyncKey(flowId, flowSource, flowArchived, userId = "") {
7072
- return `${String(userId || "")}\t${String(flowId)}\t${String(flowSource)}\t${flowArchived ? "1" : "0"}`;
7180
+ const actorScope = flowSource === "workspace" ? "" : String(userId || "");
7181
+ return `${actorScope}\t${String(flowId)}\t${String(flowSource)}\t${flowArchived ? "1" : "0"}`;
7073
7182
  }
7074
7183
 
7075
7184
  function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false, userId = "") {
@@ -7093,6 +7202,8 @@ function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false, userI
7093
7202
  const activeFlowRuns = new Map();
7094
7203
  /** 正在执行的 Workspace 临时 run(runId/sessionId → { controller, child, runNodeId, startedAt, plannedNodeIds }) */
7095
7204
  const activeWorkspaceRuns = new Map();
7205
+ const workspaceCollaborationSubscribers = new Map();
7206
+ const workspaceCollaborationSequences = new Map();
7096
7207
  const prdWorkflowSubscribers = new Map();
7097
7208
  const prdWorkflowIdempotency = new Map();
7098
7209
  const prdWorkflowActionLocks = new Map();
@@ -7105,8 +7216,9 @@ const WORKSPACE_IMPLEMENTATION_SUMMARY_ENABLED = false;
7105
7216
  const WORKSPACE_NODE_HISTORY_MAX_CHARS = 80000;
7106
7217
 
7107
7218
  function prdWorkflowKey(userCtx = {}, flowSource = "user", flowId = "", tapdId = "") {
7219
+ const actorScope = flowSource === "workspace" ? "shared" : String(userCtx?.userId || "");
7108
7220
  return [
7109
- String(userCtx?.userId || ""),
7221
+ actorScope,
7110
7222
  String(flowSource || "user"),
7111
7223
  String(flowId || ""),
7112
7224
  String(tapdId || ""),
@@ -9229,7 +9341,45 @@ function workspaceIntervalMinutesToCron(intervalMinutes) {
9229
9341
  }
9230
9342
 
9231
9343
  function workspaceRunKey(userCtx, flowSource, flowId) {
9232
- return `${userCtx?.userId || ""}:${flowSource || "user"}:${flowId}`;
9344
+ const source = flowSource || "user";
9345
+ const collaboration = listWorkspaceCollaborationsForUser(userCtx?.userId).find((record) => (
9346
+ record.flowId === flowId
9347
+ && record.archived !== true
9348
+ && (record.projectSource || record.flowSource || "workspace") === source
9349
+ ));
9350
+ if (collaboration?.id) return `shared:${collaboration.id}`;
9351
+ const actorScope = source === "workspace" ? "shared" : userCtx?.userId || "";
9352
+ return `${actorScope}:${source}:${flowId}`;
9353
+ }
9354
+
9355
+ function workspaceCollaborationEventKey(userCtx, flowSource, flowId, archived = false) {
9356
+ const source = flowSource || "user";
9357
+ const collaboration = listWorkspaceCollaborationsForUser(userCtx?.userId).find((record) => (
9358
+ record.flowId === flowId
9359
+ && record.archived === (archived === true)
9360
+ && (record.projectSource || record.flowSource || "workspace") === source
9361
+ ));
9362
+ if (collaboration?.id) return `shared:${collaboration.id}:${archived ? "1" : "0"}`;
9363
+ const actorScope = source === "workspace" ? "shared" : userCtx?.userId || "";
9364
+ return `${actorScope}:${source}:${flowId}:${archived ? "1" : "0"}`;
9365
+ }
9366
+
9367
+ function broadcastWorkspaceCollaborationEvent(userCtx, flowSource, flowId, archived, event = {}) {
9368
+ const key = workspaceCollaborationEventKey(userCtx, flowSource, flowId, archived);
9369
+ const seq = (workspaceCollaborationSequences.get(key) || 0) + 1;
9370
+ workspaceCollaborationSequences.set(key, seq);
9371
+ const payload = JSON.stringify({
9372
+ seq,
9373
+ at: new Date().toISOString(),
9374
+ ...event,
9375
+ });
9376
+ const subscribers = workspaceCollaborationSubscribers.get(key);
9377
+ if (!subscribers?.size) return seq;
9378
+ const chunk = `id: ${seq}\ndata: ${payload}\n\n`;
9379
+ for (const clientRes of subscribers) {
9380
+ try { clientRes.write(chunk); } catch (_) {}
9381
+ }
9382
+ return seq;
9233
9383
  }
9234
9384
 
9235
9385
  function workspaceRunEntryKey(scopeKey, runId) {
@@ -9353,9 +9503,19 @@ function workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId) {
9353
9503
  ].join(":");
9354
9504
  }
9355
9505
 
9506
+ function workspaceScheduleOwnerUserId(userCtx = {}, flowSource = "user", flowId = "") {
9507
+ const collaboration = listWorkspaceCollaborationsForUser(userCtx.userId).find((record) => (
9508
+ record.flowId === flowId
9509
+ && record.archived !== true
9510
+ && (record.projectSource || record.flowSource || "workspace") === flowSource
9511
+ ));
9512
+ if (collaboration?.ownerId) return String(collaboration.ownerId);
9513
+ return String(userCtx.userId || "");
9514
+ }
9515
+
9356
9516
  function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user", flowId = "") {
9357
9517
  const registry = readWorkspaceScheduleRegistry();
9358
- const userId = String(userCtx.userId || "");
9518
+ const userId = workspaceScheduleOwnerUserId(userCtx, flowSource, flowId);
9359
9519
  return Object.values(registry.schedules || {})
9360
9520
  .filter((entry) => (
9361
9521
  String(entry?.userId || "") === userId &&
@@ -9367,13 +9527,13 @@ function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user",
9367
9527
 
9368
9528
  function listWorkspaceScheduleStatuses(root, userCtx = {}) {
9369
9529
  const registry = readWorkspaceScheduleRegistry();
9370
- const userId = String(userCtx.userId || "");
9371
9530
  const flows = listFlowsJson(root, { ...userCtx, includeWorkspaceFlows: true })
9372
9531
  .filter((flow) => !flow.archived && !isReadonlyBuiltinFlowSource(flow.source || "user"));
9373
9532
  const rows = [];
9374
9533
  for (const flow of flows) {
9375
9534
  const flowId = String(flow.id || "");
9376
9535
  const flowSource = String(flow.source || "user");
9536
+ const scheduleUserId = workspaceScheduleOwnerUserId(userCtx, flowSource, flowId);
9377
9537
  const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource }, userCtx);
9378
9538
  if (scoped.error || !scoped.root) continue;
9379
9539
  let graph;
@@ -9386,7 +9546,7 @@ function listWorkspaceScheduleStatuses(root, userCtx = {}) {
9386
9546
  for (const [scheduleNodeId, instance] of Object.entries(instances)) {
9387
9547
  if (String(instance?.definitionId || "") !== "workspace_scheduled_run") continue;
9388
9548
  const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
9389
- const key = workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId);
9549
+ const key = workspaceScheduleKey(scheduleUserId, flowSource, flowId, scheduleNodeId);
9390
9550
  const current = registry.schedules?.[key] && typeof registry.schedules[key] === "object" ? registry.schedules[key] : {};
9391
9551
  const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
9392
9552
  const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
@@ -9475,15 +9635,25 @@ function setWorkspaceScheduleEnabled(root, payload = {}, authUser = {}, userCtx
9475
9635
  function syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx = {}) {
9476
9636
  const flowId = String(scoped?.flowId || "").trim();
9477
9637
  const flowSource = String(scoped?.flowSource || "user");
9478
- const userId = String(userCtx.userId || authUser?.userId || "");
9638
+ const userId = workspaceScheduleOwnerUserId(
9639
+ { userId: userCtx.userId || authUser?.userId || "" },
9640
+ flowSource,
9641
+ flowId,
9642
+ );
9479
9643
  if (!flowId || !userId) return [];
9480
9644
  const now = Date.now();
9481
9645
  const nowIso = new Date(now).toISOString();
9482
9646
  const registry = readWorkspaceScheduleRegistry();
9483
9647
  const schedules = { ...(registry.schedules || {}) };
9484
9648
  const prefix = `${userId}:${flowSource}:${flowId}:`;
9485
- for (const key of Object.keys(schedules)) {
9486
- if (key.startsWith(prefix)) delete schedules[key];
9649
+ for (const [key, entry] of Object.entries(schedules)) {
9650
+ const sameSharedFlow = (
9651
+ flowSource === "workspace"
9652
+ && scoped?.collaboration
9653
+ && String(entry?.flowSource || "") === flowSource
9654
+ && String(entry?.flowId || "") === flowId
9655
+ );
9656
+ if (sameSharedFlow || key.startsWith(prefix)) delete schedules[key];
9487
9657
  }
9488
9658
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
9489
9659
  for (const [scheduleNodeId, instance] of Object.entries(instances)) {
@@ -11155,7 +11325,49 @@ export function startUiServer({
11155
11325
  if (url.pathname === "/api/flows") {
11156
11326
  if (req.method === "GET") {
11157
11327
  try {
11158
- json(res, 200, listFlowsJson(root, userCtx));
11328
+ const flows = listFlowsJson(root, { ...userCtx, includeWorkspaceFlows: true })
11329
+ .filter((flow) => (
11330
+ !workspaceFlowCollaborationGuard(
11331
+ flow.id,
11332
+ flow.source || "user",
11333
+ flow.archived === true,
11334
+ userCtx,
11335
+ "read",
11336
+ )
11337
+ ))
11338
+ .map((flow) => {
11339
+ const source = flow.source || "user";
11340
+ const collaboration = source === "workspace"
11341
+ ? getWorkspaceCollaborationByFlow(flow.id, flow.archived === true)
11342
+ : getWorkspaceCollaborationForProject({
11343
+ flowId: flow.id,
11344
+ flowSource: source,
11345
+ archived: flow.archived === true,
11346
+ ownerId: userCtx.userId,
11347
+ });
11348
+ return collaboration
11349
+ ? { ...flow, collaboration: workspaceCollaborationSummaryWithUsers(collaboration, userCtx.userId) }
11350
+ : flow;
11351
+ });
11352
+ const existingCollaborationIds = new Set(flows.map((flow) => flow.collaboration?.id).filter(Boolean));
11353
+ for (const record of listWorkspaceCollaborationsForUser(userCtx.userId)) {
11354
+ const source = record.projectSource || record.flowSource || "workspace";
11355
+ if (source !== "user" || record.ownerId === userCtx.userId) continue;
11356
+ if (existingCollaborationIds.has(record.id)) continue;
11357
+ const ownerFlow = listFlowsJson(root, { userId: record.ownerId })
11358
+ .find((flow) => (
11359
+ flow.id === record.flowId
11360
+ && (flow.source || "user") === "user"
11361
+ && Boolean(flow.archived) === Boolean(record.archived)
11362
+ ));
11363
+ if (!ownerFlow) continue;
11364
+ flows.push({
11365
+ ...ownerFlow,
11366
+ collaboration: workspaceCollaborationSummaryWithUsers(record, userCtx.userId),
11367
+ });
11368
+ existingCollaborationIds.add(record.id);
11369
+ }
11370
+ json(res, 200, flows);
11159
11371
  } catch (e) {
11160
11372
  json(res, 500, { error: (e && e.message) || String(e) });
11161
11373
  }
@@ -11207,6 +11419,9 @@ export function startUiServer({
11207
11419
  json(res, 400, result);
11208
11420
  return;
11209
11421
  }
11422
+ if (targetSpace === "workspace") {
11423
+ ensureWorkspaceCollaboration({ flowId, userId: userCtx.userId });
11424
+ }
11210
11425
  json(res, 200, { success: true, flowId, flowSource: targetSpace });
11211
11426
  return;
11212
11427
  }
@@ -11287,6 +11502,9 @@ export function startUiServer({
11287
11502
  json(res, 400, { error: w.error });
11288
11503
  return;
11289
11504
  }
11505
+ if (targetSpace === "workspace") {
11506
+ ensureWorkspaceCollaboration({ flowId, userId: userCtx.userId });
11507
+ }
11290
11508
  json(res, 200, { success: true, flowId, flowSource: targetSpace });
11291
11509
  return;
11292
11510
  }
@@ -11391,11 +11609,188 @@ export function startUiServer({
11391
11609
  return;
11392
11610
  }
11393
11611
 
11612
+ if (req.method === "POST" && url.pathname === "/api/workspace/collaboration/accept") {
11613
+ try {
11614
+ const payload = JSON.parse(await readBody(req));
11615
+ const accepted = acceptWorkspaceCollaborationInvite({
11616
+ token: payload?.token,
11617
+ userId: userCtx.userId,
11618
+ });
11619
+ if (accepted.error) {
11620
+ json(res, accepted.status || 400, { error: accepted.error });
11621
+ return;
11622
+ }
11623
+ json(res, 200, { ok: true, workspace: accepted.workspace });
11624
+ } catch (e) {
11625
+ json(res, 400, { error: (e && e.message) || String(e) });
11626
+ }
11627
+ return;
11628
+ }
11629
+
11630
+ if (req.method === "POST" && url.pathname === "/api/workspace/collaboration/share") {
11631
+ try {
11632
+ const payload = JSON.parse(await readBody(req));
11633
+ const flowId = String(payload?.flowId || "").trim();
11634
+ const flowSource = String(payload?.flowSource || "user").trim();
11635
+ const archived = payload?.archived === true || payload?.flowArchived === true;
11636
+ if (flowSource !== "workspace" && flowSource !== "user") {
11637
+ json(res, 400, { error: "当前 Project 不支持协作分享" });
11638
+ return;
11639
+ }
11640
+ const scoped = resolveWorkspaceScopeRoot(root, {
11641
+ flowId,
11642
+ flowSource,
11643
+ workspaceId: payload.workspaceId || "",
11644
+ archived,
11645
+ }, userCtx);
11646
+ if (scoped.error) {
11647
+ json(res, scoped.status || 400, { error: scoped.error });
11648
+ return;
11649
+ }
11650
+ const ensured = ensureWorkspaceCollaboration({
11651
+ flowId,
11652
+ flowSource,
11653
+ archived,
11654
+ userId: userCtx.userId,
11655
+ });
11656
+ if (ensured.error) {
11657
+ json(res, ensured.status || 400, { error: ensured.error });
11658
+ return;
11659
+ }
11660
+ const targetUser = findWorkspaceShareUser(payload?.username || payload?.userId);
11661
+ if (!targetUser) {
11662
+ json(res, 404, { error: "未找到该用户名,请确认对方已经登录或注册 AgentFlow" });
11663
+ return;
11664
+ }
11665
+ const added = addWorkspaceCollaborationMember({
11666
+ workspaceId: ensured.workspace.id,
11667
+ userId: userCtx.userId,
11668
+ memberUserId: targetUser.userId,
11669
+ role: payload?.role,
11670
+ });
11671
+ if (added.error) {
11672
+ json(res, added.status || 400, { error: added.error });
11673
+ return;
11674
+ }
11675
+ const record = getWorkspaceCollaborationForProject({ workspaceId: ensured.workspace.id });
11676
+ broadcastWorkspaceCollaborationEvent(userCtx, flowSource, flowId, archived, {
11677
+ type: "member.added",
11678
+ actorId: userCtx.userId || "",
11679
+ memberUserId: targetUser.userId,
11680
+ });
11681
+ json(res, 200, {
11682
+ ok: true,
11683
+ workspace: workspaceCollaborationSummaryWithUsers(record, userCtx.userId),
11684
+ member: { userId: targetUser.userId, username: targetUser.username, role: "editor" },
11685
+ });
11686
+ } catch (e) {
11687
+ json(res, 400, { error: (e && e.message) || String(e) });
11688
+ }
11689
+ return;
11690
+ }
11691
+
11692
+ if (req.method === "DELETE" && url.pathname === "/api/workspace/collaboration/share") {
11693
+ try {
11694
+ const payload = JSON.parse(await readBody(req));
11695
+ const flowId = String(payload?.flowId || "").trim();
11696
+ const flowSource = String(payload?.flowSource || "user").trim();
11697
+ const archived = payload?.archived === true || payload?.flowArchived === true;
11698
+ if (!flowId || (flowSource !== "workspace" && flowSource !== "user")) {
11699
+ json(res, 400, { error: "Missing shared project" });
11700
+ return;
11701
+ }
11702
+ const record = getWorkspaceCollaborationForProject({
11703
+ workspaceId: payload.workspaceId || "",
11704
+ flowId,
11705
+ flowSource,
11706
+ archived,
11707
+ ownerId: userCtx.userId,
11708
+ }) || listWorkspaceCollaborationsForUser(userCtx.userId).find((item) => (
11709
+ item.flowId === flowId
11710
+ && (item.projectSource || item.flowSource || "workspace") === flowSource
11711
+ && item.archived === archived
11712
+ ));
11713
+ if (!record) {
11714
+ json(res, 404, { error: "Workspace collaboration not found" });
11715
+ return;
11716
+ }
11717
+ const requestedUser = String(payload?.username || payload?.memberUserId || "").trim();
11718
+ const targetUser = requestedUser ? findWorkspaceShareUser(requestedUser) : null;
11719
+ if (requestedUser && !targetUser) {
11720
+ json(res, 404, { error: "未找到该用户" });
11721
+ return;
11722
+ }
11723
+ const removed = removeWorkspaceCollaborationMember({
11724
+ workspaceId: record.id,
11725
+ userId: userCtx.userId,
11726
+ memberUserId: targetUser?.userId || userCtx.userId,
11727
+ });
11728
+ if (removed.error) {
11729
+ json(res, removed.status || 400, { error: removed.error });
11730
+ return;
11731
+ }
11732
+ broadcastWorkspaceCollaborationEvent(userCtx, flowSource, flowId, archived, {
11733
+ type: removed.left ? "member.left" : "member.removed",
11734
+ actorId: userCtx.userId || "",
11735
+ memberUserId: removed.removedUserId || "",
11736
+ });
11737
+ const nextRecord = getWorkspaceCollaborationForProject({ workspaceId: record.id });
11738
+ json(res, 200, {
11739
+ ok: true,
11740
+ left: removed.left === true,
11741
+ removedUserId: removed.removedUserId || "",
11742
+ workspace: removed.left ? null : workspaceCollaborationSummaryWithUsers(nextRecord, userCtx.userId),
11743
+ });
11744
+ } catch (e) {
11745
+ json(res, 400, { error: (e && e.message) || String(e) });
11746
+ }
11747
+ return;
11748
+ }
11749
+
11750
+ if (req.method === "GET" && url.pathname === "/api/workspace/events") {
11751
+ const scoped = resolveWorkspaceScopeRoot(root, {
11752
+ flowId: url.searchParams.get("flowId") || "",
11753
+ flowSource: url.searchParams.get("flowSource") || "user",
11754
+ workspaceId: url.searchParams.get("workspaceId") || "",
11755
+ archived: url.searchParams.get("archived") === "1",
11756
+ }, userCtx);
11757
+ if (scoped.error) {
11758
+ json(res, scoped.status || 400, { error: scoped.error });
11759
+ return;
11760
+ }
11761
+ const key = workspaceCollaborationEventKey(userCtx, scoped.flowSource, scoped.flowId, scoped.archived);
11762
+ let subscribers = workspaceCollaborationSubscribers.get(key);
11763
+ if (!subscribers) {
11764
+ subscribers = new Set();
11765
+ workspaceCollaborationSubscribers.set(key, subscribers);
11766
+ }
11767
+ res.writeHead(200, {
11768
+ "Content-Type": "text/event-stream; charset=utf-8",
11769
+ "Cache-Control": "no-cache, no-transform",
11770
+ Connection: "keep-alive",
11771
+ "X-Accel-Buffering": "no",
11772
+ });
11773
+ res.write(`event: connected\ndata: ${JSON.stringify({ seq: workspaceCollaborationSequences.get(key) || 0 })}\n\n`);
11774
+ subscribers.add(res);
11775
+ const heartbeat = setInterval(() => {
11776
+ try { res.write(`: heartbeat ${Date.now()}\n\n`); } catch (_) {}
11777
+ }, 15_000);
11778
+ const detach = () => {
11779
+ clearInterval(heartbeat);
11780
+ subscribers.delete(res);
11781
+ if (subscribers.size === 0) workspaceCollaborationSubscribers.delete(key);
11782
+ };
11783
+ req.on("close", detach);
11784
+ res.on("close", detach);
11785
+ return;
11786
+ }
11787
+
11394
11788
  if (req.method === "GET" && url.pathname === "/api/workspace/files") {
11395
11789
  try {
11396
11790
  const scoped = resolveWorkspaceScopeRoot(root, {
11397
11791
  flowId: url.searchParams.get("flowId") || "",
11398
11792
  flowSource: url.searchParams.get("flowSource") || "user",
11793
+ workspaceId: url.searchParams.get("workspaceId") || "",
11399
11794
  archived: url.searchParams.get("archived") === "1",
11400
11795
  }, userCtx);
11401
11796
  if (scoped.error) {
@@ -11414,6 +11809,7 @@ export function startUiServer({
11414
11809
  const scoped = resolveWorkspaceScopeRoot(root, {
11415
11810
  flowId: url.searchParams.get("flowId") || "",
11416
11811
  flowSource: url.searchParams.get("flowSource") || "user",
11812
+ workspaceId: url.searchParams.get("workspaceId") || "",
11417
11813
  archived: url.searchParams.get("archived") === "1",
11418
11814
  }, userCtx);
11419
11815
  const scopedRoot = scoped.error ? root : scoped.root;
@@ -11471,23 +11867,30 @@ export function startUiServer({
11471
11867
  const scoped = resolveWorkspaceScopeRoot(root, {
11472
11868
  flowId: url.searchParams.get("flowId") || "",
11473
11869
  flowSource: url.searchParams.get("flowSource") || "user",
11870
+ workspaceId: url.searchParams.get("workspaceId") || "",
11474
11871
  archived: url.searchParams.get("archived") === "1",
11475
11872
  }, userCtx);
11476
11873
  if (scoped.error) {
11477
- json(res, 400, { error: scoped.error });
11874
+ json(res, scoped.status || 400, { error: scoped.error });
11478
11875
  return;
11479
11876
  }
11480
11877
  const { path: graphPath, graph } = readWorkspaceGraph(scoped.root);
11481
11878
  const hydratedGraph = hydrateWorkspaceGraphForRuntime(root, scoped, graph, userCtx);
11879
+ const collaborationAccess = scoped.collaborationAccess || workspaceCollaborationAccess(null, userCtx.userId);
11482
11880
  json(res, 200, {
11483
11881
  ok: true,
11484
11882
  graph: hydratedGraph,
11883
+ revision: workspaceDesignRevision(hydratedGraph),
11884
+ designRevision: workspaceDesignRevision(hydratedGraph),
11885
+ runtimeRevision: workspaceRuntimeRevision(hydratedGraph),
11485
11886
  path: graphPath,
11486
11887
  root: scoped.root,
11487
11888
  flowId: scoped.flowId,
11488
11889
  flowSource: scoped.flowSource,
11489
11890
  archived: scoped.archived,
11490
- writable: !(scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)),
11891
+ writable: !(scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource))
11892
+ && collaborationAccess.writable !== false,
11893
+ collaboration: workspaceCollaborationSummaryWithUsers(scoped.collaboration, userCtx.userId),
11491
11894
  workspaceSchedules: listWorkspaceScheduleStatusesForFlow(userCtx, scoped.flowSource || "user", scoped.flowId || ""),
11492
11895
  });
11493
11896
  } catch (e) {
@@ -11508,6 +11911,7 @@ export function startUiServer({
11508
11911
  const scoped = resolveWorkspaceScopeRoot(root, {
11509
11912
  flowId: payload.flowId || "",
11510
11913
  flowSource: payload.flowSource || "user",
11914
+ workspaceId: payload.workspaceId || "",
11511
11915
  archived: payload.archived === true || payload.flowArchived === true,
11512
11916
  }, userCtx);
11513
11917
  if (scoped.error) {
@@ -11552,23 +11956,106 @@ export function startUiServer({
11552
11956
  const scoped = resolveWorkspaceScopeRoot(root, {
11553
11957
  flowId: payload.flowId || "",
11554
11958
  flowSource: payload.flowSource || "user",
11959
+ workspaceId: payload.workspaceId || "",
11555
11960
  archived: payload.archived === true || payload.flowArchived === true,
11556
11961
  }, userCtx);
11557
11962
  if (scoped.error) {
11558
- json(res, 400, { error: scoped.error });
11963
+ json(res, scoped.status || 400, { error: scoped.error });
11559
11964
  return;
11560
11965
  }
11561
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
11966
+ if (
11967
+ scoped.archived
11968
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
11969
+ || scoped.collaborationAccess?.writable === false
11970
+ ) {
11562
11971
  json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
11563
11972
  return;
11564
11973
  }
11565
11974
  const submittedGraph = hydrateWorkspaceGraphForRuntime(root, scoped, payload.graph || payload, userCtx);
11566
11975
  const graphPath = workspaceGraphPath(scoped.root);
11567
- const currentGraph = hydrateWorkspaceGraphForRuntime(root, scoped, readWorkspaceGraph(scoped.root).graph, userCtx);
11568
- const graph = mergeWorkspacePersistentNodeRefs(submittedGraph, currentGraph);
11569
- fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf-8");
11976
+ const currentStoredGraph = readWorkspaceGraph(scoped.root).graph;
11977
+ const currentGraph = hydrateWorkspaceGraphForRuntime(root, scoped, currentStoredGraph, userCtx);
11978
+ const currentRevision = workspaceDesignRevision(currentGraph);
11979
+ const baseRevision = String(payload.baseRevision || "").trim();
11980
+ if (scoped.collaboration && !baseRevision) {
11981
+ json(res, 428, {
11982
+ error: "Shared workspace save requires baseRevision",
11983
+ currentRevision,
11984
+ });
11985
+ return;
11986
+ }
11987
+ let nextGraph = submittedGraph;
11988
+ let merged = false;
11989
+ const baseGraph = payload.baseGraph;
11990
+ if (baseRevision && baseGraph && typeof baseGraph === "object") {
11991
+ const actualBaseRevision = workspaceDesignRevision(baseGraph);
11992
+ if (actualBaseRevision !== baseRevision) {
11993
+ json(res, 400, {
11994
+ error: "Workspace 合并基线与 baseRevision 不匹配",
11995
+ conflict: "invalid-merge-base",
11996
+ expectedRevision: baseRevision,
11997
+ actualBaseRevision,
11998
+ currentRevision,
11999
+ });
12000
+ return;
12001
+ }
12002
+ const mergeResult = mergeWorkspaceGraphs({
12003
+ baseGraph,
12004
+ currentGraph,
12005
+ incomingGraph: submittedGraph,
12006
+ });
12007
+ if (mergeResult.conflicts.length) {
12008
+ json(res, 409, {
12009
+ error: `Workspace 存在 ${mergeResult.conflicts.length} 处同字段冲突`,
12010
+ conflict: "field-conflict",
12011
+ expectedRevision: baseRevision,
12012
+ currentRevision,
12013
+ conflictPaths: mergeResult.conflicts.map((item) => item.path),
12014
+ conflictItems: mergeResult.conflicts,
12015
+ mergeGraph: mergeResult.graph,
12016
+ currentGraph,
12017
+ });
12018
+ return;
12019
+ }
12020
+ nextGraph = mergeResult.graph;
12021
+ merged = baseRevision !== currentRevision
12022
+ || workspaceRuntimeRevision(baseGraph) !== workspaceRuntimeRevision(currentGraph);
12023
+ } else if (baseRevision && baseRevision !== currentRevision) {
12024
+ json(res, 409, {
12025
+ error: "Workspace 已被其他成员更新,当前客户端缺少合并基线,请刷新后重试",
12026
+ conflict: "missing-merge-base",
12027
+ expectedRevision: baseRevision,
12028
+ currentRevision,
12029
+ });
12030
+ return;
12031
+ }
12032
+ const graph = mergeWorkspacePersistentNodeRefs(nextGraph, currentGraph);
12033
+ writeWorkspaceGraphAtomic(graphPath, graph);
12034
+ const revision = workspaceDesignRevision(graph);
12035
+ const runtimeRevision = workspaceRuntimeRevision(graph);
11570
12036
  const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx);
11571
- json(res, 200, { ok: true, path: graphPath, graph, workspaceSchedules });
12037
+ broadcastWorkspaceCollaborationEvent(
12038
+ userCtx,
12039
+ scoped.flowSource,
12040
+ scoped.flowId,
12041
+ scoped.archived,
12042
+ {
12043
+ type: "graph.committed",
12044
+ revision,
12045
+ actorId: userCtx.userId || "",
12046
+ clientId: String(payload.clientId || ""),
12047
+ },
12048
+ );
12049
+ json(res, 200, {
12050
+ ok: true,
12051
+ path: graphPath,
12052
+ graph,
12053
+ revision,
12054
+ designRevision: revision,
12055
+ runtimeRevision,
12056
+ merged,
12057
+ workspaceSchedules,
12058
+ });
11572
12059
  } catch (e) {
11573
12060
  json(res, 500, { error: (e && e.message) || String(e) });
11574
12061
  }
@@ -11604,6 +12091,7 @@ export function startUiServer({
11604
12091
  const scoped = resolveWorkspaceScopeRoot(root, {
11605
12092
  flowId: payload.flowId || "",
11606
12093
  flowSource: payload.flowSource || "user",
12094
+ workspaceId: payload.workspaceId || "",
11607
12095
  archived: payload.archived === true || payload.flowArchived === true,
11608
12096
  }, userCtx);
11609
12097
  if (scoped.error) {
@@ -11651,13 +12139,18 @@ export function startUiServer({
11651
12139
  const scoped = resolveWorkspaceScopeRoot(root, {
11652
12140
  flowId: payload.flowId || "",
11653
12141
  flowSource: payload.flowSource || "user",
12142
+ workspaceId: payload.workspaceId || "",
11654
12143
  archived: payload.archived === true || payload.flowArchived === true,
11655
12144
  }, userCtx);
11656
12145
  if (scoped.error) {
11657
12146
  json(res, 400, { error: scoped.error });
11658
12147
  return;
11659
12148
  }
11660
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12149
+ if (
12150
+ scoped.archived
12151
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
12152
+ || scoped.collaborationAccess?.writable === false
12153
+ ) {
11661
12154
  json(res, 400, { error: "Cannot optimize workspace graph for builtin or archived pipeline" });
11662
12155
  return;
11663
12156
  }
@@ -11673,12 +12166,20 @@ export function startUiServer({
11673
12166
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
11674
12167
  const touchedIds = new Set((result.optimized || []).map((item) => item.nodeId).filter(Boolean));
11675
12168
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
11676
- fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
12169
+ writeWorkspaceGraphAtomic(graphPath, mergedGraph);
12170
+ const revision = workspaceDesignRevision(mergedGraph);
11677
12171
  const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, mergedGraph, authUser, userCtx);
12172
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12173
+ type: "graph.committed",
12174
+ revision,
12175
+ actorId: userCtx.userId || "",
12176
+ clientId: String(payload.clientId || ""),
12177
+ });
11678
12178
  json(res, 200, {
11679
12179
  ok: true,
11680
12180
  path: graphPath,
11681
12181
  graph: mergedGraph,
12182
+ revision,
11682
12183
  order: result.order,
11683
12184
  optimized: result.optimized,
11684
12185
  skipped: result.skipped,
@@ -11702,13 +12203,18 @@ export function startUiServer({
11702
12203
  const scoped = resolveWorkspaceScopeRoot(root, {
11703
12204
  flowId: payload.flowId || "",
11704
12205
  flowSource: payload.flowSource || "user",
12206
+ workspaceId: payload.workspaceId || "",
11705
12207
  archived: payload.archived === true || payload.flowArchived === true,
11706
12208
  }, userCtx);
11707
12209
  if (scoped.error) {
11708
12210
  json(res, 400, { error: scoped.error });
11709
12211
  return;
11710
12212
  }
11711
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12213
+ if (
12214
+ scoped.archived
12215
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
12216
+ || scoped.collaborationAccess?.runnable === false
12217
+ ) {
11712
12218
  json(res, 400, { error: "Cannot run workspace graph for builtin or archived pipeline" });
11713
12219
  return;
11714
12220
  }
@@ -11718,7 +12224,27 @@ export function startUiServer({
11718
12224
  json(res, 400, { error: "Missing flowId" });
11719
12225
  return;
11720
12226
  }
11721
- const runtimeGraph = hydrateWorkspaceGraphForRuntime(root, scoped, payload.graph || {}, userCtx);
12227
+ const canonicalStoredGraph = readWorkspaceGraph(scoped.root).graph;
12228
+ const canonicalGraph = hydrateWorkspaceGraphForRuntime(
12229
+ root,
12230
+ scoped,
12231
+ canonicalStoredGraph,
12232
+ userCtx,
12233
+ );
12234
+ const canonicalRevision = workspaceDesignRevision(canonicalGraph);
12235
+ const expectedRevision = String(payload.expectedRevision || payload.baseRevision || "").trim();
12236
+ if (scoped.collaboration && expectedRevision && expectedRevision !== canonicalRevision) {
12237
+ json(res, 409, {
12238
+ error: "Workspace 已更新,请刷新后再运行",
12239
+ conflict: "revision-mismatch",
12240
+ expectedRevision,
12241
+ currentRevision: canonicalRevision,
12242
+ });
12243
+ return;
12244
+ }
12245
+ const runtimeGraph = scoped.collaboration
12246
+ ? canonicalGraph
12247
+ : hydrateWorkspaceGraphForRuntime(root, scoped, payload.graph || canonicalGraph, userCtx);
11722
12248
  const runNodeId = String(payload.runNodeId || "").trim();
11723
12249
  const plan = workspaceRunPlan(runtimeGraph, runNodeId, scoped.root);
11724
12250
  const plannedNodeIds = workspaceRunPlanNodeIds(runNodeId, plan);
@@ -11767,12 +12293,27 @@ export function startUiServer({
11767
12293
  });
11768
12294
  activeWorkspaceRuns.set(runKey, runEntry);
11769
12295
  appendWorkspaceRunStarted(runEntry);
12296
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12297
+ type: "run.started",
12298
+ runId,
12299
+ runNodeId,
12300
+ plannedNodeIds,
12301
+ revision: canonicalRevision,
12302
+ actorId: userCtx.userId || "",
12303
+ });
11770
12304
  const setActiveChild = (child, childOptions = {}) => {
11771
12305
  runControl.setChild(child, childOptions);
11772
12306
  };
11773
12307
  const clearActiveRun = (status = "finished") => {
11774
12308
  runControl.finish(status);
11775
12309
  if (activeWorkspaceRuns.get(runKey) === runEntry) activeWorkspaceRuns.delete(runKey);
12310
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12311
+ type: "run.finished",
12312
+ status,
12313
+ runId,
12314
+ runNodeId,
12315
+ actorId: userCtx.userId || "",
12316
+ });
11776
12317
  };
11777
12318
  if (wantsStream) {
11778
12319
  const graphPath = workspaceGraphPath(scoped.root);
@@ -11795,7 +12336,12 @@ export function startUiServer({
11795
12336
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
11796
12337
  const touchedIds = workspaceRunTouchedNodeIds(result);
11797
12338
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
11798
- fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
12339
+ writeWorkspaceGraphAtomic(graphPath, mergedGraph);
12340
+ const revision = workspaceDesignRevision(mergedGraph);
12341
+ const runtimeRevision = workspaceRuntimeRevision(mergedGraph);
12342
+ const collaborationEventType = revision === workspaceDesignRevision(currentGraph)
12343
+ ? "runtime.committed"
12344
+ : "graph.committed";
11799
12345
  const endedAt = Date.now();
11800
12346
  appendWorkspaceRunFinished({
11801
12347
  ...runEntry,
@@ -11807,7 +12353,14 @@ export function startUiServer({
11807
12353
  durationMs: endedAt - runEntry.startedAt,
11808
12354
  runNodeId,
11809
12355
  });
11810
- writeEvent({ type: "done", ok: true, path: graphPath, graph: mergedGraph, order: result.order, touchedNodeIds: Array.from(touchedIds), pauseNodeIds: result.pauseNodeIds || [] });
12356
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12357
+ type: collaborationEventType,
12358
+ revision,
12359
+ runtimeRevision,
12360
+ actorId: userCtx.userId || "",
12361
+ source: "run",
12362
+ });
12363
+ writeEvent({ type: "done", ok: true, path: graphPath, graph: mergedGraph, revision, runtimeRevision, order: result.order, touchedNodeIds: Array.from(touchedIds), pauseNodeIds: result.pauseNodeIds || [] });
11811
12364
  res.end();
11812
12365
  } catch (e) {
11813
12366
  const endedAt = Date.now();
@@ -11854,7 +12407,12 @@ export function startUiServer({
11854
12407
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
11855
12408
  const touchedIds = workspaceRunTouchedNodeIds(result);
11856
12409
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
11857
- fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
12410
+ writeWorkspaceGraphAtomic(graphPath, mergedGraph);
12411
+ const revision = workspaceDesignRevision(mergedGraph);
12412
+ const runtimeRevision = workspaceRuntimeRevision(mergedGraph);
12413
+ const collaborationEventType = revision === workspaceDesignRevision(currentGraph)
12414
+ ? "runtime.committed"
12415
+ : "graph.committed";
11858
12416
  const endedAt = Date.now();
11859
12417
  appendWorkspaceRunFinished({
11860
12418
  ...runEntry,
@@ -11866,7 +12424,14 @@ export function startUiServer({
11866
12424
  durationMs: endedAt - runEntry.startedAt,
11867
12425
  runNodeId,
11868
12426
  });
11869
- json(res, 200, { ok: true, path: graphPath, ...result, graph: mergedGraph, touchedNodeIds: Array.from(touchedIds) });
12427
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12428
+ type: collaborationEventType,
12429
+ revision,
12430
+ runtimeRevision,
12431
+ actorId: userCtx.userId || "",
12432
+ source: "run",
12433
+ });
12434
+ json(res, 200, { ok: true, path: graphPath, ...result, graph: mergedGraph, revision, runtimeRevision, touchedNodeIds: Array.from(touchedIds) });
11870
12435
  } catch (e) {
11871
12436
  const endedAt = Date.now();
11872
12437
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
@@ -11913,9 +12478,18 @@ export function startUiServer({
11913
12478
  const scheduleNodeId = url.searchParams.get("scheduleNodeId") || "";
11914
12479
  const runNodeId = url.searchParams.get("runNodeId") || "";
11915
12480
  const limit = Number(url.searchParams.get("limit") || 50);
12481
+ const scoped = resolveWorkspaceScopeRoot(root, {
12482
+ flowId,
12483
+ flowSource: flowSource || "user",
12484
+ archived: url.searchParams.get("archived") === "1",
12485
+ }, userCtx);
12486
+ if (scoped.error) {
12487
+ json(res, scoped.status || 400, { error: scoped.error });
12488
+ return;
12489
+ }
11916
12490
  json(res, 200, {
11917
12491
  runs: listWorkspaceRunLogs({
11918
- userId: userCtx.userId || "",
12492
+ userId: flowSource === "workspace" ? "" : userCtx.userId || "",
11919
12493
  flowId,
11920
12494
  flowSource,
11921
12495
  scheduleNodeId,
@@ -11936,7 +12510,23 @@ export function startUiServer({
11936
12510
  json(res, 400, { error: "Missing runId" });
11937
12511
  return;
11938
12512
  }
11939
- const run = listWorkspaceRunLogs({ userId: userCtx.userId || "", limit: 200 })
12513
+ const flowId = url.searchParams.get("flowId") || "";
12514
+ const flowSource = url.searchParams.get("flowSource") || "user";
12515
+ const scoped = resolveWorkspaceScopeRoot(root, {
12516
+ flowId,
12517
+ flowSource,
12518
+ archived: url.searchParams.get("archived") === "1",
12519
+ }, userCtx);
12520
+ if (scoped.error) {
12521
+ json(res, scoped.status || 400, { error: scoped.error });
12522
+ return;
12523
+ }
12524
+ const run = listWorkspaceRunLogs({
12525
+ userId: flowSource === "workspace" ? "" : userCtx.userId || "",
12526
+ flowId,
12527
+ flowSource,
12528
+ limit: 200,
12529
+ })
11940
12530
  .find((item) => String(item.runId || "") === runId);
11941
12531
  if (!run) {
11942
12532
  json(res, 404, { error: "Run log not found" });
@@ -11959,6 +12549,15 @@ export function startUiServer({
11959
12549
  return;
11960
12550
  }
11961
12551
  const flowSource = url.searchParams.get("flowSource") || "user";
12552
+ const scoped = resolveWorkspaceScopeRoot(root, {
12553
+ flowId,
12554
+ flowSource,
12555
+ archived: url.searchParams.get("archived") === "1",
12556
+ }, userCtx);
12557
+ if (scoped.error) {
12558
+ json(res, scoped.status || 400, { error: scoped.error });
12559
+ return;
12560
+ }
11962
12561
  const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
11963
12562
  const entries = workspaceActiveRunsForScope(scopeKey).map(([, entry]) => entry);
11964
12563
  const entry = entries[0] || null;
@@ -11996,7 +12595,21 @@ export function startUiServer({
11996
12595
  json(res, 400, { error: "Missing flowId" });
11997
12596
  return;
11998
12597
  }
11999
- const scopeKey = workspaceRunKey(userCtx, payload.flowSource || "user", flowId);
12598
+ const flowSource = payload.flowSource || "user";
12599
+ const scoped = resolveWorkspaceScopeRoot(root, {
12600
+ flowId,
12601
+ flowSource,
12602
+ archived: payload.archived === true || payload.flowArchived === true,
12603
+ }, userCtx);
12604
+ if (scoped.error) {
12605
+ json(res, scoped.status || 400, { error: scoped.error });
12606
+ return;
12607
+ }
12608
+ if (scoped.collaborationAccess?.runnable === false) {
12609
+ json(res, 403, { error: "Workspace collaboration run permission denied" });
12610
+ return;
12611
+ }
12612
+ const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
12000
12613
  const runId = String(payload.runId || payload.runSessionId || "").trim();
12001
12614
  const runNodeId = String(payload.runNodeId || "").trim();
12002
12615
  const entries = workspaceActiveRunsForScope(scopeKey);
@@ -12013,6 +12626,12 @@ export function startUiServer({
12013
12626
  runNodeId: entry.runNodeId || "",
12014
12627
  ts: Date.now(),
12015
12628
  });
12629
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12630
+ type: "run.stop-requested",
12631
+ runId: entry.runId,
12632
+ runNodeId: entry.runNodeId || "",
12633
+ actorId: userCtx.userId || "",
12634
+ });
12016
12635
  const result = await entry.runControl.stop();
12017
12636
  if (!result.stopped) {
12018
12637
  appendWorkspaceRunLogEvent(entry.runId, {
@@ -12064,7 +12683,13 @@ export function startUiServer({
12064
12683
  json(res, 413, { error: "File too large" });
12065
12684
  return;
12066
12685
  }
12067
- json(res, 200, { path: rel, content: fs.readFileSync(abs, "utf-8"), size: stat.size });
12686
+ const content = fs.readFileSync(abs, "utf-8");
12687
+ json(res, 200, {
12688
+ path: rel,
12689
+ content,
12690
+ size: stat.size,
12691
+ revision: crypto.createHash("sha256").update(content).digest("hex"),
12692
+ });
12068
12693
  } catch (e) {
12069
12694
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
12070
12695
  }
@@ -12185,7 +12810,11 @@ export function startUiServer({
12185
12810
  json(res, 400, { error: scoped.error });
12186
12811
  return;
12187
12812
  }
12188
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12813
+ if (
12814
+ scoped.archived
12815
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
12816
+ || scoped.collaborationAccess?.writable === false
12817
+ ) {
12189
12818
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12190
12819
  return;
12191
12820
  }
@@ -12194,9 +12823,39 @@ export function startUiServer({
12194
12823
  json(res, 400, { error: "Missing path" });
12195
12824
  return;
12196
12825
  }
12826
+ const content = String(payload.content ?? "");
12827
+ const baseRevision = String(payload.baseRevision || "").trim();
12828
+ if (baseRevision && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
12829
+ const currentContent = fs.readFileSync(abs, "utf-8");
12830
+ const currentRevision = crypto.createHash("sha256").update(currentContent).digest("hex");
12831
+ if (currentRevision !== baseRevision) {
12832
+ json(res, 409, {
12833
+ error: "文件已被其他成员更新,请处理冲突后重试",
12834
+ conflict: "revision-mismatch",
12835
+ currentRevision,
12836
+ });
12837
+ return;
12838
+ }
12839
+ }
12197
12840
  fs.mkdirSync(path.dirname(abs), { recursive: true });
12198
- fs.writeFileSync(abs, String(payload.content ?? ""), "utf-8");
12199
- json(res, 200, { ok: true, path: rel });
12841
+ const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`;
12842
+ fs.writeFileSync(tmp, content, "utf-8");
12843
+ fs.renameSync(tmp, abs);
12844
+ const revision = crypto.createHash("sha256").update(content).digest("hex");
12845
+ broadcastWorkspaceCollaborationEvent(
12846
+ userCtx,
12847
+ scoped.flowSource,
12848
+ scoped.flowId,
12849
+ scoped.archived,
12850
+ {
12851
+ type: "file.committed",
12852
+ path: rel,
12853
+ revision,
12854
+ actorId: userCtx.userId || "",
12855
+ clientId: String(payload.clientId || ""),
12856
+ },
12857
+ );
12858
+ json(res, 200, { ok: true, path: rel, revision });
12200
12859
  } catch (e) {
12201
12860
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
12202
12861
  }
@@ -12225,7 +12884,7 @@ export function startUiServer({
12225
12884
  json(res, 400, { error: scoped.error });
12226
12885
  return;
12227
12886
  }
12228
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12887
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12229
12888
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12230
12889
  return;
12231
12890
  }
@@ -12235,6 +12894,11 @@ export function startUiServer({
12235
12894
  const target = uniqueWorkspaceRelPath(scoped.root, targetRel);
12236
12895
  fs.mkdirSync(path.dirname(target.abs), { recursive: true });
12237
12896
  fs.writeFileSync(target.abs, parsed.file);
12897
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12898
+ type: "file.committed",
12899
+ path: target.rel,
12900
+ actorId: userCtx.userId || "",
12901
+ });
12238
12902
  json(res, 200, {
12239
12903
  ok: true,
12240
12904
  path: target.rel,
@@ -12265,7 +12929,7 @@ export function startUiServer({
12265
12929
  json(res, 400, { error: scoped.error });
12266
12930
  return;
12267
12931
  }
12268
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12932
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12269
12933
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12270
12934
  return;
12271
12935
  }
@@ -12275,6 +12939,11 @@ export function startUiServer({
12275
12939
  return;
12276
12940
  }
12277
12941
  fs.mkdirSync(abs, { recursive: true });
12942
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12943
+ type: "file.tree-changed",
12944
+ path: rel,
12945
+ actorId: userCtx.userId || "",
12946
+ });
12278
12947
  json(res, 200, { ok: true, path: rel });
12279
12948
  } catch (e) {
12280
12949
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
@@ -12300,7 +12969,7 @@ export function startUiServer({
12300
12969
  json(res, 400, { error: scoped.error });
12301
12970
  return;
12302
12971
  }
12303
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12972
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12304
12973
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12305
12974
  return;
12306
12975
  }
@@ -12314,6 +12983,12 @@ export function startUiServer({
12314
12983
  return;
12315
12984
  }
12316
12985
  fs.rmSync(abs, { recursive: true, force: true });
12986
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
12987
+ type: "file.tree-changed",
12988
+ path: rel,
12989
+ deleted: true,
12990
+ actorId: userCtx.userId || "",
12991
+ });
12317
12992
  json(res, 200, { ok: true, path: rel });
12318
12993
  } catch (e) {
12319
12994
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
@@ -12350,7 +13025,7 @@ export function startUiServer({
12350
13025
  json(res, 200, { ok: true, conversations: readWorkspaceConversations(scoped.root) });
12351
13026
  return;
12352
13027
  }
12353
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
13028
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12354
13029
  json(res, 400, { error: "Cannot write conversations for builtin or archived pipeline workspace" });
12355
13030
  return;
12356
13031
  }
@@ -12385,6 +13060,10 @@ export function startUiServer({
12385
13060
  json(res, 400, { error: scoped.error });
12386
13061
  return;
12387
13062
  }
13063
+ if (scoped.collaborationAccess?.writable === false) {
13064
+ json(res, 403, { error: "Workspace collaboration edit permission denied" });
13065
+ return;
13066
+ }
12388
13067
  const selectedSkillKeys = Array.isArray(payload?.selectedSkills)
12389
13068
  ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean)
12390
13069
  : [];
@@ -12471,10 +13150,14 @@ export function startUiServer({
12471
13150
  json(res, 400, { error: scoped.error });
12472
13151
  return;
12473
13152
  }
13153
+ if (scoped.collaborationAccess?.writable === false) {
13154
+ json(res, 403, { error: "Workspace collaboration edit permission denied" });
13155
+ return;
13156
+ }
12474
13157
  const targetFilePath = String(payload?.targetFilePath || "").trim();
12475
13158
  let targetFile = null;
12476
13159
  if (targetFilePath) {
12477
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
13160
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12478
13161
  json(res, 400, { error: "Cannot edit builtin or archived pipeline workspace" });
12479
13162
  return;
12480
13163
  }
@@ -13335,12 +14018,26 @@ export function startUiServer({
13335
14018
  return;
13336
14019
  }
13337
14020
  const flowArchived = url.searchParams.get("archived") === "1";
14021
+ const collaborationDenied = workspaceFlowCollaborationGuard(
14022
+ flowId,
14023
+ flowSource,
14024
+ flowArchived,
14025
+ userCtx,
14026
+ "read",
14027
+ );
14028
+ if (collaborationDenied) {
14029
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14030
+ return;
14031
+ }
13338
14032
  const result = readFlowJson(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
13339
14033
  if (result.error) {
13340
14034
  json(res, 404, result);
13341
14035
  return;
13342
14036
  }
13343
- json(res, 200, result);
14037
+ json(res, 200, {
14038
+ ...result,
14039
+ revision: crypto.createHash("sha256").update(String(result.flowYaml || "")).digest("hex").slice(0, 24),
14040
+ });
13344
14041
  return;
13345
14042
  }
13346
14043
 
@@ -13519,12 +14216,53 @@ finishedAt: "${new Date().toISOString()}"
13519
14216
  return;
13520
14217
  }
13521
14218
  const flowArchived = Boolean(payload.flowArchived);
14219
+ const collaborationDenied = workspaceFlowCollaborationGuard(
14220
+ flowId,
14221
+ flowSource,
14222
+ flowArchived,
14223
+ userCtx,
14224
+ "write",
14225
+ );
14226
+ if (collaborationDenied) {
14227
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14228
+ return;
14229
+ }
14230
+ if (flowSource === "workspace" && getWorkspaceCollaborationByFlow(flowId, flowArchived)) {
14231
+ const current = readFlowJson(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
14232
+ if (current.error) {
14233
+ json(res, 404, current);
14234
+ return;
14235
+ }
14236
+ const currentRevision = crypto.createHash("sha256")
14237
+ .update(String(current.flowYaml || ""))
14238
+ .digest("hex")
14239
+ .slice(0, 24);
14240
+ const baseRevision = String(payload.baseRevision || "").trim();
14241
+ if (!baseRevision) {
14242
+ json(res, 428, { error: "Shared workspace save requires baseRevision", currentRevision });
14243
+ return;
14244
+ }
14245
+ if (baseRevision !== currentRevision) {
14246
+ json(res, 409, {
14247
+ error: "Workflow 已被其他成员更新,请处理冲突后重试",
14248
+ conflict: "revision-mismatch",
14249
+ expectedRevision: baseRevision,
14250
+ currentRevision,
14251
+ });
14252
+ return;
14253
+ }
14254
+ }
13522
14255
  const result = writeFlowYaml(root, flowId, flowSource, flowYaml, { archived: flowArchived, ...userCtx });
13523
14256
  if (!result.success) {
13524
14257
  json(res, 400, result);
13525
14258
  return;
13526
14259
  }
13527
- json(res, 200, { success: true });
14260
+ broadcastFlowEditorSync(flowId, flowSource, flowArchived, userCtx.userId);
14261
+ const saved = readFlowJson(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
14262
+ json(res, 200, {
14263
+ success: true,
14264
+ revision: crypto.createHash("sha256").update(String(saved.flowYaml || "")).digest("hex").slice(0, 24),
14265
+ });
13528
14266
  return;
13529
14267
  }
13530
14268
 
@@ -13547,6 +14285,17 @@ finishedAt: "${new Date().toISOString()}"
13547
14285
  return;
13548
14286
  }
13549
14287
  const flowArchived = Boolean(payload.flowArchived);
14288
+ const collaborationDenied = workspaceFlowCollaborationGuard(
14289
+ flowId,
14290
+ flowSource,
14291
+ flowArchived,
14292
+ userCtx,
14293
+ "read",
14294
+ );
14295
+ if (collaborationDenied) {
14296
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14297
+ return;
14298
+ }
13550
14299
  broadcastFlowEditorSync(flowId, flowSource, flowArchived, userCtx.userId);
13551
14300
  json(res, 200, { ok: true });
13552
14301
  return;
@@ -13564,6 +14313,17 @@ finishedAt: "${new Date().toISOString()}"
13564
14313
  return;
13565
14314
  }
13566
14315
  const flowArchived = url.searchParams.get("archived") === "1";
14316
+ const collaborationDenied = workspaceFlowCollaborationGuard(
14317
+ flowId,
14318
+ flowSource,
14319
+ flowArchived,
14320
+ userCtx,
14321
+ "read",
14322
+ );
14323
+ if (collaborationDenied) {
14324
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14325
+ return;
14326
+ }
13567
14327
  const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userCtx.userId);
13568
14328
  let set = flowEditorSyncSubscribers.get(key);
13569
14329
  if (!set) {
@@ -13598,6 +14358,17 @@ finishedAt: "${new Date().toISOString()}"
13598
14358
  return;
13599
14359
  }
13600
14360
  const flowArchived = url.searchParams.get("archived") === "1";
14361
+ const collaborationDenied = workspaceFlowCollaborationGuard(
14362
+ flowId,
14363
+ flowSource,
14364
+ flowArchived,
14365
+ userCtx,
14366
+ "read",
14367
+ );
14368
+ if (collaborationDenied) {
14369
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14370
+ return;
14371
+ }
13601
14372
  const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userCtx.userId);
13602
14373
  const serverVer = flowEditorSyncVersions.get(key) ?? 0;
13603
14374
  const clientVer = parseInt(url.searchParams.get("v") ?? "0", 10) || 0;
@@ -13628,11 +14399,21 @@ finishedAt: "${new Date().toISOString()}"
13628
14399
  json(res, 400, { error: "Invalid toSource" });
13629
14400
  return;
13630
14401
  }
14402
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, fromSource, false, userCtx, "owner");
14403
+ if (collaborationDenied) {
14404
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14405
+ return;
14406
+ }
13631
14407
  const result = moveFlowDirectory(root, flowId.trim(), fromSource, toSource, userCtx);
13632
14408
  if (!result.success) {
13633
14409
  json(res, 400, { error: result.error || "Move failed" });
13634
14410
  return;
13635
14411
  }
14412
+ if (fromSource === "workspace" && toSource !== "workspace") {
14413
+ deleteWorkspaceCollaborationForFlow(flowId.trim(), false);
14414
+ } else if (fromSource !== "workspace" && toSource === "workspace") {
14415
+ ensureWorkspaceCollaboration({ flowId: flowId.trim(), userId: userCtx.userId });
14416
+ }
13636
14417
  json(res, 200, { success: true, flowId: flowId.trim(), flowSource: result.flowSource });
13637
14418
  return;
13638
14419
  }
@@ -13656,6 +14437,11 @@ finishedAt: "${new Date().toISOString()}"
13656
14437
  json(res, 400, { error: "仅支持重命名用户目录或工作区流水线" });
13657
14438
  return;
13658
14439
  }
14440
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, flowSource, false, userCtx, "owner");
14441
+ if (collaborationDenied) {
14442
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14443
+ return;
14444
+ }
13659
14445
  const validation = validateUserPipelineId(newFlowId);
13660
14446
  if (!validation.ok) {
13661
14447
  json(res, 400, { error: validation.error });
@@ -13678,6 +14464,12 @@ finishedAt: "${new Date().toISOString()}"
13678
14464
  }
13679
14465
  try {
13680
14466
  fs.renameSync(fromDir, toDir);
14467
+ updateWorkspaceCollaborationFlow({
14468
+ previousFlowId: flowId,
14469
+ flowSource,
14470
+ ownerId: userCtx.userId,
14471
+ flowId: validation.flowId,
14472
+ });
13681
14473
  json(res, 200, { success: true, flowId: validation.flowId, flowSource });
13682
14474
  } catch (e) {
13683
14475
  json(res, 500, { error: (e && e.message) || String(e) });
@@ -13708,11 +14500,24 @@ finishedAt: "${new Date().toISOString()}"
13708
14500
  json(res, 400, { error: "仅支持归档用户目录或工作区流水线" });
13709
14501
  return;
13710
14502
  }
14503
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, flowSource, false, userCtx, "owner");
14504
+ if (collaborationDenied) {
14505
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14506
+ return;
14507
+ }
13711
14508
  const result = archiveFlowPipeline(root, flowId, flowSource, userCtx);
13712
14509
  if (!result.success) {
13713
14510
  json(res, 400, { error: result.error || "归档失败" });
13714
14511
  return;
13715
14512
  }
14513
+ updateWorkspaceCollaborationFlow({
14514
+ previousFlowId: flowId,
14515
+ previousArchived: false,
14516
+ flowSource,
14517
+ ownerId: userCtx.userId,
14518
+ flowId,
14519
+ archived: true,
14520
+ });
13716
14521
  json(res, 200, { success: true, flowId, flowSource, archived: true });
13717
14522
  return;
13718
14523
  }
@@ -13741,11 +14546,53 @@ finishedAt: "${new Date().toISOString()}"
13741
14546
  json(res, 400, { error: "仅支持删除用户目录或工作区流水线" });
13742
14547
  return;
13743
14548
  }
14549
+ const collaboration = getWorkspaceCollaborationForProject({
14550
+ workspaceId: payload.workspaceId || "",
14551
+ flowId,
14552
+ flowSource,
14553
+ archived: flowArchived,
14554
+ ownerId: userCtx.userId,
14555
+ }) || listWorkspaceCollaborationsForUser(userCtx.userId).find((record) => (
14556
+ record.flowId === flowId
14557
+ && record.archived === flowArchived
14558
+ && (record.projectSource || record.flowSource || "workspace") === flowSource
14559
+ )) || null;
14560
+ const collaborationAccess = workspaceCollaborationAccess(collaboration, userCtx.userId);
14561
+ if (collaboration && collaborationAccess.allowed && collaborationAccess.role !== "owner") {
14562
+ const left = removeWorkspaceCollaborationMember({
14563
+ workspaceId: collaboration.id,
14564
+ userId: userCtx.userId,
14565
+ });
14566
+ if (left.error) {
14567
+ json(res, left.status || 400, { error: left.error });
14568
+ return;
14569
+ }
14570
+ broadcastWorkspaceCollaborationEvent(userCtx, flowSource, flowId, flowArchived, {
14571
+ type: "member.left",
14572
+ actorId: userCtx.userId || "",
14573
+ memberUserId: userCtx.userId || "",
14574
+ });
14575
+ json(res, 200, {
14576
+ success: true,
14577
+ flowId,
14578
+ flowSource,
14579
+ deleted: false,
14580
+ left: true,
14581
+ });
14582
+ return;
14583
+ }
14584
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, flowSource, flowArchived, userCtx, "owner");
14585
+ if (collaborationDenied) {
14586
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14587
+ return;
14588
+ }
13744
14589
  const result = deleteFlowPipeline(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
13745
14590
  if (!result.success) {
13746
14591
  json(res, 400, { error: result.error || "删除失败" });
13747
14592
  return;
13748
14593
  }
14594
+ if (collaboration?.id) deleteWorkspaceCollaborationById(collaboration.id);
14595
+ else deleteWorkspaceCollaborationForFlow(flowId, flowArchived);
13749
14596
  json(res, 200, { success: true, flowId, flowSource, deleted: true });
13750
14597
  return;
13751
14598
  }
@@ -14008,8 +14855,20 @@ finishedAt: "${new Date().toISOString()}"
14008
14855
  json(res, 400, { error: "Missing flowId" });
14009
14856
  return;
14010
14857
  }
14858
+ const flowSource = payload.flowSource || "user";
14859
+ const collaborationDenied = workspaceFlowCollaborationGuard(
14860
+ flowId,
14861
+ flowSource,
14862
+ false,
14863
+ userCtx,
14864
+ "run",
14865
+ );
14866
+ if (collaborationDenied) {
14867
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14868
+ return;
14869
+ }
14011
14870
  const runUuid = typeof payload.uuid === "string" ? payload.uuid.trim() : "";
14012
- const runKey = `${userCtx.userId || ""}:${payload.flowSource || "user"}:${flowId}`;
14871
+ const runKey = workspaceRunKey(userCtx, flowSource, flowId);
14013
14872
  if (activeFlowRuns.has(runKey)) {
14014
14873
  json(res, 409, { error: "该流水线已在运行中" });
14015
14874
  return;
@@ -14088,7 +14947,7 @@ finishedAt: "${new Date().toISOString()}"
14088
14947
  }
14089
14948
 
14090
14949
  /** @type {{ child: import("child_process").ChildProcess, runUuid: string | null }} */
14091
- const runEntry = { child, runUuid: runUuid || null };
14950
+ const runEntry = { child, runUuid: runUuid || null, userId: userCtx.userId || "" };
14092
14951
  activeFlowRuns.set(runKey, runEntry);
14093
14952
  log.debug(`[ui] flow/run: spawned pid=${child.pid} flowId=${flowId}${runUuid ? ` uuid=${runUuid}` : ""}`);
14094
14953
 
@@ -14162,7 +15021,19 @@ finishedAt: "${new Date().toISOString()}"
14162
15021
  json(res, 400, { error: "Missing flowId" });
14163
15022
  return;
14164
15023
  }
14165
- const runKey = `${userCtx.userId || ""}:${payload.flowSource || "user"}:${flowId}`;
15024
+ const flowSource = payload.flowSource || "user";
15025
+ const collaborationDenied = workspaceFlowCollaborationGuard(
15026
+ flowId,
15027
+ flowSource,
15028
+ false,
15029
+ userCtx,
15030
+ "run",
15031
+ );
15032
+ if (collaborationDenied) {
15033
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
15034
+ return;
15035
+ }
15036
+ const runKey = workspaceRunKey(userCtx, flowSource, flowId);
14166
15037
  const entry = activeFlowRuns.get(runKey);
14167
15038
  if (!entry || !entry.child) {
14168
15039
  json(res, 404, { error: "该流水线未在运行" });
@@ -14184,7 +15055,7 @@ finishedAt: "${new Date().toISOString()}"
14184
15055
  activeFlowRuns.delete(runKey);
14185
15056
  if (uuid) {
14186
15057
  try {
14187
- const runDir = getRunDir(root, flowId, uuid, userCtx);
15058
+ const runDir = getRunDir(root, flowId, uuid, { userId: entry.userId || userCtx.userId || "" });
14188
15059
  fs.mkdirSync(runDir, { recursive: true });
14189
15060
  fs.writeFileSync(
14190
15061
  path.join(runDir, RUN_INTERRUPTED_FILENAME),