@nanobpm/nano-workforce 0.94.0 → 0.95.0

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.
@@ -0,0 +1,170 @@
1
+ // End-to-end proof for the inter-epic capability PREFLIGHT seeded into plan-fanout (issue #292, slice
2
+ // S3). Boots the whole app against the WASM engine + virtual clock and drives the REAL
3
+ // `plan-fanout.bpmn` with `readinessProbes` seeded — the exact shape slice S3's lowering seeds for a
4
+ // DEPENDENT epic — proving the leading readiness-gate executes on the engine BEFORE wave 0:
5
+ // • GREEN — a dependent seeded with a probe that is ready reruns the reused `pr.readiness-probe`
6
+ // worker inside the multi-instance preflight, releases through `pf_gw → pf_end`, binds the
7
+ // resolved artifact into `resolvedArtifacts`, and only THEN reaches `ensure-base-branch` (the
8
+ // head of the fan-out) — it never fans a wave before the gate is green, and never escalates.
9
+ // • ROOT — an epic seeded with `readinessProbes = null` skips the gate entirely
10
+ // (`gw-readiness → ensure-base-branch`), fanning out immediately as a single epic does today.
11
+ //
12
+ // The probe is a deterministic shell builtin (`true`) with a bound artifact, so the gate itself is
13
+ // hermetic (no network, no GitHub). The fan-out head (`pr.ensure-base-branch`) that follows a green
14
+ // gate is handled by the shared hermetic admit-github stub (installAdmitGithub) like the sibling
15
+ // plan-fanout e2es, so the whole flow runs offline.
16
+ // We assert on the cumulative taken sequence flows (the WASM engine folds completed variables away).
17
+ import assert from "node:assert/strict";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { after, before, describe, test } from "node:test";
22
+ import { fileURLToPath } from "node:url";
23
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
+ import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
+
26
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+
28
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
29
+ NANO_PR_GITHUB_TRANSPORT: "token",
30
+ GITHUB_TOKEN: "",
31
+ };
32
+ const savedEnv = new Map<string, string | undefined>();
33
+
34
+ interface TakenFlow {
35
+ from: string;
36
+ to: string;
37
+ }
38
+
39
+ function takenFlows(app: TestApp): string[] {
40
+ const snapshot = app.snapshot();
41
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
42
+ return flows
43
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
44
+ .map((f) => `${f.from}->${f.to}`);
45
+ }
46
+
47
+ // The full variable set `startPlan` seeds onto a plan-fanout instance (app/plan.ts). We seed it
48
+ // directly so we can inject `readinessProbes` (which the `startPlanFanout` op never sets — only S3's
49
+ // set lowering does) without standing up a whole two-epic set + release provenance.
50
+ function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
51
+ return {
52
+ planKey: "owner/repo#2",
53
+ repo: "owner/repo",
54
+ issue: "owner/repo#2",
55
+ issueNumber: 2,
56
+ issueUrl: "https://github.com/owner/repo/issues/2",
57
+ planFindings: null,
58
+ planReviewEpoch: 0,
59
+ escalationSlaTimeout: "PT24H",
60
+ escalationAssignee: null,
61
+ blackboardUrl: "http://blackboard.local/x",
62
+ blackboardBrief: "",
63
+ baseBranch: "epic/e2e",
64
+ baseBranchBrief: "",
65
+ waveCount: 1,
66
+ readinessProbes: null,
67
+ probeTimeout: null,
68
+ gateKey: null,
69
+ resolvedArtifacts: null,
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
75
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-preflight-"));
76
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
77
+ return { app, dbDir };
78
+ }
79
+
80
+ describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #292 S3)", () => {
81
+ let restoreGithub: (() => void) | undefined;
82
+
83
+ before(() => {
84
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
85
+ savedEnv.set(k, process.env[k]);
86
+ process.env[k] = v;
87
+ }
88
+ // The fan-out head (`pr.ensure-base-branch`, ADR 0003) reads/creates the base ref via the token
89
+ // transport, which would throw `no GitHub transport available` under an empty token. Pin the
90
+ // shared hermetic admit-github stub (dummy token + fetch intercept) like the sibling plan-fanout
91
+ // e2es so base-branch admission is deterministic and offline.
92
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
93
+ });
94
+ after(() => {
95
+ restoreGithub?.();
96
+ for (const [k, v] of savedEnv) {
97
+ if (v === undefined) delete process.env[k];
98
+ else process.env[k] = v;
99
+ }
100
+ });
101
+
102
+ test("GREEN: a dependent waits on the preflight, releases green, and only THEN reaches the fan-out head", async () => {
103
+ const { app, dbDir } = await boot();
104
+ try {
105
+ const { processInstanceKey } = await app.engine.createInstance({
106
+ processDefinitionId: "plan-fanout",
107
+ variables: planVars({
108
+ // The shape S3 lowering seeds — here a hermetic green probe that binds a version, standing
109
+ // in for the `capability` probe (whose green/bind path is unit-tested in planLowering.test).
110
+ readinessProbes: [
111
+ {
112
+ kind: "command",
113
+ target: "true",
114
+ resolvedArtifact: "@scope/pkg@1.4.0",
115
+ poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" },
116
+ },
117
+ ],
118
+ probeTimeout: "PT30M",
119
+ gateKey: "preflight:owner/repo#2",
120
+ }),
121
+ });
122
+ await app.settle();
123
+
124
+ const flows = takenFlows(app);
125
+ // The DEPENDENT was gated: it entered the preflight (not the root skip) and released green.
126
+ assert.ok(
127
+ flows.includes("gw-readiness->readiness-preflight"),
128
+ `a dependent enters the preflight (flows: ${flows.join(", ")})`,
129
+ );
130
+ assert.ok(flows.includes("pf_gw->pf_end"), "the probe went green and settled the gate");
131
+ // Only AFTER the gate does it reach ensure-base-branch — the head of the fan-out (wave 0 is
132
+ // downstream of it). The gate is a true PREFLIGHT, not a parallel afterthought.
133
+ assert.ok(
134
+ flows.includes("readiness-preflight->ensure-base-branch"),
135
+ "the green gate leads into the fan-out head",
136
+ );
137
+ // A green probe never escalates.
138
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey });
139
+ assert.equal(
140
+ tasks.filter((t) => t.elementId === "readiness-escalation-pf").length,
141
+ 0,
142
+ "a green preflight never opens an escalation task",
143
+ );
144
+ } finally {
145
+ await app.stop();
146
+ rmSync(dbDir, { recursive: true, force: true });
147
+ }
148
+ });
149
+
150
+ test("ROOT: readinessProbes = null skips the gate and fans out immediately", async () => {
151
+ const { app, dbDir } = await boot();
152
+ try {
153
+ await app.engine.createInstance({
154
+ processDefinitionId: "plan-fanout",
155
+ variables: planVars({ planKey: "owner/repo#1", issue: "owner/repo#1", readinessProbes: null }),
156
+ });
157
+ await app.settle();
158
+
159
+ const flows = takenFlows(app);
160
+ assert.ok(
161
+ flows.includes("gw-readiness->ensure-base-branch"),
162
+ `a root skips straight to the fan-out head (flows: ${flows.join(", ")})`,
163
+ );
164
+ assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "a root never enters the preflight");
165
+ } finally {
166
+ await app.stop();
167
+ rmSync(dbDir, { recursive: true, force: true });
168
+ }
169
+ });
170
+ });
package/openapi.yaml CHANGED
@@ -954,11 +954,12 @@ components:
954
954
  minLength: 1
955
955
  description: The producer epic's issue handle, used to resolve which published pkg@version first carries the capability (S3).
956
956
  StartEpicSetResult:
957
- description: The result of a successful set admission — the epics admitted and the edges staged (FK-free in `admitted_plan_deps`) for S3 to materialize.
957
+ description: The result of a successful set admission + lowering (issue #292, slice S3) — the epics admitted, the roots started immediately, the dependents started behind their leading capability readiness-gate, and the inter-epic edges materialized into `plan_deps`.
958
958
  type: object
959
959
  required:
960
960
  - epics
961
961
  - roots
962
+ - dependents
962
963
  - edges
963
964
  properties:
964
965
  epics:
@@ -976,12 +977,28 @@ components:
976
977
  type: string
977
978
  roots:
978
979
  type: array
979
- description: The plan keys of epics with NO inbound edge — the roots S3 starts immediately.
980
+ description: The plan keys of epics with NO inbound edge — the roots started immediately (fan out right away).
980
981
  items:
981
982
  type: string
983
+ dependents:
984
+ type: array
985
+ description: The epics with ≥1 inbound edge — started behind a leading capability readiness-gate that holds wave 0 until every listed producer publishes its capability.
986
+ items:
987
+ type: object
988
+ required:
989
+ - planKey
990
+ - producers
991
+ properties:
992
+ planKey:
993
+ type: string
994
+ producers:
995
+ type: array
996
+ description: The producer plan keys whose capabilities this dependent waits for (ALL must be green before it fans out).
997
+ items:
998
+ type: string
982
999
  edges:
983
1000
  type: array
984
- description: The inter-epic edges staged FK-free into `admitted_plan_deps` for S3 to materialize into `plan_deps` (endpoints resolved to plan keys).
1001
+ description: The inter-epic edges materialized into the durable `plan_deps` graph (endpoints resolved to plan keys).
985
1002
  items:
986
1003
  type: object
987
1004
  required:
@@ -168,18 +168,27 @@ test("valid DAG: admits all epics and persists all edges", async () => {
168
168
  assertEquals(res.body.edges, [
169
169
  { consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` },
170
170
  ]);
171
- // S2 admits + STAGES (epics and edges) but NEVER starts an epic and NEVER writes the durable
172
- // `plans` / `plan_deps` graph (that is S3).
173
- assertEquals(started.length, 0);
174
- assertEquals(planDepsRows(tables).length, 0); // durable plan_deps untouched by S2
171
+ // S2 admits + STAGES (epics and edges); S3 (this slice) then reads that staging and LOWERS the
172
+ // set starting every epic (roots immediately, dependents behind a seeded capability preflight)
173
+ // and materializing the durable `plan_deps` graph after the `plans` rows exist.
174
+ assertEquals(started.length, 2); // both epics started by the S3 lowering
175
+ assertEquals(planDepsRows(tables).length, 1); // durable edge materialized by S3
175
176
  const epicRows = admittedEpicRows(tables);
176
- assertEquals(epicRows.length, 2); // both epics staged (roots included) for S3 to materialize
177
+ assertEquals(epicRows.length, 2); // both epics staged (roots included) then materialized
177
178
  const rows = admittedDepRows(tables);
178
179
  assertEquals(rows.length, 1);
179
180
  assertEquals(rows[0].plan_key, `${REPO}#2`);
180
181
  assertEquals(rows[0].depends_on_plan_key, `${REPO}#1`);
181
182
  assertEquals(rows[0].package, "@scope/pkg");
182
183
  assertEquals(rows[0].capability_ref, `${REPO}#1`);
184
+ // The dependent (#2) is seeded with a capability probe + bounded timeout; the root (#1) starts
185
+ // with none so it fans out immediately.
186
+ const startedByKey = new Map(started.map((s) => [s.variables?.["planKey"], s.variables ?? {}]));
187
+ const rootVars = startedByKey.get(`${REPO}#1`);
188
+ const depVars = startedByKey.get(`${REPO}#2`);
189
+ assertEquals(rootVars?.["readinessProbes"], null);
190
+ assertEquals(Array.isArray(depVars?.["readinessProbes"]), true);
191
+ assertEquals((depVars?.["readinessProbes"] as unknown[]).length, 1);
183
192
  });
184
193
  });
185
194
 
@@ -485,7 +494,11 @@ function makeSqliteApp(
485
494
  ) {
486
495
  const db = new DatabaseSync(":memory:");
487
496
  db.exec("PRAGMA foreign_keys = ON;");
488
- db.exec("CREATE TABLE plans (plan_key TEXT PRIMARY KEY, repo TEXT, base_branch TEXT, status TEXT);");
497
+ db.exec(
498
+ "CREATE TABLE plans (plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, " +
499
+ "title TEXT, base_branch TEXT, status TEXT, task_count INTEGER, epic_phase TEXT, " +
500
+ "blackboard_token TEXT, list_bucket TEXT, ack_open INTEGER, created_at TEXT, updated_at TEXT);",
501
+ );
489
502
  for (const p of seedPlans) {
490
503
  db.prepare("INSERT INTO plans (plan_key, repo, base_branch, status) VALUES (?, ?, ?, ?)")
491
504
  .run(p.plan_key, p.repo, p.base_branch, p.status);
@@ -520,7 +533,7 @@ function makeSqliteApp(
520
533
  return { app, db };
521
534
  }
522
535
 
523
- test("SQLite (FK ON): admits a set with NO plans row, stages FK-free, never writes plan_deps", async () => {
536
+ test("SQLite (FK ON): admits a set with NO plans row, then S3 lowering starts plans FK-first and materializes plan_deps", async () => {
524
537
  const gh = freshGithub(REPO);
525
538
  await withGithub(gh, async () => {
526
539
  const { app, db } = makeSqliteApp(); // no plans rows — the first-submission FK-failure condition
@@ -532,10 +545,13 @@ test("SQLite (FK ON): admits a set with NO plans row, stages FK-free, never writ
532
545
  ],
533
546
  deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
534
547
  });
535
- // Under the old code this was a 500 FK violation on `plan_deps.plan_key`.
548
+ // Under the old code this was a 500 FK violation on `plan_deps.plan_key`. S3 lowering inserts
549
+ // BOTH `plans` rows before recording the edge, so the durable `plan_deps` FK is satisfied.
536
550
  assertEquals(res.status, 202);
551
+ const planN = db.prepare("SELECT COUNT(*) AS n FROM plans").get() as { n: number };
552
+ assertEquals(planN.n, 2); // both epics materialized a plans row
537
553
  const planDepN = db.prepare("SELECT COUNT(*) AS n FROM plan_deps").get() as { n: number };
538
- assertEquals(planDepN.n, 0); // durable plan_deps untouched by S2
554
+ assertEquals(planDepN.n, 1); // durable edge materialized FK-clean by S3
539
555
  const staged = db
540
556
  .prepare("SELECT plan_key, depends_on_plan_key, package FROM admitted_plan_deps")
541
557
  .all() as { plan_key: string; depends_on_plan_key: string; package: string }[];
@@ -15,18 +15,19 @@
15
15
  // in-request intra-set shared-base guard (two members of the same set cannot silently grab the
16
16
  // same custom base, which admitPlan's durable-only rule 4 would miss). The first failure maps to
17
17
  // its 4xx (400/409) via the shared `admitPlanErrorResponse`, before anything is persisted.
18
- // 4. Only once every epic admits: STAGE the admitted set — each epic into `admitted_epics` and each
19
- // validated edge into `admitted_plan_deps` — then return the admitted epics, the roots, and the
20
- // staged edges.
18
+ // 4. Once every epic admits: STAGE the admitted set — each epic into `admitted_epics` and each
19
+ // validated edge into `admitted_plan_deps` — then LOWER it (issue #292 slice S3): start every
20
+ // ROOT immediately, start every DEPENDENT behind its leading capability readiness-gate, and
21
+ // materialize the durable `plan_deps` edges. Returns the admitted epics, the roots, the gated
22
+ // dependents, and the materialized edges.
21
23
  //
22
- // This slice deliberately does NOT start any epic or seed any readiness gate, and per the #292
23
- // design decision it MATERIALIZES neither a `plans` row nor a `plan_deps` edge. Both are owned by
24
- // slice S3 (planner lowering: schedule roots, seed the capability gate, bind the resolved version),
25
- // which reads this staging and creates `plans` + `plan_deps` when it schedules roots — where the
26
- // `plan_deps.plan_key REFERENCES plans(plan_key)` FK is satisfied by construction. S2 persists into
27
- // its OWN FK-FREE staging tables instead, so a first-time set submission can never FK-fail here.
28
- // Re-submitting the identical set is a no-op (admitPlan is idempotent on an already-created base + an
29
- // inactive plan; the staging records collapse a duplicate epic/edge).
24
+ // The FK-free staging (step 4a) survives a crash between admission and lowering: `lowerAdmittedSet`
25
+ // reads `admitted_epics` / `admitted_plan_deps` and creates the durable `plans` row (via `startPlan`)
26
+ // before recording each `plan_deps` edge, so the `plan_deps.plan_key REFERENCES plans(plan_key)` FK
27
+ // is satisfied by construction. Re-submitting the identical set is a no-op: `admitPlan` is idempotent
28
+ // on an already-created base + inactive plan, the staging records collapse a duplicate epic/edge, and
29
+ // lowering neither double-starts an epic nor re-seeds a gate (`startPlan` short-circuits a running
30
+ // plan) nor duplicates a durable edge.
30
31
 
31
32
  import { fetchDefaultBranch } from "../app/github.ts";
32
33
  import {
@@ -40,6 +41,7 @@ import {
40
41
  SharedBaseError,
41
42
  validateEpicSet,
42
43
  } from "../app/plan.ts";
44
+ import { lowerAdmittedSet } from "../app/planLowering.ts";
43
45
  import { defineOperation } from "../nano-generated/operations.ts";
44
46
 
45
47
  /** One parsed, admission-ready epic member: its parsed issue reference plus the per-epic admission
@@ -167,13 +169,13 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
167
169
  }
168
170
  }
169
171
 
170
- // ── Step 4: STAGE the admitted set + validated edges (idempotent). S2 is the admission DOOR only:
171
- // per the #292 design decision it persists into ITS OWN FK-FREE staging tables and MATERIALIZES
172
- // neither a `plans` row nor a `plan_deps` edge. Slice S3 (planner lowering) reads this staging and
173
- // creates `plans` + `plan_deps` when it schedules roots where the `plan_deps.plan_key REFERENCES
174
- // plans(plan_key)` FK is satisfied by construction. Each admitted epic (INCLUDING roots) is staged
175
- // so S3 can materialize its `plans` row; each validated edge is staged FK-free. Only reached once
176
- // the WHOLE set admitted.
172
+ // ── Step 4: STAGE the admitted set + validated edges FK-free (idempotent), THEN lower it (step 5).
173
+ // Staging first keeps the door crash-safe: `admitted_epics` / `admitted_plan_deps` carry exactly
174
+ // what `lowerAdmittedSet` needs to materialize the durable `plans` + `plan_deps` graph, so a crash
175
+ // between staging and lowering loses nothing (a re-dispatch re-reads the staging). The staging
176
+ // tables are FK-free (no `plans` row need exist yet); lowering creates the `plans` row before
177
+ // recording each `plan_deps` edge, so the FK holds by construction. Each admitted epic (INCLUDING
178
+ // roots) is staged; each validated edge is staged. Only reached once the WHOLE set admitted.
177
179
  for (const a of admitted) {
178
180
  await recordAdmittedEpic(app.data, {
179
181
  plan_key: a.parsed.planKey,
@@ -192,20 +194,25 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
192
194
  });
193
195
  }
194
196
 
195
- // Roots = admitted epics with no inbound edge the ones S3 will start immediately.
196
- const dependents = new Set(edges.map((e) => e.consumer));
197
- const roots = admitted.map((a) => a.parsed.planKey).filter((k) => !dependents.has(k));
197
+ // ── Step 5: LOWER the admitted set into a running schedule (issue #292, slice S3). Now that the
198
+ // whole set has admitted and staged, materialize it: start every ROOT immediately, start every
199
+ // DEPENDENT behind its leading capability readiness-gate (seeded from its inbound edges), and
200
+ // materialize the durable `plan_deps` edges. Idempotent — a re-submitted set neither double-starts
201
+ // an epic nor re-seeds a gate nor duplicates an edge (see lowerAdmittedSet).
202
+ const lowered = await lowerAdmittedSet(app.data, app.engine, planKeys);
198
203
 
199
- app.log.info("epic set admitted", {
204
+ app.log.info("epic set admitted + lowered", {
200
205
  epics: admitted.length,
201
206
  edges: edges.length,
202
- roots: roots.length,
207
+ roots: lowered.roots.length,
208
+ dependents: lowered.dependents.length,
203
209
  });
204
210
  return {
205
211
  status: 202,
206
212
  body: {
207
213
  epics: admitted.map((a) => ({ planKey: a.parsed.planKey, baseBranch: a.baseBranch })),
208
- roots,
214
+ roots: lowered.roots,
215
+ dependents: lowered.dependents,
209
216
  edges: edges.map((e) => ({
210
217
  consumer: e.consumer,
211
218
  producer: e.producer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.94.0",
3
+ "version": "0.95.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@nanobpm/agentic": "^0.1.0",
56
- "@nanobpm/urban": "^0.55.0"
56
+ "@nanobpm/urban": "^0.59.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@biomejs/biome": "^2.4.11",
@@ -64,7 +64,7 @@
64
64
  { "field": "promotion_state", "header": "Promotion" },
65
65
  { "field": "wave_label", "header": "Wave" },
66
66
  { "field": "task_count", "header": "Tasks" },
67
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
67
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
68
68
  ],
69
69
  "detail": {
70
70
  "linkField": "issue_url",
@@ -127,7 +127,7 @@
127
127
  { "field": "status", "header": "Status" },
128
128
  { "field": "pr_key", "header": "PR" },
129
129
  { "field": "summary", "header": "Summary" },
130
- { "field": "updated_at", "header": "Updated" }
130
+ { "field": "updated_at", "header": "Updated", "format": "datetime" }
131
131
  ],
132
132
  "detail": {
133
133
  "fields": [
@@ -202,7 +202,7 @@
202
202
  { "field": "task_b", "header": "Task B" },
203
203
  { "field": "files", "header": "Shared files" },
204
204
  { "field": "source", "header": "Source" },
205
- { "field": "updated_at", "header": "Updated" }
205
+ { "field": "updated_at", "header": "Updated", "format": "datetime" }
206
206
  ]
207
207
  }
208
208
  },
@@ -85,7 +85,7 @@
85
85
  { "field": "wave_label", "header": "Wave" },
86
86
  { "field": "task_count", "header": "Tasks" },
87
87
  { "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
88
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
88
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
89
89
  ],
90
90
  "rowActions": [
91
91
  {
@@ -91,7 +91,7 @@
91
91
  "notInPathField": "stage_skipped",
92
92
  "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
93
93
  },
94
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
94
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
95
95
  ],
96
96
  "rowActions": [
97
97
  {
@@ -207,6 +207,7 @@
207
207
  {
208
208
  "field": "updated_at",
209
209
  "header": "Updated",
210
+ "format": "datetime",
210
211
  "width": "9rem"
211
212
  }
212
213
  ],
@@ -79,7 +79,7 @@
79
79
  "link": { "kind": "processExplorer", "keyField": "process_key" }
80
80
  },
81
81
  { "field": "pr_count", "header": "PRs", "width": "5rem" },
82
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
82
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
83
83
  ],
84
84
  "detail": {
85
85
  "linkField": "issue_url",
@@ -103,7 +103,7 @@
103
103
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
104
104
  { "field": "current_round", "header": "Round" },
105
105
  { "field": "outcome", "header": "Outcome", "truncate": true },
106
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
106
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
107
107
  ],
108
108
  "detail": {
109
109
  "linkField": "url",
@@ -69,7 +69,7 @@
69
69
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
70
70
  { "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
71
71
  { "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
72
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
72
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
73
73
  ]
74
74
  }
75
75
  },
@@ -95,7 +95,7 @@
95
95
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
96
96
  { "field": "delivery_label", "header": "Landing" },
97
97
  { "field": "wave_label", "header": "Wave" },
98
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
98
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
99
99
  ],
100
100
  "rowActions": [
101
101
  {
@@ -131,7 +131,7 @@
131
131
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
132
132
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
133
133
  { "field": "delivery_label", "header": "Delivery" },
134
- { "field": "updated_at", "header": "Updated", "width": "9rem" }
134
+ { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
135
135
  ],
136
136
  "rowActions": [
137
137
  {
@@ -105,7 +105,8 @@
105
105
  },
106
106
  {
107
107
  "field": "updated_at",
108
- "header": "Updated"
108
+ "header": "Updated",
109
+ "format": "datetime"
109
110
  }
110
111
  ],
111
112
  "rowKey": "user_task_key",
@@ -170,7 +171,8 @@
170
171
  },
171
172
  {
172
173
  "field": "updated_at",
173
- "header": "Updated"
174
+ "header": "Updated",
175
+ "format": "datetime"
174
176
  }
175
177
  ],
176
178
  "rowKey": "user_task_key",
@@ -235,7 +237,8 @@
235
237
  },
236
238
  {
237
239
  "field": "updated_at",
238
- "header": "Updated"
240
+ "header": "Updated",
241
+ "format": "datetime"
239
242
  }
240
243
  ],
241
244
  "rowKey": "user_task_key",
@@ -301,7 +304,8 @@
301
304
  },
302
305
  {
303
306
  "field": "updated_at",
304
- "header": "Updated"
307
+ "header": "Updated",
308
+ "format": "datetime"
305
309
  }
306
310
  ],
307
311
  "rowKey": "user_task_key",
@@ -384,7 +388,8 @@
384
388
  },
385
389
  {
386
390
  "field": "updated_at",
387
- "header": "Updated"
391
+ "header": "Updated",
392
+ "format": "datetime"
388
393
  }
389
394
  ],
390
395
  "rowKey": "user_task_key",