@fieldwangai/agentflow 0.1.100 → 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 || ""),
@@ -7421,9 +7533,7 @@ function prdWorkflowReviewRenderTable(lines) {
7421
7533
  ].join("");
7422
7534
  }
7423
7535
 
7424
- function prdWorkflowReviewMarkdownToHtml(markdown) {
7425
- const { frontmatter, body } = prdWorkflowReviewSplitFrontmatter(markdown);
7426
- const lines = String(body || "").replace(/\r\n/g, "\n").split("\n");
7536
+ function prdWorkflowReviewMarkdownLinesToHtml(lines) {
7427
7537
  const html = [];
7428
7538
  let paragraph = [];
7429
7539
  let list = [];
@@ -7500,11 +7610,147 @@ function prdWorkflowReviewMarkdownToHtml(markdown) {
7500
7610
  }
7501
7611
  if (code) html.push(`<pre><code>${htmlEscapeAttribute(prdWorkflowReviewNormalizeText(code.lines.join("\n")))}</code></pre>`);
7502
7612
  flushBlocks();
7503
- return [prdWorkflowReviewRenderFrontmatter(frontmatter), ...html].filter(Boolean).join("\n");
7613
+ return html.join("\n");
7614
+ }
7615
+
7616
+ function prdWorkflowReviewActionLine(line) {
7617
+ const match = String(line || "").match(/^\s*[-*]\s+(?:\[( |x|X)\]\s+)?(A\d+|Action\s*\d+)(?=\s|[((::.-]|$)(.*)$/i);
7618
+ if (!match) return null;
7619
+ return {
7620
+ checked: match[1] ? match[1].toLowerCase() === "x" : null,
7621
+ label: match[2].replace(/\s+/g, " ").toUpperCase(),
7622
+ title: String(match[3] || "").replace(/^\s*[-::]\s*/, "").trim(),
7623
+ };
7624
+ }
7625
+
7626
+ function prdWorkflowReviewIsActionsHeading(line) {
7627
+ const heading = String(line || "").trim().match(/^(#{1,6})\s+(.+)$/);
7628
+ if (!heading) return null;
7629
+ const title = heading[2].replace(/[*_`]/g, "").trim();
7630
+ if (!/(?:\bTODO\s+Actions?\b|\bActions?\b|待办(?:事项|行动)?|行动项)/i.test(title)) return null;
7631
+ return { level: heading[1].length };
7504
7632
  }
7505
7633
 
7506
- function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7507
- const escapedTitle = htmlEscapeAttribute(title || "PRD Workflow Review");
7634
+ function prdWorkflowReviewRenderActionSection(lines, sectionIndex) {
7635
+ const actionStarts = [];
7636
+ lines.forEach((line, index) => {
7637
+ const action = prdWorkflowReviewActionLine(line);
7638
+ if (action) actionStarts.push({ index, action });
7639
+ });
7640
+ if (!actionStarts.length) return prdWorkflowReviewMarkdownLinesToHtml(lines);
7641
+
7642
+ const ids = new Map();
7643
+ const actions = actionStarts.map(({ index, action }, actionIndex) => {
7644
+ const nextStart = actionStarts[actionIndex + 1]?.index ?? lines.length;
7645
+ let bodyStart = index + 1;
7646
+ const titleParts = [action.title].filter(Boolean);
7647
+ while (bodyStart < nextStart) {
7648
+ const continuation = String(lines[bodyStart] || "");
7649
+ if (!/^\s{2,}\S/.test(continuation) || /^\s*[-*]\s+/.test(continuation) || /^\s*```/.test(continuation)) break;
7650
+ titleParts.push(continuation.trim());
7651
+ bodyStart += 1;
7652
+ }
7653
+ const labelId = action.label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || actionIndex + 1;
7654
+ const baseId = `action-${labelId}${sectionIndex ? `-${sectionIndex + 1}` : ""}`;
7655
+ const duplicate = ids.get(baseId) || 0;
7656
+ ids.set(baseId, duplicate + 1);
7657
+ return {
7658
+ ...action,
7659
+ id: duplicate ? `${baseId}-${sectionIndex + 1}-${duplicate + 1}` : baseId,
7660
+ title: titleParts.join(" ") || action.label,
7661
+ body: lines.slice(bodyStart, nextStart),
7662
+ };
7663
+ });
7664
+
7665
+ const intro = prdWorkflowReviewMarkdownLinesToHtml(lines.slice(0, actionStarts[0].index));
7666
+ const navigation = actions.length > 1
7667
+ ? `<nav class="action-index" aria-label="Action 快速跳转"><span class="action-index__label">快速跳转</span>${actions.map((action) => `<a href="#${htmlEscapeAttribute(action.id)}">${htmlEscapeAttribute(action.label)}</a>`).join("")}</nav>`
7668
+ : "";
7669
+ const cards = actions.map((action) => {
7670
+ const status = action.checked === null
7671
+ ? ""
7672
+ : `<span class="action-card__status${action.checked ? " is-complete" : ""}">${action.checked ? "已完成" : "待完成"}</span>`;
7673
+ const body = prdWorkflowReviewMarkdownLinesToHtml(action.body);
7674
+ return `<section class="action-card${action.checked ? " is-complete" : ""}" id="${htmlEscapeAttribute(action.id)}">
7675
+ <div class="action-card__header">
7676
+ <span class="action-card__index">${htmlEscapeAttribute(action.label)}</span>
7677
+ <h3 class="action-card__title">${prdWorkflowReviewInlineMarkdown(action.title)}</h3>
7678
+ ${status}
7679
+ </div>
7680
+ <div class="action-card__body">${body}</div>
7681
+ </section>`;
7682
+ }).join("\n");
7683
+ return [intro, navigation, cards].filter(Boolean).join("\n");
7684
+ }
7685
+
7686
+ function prdWorkflowReviewBodyToHtml(body) {
7687
+ const lines = String(body || "").replace(/\r\n/g, "\n").split("\n");
7688
+ const html = [];
7689
+ let cursor = 0;
7690
+ let sectionIndex = 0;
7691
+ while (cursor < lines.length) {
7692
+ const actionsHeading = prdWorkflowReviewIsActionsHeading(lines[cursor]);
7693
+ if (!actionsHeading) {
7694
+ const nextHeading = lines.findIndex((line, index) => index > cursor && prdWorkflowReviewIsActionsHeading(line));
7695
+ const end = nextHeading >= 0 ? nextHeading : lines.length;
7696
+ html.push(prdWorkflowReviewMarkdownLinesToHtml(lines.slice(cursor, end)));
7697
+ cursor = end;
7698
+ continue;
7699
+ }
7700
+ let end = cursor + 1;
7701
+ while (end < lines.length) {
7702
+ const heading = String(lines[end] || "").trim().match(/^(#{1,6})\s+/);
7703
+ if (heading && heading[1].length <= actionsHeading.level) break;
7704
+ end += 1;
7705
+ }
7706
+ html.push(prdWorkflowReviewMarkdownLinesToHtml([lines[cursor]]));
7707
+ html.push(prdWorkflowReviewRenderActionSection(lines.slice(cursor + 1, end), sectionIndex));
7708
+ sectionIndex += 1;
7709
+ cursor = end;
7710
+ }
7711
+ return html.filter(Boolean).join("\n");
7712
+ }
7713
+
7714
+ export function prdWorkflowReviewMarkdownToHtml(markdown) {
7715
+ const { frontmatter, body } = prdWorkflowReviewSplitFrontmatter(markdown);
7716
+ return [
7717
+ prdWorkflowReviewRenderFrontmatter(frontmatter),
7718
+ prdWorkflowReviewBodyToHtml(body),
7719
+ ].filter(Boolean).join("\n");
7720
+ }
7721
+
7722
+ function prdWorkflowReviewExtractPageTitle(markdown, fallbackTitle) {
7723
+ const { frontmatter, body } = prdWorkflowReviewSplitFrontmatter(markdown);
7724
+ const lines = String(body || "").replace(/\r\n/g, "\n").split("\n");
7725
+ const firstContentIndex = lines.findIndex((line) => String(line || "").trim());
7726
+ const heading = firstContentIndex >= 0
7727
+ ? String(lines[firstContentIndex] || "").trim().match(/^#\s+(.+)$/)
7728
+ : null;
7729
+ if (!heading) {
7730
+ return {
7731
+ markdown: String(markdown || ""),
7732
+ title: String(fallbackTitle || "PRD Workflow Review"),
7733
+ };
7734
+ }
7735
+
7736
+ lines.splice(firstContentIndex, 1);
7737
+ const markdownTitle = prdWorkflowReviewNormalizeText(heading[1])
7738
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
7739
+ .replace(/[`*_~]/g, "")
7740
+ .trim();
7741
+ const bodyWithoutTitle = lines.join("\n").replace(/^\n+/, "");
7742
+ return {
7743
+ markdown: [
7744
+ frontmatter ? `---\n${frontmatter}\n---` : "",
7745
+ bodyWithoutTitle,
7746
+ ].filter(Boolean).join("\n\n"),
7747
+ title: markdownTitle || String(fallbackTitle || "PRD Workflow Review"),
7748
+ };
7749
+ }
7750
+
7751
+ export function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7752
+ const page = prdWorkflowReviewExtractPageTitle(markdown, title);
7753
+ const escapedTitle = htmlEscapeAttribute(page.title);
7508
7754
  const escapedMeta = htmlEscapeAttribute([
7509
7755
  meta.tapdId ? `TAPD ${meta.tapdId}` : "",
7510
7756
  meta.stage ? `stage ${meta.stage}` : "",
@@ -7518,7 +7764,7 @@ function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7518
7764
  meta.persistence ? `persistence ${meta.persistence}` : "",
7519
7765
  ].filter(Boolean).join(" · ");
7520
7766
  const escapedLifecycle = htmlEscapeAttribute(lifecycle);
7521
- const renderedMarkdown = prdWorkflowReviewMarkdownToHtml(markdown || "");
7767
+ const renderedMarkdown = prdWorkflowReviewMarkdownToHtml(page.markdown);
7522
7768
  const rawHref = htmlEscapeAttribute(meta.rawHref || "?raw=1");
7523
7769
  return `<!doctype html>
7524
7770
  <html lang="zh-CN" data-theme="dark">
@@ -7530,57 +7776,80 @@ function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7530
7776
  :root {
7531
7777
  color-scheme: dark;
7532
7778
  font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
7533
- --bg: #0b1020;
7534
- --bg-radial: rgba(124,58,237,.18);
7535
- --panel: rgba(15,23,42,.68);
7536
- --panel-strong: rgba(15,23,42,.88);
7537
- --panel-soft: rgba(30,41,59,.74);
7538
- --border: rgba(148,163,184,.20);
7539
- --border-soft: rgba(148,163,184,.14);
7779
+ --bg: #1a1b26;
7780
+ --panel: #1f2335;
7781
+ --panel-strong: #24283b;
7782
+ --panel-soft: #292e42;
7783
+ --border: #3b4261;
7784
+ --border-soft: #30364f;
7540
7785
  --text: #e5e7eb;
7541
- --heading: #f8fafc;
7542
- --muted: #94a3b8;
7543
- --body: #dbe4f0;
7544
- --link: #c4b5fd;
7545
- --button: rgba(124,58,237,.16);
7546
- --button-text: #ddd6fe;
7547
- --code-bg: rgba(2,6,23,.58);
7548
- --code-block: rgba(2,6,23,.72);
7549
- --shadow: rgba(0,0,0,.28);
7786
+ --heading: #f4f4f5;
7787
+ --muted: #8b90a0;
7788
+ --body: #d4d4d8;
7789
+ --link: #7dcfff;
7790
+ --interactive: #7aa2f7;
7791
+ --purple: #bb9af7;
7792
+ --button: #24283b;
7793
+ --button-text: #cbd0da;
7794
+ --code-bg: #292e42;
7795
+ --code-inline: #7dcfff;
7796
+ --code-block: #16161e;
7797
+ --code-block-text: #e5e7eb;
7798
+ --action-bg: #1f2335;
7799
+ --action-header: #24283b;
7800
+ --pending-bg: rgba(224,175,104,.10);
7801
+ --pending-border: rgba(224,175,104,.34);
7802
+ --pending-text: #e0af68;
7803
+ --complete-bg: rgba(158,206,106,.10);
7804
+ --complete-border: rgba(158,206,106,.34);
7805
+ --complete-text: #9ece6a;
7806
+ --shadow: rgba(9,10,15,.28);
7550
7807
  background: var(--bg);
7551
7808
  }
7552
7809
  :root[data-theme="light"] {
7553
7810
  color-scheme: light;
7554
- --bg: #f8fafc;
7555
- --bg-radial: rgba(124,58,237,.12);
7556
- --panel: rgba(255,255,255,.92);
7557
- --panel-strong: rgba(255,255,255,.98);
7558
- --panel-soft: rgba(241,245,249,.96);
7559
- --border: rgba(100,116,139,.24);
7560
- --border-soft: rgba(100,116,139,.18);
7561
- --text: #0f172a;
7562
- --heading: #020617;
7563
- --muted: #64748b;
7564
- --body: #1e293b;
7565
- --link: #6d28d9;
7566
- --button: rgba(124,58,237,.10);
7567
- --button-text: #4c1d95;
7568
- --code-bg: rgba(226,232,240,.76);
7569
- --code-block: rgba(241,245,249,.92);
7570
- --shadow: rgba(15,23,42,.10);
7811
+ --bg: #e1e2e7;
7812
+ --panel: #f3f3f5;
7813
+ --panel-strong: #e9e9ed;
7814
+ --panel-soft: #dcdfe7;
7815
+ --border: #c8cad4;
7816
+ --border-soft: #d5d7df;
7817
+ --text: #4c505e;
7818
+ --heading: #343b58;
7819
+ --muted: #7f849c;
7820
+ --body: #4c505e;
7821
+ --link: #007197;
7822
+ --interactive: #2e7de9;
7823
+ --purple: #7847bd;
7824
+ --button: #e7e8ed;
7825
+ --button-text: #4c505e;
7826
+ --code-bg: #dcdfe7;
7827
+ --code-inline: #007197;
7828
+ --code-block: #d5d8e1;
7829
+ --code-block-text: #343b58;
7830
+ --action-bg: #f3f3f5;
7831
+ --action-header: #e9e9ed;
7832
+ --pending-bg: rgba(177,92,0,.08);
7833
+ --pending-border: rgba(177,92,0,.28);
7834
+ --pending-text: #9a5200;
7835
+ --complete-bg: rgba(88,117,57,.10);
7836
+ --complete-border: rgba(88,117,57,.28);
7837
+ --complete-text: #587539;
7838
+ --shadow: rgba(52,59,88,.10);
7571
7839
  }
7572
7840
  *, *::before, *::after { box-sizing: border-box; }
7573
7841
  html, body { overflow-x: hidden; }
7574
- body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, var(--bg-radial), transparent 34rem), var(--bg); color: var(--text); }
7575
- main { width: min(100%, 1120px); margin: 0 auto; padding: 40px 24px 72px; min-width: 0; }
7842
+ body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); }
7843
+ main { width: min(100%, 1180px); margin: 0 auto; padding: 40px 24px 72px; min-width: 0; }
7576
7844
  header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 22px; }
7577
- h1 { margin: 0 0 10px; font-size: clamp(28px, 5vw, 56px); line-height: 1.05; letter-spacing: 0; }
7845
+ h1 { margin: 0 0 10px; font-size: clamp(28px, 4vw, 44px); line-height: 1.12; letter-spacing: -.015em; }
7578
7846
  .meta { margin: 0; color: var(--muted); font-size: 14px; }
7579
7847
  .toolbar { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
7580
- .raw, .theme-toggle { border: 1px solid rgba(124,58,237,.34); border-radius: 999px; color: var(--button-text); background: var(--button); padding: 9px 14px; text-decoration: none; font-size: 13px; font-weight: 800; line-height: 1.2; }
7848
+ .raw, .theme-toggle { border: 1px solid var(--border); border-radius: 999px; color: var(--button-text); background: var(--button); padding: 9px 14px; text-decoration: none; font-size: 13px; font-weight: 800; line-height: 1.2; }
7849
+ .raw:hover, .theme-toggle:hover { border-color: var(--interactive); color: var(--link); }
7581
7850
  .theme-toggle { cursor: pointer; font-family: inherit; }
7582
- .lifecycle { margin-top: 10px; display: inline-flex; max-width: 100%; border: 1px solid rgba(124,58,237,.30); border-radius: 999px; background: rgba(124,58,237,.12); color: var(--button-text); padding: 6px 10px; font-size: 12px; font-weight: 800; line-height: 1.35; overflow-wrap: anywhere; }
7583
- article { min-width: 0; border: 1px solid var(--border); border-radius: 14px; background: var(--panel); box-shadow: 0 28px 80px var(--shadow); padding: clamp(20px, 4vw, 34px); }
7851
+ .lifecycle { margin-top: 10px; display: inline-flex; max-width: 100%; border: 1px solid var(--border); border-radius: 999px; background: var(--button); color: var(--muted); padding: 6px 10px; font-size: 12px; font-weight: 800; line-height: 1.35; overflow-wrap: anywhere; }
7852
+ article { min-width: 0; border: 1px solid var(--border); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 50px var(--shadow); padding: clamp(20px, 4vw, 34px); }
7584
7853
  article > *:first-child { margin-top: 0; }
7585
7854
  article > *:last-child { margin-bottom: 0; }
7586
7855
  h2, h3, h4, h5, h6 { margin: 1.7em 0 .65em; line-height: 1.25; letter-spacing: 0; color: var(--heading); overflow-wrap: anywhere; }
@@ -7591,10 +7860,10 @@ function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7591
7860
  ul { margin: .65rem 0 1rem; padding-left: 1.35rem; }
7592
7861
  li { margin: .28rem 0; color: var(--body); }
7593
7862
  li input { margin-right: .38rem; transform: translateY(1px); }
7594
- code { display: inline; max-width: 100%; border: 1px solid var(--border-soft); border-radius: 6px; background: var(--code-bg); color: var(--heading); padding: .1rem .34rem; font-family: "SFMono-Regular", Consolas, monospace; font-size: .92em; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
7863
+ code { display: inline; max-width: 100%; border: 1px solid var(--border-soft); border-radius: 6px; background: var(--code-bg); color: var(--code-inline); padding: .1rem .34rem; font-family: "SFMono-Regular", Consolas, monospace; font-size: .92em; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
7595
7864
  pre { max-width: 100%; overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--code-block); padding: 16px; line-height: 1.65; }
7596
- pre code { border: 0; background: transparent; padding: 0; white-space: pre; overflow-wrap: normal; word-break: normal; }
7597
- blockquote { margin: 1rem 0; border-left: 3px solid rgba(124,58,237,.72); background: rgba(124,58,237,.10); padding: .75rem 1rem; color: var(--body); }
7865
+ pre code { border: 0; background: transparent; color: var(--code-block-text); padding: 0; white-space: pre; overflow-wrap: normal; word-break: normal; }
7866
+ blockquote { margin: 1rem 0; border-left: 3px solid var(--purple); background: var(--panel-soft); padding: .75rem 1rem; color: var(--body); }
7598
7867
  a { color: var(--link); text-decoration-thickness: .08em; text-underline-offset: .16em; overflow-wrap: anywhere; }
7599
7868
  .table-wrap { max-width: 100%; overflow-x: auto; margin: 1rem 0 1.25rem; border: 1px solid var(--border-soft); border-radius: 10px; background: var(--panel-strong); }
7600
7869
  table { width: 100%; max-width: 100%; border-collapse: collapse; table-layout: fixed; }
@@ -7606,7 +7875,34 @@ function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7606
7875
  .frontmatter table { min-width: 0; margin-top: .7rem; }
7607
7876
  .frontmatter th { width: min(34%, 12rem); background: var(--panel-soft); color: var(--body); }
7608
7877
  .frontmatter-list { margin: 0; padding-left: 1.1rem; }
7609
- @media (max-width: 720px) { main { padding: 28px 14px 48px; } header { display: block; } .toolbar { justify-content: flex-start; margin-top: 14px; } article { padding: 18px; } th, td { padding: .58rem .65rem; } }
7878
+ .action-index { position: sticky; top: 10px; z-index: 4; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 1rem 0 1.25rem; border: 1px solid var(--border); border-radius: 12px; background: var(--panel-strong); box-shadow: 0 8px 22px var(--shadow); padding: 10px 12px; }
7879
+ .action-index__label { margin-right: 2px; color: var(--muted); font-size: 12px; font-weight: 800; }
7880
+ .action-index a { min-width: 38px; border: 1px solid color-mix(in srgb, var(--interactive) 38%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--interactive) 10%, var(--button)); color: var(--interactive); padding: 5px 10px; text-align: center; text-decoration: none; font-size: 12px; font-weight: 900; }
7881
+ .action-index a:hover { border-color: var(--link); background: color-mix(in srgb, var(--interactive) 18%, var(--button)); color: var(--link); }
7882
+ .action-card { scroll-margin-top: 78px; margin: 0 0 20px; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--action-bg); box-shadow: 0 8px 24px var(--shadow); }
7883
+ .action-card:target { border-color: var(--interactive); box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive) 18%, transparent), 0 8px 24px var(--shadow); }
7884
+ .action-card__header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: 12px; border-bottom: 1px solid var(--border-soft); background: var(--action-header); padding: 16px 18px; }
7885
+ .action-card__index { display: inline-grid; place-items: center; min-width: 42px; min-height: 30px; border: 1px solid color-mix(in srgb, var(--interactive) 34%, var(--border)); border-radius: 8px; background: color-mix(in srgb, var(--interactive) 10%, var(--panel-soft)); color: var(--interactive); font: 900 13px/1 "SFMono-Regular", Consolas, monospace; }
7886
+ .action-card__title { margin: 3px 0 0; font-size: 16px; line-height: 1.55; letter-spacing: 0; color: var(--heading); }
7887
+ .action-card__status { margin-top: 2px; border: 1px solid var(--pending-border); border-radius: 999px; background: var(--pending-bg); color: var(--pending-text); padding: 5px 9px; font-size: 11px; font-weight: 900; white-space: nowrap; }
7888
+ .action-card__status.is-complete { border-color: var(--complete-border); background: var(--complete-bg); color: var(--complete-text); }
7889
+ .action-card__body { padding: 15px 20px 20px; }
7890
+ .action-card__body > *:first-child { margin-top: 0; }
7891
+ .action-card__body > *:last-child { margin-bottom: 0; }
7892
+ .action-card__body > ul { margin: 0 0 1rem; padding-left: 1.3rem; }
7893
+ .action-card__body > ul > li { margin: .55rem 0; padding-left: .15rem; }
7894
+ .action-card__body pre { margin: .9rem 0 1.1rem; }
7895
+ @media (max-width: 720px) {
7896
+ main { padding: 28px 14px 48px; }
7897
+ header { display: block; }
7898
+ .toolbar { justify-content: flex-start; margin-top: 14px; }
7899
+ article { padding: 18px; }
7900
+ th, td { padding: .58rem .65rem; }
7901
+ .action-index { top: 6px; }
7902
+ .action-card__header { grid-template-columns: auto minmax(0, 1fr); padding: 14px; }
7903
+ .action-card__status { grid-column: 2; justify-self: start; }
7904
+ .action-card__body { padding: 14px 16px 18px; }
7905
+ }
7610
7906
  </style>
7611
7907
  </head>
7612
7908
  <body>
@@ -9045,7 +9341,45 @@ function workspaceIntervalMinutesToCron(intervalMinutes) {
9045
9341
  }
9046
9342
 
9047
9343
  function workspaceRunKey(userCtx, flowSource, flowId) {
9048
- 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;
9049
9383
  }
9050
9384
 
9051
9385
  function workspaceRunEntryKey(scopeKey, runId) {
@@ -9169,9 +9503,19 @@ function workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId) {
9169
9503
  ].join(":");
9170
9504
  }
9171
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
+
9172
9516
  function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user", flowId = "") {
9173
9517
  const registry = readWorkspaceScheduleRegistry();
9174
- const userId = String(userCtx.userId || "");
9518
+ const userId = workspaceScheduleOwnerUserId(userCtx, flowSource, flowId);
9175
9519
  return Object.values(registry.schedules || {})
9176
9520
  .filter((entry) => (
9177
9521
  String(entry?.userId || "") === userId &&
@@ -9183,13 +9527,13 @@ function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user",
9183
9527
 
9184
9528
  function listWorkspaceScheduleStatuses(root, userCtx = {}) {
9185
9529
  const registry = readWorkspaceScheduleRegistry();
9186
- const userId = String(userCtx.userId || "");
9187
9530
  const flows = listFlowsJson(root, { ...userCtx, includeWorkspaceFlows: true })
9188
9531
  .filter((flow) => !flow.archived && !isReadonlyBuiltinFlowSource(flow.source || "user"));
9189
9532
  const rows = [];
9190
9533
  for (const flow of flows) {
9191
9534
  const flowId = String(flow.id || "");
9192
9535
  const flowSource = String(flow.source || "user");
9536
+ const scheduleUserId = workspaceScheduleOwnerUserId(userCtx, flowSource, flowId);
9193
9537
  const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource }, userCtx);
9194
9538
  if (scoped.error || !scoped.root) continue;
9195
9539
  let graph;
@@ -9202,7 +9546,7 @@ function listWorkspaceScheduleStatuses(root, userCtx = {}) {
9202
9546
  for (const [scheduleNodeId, instance] of Object.entries(instances)) {
9203
9547
  if (String(instance?.definitionId || "") !== "workspace_scheduled_run") continue;
9204
9548
  const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
9205
- const key = workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId);
9549
+ const key = workspaceScheduleKey(scheduleUserId, flowSource, flowId, scheduleNodeId);
9206
9550
  const current = registry.schedules?.[key] && typeof registry.schedules[key] === "object" ? registry.schedules[key] : {};
9207
9551
  const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
9208
9552
  const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
@@ -9291,15 +9635,25 @@ function setWorkspaceScheduleEnabled(root, payload = {}, authUser = {}, userCtx
9291
9635
  function syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx = {}) {
9292
9636
  const flowId = String(scoped?.flowId || "").trim();
9293
9637
  const flowSource = String(scoped?.flowSource || "user");
9294
- const userId = String(userCtx.userId || authUser?.userId || "");
9638
+ const userId = workspaceScheduleOwnerUserId(
9639
+ { userId: userCtx.userId || authUser?.userId || "" },
9640
+ flowSource,
9641
+ flowId,
9642
+ );
9295
9643
  if (!flowId || !userId) return [];
9296
9644
  const now = Date.now();
9297
9645
  const nowIso = new Date(now).toISOString();
9298
9646
  const registry = readWorkspaceScheduleRegistry();
9299
9647
  const schedules = { ...(registry.schedules || {}) };
9300
9648
  const prefix = `${userId}:${flowSource}:${flowId}:`;
9301
- for (const key of Object.keys(schedules)) {
9302
- 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];
9303
9657
  }
9304
9658
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
9305
9659
  for (const [scheduleNodeId, instance] of Object.entries(instances)) {
@@ -10971,7 +11325,49 @@ export function startUiServer({
10971
11325
  if (url.pathname === "/api/flows") {
10972
11326
  if (req.method === "GET") {
10973
11327
  try {
10974
- 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);
10975
11371
  } catch (e) {
10976
11372
  json(res, 500, { error: (e && e.message) || String(e) });
10977
11373
  }
@@ -11023,6 +11419,9 @@ export function startUiServer({
11023
11419
  json(res, 400, result);
11024
11420
  return;
11025
11421
  }
11422
+ if (targetSpace === "workspace") {
11423
+ ensureWorkspaceCollaboration({ flowId, userId: userCtx.userId });
11424
+ }
11026
11425
  json(res, 200, { success: true, flowId, flowSource: targetSpace });
11027
11426
  return;
11028
11427
  }
@@ -11103,6 +11502,9 @@ export function startUiServer({
11103
11502
  json(res, 400, { error: w.error });
11104
11503
  return;
11105
11504
  }
11505
+ if (targetSpace === "workspace") {
11506
+ ensureWorkspaceCollaboration({ flowId, userId: userCtx.userId });
11507
+ }
11106
11508
  json(res, 200, { success: true, flowId, flowSource: targetSpace });
11107
11509
  return;
11108
11510
  }
@@ -11207,11 +11609,188 @@ export function startUiServer({
11207
11609
  return;
11208
11610
  }
11209
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
+
11210
11788
  if (req.method === "GET" && url.pathname === "/api/workspace/files") {
11211
11789
  try {
11212
11790
  const scoped = resolveWorkspaceScopeRoot(root, {
11213
11791
  flowId: url.searchParams.get("flowId") || "",
11214
11792
  flowSource: url.searchParams.get("flowSource") || "user",
11793
+ workspaceId: url.searchParams.get("workspaceId") || "",
11215
11794
  archived: url.searchParams.get("archived") === "1",
11216
11795
  }, userCtx);
11217
11796
  if (scoped.error) {
@@ -11230,6 +11809,7 @@ export function startUiServer({
11230
11809
  const scoped = resolveWorkspaceScopeRoot(root, {
11231
11810
  flowId: url.searchParams.get("flowId") || "",
11232
11811
  flowSource: url.searchParams.get("flowSource") || "user",
11812
+ workspaceId: url.searchParams.get("workspaceId") || "",
11233
11813
  archived: url.searchParams.get("archived") === "1",
11234
11814
  }, userCtx);
11235
11815
  const scopedRoot = scoped.error ? root : scoped.root;
@@ -11287,23 +11867,30 @@ export function startUiServer({
11287
11867
  const scoped = resolveWorkspaceScopeRoot(root, {
11288
11868
  flowId: url.searchParams.get("flowId") || "",
11289
11869
  flowSource: url.searchParams.get("flowSource") || "user",
11870
+ workspaceId: url.searchParams.get("workspaceId") || "",
11290
11871
  archived: url.searchParams.get("archived") === "1",
11291
11872
  }, userCtx);
11292
11873
  if (scoped.error) {
11293
- json(res, 400, { error: scoped.error });
11874
+ json(res, scoped.status || 400, { error: scoped.error });
11294
11875
  return;
11295
11876
  }
11296
11877
  const { path: graphPath, graph } = readWorkspaceGraph(scoped.root);
11297
11878
  const hydratedGraph = hydrateWorkspaceGraphForRuntime(root, scoped, graph, userCtx);
11879
+ const collaborationAccess = scoped.collaborationAccess || workspaceCollaborationAccess(null, userCtx.userId);
11298
11880
  json(res, 200, {
11299
11881
  ok: true,
11300
11882
  graph: hydratedGraph,
11883
+ revision: workspaceDesignRevision(hydratedGraph),
11884
+ designRevision: workspaceDesignRevision(hydratedGraph),
11885
+ runtimeRevision: workspaceRuntimeRevision(hydratedGraph),
11301
11886
  path: graphPath,
11302
11887
  root: scoped.root,
11303
11888
  flowId: scoped.flowId,
11304
11889
  flowSource: scoped.flowSource,
11305
11890
  archived: scoped.archived,
11306
- writable: !(scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)),
11891
+ writable: !(scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource))
11892
+ && collaborationAccess.writable !== false,
11893
+ collaboration: workspaceCollaborationSummaryWithUsers(scoped.collaboration, userCtx.userId),
11307
11894
  workspaceSchedules: listWorkspaceScheduleStatusesForFlow(userCtx, scoped.flowSource || "user", scoped.flowId || ""),
11308
11895
  });
11309
11896
  } catch (e) {
@@ -11324,6 +11911,7 @@ export function startUiServer({
11324
11911
  const scoped = resolveWorkspaceScopeRoot(root, {
11325
11912
  flowId: payload.flowId || "",
11326
11913
  flowSource: payload.flowSource || "user",
11914
+ workspaceId: payload.workspaceId || "",
11327
11915
  archived: payload.archived === true || payload.flowArchived === true,
11328
11916
  }, userCtx);
11329
11917
  if (scoped.error) {
@@ -11368,23 +11956,106 @@ export function startUiServer({
11368
11956
  const scoped = resolveWorkspaceScopeRoot(root, {
11369
11957
  flowId: payload.flowId || "",
11370
11958
  flowSource: payload.flowSource || "user",
11959
+ workspaceId: payload.workspaceId || "",
11371
11960
  archived: payload.archived === true || payload.flowArchived === true,
11372
11961
  }, userCtx);
11373
11962
  if (scoped.error) {
11374
- json(res, 400, { error: scoped.error });
11963
+ json(res, scoped.status || 400, { error: scoped.error });
11375
11964
  return;
11376
11965
  }
11377
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
11966
+ if (
11967
+ scoped.archived
11968
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
11969
+ || scoped.collaborationAccess?.writable === false
11970
+ ) {
11378
11971
  json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
11379
11972
  return;
11380
11973
  }
11381
11974
  const submittedGraph = hydrateWorkspaceGraphForRuntime(root, scoped, payload.graph || payload, userCtx);
11382
11975
  const graphPath = workspaceGraphPath(scoped.root);
11383
- const currentGraph = hydrateWorkspaceGraphForRuntime(root, scoped, readWorkspaceGraph(scoped.root).graph, userCtx);
11384
- const graph = mergeWorkspacePersistentNodeRefs(submittedGraph, currentGraph);
11385
- 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);
11386
12036
  const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx);
11387
- 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
+ });
11388
12059
  } catch (e) {
11389
12060
  json(res, 500, { error: (e && e.message) || String(e) });
11390
12061
  }
@@ -11420,6 +12091,7 @@ export function startUiServer({
11420
12091
  const scoped = resolveWorkspaceScopeRoot(root, {
11421
12092
  flowId: payload.flowId || "",
11422
12093
  flowSource: payload.flowSource || "user",
12094
+ workspaceId: payload.workspaceId || "",
11423
12095
  archived: payload.archived === true || payload.flowArchived === true,
11424
12096
  }, userCtx);
11425
12097
  if (scoped.error) {
@@ -11467,13 +12139,18 @@ export function startUiServer({
11467
12139
  const scoped = resolveWorkspaceScopeRoot(root, {
11468
12140
  flowId: payload.flowId || "",
11469
12141
  flowSource: payload.flowSource || "user",
12142
+ workspaceId: payload.workspaceId || "",
11470
12143
  archived: payload.archived === true || payload.flowArchived === true,
11471
12144
  }, userCtx);
11472
12145
  if (scoped.error) {
11473
12146
  json(res, 400, { error: scoped.error });
11474
12147
  return;
11475
12148
  }
11476
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12149
+ if (
12150
+ scoped.archived
12151
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
12152
+ || scoped.collaborationAccess?.writable === false
12153
+ ) {
11477
12154
  json(res, 400, { error: "Cannot optimize workspace graph for builtin or archived pipeline" });
11478
12155
  return;
11479
12156
  }
@@ -11489,12 +12166,20 @@ export function startUiServer({
11489
12166
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
11490
12167
  const touchedIds = new Set((result.optimized || []).map((item) => item.nodeId).filter(Boolean));
11491
12168
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
11492
- fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
12169
+ writeWorkspaceGraphAtomic(graphPath, mergedGraph);
12170
+ const revision = workspaceDesignRevision(mergedGraph);
11493
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
+ });
11494
12178
  json(res, 200, {
11495
12179
  ok: true,
11496
12180
  path: graphPath,
11497
12181
  graph: mergedGraph,
12182
+ revision,
11498
12183
  order: result.order,
11499
12184
  optimized: result.optimized,
11500
12185
  skipped: result.skipped,
@@ -11518,13 +12203,18 @@ export function startUiServer({
11518
12203
  const scoped = resolveWorkspaceScopeRoot(root, {
11519
12204
  flowId: payload.flowId || "",
11520
12205
  flowSource: payload.flowSource || "user",
12206
+ workspaceId: payload.workspaceId || "",
11521
12207
  archived: payload.archived === true || payload.flowArchived === true,
11522
12208
  }, userCtx);
11523
12209
  if (scoped.error) {
11524
12210
  json(res, 400, { error: scoped.error });
11525
12211
  return;
11526
12212
  }
11527
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12213
+ if (
12214
+ scoped.archived
12215
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
12216
+ || scoped.collaborationAccess?.runnable === false
12217
+ ) {
11528
12218
  json(res, 400, { error: "Cannot run workspace graph for builtin or archived pipeline" });
11529
12219
  return;
11530
12220
  }
@@ -11534,7 +12224,27 @@ export function startUiServer({
11534
12224
  json(res, 400, { error: "Missing flowId" });
11535
12225
  return;
11536
12226
  }
11537
- 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);
11538
12248
  const runNodeId = String(payload.runNodeId || "").trim();
11539
12249
  const plan = workspaceRunPlan(runtimeGraph, runNodeId, scoped.root);
11540
12250
  const plannedNodeIds = workspaceRunPlanNodeIds(runNodeId, plan);
@@ -11583,12 +12293,27 @@ export function startUiServer({
11583
12293
  });
11584
12294
  activeWorkspaceRuns.set(runKey, runEntry);
11585
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
+ });
11586
12304
  const setActiveChild = (child, childOptions = {}) => {
11587
12305
  runControl.setChild(child, childOptions);
11588
12306
  };
11589
12307
  const clearActiveRun = (status = "finished") => {
11590
12308
  runControl.finish(status);
11591
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
+ });
11592
12317
  };
11593
12318
  if (wantsStream) {
11594
12319
  const graphPath = workspaceGraphPath(scoped.root);
@@ -11611,7 +12336,12 @@ export function startUiServer({
11611
12336
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
11612
12337
  const touchedIds = workspaceRunTouchedNodeIds(result);
11613
12338
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
11614
- 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";
11615
12345
  const endedAt = Date.now();
11616
12346
  appendWorkspaceRunFinished({
11617
12347
  ...runEntry,
@@ -11623,7 +12353,14 @@ export function startUiServer({
11623
12353
  durationMs: endedAt - runEntry.startedAt,
11624
12354
  runNodeId,
11625
12355
  });
11626
- 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 || [] });
11627
12364
  res.end();
11628
12365
  } catch (e) {
11629
12366
  const endedAt = Date.now();
@@ -11670,7 +12407,12 @@ export function startUiServer({
11670
12407
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
11671
12408
  const touchedIds = workspaceRunTouchedNodeIds(result);
11672
12409
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
11673
- 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";
11674
12416
  const endedAt = Date.now();
11675
12417
  appendWorkspaceRunFinished({
11676
12418
  ...runEntry,
@@ -11682,7 +12424,14 @@ export function startUiServer({
11682
12424
  durationMs: endedAt - runEntry.startedAt,
11683
12425
  runNodeId,
11684
12426
  });
11685
- 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) });
11686
12435
  } catch (e) {
11687
12436
  const endedAt = Date.now();
11688
12437
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
@@ -11729,9 +12478,18 @@ export function startUiServer({
11729
12478
  const scheduleNodeId = url.searchParams.get("scheduleNodeId") || "";
11730
12479
  const runNodeId = url.searchParams.get("runNodeId") || "";
11731
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
+ }
11732
12490
  json(res, 200, {
11733
12491
  runs: listWorkspaceRunLogs({
11734
- userId: userCtx.userId || "",
12492
+ userId: flowSource === "workspace" ? "" : userCtx.userId || "",
11735
12493
  flowId,
11736
12494
  flowSource,
11737
12495
  scheduleNodeId,
@@ -11752,7 +12510,23 @@ export function startUiServer({
11752
12510
  json(res, 400, { error: "Missing runId" });
11753
12511
  return;
11754
12512
  }
11755
- 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
+ })
11756
12530
  .find((item) => String(item.runId || "") === runId);
11757
12531
  if (!run) {
11758
12532
  json(res, 404, { error: "Run log not found" });
@@ -11775,6 +12549,15 @@ export function startUiServer({
11775
12549
  return;
11776
12550
  }
11777
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
+ }
11778
12561
  const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
11779
12562
  const entries = workspaceActiveRunsForScope(scopeKey).map(([, entry]) => entry);
11780
12563
  const entry = entries[0] || null;
@@ -11812,7 +12595,21 @@ export function startUiServer({
11812
12595
  json(res, 400, { error: "Missing flowId" });
11813
12596
  return;
11814
12597
  }
11815
- 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);
11816
12613
  const runId = String(payload.runId || payload.runSessionId || "").trim();
11817
12614
  const runNodeId = String(payload.runNodeId || "").trim();
11818
12615
  const entries = workspaceActiveRunsForScope(scopeKey);
@@ -11829,6 +12626,12 @@ export function startUiServer({
11829
12626
  runNodeId: entry.runNodeId || "",
11830
12627
  ts: Date.now(),
11831
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
+ });
11832
12635
  const result = await entry.runControl.stop();
11833
12636
  if (!result.stopped) {
11834
12637
  appendWorkspaceRunLogEvent(entry.runId, {
@@ -11880,7 +12683,13 @@ export function startUiServer({
11880
12683
  json(res, 413, { error: "File too large" });
11881
12684
  return;
11882
12685
  }
11883
- 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
+ });
11884
12693
  } catch (e) {
11885
12694
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
11886
12695
  }
@@ -12001,7 +12810,11 @@ export function startUiServer({
12001
12810
  json(res, 400, { error: scoped.error });
12002
12811
  return;
12003
12812
  }
12004
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12813
+ if (
12814
+ scoped.archived
12815
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
12816
+ || scoped.collaborationAccess?.writable === false
12817
+ ) {
12005
12818
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12006
12819
  return;
12007
12820
  }
@@ -12010,9 +12823,39 @@ export function startUiServer({
12010
12823
  json(res, 400, { error: "Missing path" });
12011
12824
  return;
12012
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
+ }
12013
12840
  fs.mkdirSync(path.dirname(abs), { recursive: true });
12014
- fs.writeFileSync(abs, String(payload.content ?? ""), "utf-8");
12015
- 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 });
12016
12859
  } catch (e) {
12017
12860
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
12018
12861
  }
@@ -12041,7 +12884,7 @@ export function startUiServer({
12041
12884
  json(res, 400, { error: scoped.error });
12042
12885
  return;
12043
12886
  }
12044
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12887
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12045
12888
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12046
12889
  return;
12047
12890
  }
@@ -12051,6 +12894,11 @@ export function startUiServer({
12051
12894
  const target = uniqueWorkspaceRelPath(scoped.root, targetRel);
12052
12895
  fs.mkdirSync(path.dirname(target.abs), { recursive: true });
12053
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
+ });
12054
12902
  json(res, 200, {
12055
12903
  ok: true,
12056
12904
  path: target.rel,
@@ -12081,7 +12929,7 @@ export function startUiServer({
12081
12929
  json(res, 400, { error: scoped.error });
12082
12930
  return;
12083
12931
  }
12084
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12932
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12085
12933
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12086
12934
  return;
12087
12935
  }
@@ -12091,6 +12939,11 @@ export function startUiServer({
12091
12939
  return;
12092
12940
  }
12093
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
+ });
12094
12947
  json(res, 200, { ok: true, path: rel });
12095
12948
  } catch (e) {
12096
12949
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
@@ -12116,7 +12969,7 @@ export function startUiServer({
12116
12969
  json(res, 400, { error: scoped.error });
12117
12970
  return;
12118
12971
  }
12119
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
12972
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12120
12973
  json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
12121
12974
  return;
12122
12975
  }
@@ -12130,6 +12983,12 @@ export function startUiServer({
12130
12983
  return;
12131
12984
  }
12132
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
+ });
12133
12992
  json(res, 200, { ok: true, path: rel });
12134
12993
  } catch (e) {
12135
12994
  json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
@@ -12166,7 +13025,7 @@ export function startUiServer({
12166
13025
  json(res, 200, { ok: true, conversations: readWorkspaceConversations(scoped.root) });
12167
13026
  return;
12168
13027
  }
12169
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
13028
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12170
13029
  json(res, 400, { error: "Cannot write conversations for builtin or archived pipeline workspace" });
12171
13030
  return;
12172
13031
  }
@@ -12201,6 +13060,10 @@ export function startUiServer({
12201
13060
  json(res, 400, { error: scoped.error });
12202
13061
  return;
12203
13062
  }
13063
+ if (scoped.collaborationAccess?.writable === false) {
13064
+ json(res, 403, { error: "Workspace collaboration edit permission denied" });
13065
+ return;
13066
+ }
12204
13067
  const selectedSkillKeys = Array.isArray(payload?.selectedSkills)
12205
13068
  ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean)
12206
13069
  : [];
@@ -12287,10 +13150,14 @@ export function startUiServer({
12287
13150
  json(res, 400, { error: scoped.error });
12288
13151
  return;
12289
13152
  }
13153
+ if (scoped.collaborationAccess?.writable === false) {
13154
+ json(res, 403, { error: "Workspace collaboration edit permission denied" });
13155
+ return;
13156
+ }
12290
13157
  const targetFilePath = String(payload?.targetFilePath || "").trim();
12291
13158
  let targetFile = null;
12292
13159
  if (targetFilePath) {
12293
- if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
13160
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
12294
13161
  json(res, 400, { error: "Cannot edit builtin or archived pipeline workspace" });
12295
13162
  return;
12296
13163
  }
@@ -13151,12 +14018,26 @@ export function startUiServer({
13151
14018
  return;
13152
14019
  }
13153
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
+ }
13154
14032
  const result = readFlowJson(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
13155
14033
  if (result.error) {
13156
14034
  json(res, 404, result);
13157
14035
  return;
13158
14036
  }
13159
- 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
+ });
13160
14041
  return;
13161
14042
  }
13162
14043
 
@@ -13335,12 +14216,53 @@ finishedAt: "${new Date().toISOString()}"
13335
14216
  return;
13336
14217
  }
13337
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
+ }
13338
14255
  const result = writeFlowYaml(root, flowId, flowSource, flowYaml, { archived: flowArchived, ...userCtx });
13339
14256
  if (!result.success) {
13340
14257
  json(res, 400, result);
13341
14258
  return;
13342
14259
  }
13343
- 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
+ });
13344
14266
  return;
13345
14267
  }
13346
14268
 
@@ -13363,6 +14285,17 @@ finishedAt: "${new Date().toISOString()}"
13363
14285
  return;
13364
14286
  }
13365
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
+ }
13366
14299
  broadcastFlowEditorSync(flowId, flowSource, flowArchived, userCtx.userId);
13367
14300
  json(res, 200, { ok: true });
13368
14301
  return;
@@ -13380,6 +14313,17 @@ finishedAt: "${new Date().toISOString()}"
13380
14313
  return;
13381
14314
  }
13382
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
+ }
13383
14327
  const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userCtx.userId);
13384
14328
  let set = flowEditorSyncSubscribers.get(key);
13385
14329
  if (!set) {
@@ -13414,6 +14358,17 @@ finishedAt: "${new Date().toISOString()}"
13414
14358
  return;
13415
14359
  }
13416
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
+ }
13417
14372
  const key = flowEditorSyncKey(flowId, flowSource, flowArchived, userCtx.userId);
13418
14373
  const serverVer = flowEditorSyncVersions.get(key) ?? 0;
13419
14374
  const clientVer = parseInt(url.searchParams.get("v") ?? "0", 10) || 0;
@@ -13444,11 +14399,21 @@ finishedAt: "${new Date().toISOString()}"
13444
14399
  json(res, 400, { error: "Invalid toSource" });
13445
14400
  return;
13446
14401
  }
14402
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, fromSource, false, userCtx, "owner");
14403
+ if (collaborationDenied) {
14404
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14405
+ return;
14406
+ }
13447
14407
  const result = moveFlowDirectory(root, flowId.trim(), fromSource, toSource, userCtx);
13448
14408
  if (!result.success) {
13449
14409
  json(res, 400, { error: result.error || "Move failed" });
13450
14410
  return;
13451
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
+ }
13452
14417
  json(res, 200, { success: true, flowId: flowId.trim(), flowSource: result.flowSource });
13453
14418
  return;
13454
14419
  }
@@ -13472,6 +14437,11 @@ finishedAt: "${new Date().toISOString()}"
13472
14437
  json(res, 400, { error: "仅支持重命名用户目录或工作区流水线" });
13473
14438
  return;
13474
14439
  }
14440
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, flowSource, false, userCtx, "owner");
14441
+ if (collaborationDenied) {
14442
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14443
+ return;
14444
+ }
13475
14445
  const validation = validateUserPipelineId(newFlowId);
13476
14446
  if (!validation.ok) {
13477
14447
  json(res, 400, { error: validation.error });
@@ -13494,6 +14464,12 @@ finishedAt: "${new Date().toISOString()}"
13494
14464
  }
13495
14465
  try {
13496
14466
  fs.renameSync(fromDir, toDir);
14467
+ updateWorkspaceCollaborationFlow({
14468
+ previousFlowId: flowId,
14469
+ flowSource,
14470
+ ownerId: userCtx.userId,
14471
+ flowId: validation.flowId,
14472
+ });
13497
14473
  json(res, 200, { success: true, flowId: validation.flowId, flowSource });
13498
14474
  } catch (e) {
13499
14475
  json(res, 500, { error: (e && e.message) || String(e) });
@@ -13524,11 +14500,24 @@ finishedAt: "${new Date().toISOString()}"
13524
14500
  json(res, 400, { error: "仅支持归档用户目录或工作区流水线" });
13525
14501
  return;
13526
14502
  }
14503
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, flowSource, false, userCtx, "owner");
14504
+ if (collaborationDenied) {
14505
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
14506
+ return;
14507
+ }
13527
14508
  const result = archiveFlowPipeline(root, flowId, flowSource, userCtx);
13528
14509
  if (!result.success) {
13529
14510
  json(res, 400, { error: result.error || "归档失败" });
13530
14511
  return;
13531
14512
  }
14513
+ updateWorkspaceCollaborationFlow({
14514
+ previousFlowId: flowId,
14515
+ previousArchived: false,
14516
+ flowSource,
14517
+ ownerId: userCtx.userId,
14518
+ flowId,
14519
+ archived: true,
14520
+ });
13532
14521
  json(res, 200, { success: true, flowId, flowSource, archived: true });
13533
14522
  return;
13534
14523
  }
@@ -13557,11 +14546,53 @@ finishedAt: "${new Date().toISOString()}"
13557
14546
  json(res, 400, { error: "仅支持删除用户目录或工作区流水线" });
13558
14547
  return;
13559
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
+ }
13560
14589
  const result = deleteFlowPipeline(root, flowId, flowSource, { archived: flowArchived, ...userCtx });
13561
14590
  if (!result.success) {
13562
14591
  json(res, 400, { error: result.error || "删除失败" });
13563
14592
  return;
13564
14593
  }
14594
+ if (collaboration?.id) deleteWorkspaceCollaborationById(collaboration.id);
14595
+ else deleteWorkspaceCollaborationForFlow(flowId, flowArchived);
13565
14596
  json(res, 200, { success: true, flowId, flowSource, deleted: true });
13566
14597
  return;
13567
14598
  }
@@ -13824,8 +14855,20 @@ finishedAt: "${new Date().toISOString()}"
13824
14855
  json(res, 400, { error: "Missing flowId" });
13825
14856
  return;
13826
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
+ }
13827
14870
  const runUuid = typeof payload.uuid === "string" ? payload.uuid.trim() : "";
13828
- const runKey = `${userCtx.userId || ""}:${payload.flowSource || "user"}:${flowId}`;
14871
+ const runKey = workspaceRunKey(userCtx, flowSource, flowId);
13829
14872
  if (activeFlowRuns.has(runKey)) {
13830
14873
  json(res, 409, { error: "该流水线已在运行中" });
13831
14874
  return;
@@ -13904,7 +14947,7 @@ finishedAt: "${new Date().toISOString()}"
13904
14947
  }
13905
14948
 
13906
14949
  /** @type {{ child: import("child_process").ChildProcess, runUuid: string | null }} */
13907
- const runEntry = { child, runUuid: runUuid || null };
14950
+ const runEntry = { child, runUuid: runUuid || null, userId: userCtx.userId || "" };
13908
14951
  activeFlowRuns.set(runKey, runEntry);
13909
14952
  log.debug(`[ui] flow/run: spawned pid=${child.pid} flowId=${flowId}${runUuid ? ` uuid=${runUuid}` : ""}`);
13910
14953
 
@@ -13978,7 +15021,19 @@ finishedAt: "${new Date().toISOString()}"
13978
15021
  json(res, 400, { error: "Missing flowId" });
13979
15022
  return;
13980
15023
  }
13981
- 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);
13982
15037
  const entry = activeFlowRuns.get(runKey);
13983
15038
  if (!entry || !entry.child) {
13984
15039
  json(res, 404, { error: "该流水线未在运行" });
@@ -14000,7 +15055,7 @@ finishedAt: "${new Date().toISOString()}"
14000
15055
  activeFlowRuns.delete(runKey);
14001
15056
  if (uuid) {
14002
15057
  try {
14003
- const runDir = getRunDir(root, flowId, uuid, userCtx);
15058
+ const runDir = getRunDir(root, flowId, uuid, { userId: entry.userId || userCtx.userId || "" });
14004
15059
  fs.mkdirSync(runDir, { recursive: true });
14005
15060
  fs.writeFileSync(
14006
15061
  path.join(runDir, RUN_INTERRUPTED_FILENAME),