@fieldwangai/agentflow 0.1.165 → 0.1.167

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.
@@ -11,8 +11,21 @@
11
11
  * 是请求回调的闭包,解构之后路由体里的写法完全不变。
12
12
  */
13
13
 
14
- import { listFlowsJson, listNodesJson, readNodeDetailJson, readNodeFilePreview } from "./catalog-flows.mjs";
14
+ import { listNodesJson, readNodeDetailJson, readNodeFilePreview } from "./catalog-flows.mjs";
15
15
  import { startComposerAgent } from "./composer-agent.mjs";
16
+ import {
17
+ aiMaterializationPrompt,
18
+ aiPlanPrompt,
19
+ appendAiTraceEvents,
20
+ createAiExplorationSession,
21
+ classifyAiToolSideEffect,
22
+ listAiExplorationSessions,
23
+ materializableAiTraceEvents,
24
+ parseAiPlanResult,
25
+ readAiExplorationSession,
26
+ updateAiExplorationSession,
27
+ writeAiExplorationMaterialization,
28
+ } from "./ai-exploration.mjs";
16
29
  import { buildSkillCompactInjectionBlock, loadResourcesForSkillKeys } from "./composer-skill-router.mjs";
17
30
  import { execFileBuffered } from "./exec-buffered.mjs";
18
31
  import { runGit } from "./git-worktree.mjs";
@@ -30,22 +43,30 @@ import {
30
43
  } from "./marketplace.mjs";
31
44
  import {
32
45
  appendMarketplaceUsageEvent,
33
- marketplaceStatsFor,
34
- marketplaceUsageStats,
35
46
  marketplaceResourcesForRun,
36
- readMarketplaceFlowOrigin,
37
47
  writeMarketplaceFlowOrigin,
38
48
  } from "./marketplace-usage.mjs";
49
+ import {
50
+ indexedProjectFlowPreview,
51
+ getRepositoryIndex,
52
+ indexedMarketplaceFlowCopies,
53
+ listIndexedNodes,
54
+ listIndexedProjectFlows,
55
+ markRepositoryIndexDirty,
56
+ updateIndexedProjectFlowVisibility,
57
+ updateIndexedNodeVisibility,
58
+ writeProjectFlowMarketplaceMetadata,
59
+ } from "./repository-index.mjs";
39
60
  import { NODE_PACKAGE_ENTRY, nodePackageExportsRun, readNodePackageManifest } from "./node-package-manifest.mjs";
40
61
  import { inspectNodePackageDirectory } from "./node-package-archive.mjs";
41
62
  import { json, readBody } from "./http-util.mjs";
42
63
  import { log } from "./log.mjs";
43
- import { PACKAGE_ROOT, PIPELINES_DIR, getAgentflowUserDataRoot, getUserPipelinesRoot, listAgentflowUserIds } from "./paths.mjs";
64
+ import { PACKAGE_ROOT, PIPELINES_DIR, getAgentflowUserDataRoot, getUserPipelinesRoot } from "./paths.mjs";
44
65
  import { validateUserPipelineId } from "./flow-write.mjs";
45
66
  import { runLedgerId } from "./run-ledger.mjs";
46
67
  import { getTeamById, getTeamForUser } from "./teams.mjs";
47
68
  import { readMergedEnvObject, runtimeEnvForUser } from "./user-env.mjs";
48
- import { acceptWorkspaceCollaborationInvite, addWorkspaceCollaborationMember, deleteWorkspaceCollaborationForFlow, ensureWorkspaceCollaboration, getWorkspaceCollaborationByFlow, getWorkspaceCollaborationForProject, listWorkspaceCollaborationsForUser, removeWorkspaceCollaborationMember, removeWorkspaceCollaborationTeamShare, setWorkspaceCollaborationTeamShare, workspaceCollaborationAccess } from "./workspace-collaboration.mjs";
69
+ import { acceptWorkspaceCollaborationInvite, addWorkspaceCollaborationMember, deleteWorkspaceCollaborationForFlow, ensureWorkspaceCollaboration, getWorkspaceCollaborationForProject, listWorkspaceCollaborationsForUser, removeWorkspaceCollaborationMember, removeWorkspaceCollaborationTeamShare, setWorkspaceCollaborationTeamShare, workspaceCollaborationAccess } from "./workspace-collaboration.mjs";
49
70
  import { WorkspaceFlowParseError } from "./workspace-flow-store.mjs";
50
71
  import { mergeWorkspaceGraphs, workspaceDesignRevision, workspaceRuntimeRevision } from "./workspace-graph-merge.mjs";
51
72
  import { DEFAULT_WORKSPACE_PREVIEW_TTL_MS, createWorkspacePreviewId, normalizeWorkspacePreviewTtlMs, readWorkspacePreviewMetadata, workspaceSharedPreviewFlowDir, writeWorkspacePreviewMetadata } from "./workspace-preview.mjs";
@@ -248,6 +269,170 @@ function missingWorkspaceGraphNodePackages(workspaceRoot, scoped, graph, userCtx
248
269
  return [...missing].sort();
249
270
  }
250
271
 
272
+ const NODE_REVIEW_MAX_FILES = 100;
273
+ const NODE_REVIEW_MAX_FILE_BYTES = 256 * 1024;
274
+ const NODE_REVIEW_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
275
+ const NODE_REVIEW_TEXT_EXTENSIONS = new Set([
276
+ ".cjs", ".css", ".env", ".go", ".html", ".ini", ".java", ".js", ".json", ".jsx", ".kt",
277
+ ".md", ".mdx", ".mjs", ".py", ".rs", ".scss", ".sh", ".sql", ".toml", ".ts", ".tsx",
278
+ ".txt", ".xml", ".yaml", ".yml",
279
+ ]);
280
+
281
+ function workspaceNodeReviewLanguage(filePath = "") {
282
+ const extension = path.extname(String(filePath || "")).toLowerCase().replace(/^\./, "");
283
+ const aliases = { cjs: "javascript", js: "javascript", jsx: "javascript", mjs: "javascript", py: "python", sh: "shell", ts: "typescript", tsx: "typescript", yml: "yaml" };
284
+ return aliases[extension] || extension || "text";
285
+ }
286
+
287
+ function workspaceNodeReviewSource({ sourcePath = "", title = "", kind = "file", content = "" } = {}) {
288
+ const text = String(content || "").replace(/\r\n/g, "\n");
289
+ const reviewPath = String(sourcePath || title || "source.txt").replace(/\\/g, "/");
290
+ return {
291
+ path: reviewPath,
292
+ title: String(title || path.basename(reviewPath) || reviewPath),
293
+ kind,
294
+ language: workspaceNodeReviewLanguage(reviewPath),
295
+ size: Buffer.byteLength(text),
296
+ sha256: crypto.createHash("sha256").update(text).digest("hex"),
297
+ content: text,
298
+ };
299
+ }
300
+
301
+ function workspaceNodeReviewFlowFile(flowRoot, fileRef, kind) {
302
+ const ref = String(fileRef || "").trim();
303
+ if (!ref) return null;
304
+ try {
305
+ const { abs, rel } = resolveWorkspaceFilePath(flowRoot, ref);
306
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
307
+ return { error: `找不到 ${ref}` };
308
+ }
309
+ const stat = fs.statSync(abs);
310
+ if (stat.size > NODE_REVIEW_MAX_FILE_BYTES) {
311
+ return { error: `${ref} 超过可预览大小限制` };
312
+ }
313
+ return workspaceNodeReviewSource({ sourcePath: rel, title: rel, kind, content: fs.readFileSync(abs, "utf-8") });
314
+ } catch (error) {
315
+ return { error: `${ref}: ${(error && error.message) || String(error)}` };
316
+ }
317
+ }
318
+
319
+ function workspaceNodeReviewPackageFiles(packageDir) {
320
+ const root = path.resolve(packageDir);
321
+ const candidates = [];
322
+ const walk = (dir) => {
323
+ if (candidates.length >= NODE_REVIEW_MAX_FILES) return;
324
+ let entries = [];
325
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
326
+ entries.sort((a, b) => a.name.localeCompare(b.name));
327
+ for (const entry of entries) {
328
+ if (candidates.length >= NODE_REVIEW_MAX_FILES) break;
329
+ if ([".git", "node_modules"].includes(entry.name)) continue;
330
+ const abs = path.join(dir, entry.name);
331
+ if (entry.isDirectory()) walk(abs);
332
+ else if (entry.isFile() && NODE_REVIEW_TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) candidates.push(abs);
333
+ }
334
+ };
335
+ walk(root);
336
+ candidates.sort((left, right) => {
337
+ const leftRel = path.relative(root, left).replace(/\\/g, "/");
338
+ const rightRel = path.relative(root, right).replace(/\\/g, "/");
339
+ if (leftRel === NODE_PACKAGE_ENTRY) return -1;
340
+ if (rightRel === NODE_PACKAGE_ENTRY) return 1;
341
+ return leftRel.localeCompare(rightRel);
342
+ });
343
+ const sources = [];
344
+ let totalBytes = 0;
345
+ for (const abs of candidates) {
346
+ let stat;
347
+ try { stat = fs.statSync(abs); } catch { continue; }
348
+ if (!stat.isFile() || stat.size > NODE_REVIEW_MAX_FILE_BYTES || totalBytes + stat.size > NODE_REVIEW_MAX_TOTAL_BYTES) continue;
349
+ const rel = path.relative(root, abs).replace(/\\/g, "/");
350
+ sources.push(workspaceNodeReviewSource({ sourcePath: rel, title: rel, kind: "package", content: fs.readFileSync(abs, "utf-8") }));
351
+ totalBytes += stat.size;
352
+ }
353
+ return sources;
354
+ }
355
+
356
+ function workspaceNodeReviewSnapshot(workspaceRoot, flowRoot, graph, nodeId, userCtx = {}, label = "Draft") {
357
+ const instance = graph?.instances?.[nodeId];
358
+ if (!instance) return null;
359
+ const definitionId = String(instance.definitionId || nodeId);
360
+ const marketplaceRef = String(instance.marketplaceRef || definitionId).trim();
361
+ const sources = [];
362
+ const errors = [];
363
+ let packageInfo = null;
364
+
365
+ if (marketplaceRef.startsWith("marketplace:")) {
366
+ const resolved = resolveMarketplaceNodePackage(
367
+ workspaceRoot,
368
+ flowRoot,
369
+ marketplaceRef,
370
+ graph,
371
+ { ...userCtx, marketplaceScope: "all" },
372
+ );
373
+ if (!resolved) {
374
+ errors.push(`无法解析节点包 ${marketplaceRef}`);
375
+ } else {
376
+ const inspected = inspectNodePackageDirectory(resolved.packageDir, { allowLegacyManifest: true });
377
+ sources.push(...workspaceNodeReviewPackageFiles(resolved.packageDir));
378
+ packageInfo = {
379
+ id: resolved.id,
380
+ version: resolved.version,
381
+ definitionId: resolved.resolvedDefinitionId || marketplaceRef,
382
+ source: resolved.source || "marketplace",
383
+ contentSha256: inspected.ok ? inspected.contentSha256 : String(resolved.contentSha256 || ""),
384
+ };
385
+ if (!sources.length) errors.push(`节点包 ${marketplaceRef} 没有可预览的文本文件`);
386
+ }
387
+ } else if (String(instance.script || "").trim()) {
388
+ sources.push(workspaceNodeReviewSource({
389
+ sourcePath: `nodes/${nodeId}/inline-script.mjs`,
390
+ title: "内联脚本",
391
+ kind: "inline",
392
+ content: instance.script,
393
+ }));
394
+ }
395
+
396
+ for (const [ref, kind] of [[instance.scriptRef, "script"], [instance.implementationRef, "implementation"]]) {
397
+ const source = workspaceNodeReviewFlowFile(flowRoot, ref, kind);
398
+ if (source?.error) errors.push(source.error);
399
+ else if (source) sources.push(source);
400
+ }
401
+
402
+ const body = String(instance.body || "");
403
+ if (body.trim()) {
404
+ const looksJson = /^[\[{]/.test(body.trim());
405
+ sources.push(workspaceNodeReviewSource({
406
+ sourcePath: `nodes/${nodeId}/${looksJson ? "configuration.json" : "prompt.md"}`,
407
+ title: looksJson ? "节点配置" : "Prompt / 指令",
408
+ kind: looksJson ? "configuration" : "prompt",
409
+ content: body,
410
+ }));
411
+ }
412
+
413
+ const uniqueSources = [];
414
+ const seen = new Set();
415
+ for (const source of sources) {
416
+ const key = `${source.kind}\u0000${source.path}\u0000${source.sha256}`;
417
+ if (seen.has(key)) continue;
418
+ seen.add(key);
419
+ uniqueSources.push(source);
420
+ }
421
+ return {
422
+ label,
423
+ nodeId,
424
+ definitionId,
425
+ model: String(instance.model || ""),
426
+ role: String(instance.role || "normal"),
427
+ implementationMode: String(instance.implementationMode || ""),
428
+ marketplaceRef: marketplaceRef.startsWith("marketplace:") ? marketplaceRef : "",
429
+ package: packageInfo,
430
+ sources: uniqueSources,
431
+ errors,
432
+ reviewable: uniqueSources.length > 0,
433
+ };
434
+ }
435
+
251
436
  const NODE_STUDIO_DRAFTS_DIRNAME = "node-studio/drafts";
252
437
 
253
438
  function writeUserWorkspaces(userCtx = {}, entries = []) {
@@ -912,133 +1097,8 @@ function workspaceBufferLooksLikeZip(buf) {
912
1097
  );
913
1098
  }
914
1099
 
915
- function installedMarketplaceFlowCopies(userId) {
916
- const copies = new Map();
917
- const pipelinesRoot = getUserPipelinesRoot(userId);
918
- if (!fs.existsSync(pipelinesRoot)) return copies;
919
- for (const entry of fs.readdirSync(pipelinesRoot, { withFileTypes: true })) {
920
- if (!entry.isDirectory()) continue;
921
- const origin = readMarketplaceFlowOrigin(path.join(pipelinesRoot, entry.name));
922
- if (!origin) continue;
923
- const key = `${origin.id}@${origin.version}`;
924
- const flowIds = copies.get(key) || [];
925
- flowIds.push(entry.name);
926
- copies.set(key, flowIds);
927
- }
928
- return copies;
929
- }
930
-
931
- const PROJECT_FLOW_MARKETPLACE_FILENAME = path.join(".workspace", "agentflow", "marketplace.json");
932
-
933
- function projectFlowMarketplaceId(ownerUserId, flowSource, flowId) {
934
- return `project-flow:${String(ownerUserId || "").trim()}:${String(flowSource || "user").trim()}:${String(flowId || "").trim()}`;
935
- }
936
-
937
- function projectFlowMarketplaceMetadataPath(flowRoot) {
938
- return path.join(path.resolve(flowRoot), PROJECT_FLOW_MARKETPLACE_FILENAME);
939
- }
940
-
941
- function readProjectFlowMarketplaceMetadata(flowRoot) {
942
- try {
943
- const parsed = JSON.parse(fs.readFileSync(projectFlowMarketplaceMetadataPath(flowRoot), "utf-8"));
944
- return {
945
- visibility: String(parsed?.visibility || "").trim() === "private" ? "private" : "public",
946
- updatedAt: String(parsed?.updatedAt || "").trim(),
947
- };
948
- } catch {
949
- return { visibility: "public", updatedAt: "" };
950
- }
951
- }
952
-
953
- function writeProjectFlowMarketplaceMetadata(flowRoot, visibility) {
954
- const filePath = projectFlowMarketplaceMetadataPath(flowRoot);
955
- const value = {
956
- version: 1,
957
- visibility: String(visibility || "").trim() === "private" ? "private" : "public",
958
- updatedAt: new Date().toISOString(),
959
- };
960
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
961
- const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
962
- fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
963
- fs.renameSync(tempPath, filePath);
964
- return value;
965
- }
966
-
967
- function graphHasRunnableEntry(graph) {
968
- return Object.values(graph?.instances || {}).some((instance) => (
969
- instance?.definitionId === "workspace_run"
970
- || instance?.definitionId === "workspace_scheduled_run"
971
- ));
972
- }
973
-
974
- function projectFlowUpdatedAt(flowRoot, metadata = {}) {
975
- if (metadata.updatedAt) return metadata.updatedAt;
976
- try {
977
- return fs.statSync(path.resolve(flowRoot)).mtime.toISOString();
978
- } catch {
979
- return "";
980
- }
981
- }
982
-
983
1100
  function listRunnableProjectMarketplaceFlows(workspaceRoot, userCtx = {}, scope = "all") {
984
- const requestedUserId = String(userCtx.userId || "").trim();
985
- const stats = marketplaceUsageStats(workspaceRoot);
986
- const resources = [];
987
- const appendFlow = (ownerId, flow, flowSource = "user", workspaceId = "") => {
988
- if (!ownerId || (scope === "owned" && ownerId !== requestedUserId)) return;
989
- if (flow.archived || !flow.path) return;
990
- let stable;
991
- let graph;
992
- try {
993
- stable = readWorkspaceStableRelease(flow.path, workspaceRoot);
994
- graph = stable?.graph || readWorkspaceGraph(flow.path, workspaceRoot).graph;
995
- } catch {
996
- return;
997
- }
998
- if (!graphHasRunnableEntry(graph)) return;
999
- const metadata = readProjectFlowMarketplaceMetadata(flow.path);
1000
- const owned = ownerId === requestedUserId;
1001
- if (scope !== "owned" && metadata.visibility === "private" && !owned && userCtx.isAdmin !== true) return;
1002
- const id = projectFlowMarketplaceId(ownerId, flowSource, flow.id);
1003
- const version = stable?.release?.id || `current-${workspaceDesignRevision(graph).slice(0, 12)}`;
1004
- resources.push({
1005
- resourceType: "flow",
1006
- projectFlow: true,
1007
- id,
1008
- definitionId: flow.id,
1009
- displayName: flow.id,
1010
- description: flow.description || "",
1011
- version,
1012
- versionLabel: stable?.release?.id ? `Stable ${stable.release.id}` : "当前版本",
1013
- ownerUserId: ownerId,
1014
- liveOwnerUserId: ownerId,
1015
- liveFlowId: flow.id,
1016
- liveFlowSource: flowSource,
1017
- liveWorkspaceId: workspaceId,
1018
- visibility: metadata.visibility,
1019
- nodeCount: Object.keys(graph?.instances || {}).length,
1020
- edgeCount: Array.isArray(graph?.edges) ? graph.edges.length : 0,
1021
- updatedAt: projectFlowUpdatedAt(flow.path, metadata),
1022
- ...marketplaceStatsFor(stats, "project-flow", id, version, ownerId),
1023
- _graph: graph,
1024
- _flowRoot: flow.path,
1025
- });
1026
- };
1027
- for (const ownerUserId of listAgentflowUserIds()) {
1028
- const ownerId = String(ownerUserId || "").trim();
1029
- for (const flow of listFlowsJson(workspaceRoot, { userId: ownerId })) {
1030
- if ((flow.source || "user") !== "user" || flow.archived || !flow.path) continue;
1031
- appendFlow(ownerId, flow, "user");
1032
- }
1033
- }
1034
- for (const flow of listFlowsJson(workspaceRoot, { userId: "", includeWorkspaceFlows: true })) {
1035
- if ((flow.source || "") !== "workspace" || flow.archived || !flow.path) continue;
1036
- const collaboration = getWorkspaceCollaborationByFlow(flow.id, false);
1037
- const ownerId = String(collaboration?.ownerId || "").trim();
1038
- if (!ownerId) continue;
1039
- appendFlow(ownerId, flow, "workspace", collaboration.id);
1040
- }
1041
- return resources;
1101
+ return listIndexedProjectFlows(workspaceRoot, userCtx, scope);
1042
1102
  }
1043
1103
 
1044
1104
  function publicProjectFlowMarketplaceResource(resource) {
@@ -1062,13 +1122,26 @@ function marketplaceResourceMatches(item, queryText) {
1062
1122
  function sortMarketplaceResources(items) {
1063
1123
  return items.sort((a, b) => (
1064
1124
  Number(b.useCount || 0) - Number(a.useCount || 0)
1065
- || Number(b.installCount || 0) - Number(a.installCount || 0)
1066
1125
  || String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || ""))
1067
1126
  || String(a.displayName || a.id).localeCompare(String(b.displayName || b.id))
1068
1127
  || String(b.version || "").localeCompare(String(a.version || ""), undefined, { numeric: true, sensitivity: "base" })
1069
1128
  ));
1070
1129
  }
1071
1130
 
1131
+ function paginateMarketplaceResources(items, url, defaultLimit = 24) {
1132
+ const requestedLimit = Number(url.searchParams.get("limit") || defaultLimit);
1133
+ const limit = Math.min(100, Math.max(1, Number.isFinite(requestedLimit) ? Math.floor(requestedLimit) : defaultLimit));
1134
+ const requestedCursor = Number(url.searchParams.get("cursor") || 0);
1135
+ const offset = Math.max(0, Number.isFinite(requestedCursor) ? Math.floor(requestedCursor) : 0);
1136
+ const pageItems = items.slice(offset, offset + limit);
1137
+ const nextOffset = offset + pageItems.length;
1138
+ return {
1139
+ items: pageItems,
1140
+ total: items.length,
1141
+ nextCursor: nextOffset < items.length ? String(nextOffset) : "",
1142
+ };
1143
+ }
1144
+
1072
1145
  /**
1073
1146
  * @param {import('http').IncomingMessage} req
1074
1147
  * @param {import('http').ServerResponse} res
@@ -1131,6 +1204,7 @@ async function workspaceRoutes(req, res, ctx) {
1131
1204
  }
1132
1205
  try {
1133
1206
  const result = publishNodePackageArchive(root, parsed.file, { ownerUserId: userCtx.userId });
1207
+ if (result.ok) markRepositoryIndexDirty(root);
1134
1208
  json(res, result.ok ? (result.alreadyExists ? 200 : 201) : (result.conflict ? 409 : 400), result);
1135
1209
  } catch (e) {
1136
1210
  json(res, 500, { ok: false, error: (e && e.message) || String(e) });
@@ -1163,6 +1237,7 @@ async function workspaceRoutes(req, res, ctx) {
1163
1237
  actorUserId: userCtx.userId || "",
1164
1238
  eventId: `install:node:${id}@${version}:${userCtx.userId || "anonymous"}`,
1165
1239
  });
1240
+ markRepositoryIndexDirty(root);
1166
1241
  res.writeHead(200, {
1167
1242
  "Content-Type": "application/zip",
1168
1243
  "Content-Length": result.archive.length,
@@ -1186,7 +1261,7 @@ async function workspaceRoutes(req, res, ctx) {
1186
1261
  const marketplaceScope = scope === "owned" ? "owned" : "all";
1187
1262
  const queryText = String(url.searchParams.get("q") || "").trim().toLowerCase();
1188
1263
  if (kind === "node") {
1189
- const marketplaceNodes = listMarketplacePackages(root, { ...userCtx, marketplaceScope }).nodes
1264
+ const marketplaceNodes = listIndexedNodes(root, userCtx, marketplaceScope)
1190
1265
  .map((node) => ({ ...node, resourceType: "node", installed: true }));
1191
1266
  let nodes = marketplaceNodes;
1192
1267
  if (scope === "installed") {
@@ -1217,23 +1292,23 @@ async function workspaceRoutes(req, res, ctx) {
1217
1292
  });
1218
1293
  }
1219
1294
  nodes = sortMarketplaceResources(nodes.filter((item) => marketplaceResourceMatches(item, queryText)));
1220
- json(res, 200, { kind, scope, sort: "useCount", order: "desc", items: nodes });
1295
+ const page = paginateMarketplaceResources(nodes, url);
1296
+ const index = getRepositoryIndex(root);
1297
+ json(res, 200, {
1298
+ kind,
1299
+ scope,
1300
+ sort: "useCount",
1301
+ order: "desc",
1302
+ ...page,
1303
+ index: { status: "ready", generatedAt: index.generatedAt },
1304
+ });
1221
1305
  return;
1222
1306
  }
1223
1307
  if (kind !== "flow") {
1224
1308
  json(res, 400, { error: "kind must be flow or node" });
1225
1309
  return;
1226
1310
  }
1227
- const installedCopies = installedMarketplaceFlowCopies(userCtx.userId);
1228
- const flows = listMarketplaceFlows(root, { ...userCtx, marketplaceScope }).flows.map((flow) => {
1229
- const installedFlowIds = installedCopies.get(`${flow.id}@${flow.version}`) || [];
1230
- return {
1231
- ...flow,
1232
- resourceType: "flow",
1233
- installed: installedFlowIds.length > 0,
1234
- installedFlowIds,
1235
- };
1236
- });
1311
+ const installedCopies = indexedMarketplaceFlowCopies(root, userCtx.userId);
1237
1312
  const projectFlows = listRunnableProjectMarketplaceFlows(root, userCtx, scope).map((flow) => {
1238
1313
  const installedFlowIds = installedCopies.get(`${flow.id}@${flow.version}`) || [];
1239
1314
  return publicProjectFlowMarketplaceResource({
@@ -1242,14 +1317,21 @@ async function workspaceRoutes(req, res, ctx) {
1242
1317
  installedFlowIds,
1243
1318
  });
1244
1319
  });
1245
- const snippets = scope === "installed" ? [] : listMarketplaceFlowSnippets(root, { ...userCtx, marketplaceScope }).snippets
1246
- .map((snippet) => ({ ...snippet, resourceType: "flow-snippet", installed: false }));
1247
1320
  const items = sortMarketplaceResources(
1248
- [...projectFlows, ...flows, ...snippets]
1321
+ projectFlows
1249
1322
  .filter((item) => scope !== "installed" || item.installed)
1250
1323
  .filter((item) => marketplaceResourceMatches(item, queryText)),
1251
1324
  );
1252
- json(res, 200, { kind, scope, sort: "useCount", order: "desc", items });
1325
+ const page = paginateMarketplaceResources(items, url);
1326
+ const index = getRepositoryIndex(root);
1327
+ json(res, 200, {
1328
+ kind,
1329
+ scope,
1330
+ sort: "useCount",
1331
+ order: "desc",
1332
+ ...page,
1333
+ index: { status: "ready", generatedAt: index.generatedAt },
1334
+ });
1253
1335
  } catch (e) {
1254
1336
  json(res, 500, { error: (e && e.message) || String(e) });
1255
1337
  }
@@ -1293,6 +1375,220 @@ async function workspaceRoutes(req, res, ctx) {
1293
1375
  return;
1294
1376
  }
1295
1377
 
1378
+ if (req.method === "GET" && url.pathname === "/api/marketplace/flows/preview") {
1379
+ const id = String(url.searchParams.get("id") || "").trim();
1380
+ const version = String(url.searchParams.get("version") || "").trim();
1381
+ const projectFlow = url.searchParams.get("projectFlow") === "1";
1382
+ if (!id || !version) {
1383
+ json(res, 400, { error: "Missing marketplace flow id or version" });
1384
+ return;
1385
+ }
1386
+ try {
1387
+ const installedCopies = indexedMarketplaceFlowCopies(root, userCtx.userId);
1388
+ if (projectFlow) {
1389
+ const source = listRunnableProjectMarketplaceFlows(root, userCtx, "all")
1390
+ .find((flow) => flow.id === id);
1391
+ if (!source) {
1392
+ json(res, 404, { error: "Project flow not found or is private" });
1393
+ return;
1394
+ }
1395
+ if (source.version !== version) {
1396
+ json(res, 409, { error: "该流程已有新版本,请刷新流程仓库后重试" });
1397
+ return;
1398
+ }
1399
+ const preview = indexedProjectFlowPreview(root, source);
1400
+ if (!preview || preview.stale) {
1401
+ markRepositoryIndexDirty(root);
1402
+ json(res, preview?.stale ? 409 : 404, { error: preview?.stale ? "该流程已有新版本,请刷新流程仓库后重试" : "Project flow preview is unavailable" });
1403
+ return;
1404
+ }
1405
+ json(res, 200, {
1406
+ flow: {
1407
+ ...publicProjectFlowMarketplaceResource(source),
1408
+ owned: source.ownerUserId === userCtx.userId,
1409
+ installedFlowIds: installedCopies.get(`${id}@${version}`) || [],
1410
+ },
1411
+ graph: preview.graph,
1412
+ });
1413
+ return;
1414
+ }
1415
+ const source = readMarketplaceFlow(root, id, version, { ...userCtx, marketplaceScope: "all" });
1416
+ if (!source.ok) {
1417
+ json(res, 404, { error: source.error || "Marketplace flow not found" });
1418
+ return;
1419
+ }
1420
+ const metadata = listMarketplaceFlows(root, { ...userCtx, marketplaceScope: "all" }).flows
1421
+ .find((flow) => flow.id === id && flow.version === version) || {};
1422
+ json(res, 200, {
1423
+ flow: {
1424
+ ...metadata,
1425
+ id,
1426
+ version,
1427
+ resourceType: "flow",
1428
+ owned: metadata.ownerUserId === userCtx.userId,
1429
+ installedFlowIds: installedCopies.get(`${id}@${version}`) || [],
1430
+ },
1431
+ graph: source.graph,
1432
+ });
1433
+ } catch (e) {
1434
+ json(res, 500, { error: (e && e.message) || String(e) });
1435
+ }
1436
+ return;
1437
+ }
1438
+
1439
+ if (req.method === "POST" && url.pathname === "/api/marketplace/flows/workspace-preview") {
1440
+ let payload;
1441
+ try {
1442
+ payload = JSON.parse(await readBody(req));
1443
+ } catch {
1444
+ json(res, 400, { error: "Invalid JSON body" });
1445
+ return;
1446
+ }
1447
+ const id = String(payload?.id || "").trim();
1448
+ const version = String(payload?.version || "").trim();
1449
+ const previewKind = ["snippet", "node"].includes(payload?.kind) ? payload.kind : "flow";
1450
+ const projectFlow = payload?.projectFlow === true;
1451
+ if (!id || !version) {
1452
+ json(res, 400, { error: "Missing marketplace flow id or version" });
1453
+ return;
1454
+ }
1455
+ try {
1456
+ const installedCopies = indexedMarketplaceFlowCopies(root, userCtx.userId);
1457
+ let graph;
1458
+ let resource;
1459
+ if (previewKind === "node") {
1460
+ const node = listIndexedNodes(root, userCtx, "all")
1461
+ .find((item) => item.id === id && item.version === version);
1462
+ if (!node) {
1463
+ json(res, 404, { error: "Node package not found or is private" });
1464
+ return;
1465
+ }
1466
+ graph = {
1467
+ version: 1,
1468
+ instances: {
1469
+ node_preview: {
1470
+ definitionId: node.definitionId,
1471
+ marketplaceRef: node.definitionId,
1472
+ marketplacePackageId: node.id,
1473
+ marketplaceVersion: node.version,
1474
+ label: node.displayName || node.id,
1475
+ role: "normal",
1476
+ body: "",
1477
+ input: Array.isArray(node.inputs) ? node.inputs : [],
1478
+ output: Array.isArray(node.outputs) ? node.outputs : [],
1479
+ },
1480
+ },
1481
+ edges: [],
1482
+ ui: { nodePositions: { node_preview: { x: 320, y: 220 } } },
1483
+ };
1484
+ resource = { ...node, resourceType: "node", owned: node.ownerUserId === userCtx.userId };
1485
+ } else if (previewKind === "snippet") {
1486
+ const snippet = listMarketplaceFlowSnippets(root, { ...userCtx, marketplaceScope: "all" }).snippets
1487
+ .find((item) => item.id === id && item.version === version);
1488
+ if (!snippet) {
1489
+ json(res, 404, { error: "Flow snippet not found or is private" });
1490
+ return;
1491
+ }
1492
+ graph = snippet.snippet;
1493
+ resource = { ...snippet, resourceType: "flow-snippet" };
1494
+ } else if (projectFlow) {
1495
+ const source = listRunnableProjectMarketplaceFlows(root, userCtx, "all")
1496
+ .find((flow) => flow.id === id);
1497
+ if (!source) {
1498
+ json(res, 404, { error: "Project flow not found or is private" });
1499
+ return;
1500
+ }
1501
+ if (source.version !== version) {
1502
+ json(res, 409, { error: "该流程已有新版本,请刷新流程仓库后重试" });
1503
+ return;
1504
+ }
1505
+ const preview = indexedProjectFlowPreview(root, source);
1506
+ if (!preview || preview.stale) {
1507
+ markRepositoryIndexDirty(root);
1508
+ json(res, preview?.stale ? 409 : 404, { error: preview?.stale ? "该流程已有新版本,请刷新流程仓库后重试" : "Project flow preview is unavailable" });
1509
+ return;
1510
+ }
1511
+ graph = preview.graph;
1512
+ resource = {
1513
+ ...publicProjectFlowMarketplaceResource(source),
1514
+ owned: source.ownerUserId === userCtx.userId,
1515
+ installedFlowIds: installedCopies.get(`${id}@${version}`) || [],
1516
+ };
1517
+ } else {
1518
+ const source = readMarketplaceFlow(root, id, version, { ...userCtx, marketplaceScope: "all" });
1519
+ if (!source.ok) {
1520
+ json(res, 404, { error: source.error || "Marketplace flow not found" });
1521
+ return;
1522
+ }
1523
+ const metadata = listMarketplaceFlows(root, { ...userCtx, marketplaceScope: "all" }).flows
1524
+ .find((flow) => flow.id === id && flow.version === version) || {};
1525
+ graph = source.graph;
1526
+ resource = {
1527
+ ...metadata,
1528
+ id,
1529
+ version,
1530
+ resourceType: "flow",
1531
+ owned: metadata.ownerUserId === userCtx.userId,
1532
+ installedFlowIds: installedCopies.get(`${id}@${version}`) || [],
1533
+ };
1534
+ }
1535
+ const flowId = createWorkspacePreviewId();
1536
+ const flowDir = workspaceSharedPreviewFlowDir(root, flowId);
1537
+ const now = Date.now();
1538
+ const metadata = {
1539
+ version: 1,
1540
+ flowId,
1541
+ ownerId: authUser.userId,
1542
+ title: String(resource.displayName || resource.definitionId || id).trim().slice(0, 200),
1543
+ createdAt: new Date(now).toISOString(),
1544
+ updatedAt: new Date(now).toISOString(),
1545
+ expiresAt: new Date(now + DEFAULT_WORKSPACE_PREVIEW_TTL_MS).toISOString(),
1546
+ marketplace: { id, version, projectFlow },
1547
+ };
1548
+ fs.mkdirSync(flowDir, { recursive: true });
1549
+ writeWorkspaceGraph(flowDir, graph, root);
1550
+ writeWorkspacePreviewMetadata(flowDir, metadata);
1551
+ const installedFlowId = resource.installedFlowIds?.[0] || "";
1552
+ const action = previewKind === "node"
1553
+ ? "add-node"
1554
+ : previewKind === "snippet" ? "add-snippet"
1555
+ : resource.projectFlow && resource.owned
1556
+ ? "open-source"
1557
+ : installedFlowId ? "open-installed" : "install";
1558
+ const previewParams = new URLSearchParams({
1559
+ flowId,
1560
+ flowSource: "workspace",
1561
+ archived: "1",
1562
+ marketplacePreview: "1",
1563
+ marketplaceKind: previewKind,
1564
+ marketplaceResourceId: id,
1565
+ marketplaceVersion: version,
1566
+ marketplaceProjectFlow: projectFlow ? "1" : "0",
1567
+ marketplaceTitle: resource.displayName || resource.definitionId || id,
1568
+ marketplaceAction: action,
1569
+ marketplaceInstallFlowId: resource.installFlowId || resource.liveFlowId || resource.definitionId || id,
1570
+ });
1571
+ if (previewKind === "node") previewParams.set("focusNodeId", "node_preview");
1572
+ if (action === "open-source") {
1573
+ previewParams.set("marketplaceTargetFlowId", resource.liveFlowId || resource.definitionId || "");
1574
+ previewParams.set("marketplaceTargetFlowSource", resource.liveFlowSource || "user");
1575
+ if (resource.liveWorkspaceId) previewParams.set("marketplaceTargetWorkspaceId", resource.liveWorkspaceId);
1576
+ } else if (action === "open-installed") {
1577
+ previewParams.set("marketplaceTargetFlowId", installedFlowId);
1578
+ previewParams.set("marketplaceTargetFlowSource", "user");
1579
+ }
1580
+ json(res, 200, {
1581
+ ok: true,
1582
+ preview: true,
1583
+ expiresAt: metadata.expiresAt,
1584
+ url: `/workspace?${previewParams}`,
1585
+ });
1586
+ } catch (e) {
1587
+ json(res, 500, { error: (e && e.message) || String(e) });
1588
+ }
1589
+ return;
1590
+ }
1591
+
1296
1592
  if (req.method === "POST" && url.pathname === "/api/marketplace/flows/publish") {
1297
1593
  let payload;
1298
1594
  try {
@@ -1329,6 +1625,7 @@ async function workspaceRoutes(req, res, ctx) {
1329
1625
  visibility: payload?.visibility || "public",
1330
1626
  graph,
1331
1627
  }, userCtx);
1628
+ if (result.ok) markRepositoryIndexDirty(root);
1332
1629
  json(res, result.ok ? (result.alreadyExists ? 200 : 201) : (result.conflict ? 409 : 400), result);
1333
1630
  } catch (e) {
1334
1631
  json(res, 500, { error: (e && e.message) || String(e) });
@@ -1372,7 +1669,13 @@ async function workspaceRoutes(req, res, ctx) {
1372
1669
  json(res, 409, { error: "该流程已有新版本,请刷新流程仓库后重试" });
1373
1670
  return;
1374
1671
  }
1375
- sourceGraph = source._graph;
1672
+ const preview = indexedProjectFlowPreview(root, source);
1673
+ if (!preview || preview.stale) {
1674
+ markRepositoryIndexDirty(root);
1675
+ json(res, preview?.stale ? 409 : 404, { error: preview?.stale ? "该流程已有新版本,请刷新流程仓库后重试" : "Project flow preview is unavailable" });
1676
+ return;
1677
+ }
1678
+ sourceGraph = preview.graph;
1376
1679
  originKind = "project-flow";
1377
1680
  } else {
1378
1681
  const source = readMarketplaceFlow(root, id, version, { ...userCtx, marketplaceScope: "all" });
@@ -1394,6 +1697,7 @@ async function workspaceRoutes(req, res, ctx) {
1394
1697
  actorUserId: userCtx.userId || "",
1395
1698
  eventId: `install:${originKind}:${id}@${version}:${userCtx.userId || "anonymous"}`,
1396
1699
  });
1700
+ markRepositoryIndexDirty(root);
1397
1701
  json(res, 201, {
1398
1702
  ok: true,
1399
1703
  id,
@@ -1429,17 +1733,27 @@ async function workspaceRoutes(req, res, ctx) {
1429
1733
  return;
1430
1734
  }
1431
1735
  const metadata = writeProjectFlowMarketplaceMetadata(source._flowRoot, payload?.visibility);
1736
+ updateIndexedProjectFlowVisibility(root, id, metadata.visibility, metadata.updatedAt);
1432
1737
  json(res, 200, { ok: true, kind: "project-flow", id, version: source.version, ...metadata });
1433
1738
  return;
1434
1739
  }
1740
+ const resourceKind = String(payload?.kind || "");
1741
+ const resourceId = String(payload?.id || "");
1742
+ const resourceVersion = String(payload?.version || "");
1743
+ const resourceVisibility = String(payload?.visibility || "public");
1435
1744
  const result = setMarketplaceVisibility(
1436
1745
  root,
1437
- String(payload?.kind || ""),
1438
- String(payload?.id || ""),
1439
- String(payload?.version || ""),
1440
- String(payload?.visibility || "public"),
1746
+ resourceKind,
1747
+ resourceId,
1748
+ resourceVersion,
1749
+ resourceVisibility,
1441
1750
  userCtx,
1442
1751
  );
1752
+ if (result.ok && resourceKind === "node") {
1753
+ updateIndexedNodeVisibility(root, resourceId, resourceVersion, resourceVisibility);
1754
+ } else if (result.ok) {
1755
+ markRepositoryIndexDirty(root);
1756
+ }
1443
1757
  json(res, result.ok ? 200 : 400, result);
1444
1758
  } catch (e) {
1445
1759
  json(res, 500, { error: (e && e.message) || String(e) });
@@ -1729,6 +2043,49 @@ async function workspaceRoutes(req, res, ctx) {
1729
2043
  return;
1730
2044
  }
1731
2045
 
2046
+ if (req.method === "GET" && url.pathname === "/api/workspace/node-review") {
2047
+ try {
2048
+ const nodeId = String(url.searchParams.get("nodeId") || "").trim();
2049
+ if (!nodeId) {
2050
+ json(res, 400, { error: "Missing nodeId" });
2051
+ return;
2052
+ }
2053
+ const scoped = resolveWorkspaceScopeRoot(root, {
2054
+ flowId: url.searchParams.get("flowId") || "",
2055
+ flowSource: url.searchParams.get("flowSource") || "user",
2056
+ workspaceId: url.searchParams.get("workspaceId") || "",
2057
+ adminOwnerId: url.searchParams.get("adminOwnerId") || "",
2058
+ archived: url.searchParams.get("archived") === "1",
2059
+ }, userCtx);
2060
+ if (scoped.error) {
2061
+ json(res, scoped.status || 400, { error: scoped.error });
2062
+ return;
2063
+ }
2064
+ const scopedUserCtx = workspaceScopedUserContext(scoped, userCtx);
2065
+ const draftGraph = readWorkspaceGraph(scoped.root, root).graph;
2066
+ const stableRelease = readWorkspaceStableRelease(scoped.root, root);
2067
+ const draft = workspaceNodeReviewSnapshot(root, scoped.root, draftGraph, nodeId, scopedUserCtx, "Draft");
2068
+ const stable = stableRelease
2069
+ ? workspaceNodeReviewSnapshot(root, stableRelease.root, stableRelease.graph, nodeId, scopedUserCtx, `Stable ${stableRelease.release.id}`)
2070
+ : null;
2071
+ if (!draft && !stable) {
2072
+ json(res, 404, { error: "Node not found" });
2073
+ return;
2074
+ }
2075
+ json(res, 200, {
2076
+ nodeId,
2077
+ draft,
2078
+ stable,
2079
+ stableReleaseId: stableRelease?.release?.id || "",
2080
+ stableRevision: stableRelease?.release?.designRevision || "",
2081
+ draftRevision: workspaceDesignRevision(draftGraph),
2082
+ });
2083
+ } catch (e) {
2084
+ json(res, 500, { error: (e && e.message) || String(e) });
2085
+ }
2086
+ return;
2087
+ }
2088
+
1732
2089
  if (req.method === "GET" && url.pathname === "/api/workspaces") {
1733
2090
  try {
1734
2091
  const scoped = resolveWorkspaceScopeRoot(root, {
@@ -1931,6 +2288,7 @@ async function workspaceRoutes(req, res, ctx) {
1931
2288
  const graph = workspaceGraphWithScheduleMode(draftGraph, payload.scheduleMode || "disabled");
1932
2289
  fs.mkdirSync(targetDir, { recursive: true });
1933
2290
  writeWorkspaceGraph(targetDir, graph, root);
2291
+ markRepositoryIndexDirty(root);
1934
2292
  let collaborationCreated = false;
1935
2293
  if (flowSource === "workspace") {
1936
2294
  ensureWorkspaceCollaboration({ flowId, userId: authUser.userId });
@@ -2214,6 +2572,7 @@ async function workspaceRoutes(req, res, ctx) {
2214
2572
  json(res, result.conflict ? 409 : 400, result);
2215
2573
  return;
2216
2574
  }
2575
+ markRepositoryIndexDirty(root);
2217
2576
  const graph = readWorkspaceGraph(scoped.root, root).graph;
2218
2577
  const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx);
2219
2578
  broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
@@ -2262,6 +2621,7 @@ async function workspaceRoutes(req, res, ctx) {
2262
2621
  json(res, 404, result);
2263
2622
  return;
2264
2623
  }
2624
+ markRepositoryIndexDirty(root);
2265
2625
  const graph = readWorkspaceGraph(scoped.root, root).graph;
2266
2626
  const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx);
2267
2627
  broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
@@ -2389,6 +2749,7 @@ async function workspaceRoutes(req, res, ctx) {
2389
2749
  }
2390
2750
  throw new Error(`Workspace graph save rolled back: ${(scheduleError && scheduleError.message) || String(scheduleError)}`);
2391
2751
  }
2752
+ markRepositoryIndexDirty(root);
2392
2753
  broadcastWorkspaceCollaborationEvent(
2393
2754
  userCtx,
2394
2755
  scoped.flowSource,
@@ -2500,6 +2861,7 @@ async function workspaceRoutes(req, res, ctx) {
2500
2861
  graph.instances[scheduleNodeId] = { ...instance, body: JSON.stringify(config) };
2501
2862
  const committed = commitWorkspaceGraph(root, scoped, graph, userCtx);
2502
2863
  const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, committed.graph, authUser, userCtx);
2864
+ markRepositoryIndexDirty(root);
2503
2865
  const schedule = workspaceSchedules.find((item) => item.scheduleNodeId === scheduleNodeId) || {
2504
2866
  flowId,
2505
2867
  flowSource,
@@ -3572,6 +3934,436 @@ async function workspaceRoutes(req, res, ctx) {
3572
3934
  return;
3573
3935
  }
3574
3936
 
3937
+ if (req.method === "GET" && url.pathname === "/api/workspace/explorations") {
3938
+ try {
3939
+ const scoped = resolveWorkspaceScopeRoot(root, {
3940
+ flowId: url.searchParams.get("flowId") || "",
3941
+ flowSource: url.searchParams.get("flowSource") || "user",
3942
+ adminOwnerId: url.searchParams.get("adminOwnerId") || "",
3943
+ archived: url.searchParams.get("archived") === "1" || url.searchParams.get("flowArchived") === "1",
3944
+ }, userCtx);
3945
+ if (scoped.error) {
3946
+ json(res, 400, { error: scoped.error });
3947
+ return;
3948
+ }
3949
+ json(res, 200, { ok: true, explorations: listAiExplorationSessions(scoped.root) });
3950
+ } catch (e) {
3951
+ json(res, 500, { error: (e && e.message) || String(e) });
3952
+ }
3953
+ return;
3954
+ }
3955
+
3956
+ if (req.method === "GET" && url.pathname === "/api/workspace/exploration") {
3957
+ try {
3958
+ const scoped = resolveWorkspaceScopeRoot(root, {
3959
+ flowId: url.searchParams.get("flowId") || "",
3960
+ flowSource: url.searchParams.get("flowSource") || "user",
3961
+ adminOwnerId: url.searchParams.get("adminOwnerId") || "",
3962
+ archived: url.searchParams.get("archived") === "1" || url.searchParams.get("flowArchived") === "1",
3963
+ }, userCtx);
3964
+ if (scoped.error) {
3965
+ json(res, 400, { error: scoped.error });
3966
+ return;
3967
+ }
3968
+ json(res, 200, { ok: true, exploration: readAiExplorationSession(scoped.root, url.searchParams.get("id") || "") });
3969
+ } catch (e) {
3970
+ json(res, /Invalid exploration|ENOENT/.test(String(e?.message || e)) ? 404 : 500, { error: (e && e.message) || String(e) });
3971
+ }
3972
+ return;
3973
+ }
3974
+
3975
+ if (req.method === "POST" && url.pathname === "/api/workspace/exploration") {
3976
+ let payload;
3977
+ try { payload = JSON.parse(await readBody(req)); } catch {
3978
+ json(res, 400, { error: "Invalid JSON body" });
3979
+ return;
3980
+ }
3981
+ try {
3982
+ const scoped = resolveWorkspaceScopeRoot(root, {
3983
+ flowId: payload.flowId || "",
3984
+ flowSource: payload.flowSource || "user",
3985
+ adminOwnerId: payload.adminOwnerId || "",
3986
+ archived: payload.archived === true || payload.flowArchived === true,
3987
+ }, userCtx);
3988
+ if (scoped.error) {
3989
+ json(res, 400, { error: scoped.error });
3990
+ return;
3991
+ }
3992
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
3993
+ json(res, 403, { error: "Exploration write permission denied" });
3994
+ return;
3995
+ }
3996
+ const exploration = createAiExplorationSession(scoped.root, {
3997
+ title: payload.title,
3998
+ goal: payload.goal,
3999
+ mode: payload.mode || "observed",
4000
+ status: payload.status || "running",
4001
+ source: payload.source || { provider: "external", agent: "custom" },
4002
+ });
4003
+ json(res, 201, {
4004
+ ok: true,
4005
+ exploration,
4006
+ ingest: {
4007
+ method: "POST",
4008
+ path: "/api/workspace/exploration/events",
4009
+ body: {
4010
+ flowId: payload.flowId || "",
4011
+ flowSource: payload.flowSource || "user",
4012
+ id: exploration.id,
4013
+ events: [],
4014
+ },
4015
+ },
4016
+ });
4017
+ } catch (e) {
4018
+ json(res, 500, { error: (e && e.message) || String(e) });
4019
+ }
4020
+ return;
4021
+ }
4022
+
4023
+ if (req.method === "POST" && url.pathname === "/api/workspace/exploration/events") {
4024
+ let payload;
4025
+ try { payload = JSON.parse(await readBody(req)); } catch {
4026
+ json(res, 400, { error: "Invalid JSON body" });
4027
+ return;
4028
+ }
4029
+ try {
4030
+ const scoped = resolveWorkspaceScopeRoot(root, {
4031
+ flowId: payload.flowId || "",
4032
+ flowSource: payload.flowSource || "user",
4033
+ adminOwnerId: payload.adminOwnerId || "",
4034
+ archived: payload.archived === true || payload.flowArchived === true,
4035
+ }, userCtx);
4036
+ if (scoped.error) {
4037
+ json(res, 400, { error: scoped.error });
4038
+ return;
4039
+ }
4040
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
4041
+ json(res, 403, { error: "Exploration write permission denied" });
4042
+ return;
4043
+ }
4044
+ const appended = appendAiTraceEvents(scoped.root, payload.id, payload.events || [], {
4045
+ phase: payload.phase || "observed",
4046
+ });
4047
+ if (payload.status || payload.summary) {
4048
+ appended.session = updateAiExplorationSession(scoped.root, payload.id, {
4049
+ ...(payload.status ? { status: payload.status } : {}),
4050
+ ...(payload.summary ? { summary: payload.summary } : {}),
4051
+ });
4052
+ }
4053
+ json(res, 200, { ok: true, ...appended });
4054
+ } catch (e) {
4055
+ json(res, /Invalid exploration|ENOENT/.test(String(e?.message || e)) ? 404 : 400, { error: (e && e.message) || String(e) });
4056
+ }
4057
+ return;
4058
+ }
4059
+
4060
+ if (req.method === "POST" && url.pathname === "/api/workspace/exploration/plan") {
4061
+ let payload;
4062
+ try { payload = JSON.parse(await readBody(req)); } catch {
4063
+ json(res, 400, { error: "Invalid JSON body" });
4064
+ return;
4065
+ }
4066
+ const goal = String(payload?.goal || "").trim();
4067
+ if (!goal) {
4068
+ json(res, 400, { error: "Missing exploration goal" });
4069
+ return;
4070
+ }
4071
+ let scoped;
4072
+ let exploration;
4073
+ try {
4074
+ scoped = resolveWorkspaceScopeRoot(root, {
4075
+ flowId: payload.flowId || "",
4076
+ flowSource: payload.flowSource || "user",
4077
+ adminOwnerId: payload.adminOwnerId || "",
4078
+ archived: payload.archived === true || payload.flowArchived === true,
4079
+ }, userCtx);
4080
+ if (scoped.error) {
4081
+ json(res, 400, { error: scoped.error });
4082
+ return;
4083
+ }
4084
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
4085
+ json(res, 403, { error: "Exploration write permission denied" });
4086
+ return;
4087
+ }
4088
+ const graphRead = readWorkspaceGraph(scoped.root);
4089
+ const graph = graphRead?.graph || graphRead;
4090
+ const workspaceSource = workspaceGraphAsSource(graph);
4091
+ exploration = createAiExplorationSession(scoped.root, {
4092
+ title: payload.title || goal.slice(0, 80),
4093
+ goal,
4094
+ mode: "planned",
4095
+ status: "planning",
4096
+ source: { provider: "agentflow", agent: String(payload.model || "default") },
4097
+ });
4098
+ const chunks = [];
4099
+ let result = "";
4100
+ const handle = startComposerAgent({
4101
+ uiWorkspaceRoot: scoped.root,
4102
+ cliWorkspace: scoped.root,
4103
+ prompt: aiPlanPrompt({ goal, workspaceSource }),
4104
+ modelKey: typeof payload.model === "string" ? payload.model.trim() : "",
4105
+ agentflowUserId: userCtx.userId || "",
4106
+ mode: "plan",
4107
+ force: false,
4108
+ sandboxDisabled: false,
4109
+ approveMcps: false,
4110
+ sandboxMode: "read-only",
4111
+ allowDanger: false,
4112
+ includeJsonResult: true,
4113
+ onStreamEvent(ev) {
4114
+ if (ev?.type !== "natural" || typeof ev.text !== "string") return;
4115
+ if (ev.kind === "result") result = ev.text.trim();
4116
+ else if (ev.kind === "assistant" && ev.text.trim()) chunks.push(ev.text.trim());
4117
+ },
4118
+ });
4119
+ await handle.finished;
4120
+ const plan = parseAiPlanResult(result || chunks.at(-1) || chunks.join("\n"), exploration.id);
4121
+ const appended = appendAiTraceEvents(scoped.root, exploration.id, plan.events, { phase: "planned" });
4122
+ const ready = updateAiExplorationSession(scoped.root, exploration.id, {
4123
+ title: plan.title,
4124
+ summary: plan.summary,
4125
+ status: "ready",
4126
+ eventCount: appended.session.eventCount,
4127
+ });
4128
+ json(res, 201, { ok: true, exploration: { ...ready, events: appended.events } });
4129
+ } catch (e) {
4130
+ if (scoped?.root && exploration?.id) {
4131
+ try { updateAiExplorationSession(scoped.root, exploration.id, { status: "failed", summary: String(e?.message || e) }); } catch {}
4132
+ }
4133
+ json(res, 500, { error: (e && e.message) || String(e), ...(exploration?.id ? { explorationId: exploration.id } : {}) });
4134
+ }
4135
+ return;
4136
+ }
4137
+
4138
+ if (req.method === "POST" && url.pathname === "/api/workspace/exploration/dry-run") {
4139
+ let payload;
4140
+ try { payload = JSON.parse(await readBody(req)); } catch {
4141
+ json(res, 400, { error: "Invalid JSON body" });
4142
+ return;
4143
+ }
4144
+ try {
4145
+ const scoped = resolveWorkspaceScopeRoot(root, {
4146
+ flowId: payload.flowId || "",
4147
+ flowSource: payload.flowSource || "user",
4148
+ adminOwnerId: payload.adminOwnerId || "",
4149
+ archived: payload.archived === true || payload.flowArchived === true,
4150
+ }, userCtx);
4151
+ if (scoped.error) {
4152
+ json(res, 400, { error: scoped.error });
4153
+ return;
4154
+ }
4155
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
4156
+ json(res, 403, { error: "Exploration dry-run permission denied" });
4157
+ return;
4158
+ }
4159
+ const exploration = readAiExplorationSession(scoped.root, payload.id);
4160
+ const planned = exploration.events.filter((event) => event.phase === "planned");
4161
+ if (!planned.length) {
4162
+ json(res, 400, { error: "Exploration has no planned spans" });
4163
+ return;
4164
+ }
4165
+ const runToken = Date.now().toString(36);
4166
+ const simulated = planned.map((event, index) => {
4167
+ const blocked = event.requiresApproval === true || ["write", "external"].includes(event.sideEffect);
4168
+ return {
4169
+ id: `dry_${runToken}_${index + 1}`,
4170
+ spanId: `dry_${runToken}_${event.spanId}`,
4171
+ parentSpanId: event.parentSpanId ? `dry_${runToken}_${event.parentSpanId}` : "",
4172
+ phase: "simulated",
4173
+ type: event.type,
4174
+ name: event.name,
4175
+ summary: blocked
4176
+ ? `策略预检已阻断:${event.summary || event.name}。真实执行前需要明确授权。`
4177
+ : `策略预检通过:${event.summary || event.name}。本次未执行真实工具。`,
4178
+ sideEffect: event.sideEffect,
4179
+ requiresApproval: blocked,
4180
+ status: blocked ? "blocked" : "success",
4181
+ inputPreview: event.inputPreview || "",
4182
+ outputPreview: blocked ? "副作用未执行" : "约束检查通过,未执行真实工具",
4183
+ };
4184
+ });
4185
+ const appended = appendAiTraceEvents(scoped.root, exploration.id, simulated, { phase: "simulated" });
4186
+ const blockedCount = appended.events.filter((event) => event.status === "blocked").length;
4187
+ const updated = updateAiExplorationSession(scoped.root, exploration.id, {
4188
+ mode: "simulated",
4189
+ status: "ready",
4190
+ summary: `Dry-run 策略预检完成:${appended.events.length - blockedCount} 项通过,${blockedCount} 项等待授权。`,
4191
+ eventCount: appended.session.eventCount,
4192
+ });
4193
+ json(res, 200, {
4194
+ ok: true,
4195
+ exploration: { ...updated, events: [...exploration.events, ...appended.events] },
4196
+ dryRun: { kind: "policy-check", executedTools: false, blockedCount },
4197
+ });
4198
+ } catch (e) {
4199
+ json(res, 500, { error: (e && e.message) || String(e) });
4200
+ }
4201
+ return;
4202
+ }
4203
+
4204
+ if (req.method === "POST" && url.pathname === "/api/workspace/exploration/materialize") {
4205
+ let payload;
4206
+ try { payload = JSON.parse(await readBody(req)); } catch {
4207
+ json(res, 400, { error: "Invalid JSON body" });
4208
+ return;
4209
+ }
4210
+ let scoped;
4211
+ let exploration;
4212
+ let materializeRunId = "";
4213
+ try {
4214
+ scoped = resolveWorkspaceScopeRoot(root, {
4215
+ flowId: payload.flowId || "",
4216
+ flowSource: payload.flowSource || "user",
4217
+ adminOwnerId: payload.adminOwnerId || "",
4218
+ archived: payload.archived === true || payload.flowArchived === true,
4219
+ }, userCtx);
4220
+ if (scoped.error) {
4221
+ json(res, 400, { error: scoped.error });
4222
+ return;
4223
+ }
4224
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
4225
+ json(res, 403, { error: "Exploration materialization permission denied" });
4226
+ return;
4227
+ }
4228
+ exploration = readAiExplorationSession(scoped.root, payload.id);
4229
+ if (!exploration.events.length) {
4230
+ json(res, 400, { error: "Exploration has no trace events" });
4231
+ return;
4232
+ }
4233
+ const materializableEvents = materializableAiTraceEvents(exploration);
4234
+ if (!materializableEvents.length) {
4235
+ json(res, 400, { error: "Exploration has no planned or observed spans to materialize" });
4236
+ return;
4237
+ }
4238
+ const dangerousEvents = materializableEvents.filter((event) => event.requiresApproval || ["write", "external"].includes(event.sideEffect));
4239
+ if (dangerousEvents.length && payload.approveSideEffects !== true) {
4240
+ json(res, 409, {
4241
+ error: "Side-effect review is required before materialization",
4242
+ sideEffects: dangerousEvents.map((event) => ({ spanId: event.spanId, name: event.name, sideEffect: event.sideEffect })),
4243
+ });
4244
+ return;
4245
+ }
4246
+ const beforeGraphRead = readWorkspaceGraph(scoped.root);
4247
+ const beforeGraph = beforeGraphRead?.graph || beforeGraphRead;
4248
+ const beforeIds = new Set([
4249
+ ...(beforeGraph.nodes || []).map((node) => String(node.id || "")),
4250
+ ...Object.keys(beforeGraph.instances || {}),
4251
+ ].filter(Boolean));
4252
+ materializeRunId = `materialize_${Date.now().toString(36)}`;
4253
+ appendAiTraceEvents(scoped.root, exploration.id, [{
4254
+ id: `${materializeRunId}_start`,
4255
+ spanId: materializeRunId,
4256
+ type: "agent",
4257
+ name: "固化 Agent",
4258
+ summary: "开始把已审核 Trace 转换为 Workspace DSL 调整态",
4259
+ status: "running",
4260
+ phase: "observed",
4261
+ sideEffect: "write",
4262
+ }], { phase: "observed" });
4263
+ updateAiExplorationSession(scoped.root, exploration.id, { mode: "observed", status: "running" });
4264
+ let reply = "";
4265
+ let toolIndex = 0;
4266
+ const handle = startComposerAgent({
4267
+ uiWorkspaceRoot: scoped.root,
4268
+ cliWorkspace: scoped.root,
4269
+ prompt: aiMaterializationPrompt(exploration, workspaceGraphAsSource(beforeGraph)),
4270
+ modelKey: typeof payload.model === "string" ? payload.model.trim() : "",
4271
+ agentflowUserId: userCtx.userId || "",
4272
+ force: true,
4273
+ sandboxDisabled: false,
4274
+ approveMcps: false,
4275
+ sandboxMode: "workspace-write",
4276
+ allowDanger: false,
4277
+ onToolCall(subtype, toolName) {
4278
+ toolIndex += 1;
4279
+ try {
4280
+ const toolStatus = /(?:complete|success|done)/i.test(String(subtype || ""))
4281
+ ? "success"
4282
+ : /(?:error|fail)/i.test(String(subtype || "")) ? "error" : "running";
4283
+ const sideEffect = classifyAiToolSideEffect(toolName, subtype);
4284
+ appendAiTraceEvents(scoped.root, exploration.id, [{
4285
+ id: `${materializeRunId}_tool_${toolIndex}`,
4286
+ spanId: `${materializeRunId}_tool_${toolIndex}`,
4287
+ parentSpanId: materializeRunId,
4288
+ type: String(subtype || "").toLowerCase() === "thinking" ? "decision" : "tool",
4289
+ name: String(toolName || subtype || "Agent tool"),
4290
+ summary: subtype ? `Agent 工具事件:${subtype}` : "Agent 工具调用",
4291
+ status: toolStatus,
4292
+ phase: "observed",
4293
+ sideEffect,
4294
+ }], { phase: "observed" });
4295
+ } catch {
4296
+ // Trace persistence must not interrupt an in-flight materialization.
4297
+ }
4298
+ },
4299
+ onStreamEvent(ev) {
4300
+ if (ev?.type === "natural" && ["assistant", "result"].includes(ev.kind) && typeof ev.text === "string" && ev.text.trim()) {
4301
+ reply = ev.text.trim();
4302
+ }
4303
+ },
4304
+ });
4305
+ await handle.finished;
4306
+ appendAiTraceEvents(scoped.root, exploration.id, [{
4307
+ id: `${materializeRunId}_finish`,
4308
+ spanId: materializeRunId,
4309
+ type: "agent",
4310
+ name: "固化 Agent",
4311
+ summary: reply || "Workspace DSL 调整态生成完成",
4312
+ status: "success",
4313
+ phase: "observed",
4314
+ sideEffect: "write",
4315
+ endedAt: new Date().toISOString(),
4316
+ }], { phase: "observed" });
4317
+ const graphRead = readWorkspaceGraph(scoped.root);
4318
+ const graph = graphRead?.graph || graphRead;
4319
+ const graphIds = [
4320
+ ...(graph.nodes || []).map((node) => String(node.id || "")),
4321
+ ...Object.keys(graph.instances || {}),
4322
+ ].filter(Boolean);
4323
+ const nodeIds = [...new Set(graphIds)].filter((id) => !beforeIds.has(id));
4324
+ const materialization = writeAiExplorationMaterialization(scoped.root, exploration.id, {
4325
+ nodeIds,
4326
+ designRevision: workspaceDesignRevision(graph),
4327
+ });
4328
+ appendAiTraceEvents(scoped.root, exploration.id, [{
4329
+ type: "status",
4330
+ name: "固化为 Workspace DSL",
4331
+ summary: reply || `新增 ${nodeIds.length} 个节点`,
4332
+ status: "success",
4333
+ phase: "materialized",
4334
+ sideEffect: "write",
4335
+ artifacts: [{ kind: "dsl", path: "workspace.flow.js", label: "Workspace DSL" }],
4336
+ }], { phase: "materialized" });
4337
+ const updated = updateAiExplorationSession(scoped.root, exploration.id, {
4338
+ mode: "materialized",
4339
+ status: "completed",
4340
+ materializedAt: materialization.materializedAt,
4341
+ });
4342
+ json(res, 200, { ok: true, exploration: updated, materialization, graph, reply });
4343
+ } catch (e) {
4344
+ if (scoped?.root && exploration?.id) {
4345
+ try {
4346
+ if (materializeRunId) {
4347
+ appendAiTraceEvents(scoped.root, exploration.id, [{
4348
+ id: `${materializeRunId}_error`,
4349
+ spanId: materializeRunId,
4350
+ type: "agent",
4351
+ name: "固化 Agent",
4352
+ summary: String(e?.message || e),
4353
+ status: "error",
4354
+ phase: "observed",
4355
+ sideEffect: "write",
4356
+ endedAt: new Date().toISOString(),
4357
+ }], { phase: "observed" });
4358
+ }
4359
+ updateAiExplorationSession(scoped.root, exploration.id, { mode: "observed", status: "failed" });
4360
+ } catch {}
4361
+ }
4362
+ json(res, 500, { error: (e && e.message) || String(e) });
4363
+ }
4364
+ return;
4365
+ }
4366
+
3575
4367
  if (url.pathname === "/api/workspace/conversations") {
3576
4368
  let payload = {};
3577
4369
  if (req.method === "POST") {
@@ -4018,6 +4810,7 @@ async function workspaceRoutes(req, res, ctx) {
4018
4810
  json(res, result.conflict ? 409 : 400, { error: result.error || "发布失败" });
4019
4811
  return;
4020
4812
  }
4813
+ markRepositoryIndexDirty(root);
4021
4814
  json(res, 200, { ok: true, ...result });
4022
4815
  } catch (e) {
4023
4816
  json(res, 500, { error: (e && e.message) || String(e) });