@fieldwangai/agentflow 0.1.147 → 0.1.153

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.
package/README.md CHANGED
@@ -24,6 +24,13 @@
24
24
 
25
25
  ![Running Status](docs/running.png)
26
26
 
27
+ ### Terminology
28
+
29
+ In this repository, an executable node graph is called a **Flow** (also
30
+ shown as a Pipeline in the editor) and is stored in `flow.yaml`. A product
31
+ requirement **Workflow** is a separate TAPD-backed record addressed as
32
+ `tapd:<id>`; it is not created, archived, or updated by the Flow editor.
33
+
27
34
  ## The Problem
28
35
 
29
36
  Coding agents like Cursor, Claude Code, and Codex are great — until the task gets long.
@@ -61,6 +68,11 @@ agentflow ui
61
68
  # Generate and open a single-file static preview using the platform canvas (no local server)
62
69
  agentflow flow preview ./my-flow/flow.yaml
63
70
 
71
+ # Upload a Workspace graph to a server-side temporary preview project
72
+ node skills/agentflow-cli/scripts/agentflow-cli.mjs workspace-preview \
73
+ --file .workspace/agentflow/pipelines/my-flow/workspace.graph.json \
74
+ --ttl-seconds 7200
75
+
64
76
  # Or run a flow directly
65
77
  agentflow apply <FlowName>
66
78
  ```
package/README.zh-CN.md CHANGED
@@ -24,6 +24,12 @@
24
24
 
25
25
  ![Running Status](docs/running.png)
26
26
 
27
+ ### 术语边界
28
+
29
+ 本仓库中的可执行节点图称为 **Flow**(编辑器中也显示为流水线),以
30
+ `flow.yaml` 保存。产品需求 **Workflow** 是另一套由 TAPD 驱动、使用
31
+ `tapd:<id>` 标识的需求实体;它不由 Flow 编辑器创建、归档或修改。
32
+
27
33
  ## 解决什么问题
28
34
 
29
35
  Cursor、Claude Code、Codex 这些 Coding Agent 很好用——直到任务变长。
@@ -21,6 +21,8 @@ import {
21
21
  } from "./admin-builtin-pipelines.mjs";
22
22
  import { Table } from "./table.mjs";
23
23
  import { listMarketplaceNodes, parseMarketplaceDefinitionId, resolveMarketplaceNodePackage } from "./marketplace.mjs";
24
+ import { isWorkspacePreviewDir } from "./workspace-preview.mjs";
25
+ import { LEGACY_FLOW_NODE_IDS } from "./legacy-flow-execution.mjs";
24
26
 
25
27
  /** 从指定目录收集含 flow.yaml 的子目录名。 */
26
28
  export function collectPipelineNamesFromDir(dirPath) {
@@ -29,6 +31,7 @@ export function collectPipelineNamesFromDir(dirPath) {
29
31
  return entries
30
32
  .filter((e) => e.isDirectory())
31
33
  .filter((e) => fs.existsSync(path.join(dirPath, e.name, "flow.yaml")))
34
+ .filter((e) => !isWorkspacePreviewDir(path.join(dirPath, e.name)))
32
35
  .map((e) => e.name);
33
36
  }
34
37
 
@@ -259,6 +262,7 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
259
262
  const files = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md"));
260
263
  for (const e of files) {
261
264
  const id = e.name.replace(/\.mdx?$/i, "").replace(/\.markdown$/i, "");
265
+ if (LEGACY_FLOW_NODE_IDS.has(id)) continue;
262
266
  let type = "agent";
263
267
  if (/^control/i.test(id)) type = "control";
264
268
  else if (/^provide/i.test(id)) type = "provide";
@@ -247,6 +247,44 @@ export function archiveFlowPipeline(workspaceRoot, flowId, flowSource, opts = {}
247
247
  return { success: true };
248
248
  }
249
249
 
250
+ /**
251
+ * 将归档流水线目录恢复到活动目录(仅 user / workspace)。
252
+ * @param {string} workspaceRoot
253
+ * @param {string} flowId
254
+ * @param {FlowWriteSource} flowSource
255
+ * @returns {{ success: true } | { success: false, error: string }}
256
+ */
257
+ export function restoreArchivedFlowPipeline(workspaceRoot, flowId, flowSource, opts = {}) {
258
+ if (flowSource !== "user" && flowSource !== "workspace") {
259
+ return { success: false, error: "仅支持恢复用户目录或工作区流水线" };
260
+ }
261
+ if (!flowId || typeof flowId !== "string" || /[/\\.]/.test(flowId) || flowId === "..") {
262
+ return { success: false, error: "invalid flowId" };
263
+ }
264
+ const archivedRes = resolveArchivedFlowDirForWrite(workspaceRoot, flowId, flowSource, opts);
265
+ if (archivedRes.error || !archivedRes.flowDir) {
266
+ return { success: false, error: archivedRes.error || "无法解析归档路径" };
267
+ }
268
+ const fromDir = archivedRes.flowDir;
269
+ if (!fs.existsSync(path.join(fromDir, FLOW_YAML_FILENAME))) {
270
+ return { success: false, error: "找不到归档流水线" };
271
+ }
272
+ const activeRes = resolveFlowDirForWrite(workspaceRoot, flowId, flowSource, opts);
273
+ if (activeRes.error || !activeRes.flowDir) {
274
+ return { success: false, error: activeRes.error || "无法解析活动路径" };
275
+ }
276
+ if (fs.existsSync(activeRes.flowDir)) {
277
+ return { success: false, error: "活动目录已存在同名流水线" };
278
+ }
279
+ try {
280
+ fs.mkdirSync(path.dirname(activeRes.flowDir), { recursive: true });
281
+ fs.renameSync(fromDir, activeRes.flowDir);
282
+ return { success: true };
283
+ } catch (e) {
284
+ return { success: false, error: (e && e.message) || String(e) };
285
+ }
286
+ }
287
+
250
288
  /**
251
289
  * 在用户目录与工作区之间移动整个流水线目录(含 nodes 等)。
252
290
  * @param {string} workspaceRoot
package/bin/lib/help.mjs CHANGED
@@ -26,7 +26,7 @@ AgentFlow CLI — 使用 Cursor / OpenCode / Claude Code / Codex CLI 流式输
26
26
  agentflow marketplace list [--json] 查看 workspace 本地节点市场
27
27
  agentflow marketplace publish-node <dir> 发布本地节点包到 workspace market
28
28
  agentflow marketplace install-node <FlowName> <nodeSpec> 将 market 节点依赖写入 flow
29
- agentflow apply <FlowName> [uuid] agentflow apply <uuid>(由 uuid 反查 pipeline)
29
+ agentflow apply <FlowName> [uuid] 已下线;请改用 Workspace Run
30
30
  agentflow validate <FlowName> [uuid] 校验流程;终端下输出易读结果,--json 或管道时输出 JSON;传 uuid 时写入 runDir/intermediate/validation.json
31
31
  agentflow resume <FlowName> <uuid> [instanceId] 将 pending 与 failed 节点标为已确认并继续 apply
32
32
  agentflow replay [flowName] <uuid> <instanceId>
@@ -97,7 +97,7 @@ Usage:
97
97
  agentflow marketplace list [--json] Show workspace local marketplace packages
98
98
  agentflow marketplace publish-node <dir> Publish a local node package to the workspace market
99
99
  agentflow marketplace install-node <FlowName> <nodeSpec> Add a marketplace node dependency to a flow
100
- agentflow apply <FlowName> [uuid] Or agentflow apply <uuid> (resolve pipeline from uuid)
100
+ agentflow apply <FlowName> [uuid] Retired; use Workspace Run instead
101
101
  agentflow validate <FlowName> [uuid] Validate flow; readable output in terminal, JSON with --json or pipe; writes to runDir/intermediate/validation.json when uuid provided
102
102
  agentflow resume <FlowName> <uuid> [instanceId] Mark pending and failed nodes as acknowledged and continue apply
103
103
  agentflow replay [flowName] <uuid> <instanceId>
@@ -0,0 +1,6 @@
1
+ export const LEGACY_FLOW_EXECUTION_DISABLED = true;
2
+
3
+ export const LEGACY_FLOW_EXECUTION_MESSAGE =
4
+ "Legacy Start/End Pipeline execution has been retired. Use the Workspace graph with Run or Scheduled Run.";
5
+
6
+ export const LEGACY_FLOW_NODE_IDS = new Set(["control_start", "control_end"]);
package/bin/lib/main.mjs CHANGED
@@ -36,6 +36,7 @@ import { cancelScheduledRun, listScheduleStatuses, startScheduler } from "./sche
36
36
  import { installFlowDependency, listMarketplacePackages, publishNodePackage } from "./marketplace.mjs";
37
37
  import { startMcpServer } from "./mcp-server.mjs";
38
38
  import { writeStaticFlowPreview } from "./flow-static-preview.mjs";
39
+ import { LEGACY_FLOW_EXECUTION_DISABLED, LEGACY_FLOW_EXECUTION_MESSAGE } from "./legacy-flow-execution.mjs";
39
40
 
40
41
  async function readStdin() {
41
42
  const chunks = [];
@@ -553,6 +554,7 @@ export async function main() {
553
554
  if (sub === "list") {
554
555
  listPipelines(workspaceRoot);
555
556
  } else if (sub === "apply") {
557
+ if (LEGACY_FLOW_EXECUTION_DISABLED) throw new Error(LEGACY_FLOW_EXECUTION_MESSAGE);
556
558
  const aiMode = argv[0] === "-ai" || argv[0] === "--ai";
557
559
  if (aiMode) {
558
560
  argv.shift();
@@ -584,12 +586,14 @@ export async function main() {
584
586
  }
585
587
  await apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel, force, parallel, cliInputs);
586
588
  } else if (sub === "resume") {
589
+ if (LEGACY_FLOW_EXECUTION_DISABLED) throw new Error(LEGACY_FLOW_EXECUTION_MESSAGE);
587
590
  const flowName = shift();
588
591
  const uuidArg = shift();
589
592
  if (!flowName || !uuidArg) throw new Error("Usage: agentflow resume <FlowName> <uuid> [instanceId]");
590
593
  const instanceIdOpt = argv.length > 0 && !argv[0].startsWith("--") ? shift() : undefined;
591
594
  await resume(workspaceRoot, flowName, uuidArg, instanceIdOpt, agentModel, force, parallel);
592
595
  } else if (sub === "replay") {
596
+ if (LEGACY_FLOW_EXECUTION_DISABLED) throw new Error(LEGACY_FLOW_EXECUTION_MESSAGE);
593
597
  const a = shift(),
594
598
  b = shift(),
595
599
  c = shift();
@@ -30,6 +30,7 @@ import {
30
30
  deleteFlowPipeline,
31
31
  moveFlowDirectory,
32
32
  resolveFlowDirForWrite,
33
+ restoreArchivedFlowPipeline,
33
34
  validateUserPipelineId,
34
35
  writeFlowYaml,
35
36
  } from "./flow-write.mjs";
@@ -73,6 +74,16 @@ import {
73
74
  writePipelineTree,
74
75
  } from "./flow-import.mjs";
75
76
  import { getWorkspaceTree, getPipelineFiles } from "./workspace-tree.mjs";
77
+ import {
78
+ DEFAULT_WORKSPACE_PREVIEW_TTL_MS,
79
+ createWorkspacePreviewId,
80
+ listExpiredWorkspacePreviews,
81
+ normalizeWorkspacePreviewTtlMs,
82
+ readWorkspacePreviewMetadata,
83
+ workspacePreviewFlowDir,
84
+ writeWorkspacePreviewMetadata,
85
+ } from "./workspace-preview.mjs";
86
+ import { LEGACY_FLOW_EXECUTION_DISABLED, LEGACY_FLOW_EXECUTION_MESSAGE } from "./legacy-flow-execution.mjs";
76
87
  import {
77
88
  createComposerSession,
78
89
  logComposerEvent,
@@ -8236,6 +8247,23 @@ function isValidFlowSourceWrite(s) {
8236
8247
  return s === "user" || s === "workspace";
8237
8248
  }
8238
8249
 
8250
+ function cleanupExpiredWorkspacePreviews() {
8251
+ const roots = new Set(listAgentflowUserIds().map((id) => getUserPipelinesRoot(id)));
8252
+ roots.add(getUserPipelinesRoot(""));
8253
+ let removed = 0;
8254
+ for (const pipelinesRoot of roots) {
8255
+ for (const item of listExpiredWorkspacePreviews(pipelinesRoot)) {
8256
+ try {
8257
+ fs.rmSync(item.flowDir, { recursive: true, force: true });
8258
+ removed += 1;
8259
+ } catch (e) {
8260
+ log.debug(`[workspace-preview] cleanup failed: ${(e && e.message) || String(e)}`);
8261
+ }
8262
+ }
8263
+ }
8264
+ return removed;
8265
+ }
8266
+
8239
8267
  /** Composer 打开的画布通过 SSE 订阅;POST /api/flow-editor-sync 向对应 flow 推送刷新 */
8240
8268
  const flowEditorSyncSubscribers = new Map();
8241
8269
  /** 每次 broadcastFlowEditorSync 时递增,供轮询端点 /api/flow-editor-sync-poll 使用 */
@@ -13577,6 +13605,16 @@ export function startUiServer({
13577
13605
  isAdmin: Boolean(authUser.isAdmin),
13578
13606
  adminOwnerId: String(url.searchParams.get("adminOwnerId") || "").trim(),
13579
13607
  } : {};
13608
+ const legacyFlowManagementPath = new Set([
13609
+ "/api/flow/run-config",
13610
+ "/api/flow/schedule",
13611
+ "/api/flow/schedules",
13612
+ "/api/flow/schedule/disable",
13613
+ ]);
13614
+ if (LEGACY_FLOW_EXECUTION_DISABLED && legacyFlowManagementPath.has(url.pathname)) {
13615
+ json(res, 410, { error: LEGACY_FLOW_EXECUTION_MESSAGE, code: "legacy_flow_execution_disabled" });
13616
+ return;
13617
+ }
13580
13618
  if (req.method === "GET" && url.pathname.startsWith("/w/")) {
13581
13619
  const parts = url.pathname.split("/").filter(Boolean);
13582
13620
  if (parts.length !== 2) {
@@ -17367,6 +17405,77 @@ export function startUiServer({
17367
17405
  return;
17368
17406
  }
17369
17407
 
17408
+ if (req.method === "POST" && url.pathname === "/api/workspace/preview") {
17409
+ if (!authUser?.userId) {
17410
+ json(res, 401, { error: "Authentication required" });
17411
+ return;
17412
+ }
17413
+ let payload;
17414
+ try {
17415
+ payload = JSON.parse(await readBody(req, 4 * 1024 * 1024));
17416
+ } catch {
17417
+ json(res, 400, { error: "Invalid JSON" });
17418
+ return;
17419
+ }
17420
+ const graph = payload?.graph;
17421
+ if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
17422
+ json(res, 400, { error: "graph must be an object" });
17423
+ return;
17424
+ }
17425
+ const instances = graph.instances && typeof graph.instances === "object" && !Array.isArray(graph.instances)
17426
+ ? graph.instances
17427
+ : {};
17428
+ if (Object.values(instances).some((item) => String(item?.definitionId || "") === "workspace_scheduled_run")) {
17429
+ json(res, 400, { error: "Temporary Workspace preview cannot contain scheduled-run nodes" });
17430
+ return;
17431
+ }
17432
+ const rawRequestedId = String(payload.previewId || "").trim();
17433
+ const flowId = rawRequestedId || createWorkspacePreviewId();
17434
+ const flowDir = workspacePreviewFlowDir(flowId, authUser.userId);
17435
+ if (!flowDir) {
17436
+ json(res, 400, { error: "Invalid previewId" });
17437
+ return;
17438
+ }
17439
+ const existing = readWorkspacePreviewMetadata(flowDir);
17440
+ if (existing && existing.ownerId !== authUser.userId) {
17441
+ json(res, 403, { error: "Preview ownership denied" });
17442
+ return;
17443
+ }
17444
+ if (rawRequestedId && !existing && fs.existsSync(flowDir)) {
17445
+ json(res, 409, { error: "Preview project already exists but is not a preview" });
17446
+ return;
17447
+ }
17448
+ const now = Date.now();
17449
+ const ttlInput = payload.ttlMs != null
17450
+ ? Number(payload.ttlMs)
17451
+ : payload.ttlSeconds != null
17452
+ ? Number(payload.ttlSeconds) * 1000
17453
+ : DEFAULT_WORKSPACE_PREVIEW_TTL_MS;
17454
+ const ttlMs = normalizeWorkspacePreviewTtlMs(ttlInput);
17455
+ const metadata = {
17456
+ version: 1,
17457
+ flowId,
17458
+ ownerId: authUser.userId,
17459
+ title: String(payload.title || "Workspace Preview").trim().slice(0, 200),
17460
+ createdAt: existing?.createdAt || new Date(now).toISOString(),
17461
+ updatedAt: new Date(now).toISOString(),
17462
+ expiresAt: new Date(now + ttlMs).toISOString(),
17463
+ };
17464
+ try {
17465
+ fs.mkdirSync(flowDir, { recursive: true });
17466
+ fs.writeFileSync(path.join(flowDir, "flow.yaml"), "instances: {}\nedges: []\n", "utf8");
17467
+ fs.writeFileSync(path.join(flowDir, "workspace.graph.json"), `${JSON.stringify(graph, null, 2)}\n`, "utf8");
17468
+ writeWorkspacePreviewMetadata(flowDir, metadata);
17469
+ } catch (e) {
17470
+ json(res, 500, { error: (e && e.message) || String(e) });
17471
+ return;
17472
+ }
17473
+ const baseUrl = `${url.protocol}//${url.host}`;
17474
+ const workspaceUrl = `${baseUrl}/workspace?flowId=${encodeURIComponent(flowId)}&flowSource=user`;
17475
+ json(res, 200, { ok: true, flowId, flowSource: "user", preview: true, expiresAt: metadata.expiresAt, url: workspaceUrl });
17476
+ return;
17477
+ }
17478
+
17370
17479
  if (req.method === "GET" && url.pathname === "/api/workspace/graph") {
17371
17480
  try {
17372
17481
  const scoped = resolveWorkspaceScopeRoot(root, {
@@ -20121,6 +20230,46 @@ finishedAt: "${new Date().toISOString()}"
20121
20230
  return;
20122
20231
  }
20123
20232
 
20233
+ if (req.method === "POST" && url.pathname === "/api/flow/restore") {
20234
+ let payload;
20235
+ try {
20236
+ payload = JSON.parse(await readBody(req));
20237
+ } catch {
20238
+ json(res, 400, { error: "Invalid JSON body" });
20239
+ return;
20240
+ }
20241
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
20242
+ const flowSource = payload.flowSource || "user";
20243
+ if (!flowId) {
20244
+ json(res, 400, { error: "Missing or invalid flowId" });
20245
+ return;
20246
+ }
20247
+ if (flowSource !== "user" && flowSource !== "workspace") {
20248
+ json(res, 400, { error: "仅支持恢复用户目录或工作区流水线" });
20249
+ return;
20250
+ }
20251
+ const collaborationDenied = workspaceFlowCollaborationGuard(flowId, flowSource, true, userCtx, "owner");
20252
+ if (collaborationDenied) {
20253
+ json(res, collaborationDenied.status, { error: collaborationDenied.error });
20254
+ return;
20255
+ }
20256
+ const result = restoreArchivedFlowPipeline(root, flowId, flowSource, userCtx);
20257
+ if (!result.success) {
20258
+ json(res, 400, { error: result.error || "恢复失败" });
20259
+ return;
20260
+ }
20261
+ updateWorkspaceCollaborationFlow({
20262
+ previousFlowId: flowId,
20263
+ previousArchived: true,
20264
+ flowSource,
20265
+ ownerId: userCtx.userId,
20266
+ flowId,
20267
+ archived: false,
20268
+ });
20269
+ json(res, 200, { success: true, flowId, flowSource, archived: false });
20270
+ return;
20271
+ }
20272
+
20124
20273
  if (req.method === "POST" && url.pathname === "/api/flow/delete") {
20125
20274
  let payload;
20126
20275
  try {
@@ -20442,6 +20591,10 @@ finishedAt: "${new Date().toISOString()}"
20442
20591
  }
20443
20592
 
20444
20593
  if (req.method === "POST" && url.pathname === "/api/flow/run") {
20594
+ if (LEGACY_FLOW_EXECUTION_DISABLED) {
20595
+ json(res, 410, { error: LEGACY_FLOW_EXECUTION_MESSAGE, code: "legacy_flow_execution_disabled" });
20596
+ return;
20597
+ }
20445
20598
  let payload;
20446
20599
  try {
20447
20600
  payload = JSON.parse(await readBody(req));
@@ -21170,6 +21323,22 @@ finishedAt: "${new Date().toISOString()}"
21170
21323
  }, 1000).unref?.();
21171
21324
  }
21172
21325
 
21326
+ const workspacePreviewCleanupTimer = setInterval(() => {
21327
+ try {
21328
+ const removed = cleanupExpiredWorkspacePreviews();
21329
+ if (removed > 0) log.debug(`[workspace-preview] removed ${removed} expired preview project(s)`);
21330
+ } catch (e) {
21331
+ log.debug(`[workspace-preview] cleanup poll failed: ${(e && e.message) || String(e)}`);
21332
+ }
21333
+ }, 60_000);
21334
+ try {
21335
+ workspacePreviewCleanupTimer.unref?.();
21336
+ } catch (_) {}
21337
+ server.on("close", () => clearInterval(workspacePreviewCleanupTimer));
21338
+ try {
21339
+ cleanupExpiredWorkspacePreviews();
21340
+ } catch (_) {}
21341
+
21173
21342
  return new Promise((resolve, reject) => {
21174
21343
  server.once("error", reject);
21175
21344
  server.listen(port, host, () => {
@@ -0,0 +1,74 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { getUserPipelinesRoot } from "./paths.mjs";
5
+
6
+ export const WORKSPACE_PREVIEW_METADATA_FILENAME = ".agentflow-workspace-preview.json";
7
+ export const DEFAULT_WORKSPACE_PREVIEW_TTL_MS = 2 * 60 * 60 * 1000;
8
+ export const MAX_WORKSPACE_PREVIEW_TTL_MS = 24 * 60 * 60 * 1000;
9
+
10
+ function safePreviewId(value = "") {
11
+ const raw = String(value || "").trim();
12
+ return /^preview_[a-z0-9_-]{8,100}$/i.test(raw) ? raw : "";
13
+ }
14
+
15
+ export function workspacePreviewMetadataPath(flowDir) {
16
+ return path.join(flowDir, WORKSPACE_PREVIEW_METADATA_FILENAME);
17
+ }
18
+
19
+ export function readWorkspacePreviewMetadata(flowDir) {
20
+ try {
21
+ const filePath = workspacePreviewMetadataPath(flowDir);
22
+ if (!fs.existsSync(filePath)) return null;
23
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
25
+ const flowId = safePreviewId(value.flowId);
26
+ const ownerId = String(value.ownerId || "").trim();
27
+ const expiresAt = String(value.expiresAt || "").trim();
28
+ if (!flowId || !ownerId || !Number.isFinite(Date.parse(expiresAt))) return null;
29
+ return { ...value, flowId, ownerId, expiresAt };
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function isWorkspacePreviewDir(flowDir) {
36
+ return Boolean(readWorkspacePreviewMetadata(flowDir));
37
+ }
38
+
39
+ export function workspacePreviewFlowDir(flowId, userId = "") {
40
+ const safeId = safePreviewId(flowId);
41
+ if (!safeId) return "";
42
+ return path.join(getUserPipelinesRoot(userId), safeId);
43
+ }
44
+
45
+ export function createWorkspacePreviewId() {
46
+ return `preview_${crypto.randomBytes(10).toString("hex")}`;
47
+ }
48
+
49
+ export function normalizeWorkspacePreviewTtlMs(value) {
50
+ const requested = Number(value);
51
+ if (!Number.isFinite(requested) || requested <= 0) return DEFAULT_WORKSPACE_PREVIEW_TTL_MS;
52
+ return Math.min(MAX_WORKSPACE_PREVIEW_TTL_MS, Math.max(60_000, Math.round(requested)));
53
+ }
54
+
55
+ export function writeWorkspacePreviewMetadata(flowDir, metadata) {
56
+ const filePath = workspacePreviewMetadataPath(flowDir);
57
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
58
+ fs.writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
59
+ fs.renameSync(tempPath, filePath);
60
+ }
61
+
62
+ export function listExpiredWorkspacePreviews(userPipelinesRoot, now = Date.now()) {
63
+ if (!fs.existsSync(userPipelinesRoot)) return [];
64
+ const expired = [];
65
+ for (const entry of fs.readdirSync(userPipelinesRoot, { withFileTypes: true })) {
66
+ if (!entry.isDirectory()) continue;
67
+ const flowDir = path.join(userPipelinesRoot, entry.name);
68
+ const metadata = readWorkspacePreviewMetadata(flowDir);
69
+ if (!metadata) continue;
70
+ if (Date.parse(metadata.expiresAt) <= now) expired.push({ flowDir, metadata });
71
+ }
72
+ return expired;
73
+ }
74
+