Sfoglia il codice sorgente

Merge branch 'workflow-list-and-stop-mode'

Workflow list now shows real created and modified dates, the owner and the
configured triggers; stop-and-error gains a mode that ends a run without
marking it failed.
fszontagh 1 mese fa
parent
commit
6afb853cd7

+ 29 - 5
nodes/core/stop-and-error.js

@@ -3,17 +3,24 @@
  * @name Stop and Error
  * @category flow-control
  * @version 1.0.0
- * @description Fail the workflow deliberately with a message, picked up by the error trigger
+ * @description End the workflow deliberately, either as a failure or as an ordinary stop
  * @icon octagon-x
  */
 
 const configSchema = {
     type: 'object',
     properties: {
+        mode: {
+            type: 'string',
+            title: 'Mode',
+            enum: ['error', 'stop'],
+            default: 'error',
+            description: 'error fails the run, which is what the error trigger reacts to and what shows red in the execution list. stop ends it as an ordinary completed run. Use stop for a condition that is expected, such as a scheduled poll finding nothing to do - marking those failed every few minutes buries the real failures'
+        },
         message: {
             type: 'string',
             title: 'Message',
-            description: 'The error text. Expressions are resolved, so it can carry values from the run',
+            description: 'The reason for ending the run. Expressions are resolved, so it can carry values from the run',
             default: 'Workflow stopped'
         }
     },
@@ -29,13 +36,30 @@ const inputSchema = {
 
 const outputSchema = {
     type: 'object',
-    properties: {}
+    properties: {
+        stopped: { type: 'boolean', description: 'True when the run was ended by this node in stop mode' },
+        reason: { type: 'string', description: 'The message the run was ended with' }
+    }
 };
 
 async function execute(config, input, context) {
     const message = config.message || 'Workflow stopped';
-    smartbotic.log.warn('Stop and Error: ' + message);
-    throw new Error(message);
+
+    // Default to failing. This node has always thrown, and a workflow relying
+    // on the error trigger firing must keep working after this option arrived.
+    if ((config.mode || 'error') !== 'stop') {
+        smartbotic.log.warn('Stop and Error: ' + message);
+        throw new Error(message);
+    }
+
+    // Throwing is the only way a node can halt a run by itself, and a throw is
+    // by definition a failure. Ending a run without failing needs the engine's
+    // cooperation, which is what this marker asks for: it stops the walk, skips
+    // the remaining nodes, and still finishes the execution as completed.
+    smartbotic.log.info('Stop: ' + message);
+    return {
+        _stop: { reason: message }
+    };
 }
 
 module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 75 - 2
src/runner/workflow_engine.cpp

@@ -697,6 +697,30 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
                 result.webhook_response = node_result.output["_webhookResponse"];
             }
 
+            // A node asking to stop ends the walk without failing the run. The
+            // distinction matters for anything that polls: a scheduled workflow
+            // that finds nothing to do has completed successfully, and marking
+            // it failed every five minutes buries real failures in noise.
+            if (node_result.status == NodeStatus::Completed &&
+                node_result.output.contains("_stop")) {
+                const auto& stop = node_result.output["_stop"];
+
+                result.stop_requested = true;
+                result.stopped_node_id = node_id;
+                result.stop_reason = stop.value("reason", "");
+
+                // The marker is engine plumbing. What the node reports is that
+                // it stopped and why, not the mechanism that carried it.
+                auto& stored_node = result.node_results[node_id];
+                stored_node.output.erase("_stop");
+                stored_node.output["stopped"] = true;
+                stored_node.output["reason"] = result.stop_reason;
+
+                LOG_INFO("Execution {} stopped at node {}: {}", result.execution_id, node_id,
+                         result.stop_reason.empty() ? "no reason given" : result.stop_reason);
+                break;
+            }
+
             // A node asking to pause ends this pass. The execution is stored as
             // Waiting with everything computed so far, and a later resume picks
             // it up from here rather than starting again.
@@ -773,6 +797,13 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
                     break;
                 }
 
+                // A body node asked to stop the run. The loop already unwound
+                // its own iterations; this ends the outer walk too, so nodes
+                // after the loop do not run.
+                if (result.stop_requested) {
+                    break;
+                }
+
                 continue;  // Loop handles its own downstream execution
             }
 
@@ -791,8 +822,16 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
         if (result.status == ExecutionStatus::Running) {
             result.status = ExecutionStatus::Completed;
 
-            // Get output from last executed node
-            if (!execution_order.empty()) {
+            if (result.stop_requested) {
+                // The last node in the order never ran - the walk ended early on
+                // purpose - so the useful final output is the node that stopped
+                // it, not an entry that will not be found.
+                auto it = result.node_results.find(result.stopped_node_id);
+                if (it != result.node_results.end()) {
+                    result.final_output = it->second.output;
+                }
+            } else if (!execution_order.empty()) {
+                // Get output from last executed node
                 auto it = result.node_results.find(execution_order.back());
                 if (it != result.node_results.end()) {
                     result.final_output = it->second.output;
@@ -1821,6 +1860,10 @@ bool WorkflowEngine::executeLoopBody(
         }
     };
 
+    // Set when a body node asks to stop the whole run, so both the body-node
+    // loop and the per-item loop can unwind.
+    bool stopped_in_body = false;
+
     // Execute body for each item
     for (size_t i = 0; i < ctx.items.size(); ++i) {
         ctx.current_index = i;
@@ -2092,6 +2135,30 @@ bool WorkflowEngine::executeLoopBody(
                 result.webhook_response = body_result.output["_webhookResponse"];
             }
 
+            // A stop raised inside a loop body ends the whole run, not just the
+            // iteration - "stop the workflow" would be a strange thing to mean
+            // per-item. The flag travels out on the result because this walk
+            // cannot end the outer one itself.
+            if (body_result.status == NodeStatus::Completed &&
+                body_result.output.contains("_stop")) {
+                const auto& stop = body_result.output["_stop"];
+
+                result.stop_requested = true;
+                result.stopped_node_id = body_node_id;
+                result.stop_reason = stop.value("reason", "");
+
+                auto& stored_body = result.node_results[result_key];
+                stored_body.output.erase("_stop");
+                stored_body.output["stopped"] = true;
+                stored_body.output["reason"] = result.stop_reason;
+
+                LOG_INFO("Execution {} stopped at node {} during loop iteration {}: {}",
+                         result.execution_id, body_node_id, i,
+                         result.stop_reason.empty() ? "no reason given" : result.stop_reason);
+                stopped_in_body = true;
+                break;
+            }
+
             if (callback) {
                 nlohmann::json event_data = {
                     {"executionId", result.execution_id},
@@ -2139,6 +2206,12 @@ bool WorkflowEngine::executeLoopBody(
         if (iteration_failed && !ctx.continue_on_error) {
             break;
         }
+
+        // A stop ends the remaining items too. Results collected so far are
+        // kept - they were produced before anyone asked to stop.
+        if (stopped_in_body) {
+            break;
+        }
     }
 
     // Copy results back (ctx was passed by const ref, but we used a mutable copy)

+ 12 - 0
src/runner/workflow_engine.hpp

@@ -108,6 +108,18 @@ struct ExecutionResult {
     std::string pause_token;           // Must be presented to resume
     int64_t pause_expires_at = 0;      // Milliseconds since the epoch, 0 for never
 
+    // A node asked to end the run early without failing it. The execution still
+    // finishes as Completed - this is an ordinary outcome, not an error - and
+    // these fields record that the remaining nodes were skipped deliberately
+    // rather than never reached.
+    //
+    // It lives on the result rather than being handled locally because a stop
+    // can be raised inside a loop body, which runs in a separate walk and has
+    // to hand the decision back to the caller.
+    bool stop_requested = false;
+    std::string stopped_node_id;
+    std::string stop_reason;
+
     nlohmann::json toJson() const;
 };
 

+ 7 - 1
src/webserver/api/user_controller.cpp

@@ -65,7 +65,13 @@ void UserController::listUsers(const httplib::Request& req, httplib::Response& r
     nlohmann::json response;
     response["users"] = nlohmann::json::array();
     for (const auto& user : result.value()) {
-        response["users"].push_back(user.toJson());
+        auto entry = user.toJson();
+        // toJson() leaves the id out on purpose - it is the document key, and
+        // writing it back into the document would duplicate it. An API caller
+        // still needs it: without one, a workflow's ownerId cannot be resolved
+        // to a name, which is why the workflow list could not show an owner.
+        entry["id"] = user.id;
+        response["users"].push_back(entry);
     }
     response["page"] = page;
     response["pageSize"] = page_size;

+ 6 - 0
systemd/user/smartbotic-runner.service

@@ -4,6 +4,12 @@ Documentation=https://github.com/smartbotic/smartbotic
 After=network.target smartbotic-webserver.service
 Requires=smartbotic-webserver.service
 
+# Without PartOf, "systemctl restart smartbotic.target" starts nothing and
+# stops nothing: a target is only a grouping, and restart/stop do not reach
+# its members through Wants alone. That silently leaves an old binary running
+# after a rebuild, with systemctl still reporting the target as active.
+PartOf=smartbotic.target
+
 [Service]
 Type=simple
 WorkingDirectory=/data/smartbotic

+ 6 - 0
systemd/user/smartbotic-webserver.service

@@ -3,6 +3,12 @@ Description=SmartBotic WebServer Service
 Documentation=https://github.com/smartbotic/smartbotic
 After=network.target
 
+# Without PartOf, "systemctl restart smartbotic.target" starts nothing and
+# stops nothing: a target is only a grouping, and restart/stop do not reach
+# its members through Wants alone. That silently leaves an old binary running
+# after a rebuild, with systemctl still reporting the target as active.
+PartOf=smartbotic.target
+
 [Service]
 Type=simple
 WorkingDirectory=/data/smartbotic

+ 22 - 0
tests/nodes/stop-and-error-stop-in-loop.json

@@ -0,0 +1,22 @@
+{
+  "name": "verify-stop-and-error-stop-in-loop",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "items", "name": "Items", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: ['alpha', 'beta', 'gamma'] };"}},
+    {"id": "loop", "name": "Loop", "type": "loop", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items"}},
+    {"id": "stop", "name": "Stop", "type": "stop-and-error", "position": {"x": 0, "y": 300},
+     "config": {"mode": "stop", "message": "stopped from inside the loop"}},
+    {"id": "after", "name": "After", "type": "code", "position": {"x": 200, "y": 300},
+     "config": {"code": "return { marker: 'should not run' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "items", "targetInput": "data"},
+    {"sourceNodeId": "items", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "loop", "targetNodeId": "stop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "done", "targetNodeId": "after", "targetInput": "data"}
+  ],
+  "expectStatus": "completed",
+  "expectMissing": ["after"]
+}

+ 19 - 0
tests/nodes/stop-and-error-stop-mode.json

@@ -0,0 +1,19 @@
+{
+  "name": "verify-stop-and-error-stop-mode",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Stop", "type": "stop-and-error", "position": {"x": 0, "y": 100},
+     "config": {"mode": "stop", "message": "nothing to do this poll"}},
+    {"id": "n3", "name": "After", "type": "code", "position": {"x": 0, "y": 200},
+     "config": {"code": "return { marker: 'should not run' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expectStatus": "completed",
+  "expect": {
+    "n2": {"status": "completed", "output": {"stopped": true, "reason": "nothing to do this poll"}}
+  },
+  "expectMissing": ["n3"]
+}

+ 21 - 0
webui/src/api/users.ts

@@ -0,0 +1,21 @@
+import { api } from './client'
+
+export interface User {
+  id: string
+  username: string
+  email: string
+  role: string
+  active: boolean
+  lastLogin: number
+}
+
+export const usersApi = {
+  /**
+   * List users. Admin only - the endpoint requires the admin role, so a
+   * non-admin caller gets a 403 and any caller must tolerate that.
+   */
+  list: async (page = 1, pageSize = 100) => {
+    const { data } = await api.get('/users', { params: { page, pageSize } })
+    return (data.users || []) as User[]
+  },
+}

+ 6 - 4
webui/src/api/workflowGroups.ts

@@ -1,4 +1,5 @@
 import { api } from './client'
+import { readTimestamp } from '../utils/timestamps'
 
 export interface WorkflowGroup {
   id: string
@@ -6,8 +7,8 @@ export interface WorkflowGroup {
   description?: string
   parentId?: string
   ownerId: string
-  createdAt: number
-  updatedAt: number
+  createdAt: number | null
+  updatedAt: number | null
 }
 
 // Transform backend group data to frontend format
@@ -18,8 +19,9 @@ function transformGroup(data: any): WorkflowGroup {
     description: data.description,
     parentId: data.parentId || undefined,
     ownerId: data.ownerId,
-    createdAt: data._createdAt,
-    updatedAt: data._updatedAt,
+    // Same snake_case nanosecond metadata as workflows - see utils/timestamps.
+    createdAt: readTimestamp(data, '_created_at', '_createdAt'),
+    updatedAt: readTimestamp(data, '_updated_at', '_updatedAt'),
   }
 }
 

+ 18 - 4
webui/src/api/workflows.ts

@@ -1,4 +1,5 @@
 import { api } from './client'
+import { readTimestamp } from '../utils/timestamps'
 
 export interface WorkflowNode {
   id: string
@@ -25,8 +26,11 @@ export interface Workflow {
   nodes: WorkflowNode[]
   connections: Connection[]
   settings: Record<string, any>
-  createdAt: number
-  updatedAt: number
+  createdAt: number | null
+  updatedAt: number | null
+  ownerId?: string
+  createdBy?: string
+  updatedBy?: string
 }
 
 export interface NodeOutput {
@@ -70,8 +74,18 @@ function transformWorkflow(data: any): Workflow {
     nodes: data.nodes || [],
     connections: data.connections || [],
     settings: data.settings || {},
-    createdAt: data._createdAt,
-    updatedAt: data._updatedAt,
+    // The database writes _created_at / _updated_at in snake_case and in
+    // nanoseconds. Reading _createdAt returned undefined, which is why every
+    // workflow showed "unknown". Both spellings are tried and the unit is
+    // normalised - see utils/timestamps.
+    createdAt: readTimestamp(data, '_created_at', '_createdAt'),
+    updatedAt: readTimestamp(data, '_updated_at', '_updatedAt'),
+    ownerId: data.ownerId,
+    // _created_by is present on the document but has been empty on every record
+    // inspected so far, so ownerId is the field that actually identifies a
+    // person. Kept here for when the backend starts populating it.
+    createdBy: data._created_by || undefined,
+    updatedBy: data._updated_by || undefined,
   }
 }
 

+ 8 - 3
webui/src/components/GroupCard.tsx

@@ -11,9 +11,14 @@ interface GroupCardProps {
   onDelete: () => void
 }
 
-// Helper to safely format timestamps
-function safeFormatDistanceToNow(timestamp: number): string {
-  if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
+// Helper to safely format timestamps. The API layer normalises the database's
+// nanosecond, snake_case metadata into milliseconds and hands back null when a
+// document carries no usable timestamp.
+function safeFormatDistanceToNow(timestamp: number | null | undefined): string {
+  if (timestamp === null || timestamp === undefined) {
+    return 'unknown'
+  }
+  if (timestamp <= 0 || timestamp > 8640000000000000) {
     return 'unknown'
   }
   try {

+ 127 - 8
webui/src/pages/WorkflowsPage.tsx

@@ -1,10 +1,11 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { Link, useNavigate, useSearchParams } from 'react-router-dom'
-import { workflowsApi, Workflow } from '../api/workflows'
+import { workflowsApi, nodesApi, Workflow } from '../api/workflows'
+import { usersApi } from '../api/users'
 import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
-import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput } from 'lucide-react'
+import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap } from 'lucide-react'
 import { useState } from 'react'
-import { formatDistanceToNow } from 'date-fns'
+import { formatDistanceToNow, format } from 'date-fns'
 import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
 import { GroupCard } from '../components/GroupCard'
 import {
@@ -14,10 +15,14 @@ import {
   MoveItemModal,
 } from '../components/GroupModals'
 
-// Helper to safely format timestamps that might be invalid or too large
-function safeFormatDistanceToNow(timestamp: number): string {
-  // Check for invalid timestamps (too large, negative, or zero)
-  if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
+// Timestamps arrive already normalised to milliseconds by the API layer, which
+// is where the nanosecond and snake_case handling lives. A null here means the
+// document genuinely carries no usable timestamp, not that the unit was wrong.
+function safeFormatDistanceToNow(timestamp: number | null | undefined): string {
+  if (timestamp === null || timestamp === undefined) {
+    return 'unknown'
+  }
+  if (timestamp <= 0 || timestamp > 8640000000000000) {
     return 'unknown'
   }
   try {
@@ -27,6 +32,45 @@ function safeFormatDistanceToNow(timestamp: number): string {
   }
 }
 
+function formatExact(timestamp: number | null | undefined): string {
+  if (timestamp === null || timestamp === undefined || timestamp <= 0) {
+    return 'unknown'
+  }
+  try {
+    return format(timestamp, 'yyyy-MM-dd HH:mm')
+  } catch {
+    return 'unknown'
+  }
+}
+
+interface TriggerSummary {
+  type: string
+  label: string
+}
+
+/**
+ * Which trigger nodes a workflow contains. A trigger is a node whose definition
+ * carries isTrigger, so this needs the definitions the page already loads -
+ * the workflow itself only stores each node's type string.
+ */
+function findTriggers(
+  workflow: Workflow,
+  triggerTypes: Map<string, string>
+): TriggerSummary[] {
+  const found: TriggerSummary[] = []
+  const seen = new Set<string>()
+
+  for (const node of workflow.nodes || []) {
+    const label = triggerTypes.get(node.type)
+    if (label === undefined || seen.has(node.type)) {
+      continue
+    }
+    seen.add(node.type)
+    found.push({ type: node.type, label })
+  }
+  return found
+}
+
 export default function WorkflowsPage() {
   const queryClient = useQueryClient()
   const navigate = useNavigate()
@@ -66,6 +110,34 @@ export default function WorkflowsPage() {
   })
   const workflows: Workflow[] = workflowsData?.workflows || []
 
+  // Node definitions, purely to learn which node types are triggers. A workflow
+  // stores only a node's type string, so the definitions are the only place
+  // that says whether it starts a run.
+  const { data: nodesData } = useQuery({
+    queryKey: ['node-definitions'],
+    queryFn: () => nodesApi.list(),
+    staleTime: 5 * 60 * 1000,
+  })
+  const triggerTypes = new Map<string, string>()
+  for (const definition of nodesData?.nodes || []) {
+    if (definition.isTrigger) {
+      triggerTypes.set(definition.id, definition.name || definition.id)
+    }
+  }
+
+  // Owner names. The endpoint is admin-only, so a non-admin simply gets no
+  // names and the card falls back to showing nothing rather than erroring.
+  const { data: usersData } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => usersApi.list(),
+    staleTime: 5 * 60 * 1000,
+    retry: false,
+  })
+  const userNames = new Map<string, string>()
+  for (const user of usersData || []) {
+    userNames.set(user.id, user.username)
+  }
+
   const isLoading = isLoadingGroups || isLoadingWorkflows
 
   // Navigate to a group
@@ -265,6 +337,10 @@ export default function WorkflowsPage() {
                     key={workflow.id || Math.random()}
                     workflow={workflow}
                     groupId={currentGroupId}
+                    ownerName={
+                      workflow.ownerId ? userNames.get(workflow.ownerId) : undefined
+                    }
+                    triggers={findTriggers(workflow, triggerTypes)}
                     onToggleActive={() => {
                       if (workflow.id) {
                         toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
@@ -393,12 +469,16 @@ export default function WorkflowsPage() {
 function WorkflowCard({
   workflow,
   groupId,
+  ownerName,
+  triggers,
   onToggleActive,
   onMove,
   onDelete,
 }: {
   workflow: Workflow
   groupId: string | null
+  ownerName?: string
+  triggers: TriggerSummary[]
   onToggleActive: () => void
   onMove: () => void
   onDelete: () => void
@@ -478,6 +558,45 @@ function WorkflowCard({
         </div>
       </div>
 
+      {triggers.length > 0 && (
+        <div className="flex flex-wrap items-center gap-1.5 mb-3">
+          <Zap className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 shrink-0" />
+          {triggers.map((trigger) => (
+            <span
+              key={trigger.type}
+              className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
+              title={trigger.type}
+            >
+              {trigger.label}
+            </span>
+          ))}
+        </div>
+      )}
+
+      <div className="space-y-1 mb-3 text-xs text-gray-500 dark:text-gray-400">
+        <div className="flex items-center gap-1.5">
+          <Clock className="w-3.5 h-3.5 shrink-0" />
+          {/* The exact date sits in the tooltip because "3 months ago" is the
+              useful form at a glance, but the precise stamp is what someone
+              needs when they are actually comparing two workflows. */}
+          <span title={formatExact(workflow.createdAt)}>
+            Created {safeFormatDistanceToNow(workflow.createdAt)}
+          </span>
+        </div>
+        <div className="flex items-center gap-1.5">
+          <Clock className="w-3.5 h-3.5 shrink-0" />
+          <span title={formatExact(workflow.updatedAt)}>
+            Modified {safeFormatDistanceToNow(workflow.updatedAt)}
+          </span>
+        </div>
+        {(ownerName || workflow.createdBy) && (
+          <div className="flex items-center gap-1.5">
+            <User className="w-3.5 h-3.5 shrink-0" />
+            <span>{ownerName || workflow.createdBy}</span>
+          </div>
+        )}
+      </div>
+
       <div className="flex items-center justify-between text-sm">
         <span
           className={`px-2 py-0.5 rounded-full text-xs font-medium ${
@@ -489,7 +608,7 @@ function WorkflowCard({
           {workflow.active ? 'Active' : 'Inactive'}
         </span>
         <span className="text-gray-400 dark:text-gray-500">
-          {safeFormatDistanceToNow(workflow.updatedAt)}
+          {workflow.nodes?.length || 0} node{workflow.nodes?.length === 1 ? '' : 's'}
         </span>
       </div>
     </div>

+ 69 - 0
webui/src/utils/timestamps.ts

@@ -0,0 +1,69 @@
+/**
+ * Timestamp normalisation for values coming out of the smartbotic-database.
+ *
+ * The daemon stamps documents with `_created_at` and `_updated_at` in
+ * NANOSECONDS - around 1.78e18. Everything else in the platform works in
+ * milliseconds: Date.now() in nodes, TimeUtils::nowMs() in C++, and the
+ * startedAt / finishedAt the engine writes onto an execution.
+ *
+ * Two traps live here, and the workflow list fell into both at once:
+ *
+ *  - The metadata keys are snake_case (`_created_at`). Reading `_createdAt`
+ *    returns undefined and every date renders as "unknown".
+ *  - A nanosecond value is far larger than the largest date JavaScript accepts
+ *    (8.64e15 ms). Passing one to new Date() or date-fns yields Invalid Date,
+ *    so fixing only the key name still renders "unknown" - which makes the
+ *    first fix look like it did nothing.
+ */
+
+// The largest instant the ECMAScript Date type can represent, in milliseconds.
+const MAX_JS_DATE_MS = 8640000000000000
+
+// Well past any plausible millisecond timestamp (1e15 ms is the year 33658) and
+// well below any plausible nanosecond one, so it separates the two units
+// without having to be told which was meant.
+const NANOSECOND_THRESHOLD = 1e15
+
+/**
+ * Convert a database timestamp to milliseconds, tolerating either unit.
+ * Returns null when the value is missing or cannot be a real instant, so
+ * callers can render their own placeholder rather than an Invalid Date.
+ */
+export function toMillis(value: unknown): number | null {
+  const raw = typeof value === 'string' ? Number(value) : value
+
+  if (typeof raw !== 'number' || !isFinite(raw) || raw <= 0) {
+    return null
+  }
+
+  const millis = raw > NANOSECOND_THRESHOLD ? Math.floor(raw / 1000000) : raw
+
+  if (millis > MAX_JS_DATE_MS) {
+    return null
+  }
+
+  return millis
+}
+
+/**
+ * Read a document's timestamp regardless of which spelling it carries.
+ *
+ * The database writes snake_case, but parts of this codebase were written
+ * against camelCase and some responses are reshaped before they arrive. Trying
+ * both costs nothing and removes a whole class of silent "unknown".
+ */
+export function readTimestamp(
+  data: Record<string, any> | null | undefined,
+  ...keys: string[]
+): number | null {
+  if (!data) {
+    return null
+  }
+  for (const key of keys) {
+    const millis = toMillis(data[key])
+    if (millis !== null) {
+      return millis
+    }
+  }
+  return null
+}