@fieldwangai/agentflow 0.1.84 → 0.1.85

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.
@@ -120,6 +120,13 @@ import {
120
120
  readRunLedgerEvents,
121
121
  runLedgerId,
122
122
  } from "./run-ledger.mjs";
123
+ import {
124
+ appendWorkspaceRunLogEvent,
125
+ createWorkspaceRunLogSession,
126
+ finishWorkspaceRunLogSession,
127
+ listWorkspaceRunLogs,
128
+ readWorkspaceRunLogEvents,
129
+ } from "./workspace-run-logs.mjs";
123
130
 
124
131
  const MIME = {
125
132
  ".html": "text/html; charset=utf-8",
@@ -1482,6 +1489,7 @@ function readWorkspaceGraph(workspaceRoot) {
1482
1489
 
1483
1490
  const DISPLAY_SHARE_FILENAME = "display-shares.json";
1484
1491
  const DISPLAY_SHARE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
1492
+ const DISPLAY_SHARE_ALLOWED_EXPIRY_DAYS = new Set([1, 7, 30, 90, 365]);
1485
1493
 
1486
1494
  function displaySharesPath() {
1487
1495
  return path.join(getAgentflowDataRoot(), DISPLAY_SHARE_FILENAME);
@@ -1931,9 +1939,30 @@ function createDisplayShareId() {
1931
1939
  return crypto.randomBytes(12).toString("base64url");
1932
1940
  }
1933
1941
 
1934
- function displayShareExpiresAt(now = new Date()) {
1942
+ function normalizeDisplayShareExpiry(input = {}, now = new Date()) {
1943
+ const mode = String(input?.expiresMode || input?.expiryMode || "").trim().toLowerCase();
1944
+ const rawDays = Number(input?.expiresInDays ?? input?.expiryDays ?? input?.ttlDays);
1945
+ if (
1946
+ mode === "permanent" ||
1947
+ mode === "forever" ||
1948
+ input?.permanent === true ||
1949
+ input?.expiresAt === null ||
1950
+ String(input?.expiresAt || "").trim().toLowerCase() === "permanent"
1951
+ ) {
1952
+ return { expiresAt: "", expiresMode: "permanent", expiresInDays: null };
1953
+ }
1954
+ let days = Number.isFinite(rawDays) ? Math.round(rawDays) : 30;
1955
+ if (!DISPLAY_SHARE_ALLOWED_EXPIRY_DAYS.has(days)) days = 30;
1935
1956
  const time = now instanceof Date ? now.getTime() : Date.now();
1936
- return new Date(time + DISPLAY_SHARE_TTL_MS).toISOString();
1957
+ return {
1958
+ expiresAt: new Date(time + days * 24 * 60 * 60 * 1000).toISOString(),
1959
+ expiresMode: "days",
1960
+ expiresInDays: days,
1961
+ };
1962
+ }
1963
+
1964
+ function displayShareExpiresAt(now = new Date()) {
1965
+ return normalizeDisplayShareExpiry({ expiresInDays: 30 }, now).expiresAt;
1937
1966
  }
1938
1967
 
1939
1968
  function isDisplayShareExpired(share) {
@@ -1971,12 +2000,13 @@ function normalizeDisplayShareLayout(layout, fallback = "canvas") {
1971
2000
  return ["canvas", "gallery", "slides", "document", "single"].includes(text) ? text : fallback;
1972
2001
  }
1973
2002
 
1974
- function createDisplayShareRecord({ userId, flowId, flowSource, archived, title, layout, nodeIds }) {
2003
+ function createDisplayShareRecord({ userId, flowId, flowSource, archived, title, layout, nodeIds, expiresMode, expiresInDays, permanent, expiresAt }) {
1975
2004
  const shares = readDisplayShares();
1976
2005
  let id = createDisplayShareId();
1977
2006
  while (shares[id]) id = createDisplayShareId();
1978
2007
  const nowDate = new Date();
1979
2008
  const now = nowDate.toISOString();
2009
+ const expiry = normalizeDisplayShareExpiry({ expiresMode, expiresInDays, permanent, expiresAt }, nowDate);
1980
2010
  const share = {
1981
2011
  id,
1982
2012
  userId: String(userId || ""),
@@ -1988,13 +2018,84 @@ function createDisplayShareRecord({ userId, flowId, flowSource, archived, title,
1988
2018
  nodeIds: Array.isArray(nodeIds) ? nodeIds : [],
1989
2019
  createdAt: now,
1990
2020
  updatedAt: now,
1991
- expiresAt: displayShareExpiresAt(nowDate),
2021
+ expiresAt: expiry.expiresAt,
2022
+ expiresMode: expiry.expiresMode,
2023
+ expiresInDays: expiry.expiresInDays,
1992
2024
  };
1993
2025
  shares[id] = share;
1994
2026
  writeDisplayShares(shares);
1995
2027
  return share;
1996
2028
  }
1997
2029
 
2030
+ function displayShareSummary(share, baseUrl = "") {
2031
+ return {
2032
+ id: String(share?.id || ""),
2033
+ userId: String(share?.userId || ""),
2034
+ flowId: String(share?.flowId || ""),
2035
+ flowSource: String(share?.flowSource || "user"),
2036
+ archived: share?.archived === true,
2037
+ title: String(share?.title || "AgentFlow Display"),
2038
+ layout: String(share?.layout || "gallery"),
2039
+ nodeIds: Array.isArray(share?.nodeIds) ? share.nodeIds : [],
2040
+ createdAt: String(share?.createdAt || ""),
2041
+ updatedAt: String(share?.updatedAt || ""),
2042
+ expiresAt: String(share?.expiresAt || ""),
2043
+ expiresMode: String(share?.expiresMode || (share?.expiresAt ? "days" : "permanent")),
2044
+ expiresInDays: share?.expiresInDays == null ? null : Number(share.expiresInDays),
2045
+ url: displayShareOutputUrl(share?.id || "", baseUrl),
2046
+ };
2047
+ }
2048
+
2049
+ function listDisplaySharesForUser(userCtx = {}, baseUrl = "") {
2050
+ const userId = String(userCtx?.userId || "");
2051
+ const isAdmin = userCtx?.isAdmin === true;
2052
+ const shares = readDisplayShares();
2053
+ let changed = false;
2054
+ const rows = [];
2055
+ for (const [id, share] of Object.entries(shares)) {
2056
+ if (isDisplayShareExpired(share)) {
2057
+ delete shares[id];
2058
+ changed = true;
2059
+ continue;
2060
+ }
2061
+ if (!isAdmin && String(share?.userId || "") !== userId) continue;
2062
+ rows.push(displayShareSummary(share, baseUrl));
2063
+ }
2064
+ if (changed) writeDisplayShares(shares);
2065
+ rows.sort((a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""));
2066
+ return rows;
2067
+ }
2068
+
2069
+ function updateDisplayShareExpiryForUser(id, userCtx = {}, patch = {}) {
2070
+ const shares = readDisplayShares();
2071
+ const share = shares[id];
2072
+ if (!share) return { status: 404, error: "Display share not found" };
2073
+ const userId = String(userCtx?.userId || "");
2074
+ if (userCtx?.isAdmin !== true && String(share.userId || "") !== userId) return { status: 403, error: "Forbidden" };
2075
+ const expiry = normalizeDisplayShareExpiry(patch, new Date());
2076
+ const updated = {
2077
+ ...share,
2078
+ expiresAt: expiry.expiresAt,
2079
+ expiresMode: expiry.expiresMode,
2080
+ expiresInDays: expiry.expiresInDays,
2081
+ updatedAt: new Date().toISOString(),
2082
+ };
2083
+ shares[id] = updated;
2084
+ writeDisplayShares(shares);
2085
+ return { status: 200, share: updated };
2086
+ }
2087
+
2088
+ function deleteDisplayShareForUser(id, userCtx = {}) {
2089
+ const shares = readDisplayShares();
2090
+ const share = shares[id];
2091
+ if (!share) return { status: 404, error: "Display share not found" };
2092
+ const userId = String(userCtx?.userId || "");
2093
+ if (userCtx?.isAdmin !== true && String(share.userId || "") !== userId) return { status: 403, error: "Forbidden" };
2094
+ delete shares[id];
2095
+ writeDisplayShares(shares);
2096
+ return { status: 200 };
2097
+ }
2098
+
1998
2099
  function parseDisplayShareNodeIdInput(value) {
1999
2100
  return String(value || "")
2000
2101
  .split(/[\s,,]+/g)
@@ -2146,6 +2247,8 @@ function publicDisplayPayloadFromShare(root, share) {
2146
2247
  createdAt: share.createdAt || "",
2147
2248
  updatedAt: share.updatedAt || "",
2148
2249
  expiresAt: share.expiresAt || "",
2250
+ expiresMode: share.expiresMode || (share.expiresAt ? "days" : "permanent"),
2251
+ expiresInDays: share.expiresInDays == null ? null : Number(share.expiresInDays),
2149
2252
  },
2150
2253
  nodes,
2151
2254
  };
@@ -3832,6 +3935,135 @@ function workspaceBuildImplementationPrompt(instance, nodeId, opts = {}) {
3832
3935
  ].filter((line) => line !== "").join("\n");
3833
3936
  }
3834
3937
 
3938
+ function workspaceImplementationPlanNeighbors(graph, nodeId) {
3939
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
3940
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
3941
+ const incoming = [];
3942
+ const outgoing = [];
3943
+ for (const edge of edges) {
3944
+ if (String(edge?.target || "") === String(nodeId)) {
3945
+ const sourceId = String(edge?.source || "");
3946
+ if (sourceId) incoming.push(`${sourceId} (${instances[sourceId]?.label || instances[sourceId]?.definitionId || "node"})`);
3947
+ }
3948
+ if (String(edge?.source || "") === String(nodeId)) {
3949
+ const targetId = String(edge?.target || "");
3950
+ if (targetId) outgoing.push(`${targetId} (${instances[targetId]?.label || instances[targetId]?.definitionId || "node"})`);
3951
+ }
3952
+ }
3953
+ return {
3954
+ incoming: workspaceUniqueImplementationList(incoming, 20),
3955
+ outgoing: workspaceUniqueImplementationList(outgoing, 20),
3956
+ };
3957
+ }
3958
+
3959
+ function workspaceBuildPlannedImplementationPrompt(graph, nodeId, opts = {}) {
3960
+ const instance = graph?.instances?.[nodeId] || {};
3961
+ const defId = String(instance?.definitionId || "").trim();
3962
+ const label = String(instance?.label || nodeId || "node").trim();
3963
+ const mode = workspaceImplementationModeForInstance(instance);
3964
+ const inputValues = opts.inputValues || {};
3965
+ const task = workspaceResolveBodyPlaceholders(instance?.body || "", inputValues).trim();
3966
+ const scriptRef = String(instance?.scriptRef || "").trim();
3967
+ const inlineScript = String(instance?.script || "").trim();
3968
+ const previousImplementation = opts.previousImplementation
3969
+ ? workspaceClipImplementationText(opts.previousImplementation, 12000)
3970
+ : "";
3971
+ const neighbors = workspaceImplementationPlanNeighbors(graph, nodeId);
3972
+ return [
3973
+ "你要为 AgentFlow Workspace 的一个重复执行节点提前写“实现方案”Markdown。",
3974
+ "这个 implementation.md 会在后续 Scheduled Run 执行同一节点前作为参考上下文,目标是减少重复推理、提前固定执行路径和脚本约定。",
3975
+ "",
3976
+ "要求:",
3977
+ "- 基于当前节点任务、输入槽、上下游关系,写一份可复用的执行方案。",
3978
+ "- 写清楚下次运行应优先采用的步骤、文件路径、输入输出协议、错误处理和可复用约定。",
3979
+ "- 如果是脚本类节点,重点写清楚脚本入口、环境变量、输入 JSON/输出文件协议、幂等性和失败重试策略。",
3980
+ "- 不要假装已经执行过;这是执行前优化计划,不要引用不存在的实际结果。",
3981
+ "- 只输出 Markdown 正文,不要输出代码围栏包裹整篇。",
3982
+ "",
3983
+ "## 节点信息",
3984
+ "",
3985
+ `nodeId: ${nodeId}`,
3986
+ `label: ${label}`,
3987
+ `definitionId: ${defId || "(unknown)"}`,
3988
+ `mode: ${mode}`,
3989
+ scriptRef ? `scriptRef: ${scriptRef}` : "",
3990
+ inlineScript ? `inlineScript: ${workspaceClipImplementationText(inlineScript, 2400)}` : "",
3991
+ "",
3992
+ "## 当前任务",
3993
+ "",
3994
+ workspaceClipImplementationText(task || instance?.body || scriptRef || inlineScript || "(无显式任务)", 8000),
3995
+ "",
3996
+ "## 可见输入",
3997
+ "",
3998
+ JSON.stringify(inputValues || {}, null, 2),
3999
+ "",
4000
+ "## 上下游",
4001
+ "",
4002
+ `incoming: ${neighbors.incoming.length ? neighbors.incoming.join(", ") : "(none)"}`,
4003
+ `outgoing: ${neighbors.outgoing.length ? neighbors.outgoing.join(", ") : "(none)"}`,
4004
+ "",
4005
+ previousImplementation ? "## 上一版实现方案" : "",
4006
+ previousImplementation || "",
4007
+ ].filter((line) => line !== "").join("\n");
4008
+ }
4009
+
4010
+ async function workspaceGeneratePlannedImplementationMarkdown({
4011
+ scopedRoot,
4012
+ graph,
4013
+ nodeId,
4014
+ inputValues,
4015
+ implementationPath,
4016
+ previousImplementation,
4017
+ runPackage,
4018
+ modelKey,
4019
+ userCtx,
4020
+ emit,
4021
+ onActiveChild,
4022
+ }) {
4023
+ const prompt = workspaceBuildPlannedImplementationPrompt(graph, nodeId, {
4024
+ inputValues,
4025
+ previousImplementation,
4026
+ });
4027
+ let content = "";
4028
+ let lastAssistant = "";
4029
+ let resultText = "";
4030
+ emit?.({ type: "status", nodeId, line: `Generate implementation plan: ${nodeId}` });
4031
+ const handle = startComposerAgent({
4032
+ uiWorkspaceRoot: scopedRoot,
4033
+ cliWorkspace: runPackage?.nodeRunDir || scopedRoot,
4034
+ prompt,
4035
+ modelKey,
4036
+ agentflowUserId: userCtx?.userId || "",
4037
+ extraEnv: runtimeEnvForUser(userCtx, {
4038
+ AGENTFLOW_IMPLEMENTATION_REF: implementationPath || "",
4039
+ AGENTFLOW_NODE_RUN_DIR: runPackage?.nodeRunDir || "",
4040
+ AGENTFLOW_NODE_TMP_DIR: runPackage?.nodeTmpDir || "",
4041
+ AGENTFLOW_OUTPUTS_DIR: runPackage?.outputsDir || "",
4042
+ }),
4043
+ onStreamEvent: (ev) => {
4044
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
4045
+ lastAssistant = ev.text;
4046
+ content += (content ? "\n" : "") + ev.text;
4047
+ } else if (ev?.type === "natural" && ev.kind === "result" && typeof ev.text === "string") {
4048
+ resultText = ev.text;
4049
+ }
4050
+ },
4051
+ onToolCall: (subtype, toolName) => {
4052
+ const sub = subtype ? String(subtype) : "";
4053
+ const tool = toolName ? String(toolName) : "";
4054
+ emit?.({ type: "status", nodeId, line: `优化工具 ${tool || "thinking"}${sub ? ` (${sub})` : ""}` });
4055
+ },
4056
+ });
4057
+ if (typeof onActiveChild === "function") onActiveChild(handle.child || null);
4058
+ try {
4059
+ await handle.finished;
4060
+ } finally {
4061
+ if (typeof onActiveChild === "function") onActiveChild(null);
4062
+ }
4063
+ const markdown = String(resultText || lastAssistant || content || "").trim();
4064
+ return markdown.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
4065
+ }
4066
+
3835
4067
  async function workspaceGenerateImplementationMarkdown({
3836
4068
  scopedRoot,
3837
4069
  nodeId,
@@ -4033,6 +4265,88 @@ async function workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId,
4033
4265
  }
4034
4266
  }
4035
4267
 
4268
+ function workspaceShouldOptimizeNodeImplementation(instance) {
4269
+ const defId = String(instance?.definitionId || "").trim();
4270
+ if (!defId) return false;
4271
+ if (defId === "workspace_run" || defId === "workspace_scheduled_run") return false;
4272
+ if (defId.startsWith("display_") || defId.startsWith("provide_") || defId.startsWith("control_")) return false;
4273
+ return defId === "agent_subAgent" || defId === "tool_nodejs" || defId.startsWith("tool_");
4274
+ }
4275
+
4276
+ async function workspaceOptimizeNodeImplementation(scopedRoot, graph, nodeId, opts = {}) {
4277
+ const current = graph?.instances?.[nodeId];
4278
+ if (!current || !workspaceShouldOptimizeNodeImplementation(current)) {
4279
+ return { optimized: false, skipped: true, nodeId, reason: "not optimizable" };
4280
+ }
4281
+ const implementationRef = String(current.implementationRef || "").trim() || workspaceDefaultImplementationRef(nodeId);
4282
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
4283
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
4284
+ const previousImplementation = fs.existsSync(abs) && fs.statSync(abs).isFile()
4285
+ ? workspaceReadTextFileIfExists(abs, 60000)
4286
+ : "";
4287
+ const inputValues = workspaceInputValues(graph, nodeId, new Map(), scopedRoot);
4288
+ const runPackage = workspaceCreateNodeRunPackage(opts.runTmpRoot || workspaceCreateRunTmpRoot(scopedRoot, "optimize"), nodeId, {
4289
+ scopedRoot,
4290
+ cwd: scopedRoot,
4291
+ task: workspaceResolveBodyPlaceholders(current.body || current.script || current.scriptRef || "", inputValues),
4292
+ inputValues,
4293
+ });
4294
+ const markdown = await workspaceGeneratePlannedImplementationMarkdown({
4295
+ scopedRoot,
4296
+ graph,
4297
+ nodeId,
4298
+ inputValues: { ...inputValues, ...(runPackage.inputValues || {}) },
4299
+ implementationPath: implementationRef,
4300
+ previousImplementation,
4301
+ runPackage,
4302
+ modelKey: opts.modelKey || "",
4303
+ userCtx: opts.userCtx || {},
4304
+ emit: opts.emit,
4305
+ onActiveChild: opts.onActiveChild,
4306
+ });
4307
+ if (!markdown.trim()) throw new Error(`Implementation plan is empty for node ${nodeId}`);
4308
+ fs.writeFileSync(abs, markdown.trimEnd() + "\n", "utf-8");
4309
+ const explicitMode = String(current.implementationMode || "").trim();
4310
+ graph.instances[nodeId] = {
4311
+ ...current,
4312
+ implementationRef,
4313
+ implementationMode: explicitMode || workspaceImplementationModeForInstance(current),
4314
+ };
4315
+ return { optimized: true, nodeId, implementationRef };
4316
+ }
4317
+
4318
+ async function workspaceOptimizeRunImplementations(root, scopedRoot, payload, userCtx = {}, opts = {}) {
4319
+ const graph = hydrateWorkspaceGraphForRuntime(root, {
4320
+ root: scopedRoot,
4321
+ flowId: payload.flowId || "",
4322
+ flowSource: payload.flowSource || "user",
4323
+ archived: payload.archived === true || payload.flowArchived === true,
4324
+ }, payload.graph || {}, userCtx);
4325
+ const runNodeId = String(payload?.runNodeId || "").trim();
4326
+ const plan = workspaceRunPlan(graph, runNodeId, scopedRoot);
4327
+ const runTmpRoot = workspaceCreateRunTmpRoot(scopedRoot, `${runNodeId || "run"}-optimize`);
4328
+ const optimized = [];
4329
+ const skipped = [];
4330
+ for (const nodeId of plan.order) {
4331
+ const instance = graph.instances?.[nodeId];
4332
+ if (!workspaceShouldOptimizeNodeImplementation(instance)) {
4333
+ skipped.push({ nodeId, reason: "not optimizable" });
4334
+ continue;
4335
+ }
4336
+ opts.emit?.({ type: "node-start", nodeId, definitionId: instance.definitionId, phase: "optimize" });
4337
+ const result = await workspaceOptimizeNodeImplementation(scopedRoot, graph, nodeId, {
4338
+ runTmpRoot,
4339
+ modelKey: payload.model || "",
4340
+ userCtx,
4341
+ emit: opts.emit,
4342
+ onActiveChild: opts.onActiveChild,
4343
+ });
4344
+ optimized.push(result);
4345
+ opts.emit?.({ type: "node-done", nodeId, definitionId: instance.definitionId, phase: "optimize", implementationRef: result.implementationRef });
4346
+ }
4347
+ return { ok: true, graph, order: plan.order, optimized, skipped };
4348
+ }
4349
+
4036
4350
  function parseWorkspaceSkillKeys(raw) {
4037
4351
  const text = String(raw || "").trim();
4038
4352
  if (!text) return [];
@@ -5180,6 +5494,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
5180
5494
  title,
5181
5495
  layout,
5182
5496
  nodeIds,
5497
+ expiresMode: payload.expiresMode,
5498
+ expiresInDays: payload.expiresInDays,
5499
+ permanent: payload.permanent,
5500
+ expiresAt: payload.expiresAt,
5183
5501
  });
5184
5502
  const url = displayShareOutputUrl(share.id, baseUrl);
5185
5503
  let nextInstance = workspaceSetOutputSlot(instance, "url", url);
@@ -5570,7 +5888,7 @@ const activeFlowRuns = new Map();
5570
5888
  const activeWorkspaceRuns = new Map();
5571
5889
  const WORKSPACE_SCHEDULES_FILENAME = "workspace-schedules.json";
5572
5890
  const WORKSPACE_SCHEDULE_POLL_MS = 30_000;
5573
- const WORKSPACE_IMPLEMENTATION_REFERENCE_ENABLED = false;
5891
+ const WORKSPACE_IMPLEMENTATION_REFERENCE_ENABLED = true;
5574
5892
  const WORKSPACE_IMPLEMENTATION_SUMMARY_ENABLED = false;
5575
5893
  const WORKSPACE_NODE_HISTORY_MAX_CHARS = 80000;
5576
5894
 
@@ -5706,6 +6024,77 @@ function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user",
5706
6024
  .sort((a, b) => String(a.scheduleNodeId || a.runNodeId || "").localeCompare(String(b.scheduleNodeId || b.runNodeId || "")));
5707
6025
  }
5708
6026
 
6027
+ function listWorkspaceScheduleStatuses(root, userCtx = {}) {
6028
+ const registry = readWorkspaceScheduleRegistry();
6029
+ const userId = String(userCtx.userId || "");
6030
+ const flows = listFlowsJson(root, { ...userCtx, includeWorkspaceFlows: true })
6031
+ .filter((flow) => !flow.archived && !isReadonlyBuiltinFlowSource(flow.source || "user"));
6032
+ const rows = [];
6033
+ for (const flow of flows) {
6034
+ const flowId = String(flow.id || "");
6035
+ const flowSource = String(flow.source || "user");
6036
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource }, userCtx);
6037
+ if (scoped.error || !scoped.root) continue;
6038
+ let graph;
6039
+ try {
6040
+ graph = readWorkspaceGraph(scoped.root).graph;
6041
+ } catch {
6042
+ continue;
6043
+ }
6044
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
6045
+ for (const [scheduleNodeId, instance] of Object.entries(instances)) {
6046
+ if (String(instance?.definitionId || "") !== "workspace_scheduled_run") continue;
6047
+ const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
6048
+ const key = workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId);
6049
+ const current = registry.schedules?.[key] && typeof registry.schedules[key] === "object" ? registry.schedules[key] : {};
6050
+ const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
6051
+ const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
6052
+ const running = workspaceActiveRunsForScope(scopeKey).some(([, active]) => (
6053
+ active?.scheduled === true &&
6054
+ String(active?.runNodeId || "") === String(targetRunNodeId || scheduleNodeId)
6055
+ ));
6056
+ let nextRunAt = current.nextRunAt || null;
6057
+ let lastStatus = current.lastStatus || (config.enabled ? "armed" : "disabled");
6058
+ let lastError = current.lastError || "";
6059
+ if (config.enabled && !nextRunAt) {
6060
+ try {
6061
+ nextRunAt = workspaceScheduleNextRunAt(config, new Date());
6062
+ } catch (e) {
6063
+ lastStatus = "invalid";
6064
+ lastError = (e && e.message) || String(e);
6065
+ }
6066
+ }
6067
+ rows.push({
6068
+ kind: "workspace",
6069
+ key,
6070
+ flowId,
6071
+ flowSource,
6072
+ scheduleNodeId,
6073
+ runNodeId: targetRunNodeId,
6074
+ label: String(instance.label || "Scheduled Run"),
6075
+ enabled: config.enabled,
6076
+ cron: config.cron,
6077
+ timezone: config.timezone,
6078
+ preset: "",
6079
+ nextRunAt,
6080
+ lastTriggeredAt: current.lastTriggeredAt || null,
6081
+ lastFinishedAt: current.lastFinishedAt || null,
6082
+ lastRunId: current.lastRunId || "",
6083
+ lastStatus,
6084
+ lastError,
6085
+ running,
6086
+ waiting: 0,
6087
+ });
6088
+ }
6089
+ }
6090
+ rows.sort((a, b) => {
6091
+ const ea = a.enabled ? 0 : 1;
6092
+ const eb = b.enabled ? 0 : 1;
6093
+ return ea - eb || String(a.nextRunAt || "").localeCompare(String(b.nextRunAt || "")) || a.flowId.localeCompare(b.flowId);
6094
+ });
6095
+ return rows;
6096
+ }
6097
+
5709
6098
  function workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config = {}) {
5710
6099
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
5711
6100
  return String(instances[scheduleNodeId]?.definitionId || "") === "workspace_scheduled_run"
@@ -5713,6 +6102,35 @@ function workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config = {
5713
6102
  : "";
5714
6103
  }
5715
6104
 
6105
+ function setWorkspaceScheduleEnabled(root, payload = {}, authUser = {}, userCtx = {}) {
6106
+ const flowId = String(payload.flowId || "").trim();
6107
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
6108
+ const scheduleNodeId = String(payload.scheduleNodeId || "").trim();
6109
+ if (!flowId) return { success: false, error: "Missing flowId" };
6110
+ if (!scheduleNodeId) return { success: false, error: "Missing scheduleNodeId" };
6111
+ if (!isValidFlowSourceWrite(flowSource)) return { success: false, error: "Cannot update readonly workspace schedule" };
6112
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource }, userCtx);
6113
+ if (scoped.error) return { success: false, error: scoped.error };
6114
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
6115
+ return { success: false, error: "Cannot update schedule for builtin or archived workspace" };
6116
+ }
6117
+ const { graph } = readWorkspaceGraph(scoped.root);
6118
+ const instance = graph.instances?.[scheduleNodeId];
6119
+ if (!instance || String(instance.definitionId || "") !== "workspace_scheduled_run") {
6120
+ return { success: false, error: "Workspace schedule node not found" };
6121
+ }
6122
+ const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
6123
+ const nextConfig = { ...config, enabled: payload.enabled === true };
6124
+ graph.instances = { ...(graph.instances || {}) };
6125
+ graph.instances[scheduleNodeId] = {
6126
+ ...instance,
6127
+ body: JSON.stringify(nextConfig),
6128
+ };
6129
+ fs.writeFileSync(workspaceGraphPath(scoped.root), JSON.stringify(graph, null, 2) + "\n", "utf-8");
6130
+ const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx);
6131
+ return { success: true, workspaceSchedules };
6132
+ }
6133
+
5716
6134
  function syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx = {}) {
5717
6135
  const flowId = String(scoped?.flowId || "").trim();
5718
6136
  const flowSource = String(scoped?.flowSource || "user");
@@ -5730,7 +6148,6 @@ function syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx =
5730
6148
  for (const [scheduleNodeId, instance] of Object.entries(instances)) {
5731
6149
  if (String(instance?.definitionId || "") !== "workspace_scheduled_run") continue;
5732
6150
  const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
5733
- if (!config.enabled) continue;
5734
6151
  const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
5735
6152
  const key = workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId);
5736
6153
  const previous = registry.schedules?.[key] && typeof registry.schedules[key] === "object" ? registry.schedules[key] : {};
@@ -5744,9 +6161,15 @@ function syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx =
5744
6161
  let lastStatus = previous.lastStatus || "armed";
5745
6162
  let lastError = previous.lastError || "";
5746
6163
  try {
5747
- nextRunAt = previousMatches && Number.isFinite(previousNext) && previousNext > now
6164
+ nextRunAt = !config.enabled
6165
+ ? null
6166
+ : previousMatches && Number.isFinite(previousNext) && previousNext > now
5748
6167
  ? previousNext
5749
6168
  : workspaceScheduleNextRunAt(config, new Date(now));
6169
+ if (!config.enabled) {
6170
+ lastStatus = "disabled";
6171
+ lastError = "";
6172
+ }
5750
6173
  if (!targetRunNodeId) {
5751
6174
  lastStatus = "invalid";
5752
6175
  lastError = "Scheduled Run node is missing";
@@ -5759,7 +6182,7 @@ function syncWorkspaceSchedulesForGraph(root, scoped, graph, authUser, userCtx =
5759
6182
  schedules[key] = {
5760
6183
  ...previous,
5761
6184
  key,
5762
- enabled: true,
6185
+ enabled: config.enabled,
5763
6186
  userId,
5764
6187
  username: String(authUser?.username || previous.username || userId),
5765
6188
  flowId,
@@ -5799,6 +6222,21 @@ function updateWorkspaceScheduleEntry(key, patch) {
5799
6222
  async function runWorkspaceScheduledEntry(root, entry) {
5800
6223
  const userCtx = { userId: String(entry.userId || "") };
5801
6224
  const scopeKey = workspaceRunKey(userCtx, entry.flowSource || "user", entry.flowId || "");
6225
+ const authUsers = readAuthUsers();
6226
+ const authUser = authUsers[userCtx.userId] || {};
6227
+ const runId = runLedgerId("workspace");
6228
+ const runLog = createWorkspaceRunLogSession({
6229
+ runId,
6230
+ userId: userCtx.userId,
6231
+ username: String(authUser.username || entry.username || userCtx.userId),
6232
+ flowId: String(entry.flowId || ""),
6233
+ flowSource: String(entry.flowSource || "user"),
6234
+ scheduleNodeId: String(entry.scheduleNodeId || entry.key?.split(":").pop() || ""),
6235
+ runNodeId: String(entry.targetRunNodeId || entry.runNodeId || ""),
6236
+ scheduled: true,
6237
+ trigger: "scheduled",
6238
+ label: String(entry.label || "Scheduled Run"),
6239
+ });
5802
6240
  const fallbackConfig = {
5803
6241
  enabled: true,
5804
6242
  cron: String(entry.cron || "0 9 * * *"),
@@ -5819,10 +6257,14 @@ async function runWorkspaceScheduledEntry(root, entry) {
5819
6257
  flowSource: entry.flowSource || "user",
5820
6258
  }, userCtx);
5821
6259
  if (scoped.error || scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
6260
+ const error = scoped.error || "Workspace schedule target is not writable";
6261
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "error", error });
6262
+ finishWorkspaceRunLogSession(runLog.runId, "failed", { error });
5822
6263
  updateWorkspaceScheduleEntry(entry.key, {
5823
6264
  nextRunAt,
5824
6265
  lastStatus: "error",
5825
- lastError: scoped.error || "Workspace schedule target is not writable",
6266
+ lastRunId: runLog.runId,
6267
+ lastError: error,
5826
6268
  lastErrorAt: Date.now(),
5827
6269
  });
5828
6270
  return;
@@ -5834,31 +6276,43 @@ async function runWorkspaceScheduledEntry(root, entry) {
5834
6276
  const config = normalizeWorkspaceScheduledRunConfig(instance?.body || "");
5835
6277
  nextRunAt = computeNext(config);
5836
6278
  if (!instance || String(instance.definitionId || "") !== "workspace_scheduled_run" || !config.enabled) {
6279
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "disabled", scheduleNodeId });
6280
+ finishWorkspaceRunLogSession(runLog.runId, "disabled");
5837
6281
  updateWorkspaceScheduleEntry(entry.key, {
5838
6282
  enabled: false,
5839
6283
  nextRunAt: null,
5840
6284
  lastStatus: "disabled",
6285
+ lastRunId: runLog.runId,
5841
6286
  });
5842
6287
  return;
5843
6288
  }
5844
6289
  const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
5845
6290
  if (!targetRunNodeId) {
6291
+ const error = "Scheduled Run node is missing";
6292
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "invalid", error, scheduleNodeId });
6293
+ finishWorkspaceRunLogSession(runLog.runId, "failed", { error });
5846
6294
  updateWorkspaceScheduleEntry(entry.key, {
5847
6295
  nextRunAt,
5848
6296
  lastStatus: "invalid",
5849
- lastError: "Scheduled Run node is missing",
6297
+ lastRunId: runLog.runId,
6298
+ lastError: error,
5850
6299
  lastErrorAt: Date.now(),
5851
6300
  });
5852
6301
  return;
5853
6302
  }
6303
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "scheduler-triggered", scheduleNodeId, runNodeId: targetRunNodeId, cron: config.cron, timezone: config.timezone });
5854
6304
  let plan;
5855
6305
  try {
5856
6306
  plan = workspaceRunPlan(graph, targetRunNodeId, scoped.root);
5857
6307
  } catch (e) {
6308
+ const error = (e && e.message) || String(e);
6309
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "error", error });
6310
+ finishWorkspaceRunLogSession(runLog.runId, "failed", { error, runNodeId: targetRunNodeId });
5858
6311
  updateWorkspaceScheduleEntry(entry.key, {
5859
6312
  nextRunAt,
5860
6313
  lastStatus: "failed",
5861
- lastError: (e && e.message) || String(e),
6314
+ lastRunId: runLog.runId,
6315
+ lastError: error,
5862
6316
  lastErrorAt: Date.now(),
5863
6317
  });
5864
6318
  return;
@@ -5866,19 +6320,25 @@ async function runWorkspaceScheduledEntry(root, entry) {
5866
6320
  const plannedNodeIds = workspaceRunPlanNodeIds(targetRunNodeId, plan);
5867
6321
  const conflict = workspaceFindActiveRunConflict(scopeKey, plannedNodeIds);
5868
6322
  if (conflict) {
6323
+ appendWorkspaceRunLogEvent(runLog.runId, {
6324
+ type: "skipped",
6325
+ reason: "busy",
6326
+ runNodeId: targetRunNodeId,
6327
+ conflictRunId: conflict.entry?.runId || "",
6328
+ conflictNodeIds: conflict.conflictNodeIds,
6329
+ });
6330
+ finishWorkspaceRunLogSession(runLog.runId, "skipped", { runNodeId: targetRunNodeId, error: "" });
5869
6331
  updateWorkspaceScheduleEntry(entry.key, {
5870
6332
  nextRunAt,
5871
6333
  lastSkippedAt: Date.now(),
5872
6334
  lastStatus: "skipped: busy",
6335
+ lastRunId: runLog.runId,
5873
6336
  lastError: "",
5874
6337
  });
5875
6338
  return;
5876
6339
  }
5877
6340
 
5878
6341
  const controller = new AbortController();
5879
- const authUsers = readAuthUsers();
5880
- const authUser = authUsers[userCtx.userId] || {};
5881
- const runId = runLedgerId("workspace");
5882
6342
  const runKey = workspaceRunEntryKey(scopeKey, runId);
5883
6343
  const runEntry = {
5884
6344
  scopeKey,
@@ -5924,6 +6384,7 @@ async function runWorkspaceScheduledEntry(root, entry) {
5924
6384
  }, userCtx, {
5925
6385
  signal: controller.signal,
5926
6386
  onActiveChild: setActiveChild,
6387
+ onEvent: (event) => appendWorkspaceRunLogEvent(runLog.runId, event),
5927
6388
  });
5928
6389
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
5929
6390
  const touchedIds = workspaceRunTouchedNodeIds(result);
@@ -5931,6 +6392,11 @@ async function runWorkspaceScheduledEntry(root, entry) {
5931
6392
  fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
5932
6393
  const endedAt = Date.now();
5933
6394
  appendWorkspaceRunFinished({ ...runEntry, endedAt, durationMs: endedAt - runEntry.startedAt }, "success");
6395
+ finishWorkspaceRunLogSession(runLog.runId, "success", {
6396
+ endedAt,
6397
+ durationMs: endedAt - runEntry.startedAt,
6398
+ runNodeId: targetRunNodeId,
6399
+ });
5934
6400
  updateWorkspaceScheduleEntry(entry.key, {
5935
6401
  nextRunAt: computeNext(config),
5936
6402
  lastFinishedAt: endedAt,
@@ -5939,15 +6405,23 @@ async function runWorkspaceScheduledEntry(root, entry) {
5939
6405
  });
5940
6406
  } catch (e) {
5941
6407
  const endedAt = Date.now();
6408
+ const error = (e && e.message) || String(e);
5942
6409
  appendWorkspaceRunFinished({ ...runEntry, endedAt, durationMs: endedAt - runEntry.startedAt }, "failed");
6410
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "error", error, ts: endedAt });
6411
+ finishWorkspaceRunLogSession(runLog.runId, "failed", {
6412
+ endedAt,
6413
+ durationMs: endedAt - runEntry.startedAt,
6414
+ runNodeId: targetRunNodeId,
6415
+ error,
6416
+ });
5943
6417
  updateWorkspaceScheduleEntry(entry.key, {
5944
6418
  nextRunAt: computeNext(config),
5945
6419
  lastFinishedAt: endedAt,
5946
6420
  lastStatus: "failed",
5947
- lastError: (e && e.message) || String(e),
6421
+ lastError: error,
5948
6422
  lastErrorAt: endedAt,
5949
6423
  });
5950
- log.info(`[workspace-scheduler] failed ${entry.flowId}/${targetRunNodeId}: ${(e && e.message) || String(e)}`);
6424
+ log.info(`[workspace-scheduler] failed ${entry.flowId}/${targetRunNodeId}: ${error}`);
5951
6425
  } finally {
5952
6426
  if (activeWorkspaceRuns.get(runKey) === runEntry) activeWorkspaceRuns.delete(runKey);
5953
6427
  }
@@ -6171,6 +6645,21 @@ export function startUiServer({
6171
6645
 
6172
6646
  const authUser = getAuthUserFromRequest(req);
6173
6647
  const userCtx = authUser ? { userId: authUser.userId, isAdmin: Boolean(authUser.isAdmin) } : {};
6648
+ if (req.method === "GET" && url.pathname === "/api/display/shares") {
6649
+ try {
6650
+ if (!authUser?.userId) {
6651
+ json(res, 401, { error: "Unauthorized" });
6652
+ return;
6653
+ }
6654
+ json(res, 200, {
6655
+ shares: listDisplaySharesForUser(userCtx, requestPublicBaseUrl(req)),
6656
+ });
6657
+ } catch (e) {
6658
+ json(res, 500, { error: (e && e.message) || String(e) });
6659
+ }
6660
+ return;
6661
+ }
6662
+
6174
6663
  if (req.method === "GET" && url.pathname === "/api/display/share") {
6175
6664
  try {
6176
6665
  const id = String(url.searchParams.get("id") || "").trim();
@@ -6195,6 +6684,62 @@ export function startUiServer({
6195
6684
  return;
6196
6685
  }
6197
6686
 
6687
+ if (req.method === "PATCH" && url.pathname === "/api/display/share") {
6688
+ let payload;
6689
+ try {
6690
+ payload = JSON.parse(await readBody(req));
6691
+ } catch {
6692
+ json(res, 400, { error: "Invalid JSON body" });
6693
+ return;
6694
+ }
6695
+ try {
6696
+ if (!authUser?.userId) {
6697
+ json(res, 401, { error: "Unauthorized" });
6698
+ return;
6699
+ }
6700
+ const id = String(payload?.id || url.searchParams.get("id") || "").trim();
6701
+ if (!id) {
6702
+ json(res, 400, { error: "Missing display share id" });
6703
+ return;
6704
+ }
6705
+ const result = updateDisplayShareExpiryForUser(id, userCtx, payload);
6706
+ if (result.error) {
6707
+ json(res, result.status || 400, { error: result.error });
6708
+ return;
6709
+ }
6710
+ json(res, 200, {
6711
+ ok: true,
6712
+ share: displayShareSummary(result.share, requestPublicBaseUrl(req)),
6713
+ });
6714
+ } catch (e) {
6715
+ json(res, 500, { error: (e && e.message) || String(e) });
6716
+ }
6717
+ return;
6718
+ }
6719
+
6720
+ if (req.method === "DELETE" && url.pathname === "/api/display/share") {
6721
+ try {
6722
+ if (!authUser?.userId) {
6723
+ json(res, 401, { error: "Unauthorized" });
6724
+ return;
6725
+ }
6726
+ const id = String(url.searchParams.get("id") || "").trim();
6727
+ if (!id) {
6728
+ json(res, 400, { error: "Missing display share id" });
6729
+ return;
6730
+ }
6731
+ const result = deleteDisplayShareForUser(id, userCtx);
6732
+ if (result.error) {
6733
+ json(res, result.status || 400, { error: result.error });
6734
+ return;
6735
+ }
6736
+ json(res, 200, { ok: true });
6737
+ } catch (e) {
6738
+ json(res, 500, { error: (e && e.message) || String(e) });
6739
+ }
6740
+ return;
6741
+ }
6742
+
6198
6743
  if (req.method === "GET" && url.pathname === "/api/display/file/raw") {
6199
6744
  try {
6200
6745
  const id = String(url.searchParams.get("id") || "").trim();
@@ -6693,6 +7238,10 @@ export function startUiServer({
6693
7238
  title: payload.title,
6694
7239
  layout: payload.layout,
6695
7240
  nodeIds,
7241
+ expiresMode: payload.expiresMode,
7242
+ expiresInDays: payload.expiresInDays,
7243
+ permanent: payload.permanent,
7244
+ expiresAt: payload.expiresAt,
6696
7245
  });
6697
7246
  json(res, 200, { ok: true, share, url: `/display/${encodeURIComponent(share.id)}` });
6698
7247
  } catch (e) {
@@ -6800,6 +7349,57 @@ export function startUiServer({
6800
7349
  return;
6801
7350
  }
6802
7351
 
7352
+ if (req.method === "POST" && url.pathname === "/api/workspace/run/optimize") {
7353
+ let payload;
7354
+ try {
7355
+ payload = JSON.parse(await readBody(req));
7356
+ } catch {
7357
+ json(res, 400, { error: "Invalid JSON body" });
7358
+ return;
7359
+ }
7360
+ try {
7361
+ const scoped = resolveWorkspaceScopeRoot(root, {
7362
+ flowId: payload.flowId || "",
7363
+ flowSource: payload.flowSource || "user",
7364
+ archived: payload.archived === true || payload.flowArchived === true,
7365
+ }, userCtx);
7366
+ if (scoped.error) {
7367
+ json(res, 400, { error: scoped.error });
7368
+ return;
7369
+ }
7370
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
7371
+ json(res, 400, { error: "Cannot optimize workspace graph for builtin or archived pipeline" });
7372
+ return;
7373
+ }
7374
+ const flowId = String(payload.flowId || "").trim();
7375
+ if (!flowId) {
7376
+ json(res, 400, { error: "Missing flowId" });
7377
+ return;
7378
+ }
7379
+ const graphPath = workspaceGraphPath(scoped.root);
7380
+ const result = await workspaceOptimizeRunImplementations(root, scoped.root, payload, userCtx, {
7381
+ emit: () => {},
7382
+ });
7383
+ const currentGraph = readWorkspaceGraph(scoped.root).graph;
7384
+ const touchedIds = new Set((result.optimized || []).map((item) => item.nodeId).filter(Boolean));
7385
+ const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
7386
+ fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
7387
+ const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, mergedGraph, authUser, userCtx);
7388
+ json(res, 200, {
7389
+ ok: true,
7390
+ path: graphPath,
7391
+ graph: mergedGraph,
7392
+ order: result.order,
7393
+ optimized: result.optimized,
7394
+ skipped: result.skipped,
7395
+ workspaceSchedules,
7396
+ });
7397
+ } catch (e) {
7398
+ json(res, 500, { error: (e && e.message) || String(e) });
7399
+ }
7400
+ return;
7401
+ }
7402
+
6803
7403
  if (req.method === "POST" && url.pathname === "/api/workspace/run") {
6804
7404
  let payload;
6805
7405
  try {
@@ -6864,6 +7464,19 @@ export function startUiServer({
6864
7464
  }
6865
7465
  },
6866
7466
  };
7467
+ const runLog = createWorkspaceRunLogSession({
7468
+ runId,
7469
+ userId: runEntry.userId,
7470
+ username: runEntry.username,
7471
+ flowId: runEntry.flowId,
7472
+ flowSource: runEntry.flowSource,
7473
+ scheduleNodeId: String(runtimeGraph.instances?.[runNodeId]?.definitionId || "") === "workspace_scheduled_run" ? runNodeId : "",
7474
+ runNodeId,
7475
+ scheduled: false,
7476
+ trigger: "manual",
7477
+ label: String(runtimeGraph.instances?.[runNodeId]?.label || "Workspace Run"),
7478
+ startedAt: runEntry.startedAt,
7479
+ });
6867
7480
  activeWorkspaceRuns.set(runKey, runEntry);
6868
7481
  appendWorkspaceRunStarted(runEntry);
6869
7482
  const setActiveChild = (child) => {
@@ -6882,6 +7495,7 @@ export function startUiServer({
6882
7495
  "X-Accel-Buffering": "no",
6883
7496
  });
6884
7497
  const writeEvent = (event) => {
7498
+ appendWorkspaceRunLogEvent(runLog.runId, event);
6885
7499
  try { res.write(JSON.stringify(event) + "\n"); } catch (_) {}
6886
7500
  };
6887
7501
  try {
@@ -6894,28 +7508,47 @@ export function startUiServer({
6894
7508
  const touchedIds = workspaceRunTouchedNodeIds(result);
6895
7509
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
6896
7510
  fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
7511
+ const endedAt = Date.now();
6897
7512
  appendWorkspaceRunFinished({
6898
7513
  ...runEntry,
6899
- endedAt: Date.now(),
6900
- durationMs: Date.now() - runEntry.startedAt,
7514
+ endedAt,
7515
+ durationMs: endedAt - runEntry.startedAt,
6901
7516
  }, "success");
7517
+ finishWorkspaceRunLogSession(runLog.runId, "success", {
7518
+ endedAt,
7519
+ durationMs: endedAt - runEntry.startedAt,
7520
+ runNodeId,
7521
+ });
6902
7522
  writeEvent({ type: "done", ok: true, path: graphPath, graph: mergedGraph, order: result.order, touchedNodeIds: Array.from(touchedIds), pauseNodeIds: result.pauseNodeIds || [] });
6903
7523
  res.end();
6904
7524
  } catch (e) {
7525
+ const endedAt = Date.now();
6905
7526
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
6906
7527
  appendWorkspaceRunFinished({
6907
7528
  ...runEntry,
6908
- endedAt: Date.now(),
6909
- durationMs: Date.now() - runEntry.startedAt,
7529
+ endedAt,
7530
+ durationMs: endedAt - runEntry.startedAt,
6910
7531
  }, "stopped");
7532
+ finishWorkspaceRunLogSession(runLog.runId, "stopped", {
7533
+ endedAt,
7534
+ durationMs: endedAt - runEntry.startedAt,
7535
+ runNodeId,
7536
+ });
6911
7537
  writeEvent({ type: "stopped", ok: false, stopped: true, message: "Workspace run stopped" });
6912
7538
  } else {
7539
+ const error = (e && e.message) || String(e);
6913
7540
  appendWorkspaceRunFinished({
6914
7541
  ...runEntry,
6915
- endedAt: Date.now(),
6916
- durationMs: Date.now() - runEntry.startedAt,
7542
+ endedAt,
7543
+ durationMs: endedAt - runEntry.startedAt,
6917
7544
  }, "failed");
6918
- writeEvent({ type: "error", error: (e && e.message) || String(e) });
7545
+ finishWorkspaceRunLogSession(runLog.runId, "failed", {
7546
+ endedAt,
7547
+ durationMs: endedAt - runEntry.startedAt,
7548
+ runNodeId,
7549
+ error,
7550
+ });
7551
+ writeEvent({ type: "error", error });
6919
7552
  }
6920
7553
  res.end();
6921
7554
  } finally {
@@ -6927,32 +7560,53 @@ export function startUiServer({
6927
7560
  const result = await runWorkspaceGraph(root, scoped.root, { ...payload, requestBaseUrl: requestPublicBaseUrl(req) }, userCtx, {
6928
7561
  signal: controller.signal,
6929
7562
  onActiveChild: setActiveChild,
7563
+ onEvent: (event) => appendWorkspaceRunLogEvent(runLog.runId, event),
6930
7564
  });
6931
7565
  const graphPath = workspaceGraphPath(scoped.root);
6932
7566
  const currentGraph = readWorkspaceGraph(scoped.root).graph;
6933
7567
  const touchedIds = workspaceRunTouchedNodeIds(result);
6934
7568
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
6935
7569
  fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
7570
+ const endedAt = Date.now();
6936
7571
  appendWorkspaceRunFinished({
6937
7572
  ...runEntry,
6938
- endedAt: Date.now(),
6939
- durationMs: Date.now() - runEntry.startedAt,
7573
+ endedAt,
7574
+ durationMs: endedAt - runEntry.startedAt,
6940
7575
  }, "success");
7576
+ finishWorkspaceRunLogSession(runLog.runId, "success", {
7577
+ endedAt,
7578
+ durationMs: endedAt - runEntry.startedAt,
7579
+ runNodeId,
7580
+ });
6941
7581
  json(res, 200, { ok: true, path: graphPath, ...result, graph: mergedGraph, touchedNodeIds: Array.from(touchedIds) });
6942
7582
  } catch (e) {
7583
+ const endedAt = Date.now();
6943
7584
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
6944
7585
  appendWorkspaceRunFinished({
6945
7586
  ...runEntry,
6946
- endedAt: Date.now(),
6947
- durationMs: Date.now() - runEntry.startedAt,
7587
+ endedAt,
7588
+ durationMs: endedAt - runEntry.startedAt,
6948
7589
  }, "stopped");
7590
+ finishWorkspaceRunLogSession(runLog.runId, "stopped", {
7591
+ endedAt,
7592
+ durationMs: endedAt - runEntry.startedAt,
7593
+ runNodeId,
7594
+ });
6949
7595
  json(res, 200, { ok: false, stopped: true, message: "Workspace run stopped" });
6950
7596
  } else {
7597
+ const error = (e && e.message) || String(e);
6951
7598
  appendWorkspaceRunFinished({
6952
7599
  ...runEntry,
6953
- endedAt: Date.now(),
6954
- durationMs: Date.now() - runEntry.startedAt,
7600
+ endedAt,
7601
+ durationMs: endedAt - runEntry.startedAt,
6955
7602
  }, "failed");
7603
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "error", error, ts: endedAt });
7604
+ finishWorkspaceRunLogSession(runLog.runId, "failed", {
7605
+ endedAt,
7606
+ durationMs: endedAt - runEntry.startedAt,
7607
+ runNodeId,
7608
+ error,
7609
+ });
6956
7610
  throw e;
6957
7611
  }
6958
7612
  } finally {
@@ -6964,6 +7618,52 @@ export function startUiServer({
6964
7618
  return;
6965
7619
  }
6966
7620
 
7621
+ if (req.method === "GET" && url.pathname === "/api/workspace/run-logs") {
7622
+ try {
7623
+ const flowId = url.searchParams.get("flowId") || "";
7624
+ const flowSource = url.searchParams.get("flowSource") || "";
7625
+ const scheduleNodeId = url.searchParams.get("scheduleNodeId") || "";
7626
+ const runNodeId = url.searchParams.get("runNodeId") || "";
7627
+ const limit = Number(url.searchParams.get("limit") || 50);
7628
+ json(res, 200, {
7629
+ runs: listWorkspaceRunLogs({
7630
+ userId: userCtx.userId || "",
7631
+ flowId,
7632
+ flowSource,
7633
+ scheduleNodeId,
7634
+ runNodeId,
7635
+ limit,
7636
+ }),
7637
+ });
7638
+ } catch (e) {
7639
+ json(res, 500, { error: (e && e.message) || String(e) });
7640
+ }
7641
+ return;
7642
+ }
7643
+
7644
+ if (req.method === "GET" && url.pathname.startsWith("/api/workspace/run-logs/")) {
7645
+ try {
7646
+ const runId = decodeURIComponent(url.pathname.slice("/api/workspace/run-logs/".length));
7647
+ if (!runId) {
7648
+ json(res, 400, { error: "Missing runId" });
7649
+ return;
7650
+ }
7651
+ const run = listWorkspaceRunLogs({ userId: userCtx.userId || "", limit: 200 })
7652
+ .find((item) => String(item.runId || "") === runId);
7653
+ if (!run) {
7654
+ json(res, 404, { error: "Run log not found" });
7655
+ return;
7656
+ }
7657
+ json(res, 200, {
7658
+ run,
7659
+ events: readWorkspaceRunLogEvents(runId),
7660
+ });
7661
+ } catch (e) {
7662
+ json(res, 500, { error: (e && e.message) || String(e) });
7663
+ }
7664
+ return;
7665
+ }
7666
+
6967
7667
  if (req.method === "GET" && url.pathname === "/api/workspace/run/status") {
6968
7668
  const flowId = typeof url.searchParams.get("flowId") === "string" ? url.searchParams.get("flowId").trim() : "";
6969
7669
  if (!flowId) {
@@ -8711,6 +9411,128 @@ finishedAt: "${new Date().toISOString()}"
8711
9411
  return;
8712
9412
  }
8713
9413
 
9414
+ if (req.method === "GET" && url.pathname === "/api/flow/schedules") {
9415
+ try {
9416
+ json(res, 200, { schedules: listScheduleStatuses(root, userCtx) });
9417
+ } catch (e) {
9418
+ json(res, 500, { error: (e && e.message) || String(e) });
9419
+ }
9420
+ return;
9421
+ }
9422
+
9423
+ if (req.method === "GET" && url.pathname === "/api/schedules") {
9424
+ try {
9425
+ const pipelineSchedules = listScheduleStatuses(root, userCtx)
9426
+ .filter((schedule) => (
9427
+ schedule.enabled ||
9428
+ schedule.cron ||
9429
+ schedule.nextRunAt ||
9430
+ schedule.lastTriggeredAt ||
9431
+ schedule.lastRunUuid ||
9432
+ schedule.lastError ||
9433
+ schedule.running ||
9434
+ schedule.waiting
9435
+ ))
9436
+ .map((schedule) => ({
9437
+ kind: "pipeline",
9438
+ ...schedule,
9439
+ }));
9440
+ const workspaceSchedules = listWorkspaceScheduleStatuses(root, userCtx);
9441
+ json(res, 200, {
9442
+ schedules: [...workspaceSchedules, ...pipelineSchedules].sort((a, b) => {
9443
+ const ea = a.enabled ? 0 : 1;
9444
+ const eb = b.enabled ? 0 : 1;
9445
+ return ea - eb || String(a.nextRunAt || "").localeCompare(String(b.nextRunAt || "")) || String(a.flowId || "").localeCompare(String(b.flowId || ""));
9446
+ }),
9447
+ });
9448
+ } catch (e) {
9449
+ json(res, 500, { error: (e && e.message) || String(e) });
9450
+ }
9451
+ return;
9452
+ }
9453
+
9454
+ if (req.method === "POST" && url.pathname === "/api/schedule/toggle") {
9455
+ let payload;
9456
+ try {
9457
+ payload = JSON.parse(await readBody(req));
9458
+ } catch {
9459
+ json(res, 400, { error: "Invalid JSON body" });
9460
+ return;
9461
+ }
9462
+ const kind = String(payload.kind || "").trim();
9463
+ if (kind === "workspace") {
9464
+ try {
9465
+ const result = setWorkspaceScheduleEnabled(root, payload, authUser, userCtx);
9466
+ if (!result.success) {
9467
+ json(res, 400, { error: result.error || "Could not update workspace schedule" });
9468
+ return;
9469
+ }
9470
+ json(res, 200, { success: true });
9471
+ } catch (e) {
9472
+ json(res, 500, { error: (e && e.message) || String(e) });
9473
+ }
9474
+ return;
9475
+ }
9476
+ if (kind === "pipeline") {
9477
+ const flowId = String(payload.flowId || "").trim();
9478
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
9479
+ if (!flowId) {
9480
+ json(res, 400, { error: "Missing flowId" });
9481
+ return;
9482
+ }
9483
+ if (!isValidFlowSourceWrite(flowSource)) {
9484
+ json(res, 400, { error: "Cannot update schedule for builtin or readonly flow" });
9485
+ return;
9486
+ }
9487
+ const current = readFlowSchedule(root, flowId, flowSource, userCtx);
9488
+ if (!current.success) {
9489
+ json(res, 400, { error: current.error || "Could not read schedule" });
9490
+ return;
9491
+ }
9492
+ const result = writeFlowSchedule(root, flowId, flowSource, { ...current.schedule, enabled: payload.enabled === true }, userCtx);
9493
+ if (!result.success) {
9494
+ json(res, 400, { error: result.error || "Could not update schedule" });
9495
+ return;
9496
+ }
9497
+ json(res, 200, { success: true, schedule: result.schedule });
9498
+ return;
9499
+ }
9500
+ json(res, 400, { error: "Invalid schedule kind" });
9501
+ return;
9502
+ }
9503
+
9504
+ if (req.method === "POST" && url.pathname === "/api/flow/schedule/disable") {
9505
+ let payload;
9506
+ try {
9507
+ payload = JSON.parse(await readBody(req));
9508
+ } catch {
9509
+ json(res, 400, { error: "Invalid JSON body" });
9510
+ return;
9511
+ }
9512
+ const flowId = String(payload.flowId || "").trim();
9513
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
9514
+ if (!flowId) {
9515
+ json(res, 400, { error: "Missing flowId" });
9516
+ return;
9517
+ }
9518
+ if (!isValidFlowSourceWrite(flowSource)) {
9519
+ json(res, 400, { error: "Cannot disable schedule for builtin or readonly flow" });
9520
+ return;
9521
+ }
9522
+ const current = readFlowSchedule(root, flowId, flowSource, userCtx);
9523
+ if (!current.success) {
9524
+ json(res, 400, { error: current.error || "Could not read schedule" });
9525
+ return;
9526
+ }
9527
+ const result = writeFlowSchedule(root, flowId, flowSource, { ...current.schedule, enabled: false }, userCtx);
9528
+ if (!result.success) {
9529
+ json(res, 400, { error: result.error || "Could not disable schedule" });
9530
+ return;
9531
+ }
9532
+ json(res, 200, { success: true, schedule: result.schedule });
9533
+ return;
9534
+ }
9535
+
8714
9536
  if (req.method === "POST" && url.pathname === "/api/flow/run") {
8715
9537
  let payload;
8716
9538
  try {