@nanobpm/nano-workforce 0.183.1 → 0.184.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app/agentic/agent-history.test.ts +165 -0
  3. package/app/agentic/agent-history.ts +211 -0
  4. package/app/agentic/claim-registry.test.ts +3 -2
  5. package/app/agentic/claim-registry.ts +8 -6
  6. package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
  7. package/app/agentic/cockpit/agent-history-render.ts +168 -0
  8. package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
  9. package/app/agentic/cockpit/agent-history-view.ts +186 -0
  10. package/app/agentic/cockpit/index.ts +21 -0
  11. package/app/agentic/cockpit/mount.test.ts +133 -1
  12. package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
  13. package/app/agentic/cockpit/supply-boot.ts +134 -1
  14. package/app/agentic/cockpit/supply-view.ts +3 -3
  15. package/app/agentic/cockpit/transcript-view.ts +1 -1
  16. package/app/agentic/correlation-store.test.ts +14 -10
  17. package/app/agentic/correlation-store.ts +4 -3
  18. package/app/agentic/correlation.test.ts +24 -16
  19. package/app/agentic/correlation.ts +25 -12
  20. package/app/agentic/families/claim.family.test.ts +2 -1
  21. package/app/agentic/families/relay.family.test.ts +74 -73
  22. package/app/agentic/families/relay.family.ts +24 -21
  23. package/app/agentic/transcript-read.test.ts +108 -17
  24. package/app/agentic/transcript-read.ts +41 -7
  25. package/app/contracts.ts +10 -2
  26. package/app/mcpToolSurface.ts +8 -1
  27. package/db/migrations/101_agentic_history_read_expand.sql +34 -0
  28. package/openapi.yaml +360 -0
  29. package/operations/agentHistoryEndpoints.test.ts +106 -0
  30. package/operations/getAgentInstanceHistory.ts +37 -0
  31. package/operations/getAgenticSupply.test.ts +41 -2
  32. package/operations/getAgenticSupply.ts +7 -5
  33. package/operations/getAgenticTranscript.test.ts +5 -4
  34. package/operations/listAgentInstances.ts +42 -0
  35. package/operations/listAgenticTranscripts.test.ts +10 -9
  36. package/package.json +3 -3
  37. package/pages/cockpit/cockpit.css +104 -0
  38. package/pages/cockpit/mount.js +286 -3
  39. package/test/agentic-e2e.test.ts +4 -1
package/openapi.yaml CHANGED
@@ -1002,6 +1002,250 @@ components:
1002
1002
  description: The retained chunks with `offset >= from`, in offset order.
1003
1003
  items:
1004
1004
  $ref: "#/components/schemas/AgenticTranscriptChunk"
1005
+ AgentInstanceMetrics:
1006
+ type: object
1007
+ description: >-
1008
+ Aggregated metrics the engine rolls up on an AgentInstance across all of its model
1009
+ calls (issue #745/#747 — the CONSUMER half). Total tokens consumed, and the count of model /
1010
+ tool calls made. Distinct from the per-turn metrics carried on an AgentHistoryRecord.
1011
+ required:
1012
+ - inputTokens
1013
+ - outputTokens
1014
+ - modelCalls
1015
+ - toolCalls
1016
+ properties:
1017
+ inputTokens:
1018
+ type: integer
1019
+ description: Total input (prompt) tokens consumed across the instance's model calls.
1020
+ outputTokens:
1021
+ type: integer
1022
+ description: Total output (completion) tokens produced across the instance's model calls.
1023
+ modelCalls:
1024
+ type: integer
1025
+ description: The number of LLM model calls the agent made.
1026
+ toolCalls:
1027
+ type: integer
1028
+ description: The number of tool calls the agent dispatched.
1029
+ AgentInstance:
1030
+ type: object
1031
+ description: >-
1032
+ One engine-native AgentInstance as `searchAgentInstances` / `getAgentInstance` report
1033
+ it (issue #745/#747, umbrella #746 — Camunda 8.10 parity). The durable projection of an agent
1034
+ task's run, minted by the worker harness against the `<zeebe:agentDefinition agentType="external"/>`
1035
+ marker (#748). Keyed by `agentInstanceKey` (passed to the history read) and correlated by
1036
+ process / element-instance keys — NEVER the slash-bearing `job:<jobKey>` relay stream id.
1037
+ required:
1038
+ - agentInstanceKey
1039
+ - status
1040
+ - processInstanceKey
1041
+ properties:
1042
+ agentInstanceKey:
1043
+ type: string
1044
+ description: The engine-unique agent-instance key — the identity a caller passes to the history read.
1045
+ status:
1046
+ type: string
1047
+ description: The lifecycle status (the engine's broad `AgentInstanceStatusEnum` — e.g.
1048
+ INITIALIZING / THINKING / TOOL_CALLING / IDLE / COMPLETED), a bare string.
1049
+ processInstanceKey:
1050
+ type: string
1051
+ description: The owning process-instance key.
1052
+ elementId:
1053
+ type: string
1054
+ description: The BPMN element id (the AI-agent task) that owns the instance, when reported.
1055
+ elementInstanceKeys:
1056
+ type: array
1057
+ description: The engine element-instance keys the instance's token(s) occupied (#544) — per-occupancy
1058
+ handles, unambiguous across a looping / retried activity. Omitted when the engine reports none.
1059
+ items:
1060
+ type: string
1061
+ rootProcessInstanceKey:
1062
+ type: string
1063
+ description: The root process-instance key of the owning hierarchy, when reported.
1064
+ processDefinitionKey:
1065
+ type: string
1066
+ description: The owning process-definition key, when reported.
1067
+ processDefinitionId:
1068
+ type: string
1069
+ description: The owning process-definition id, when reported.
1070
+ metrics:
1071
+ $ref: "#/components/schemas/AgentInstanceMetrics"
1072
+ creationDate:
1073
+ type: string
1074
+ description: When the instance was created, ISO-8601, when reported.
1075
+ lastUpdatedDate:
1076
+ type: string
1077
+ description: When the instance was last updated, ISO-8601, when reported.
1078
+ completionDate:
1079
+ type: string
1080
+ description: When the instance completed, ISO-8601 (absent while still running).
1081
+ AgentInstanceList:
1082
+ type: object
1083
+ description: >-
1084
+ The list of engine-native agent instances (issue #745/#747), newest-created first —
1085
+ the cockpit "historical sessions" feed sourced from engine history (not the relay store).
1086
+ required:
1087
+ - count
1088
+ - instances
1089
+ properties:
1090
+ count:
1091
+ type: integer
1092
+ description: The number of instances returned (after any filters).
1093
+ generatedAt:
1094
+ type: string
1095
+ description: When this snapshot was taken, ISO-8601.
1096
+ instances:
1097
+ type: array
1098
+ items:
1099
+ $ref: "#/components/schemas/AgentInstance"
1100
+ AgentHistoryContentBlock:
1101
+ type: object
1102
+ description: One typed content block in a turn's message (Camunda `AgentHistoryMessageContentValue`
1103
+ parity). Exactly one payload is populated per `contentType`.
1104
+ required:
1105
+ - contentType
1106
+ properties:
1107
+ contentType:
1108
+ type: string
1109
+ enum: [TEXT, DOCUMENT, OBJECT, UNSPECIFIED]
1110
+ description: The content type; selects which payload field is populated.
1111
+ text:
1112
+ type: string
1113
+ description: Text payload; populated when `contentType` is TEXT.
1114
+ documentReference:
1115
+ type: string
1116
+ description: Document reference; populated when `contentType` is DOCUMENT.
1117
+ object:
1118
+ description: JSON value payload (any JSON type); populated when `contentType` is OBJECT.
1119
+ AgentHistoryToolCall:
1120
+ type: object
1121
+ description: A tool call embedded in a turn (Camunda `AgentHistoryEmbeddedToolCallValue` parity).
1122
+ required:
1123
+ - toolCallId
1124
+ - toolName
1125
+ - arguments
1126
+ properties:
1127
+ toolCallId:
1128
+ type: string
1129
+ description: The stable tool-call id (pairs a call to its result).
1130
+ toolName:
1131
+ type: string
1132
+ description: The tool that was called.
1133
+ elementId:
1134
+ type: string
1135
+ description: The tool task's BPMN element id, when reported.
1136
+ arguments:
1137
+ type: object
1138
+ description: The arguments passed to the tool (an arbitrary JSON object).
1139
+ additionalProperties: true
1140
+ AgentHistoryTurnMetrics:
1141
+ type: object
1142
+ description: Per-turn metrics (Camunda `AgentHistoryMetricsValue` parity) — the token counts a
1143
+ single turn's LLM call consumed/produced and its wall-clock duration. Distinct from the
1144
+ instance-level `AgentInstanceMetrics`.
1145
+ required:
1146
+ - inputTokens
1147
+ - outputTokens
1148
+ - reasoningTokenCount
1149
+ - cacheCreationTokenCount
1150
+ - cacheReadTokenCount
1151
+ - durationMs
1152
+ properties:
1153
+ inputTokens:
1154
+ type: integer
1155
+ outputTokens:
1156
+ type: integer
1157
+ reasoningTokenCount:
1158
+ type: integer
1159
+ cacheCreationTokenCount:
1160
+ type: integer
1161
+ cacheReadTokenCount:
1162
+ type: integer
1163
+ durationMs:
1164
+ type: integer
1165
+ AgentHistoryRecord:
1166
+ type: object
1167
+ description: >-
1168
+ One agent-instance history item (a turn) as `searchAgentInstanceHistory` reports it
1169
+ (issue #745/#747) — the projection of the engine's `AgentInstanceHistoryItemResult`, i.e. one
1170
+ Camunda `AgentHistoryRecordValue`. Its conversation grammar reuses the transcript parity types,
1171
+ so the engine-read seam and the relay transcript store project the SAME shape (No Drift Surfaces).
1172
+ required:
1173
+ - historyItemKey
1174
+ - agentInstanceKey
1175
+ - loopIteration
1176
+ - role
1177
+ - content
1178
+ - toolCalls
1179
+ - commitStatus
1180
+ properties:
1181
+ historyItemKey:
1182
+ type: string
1183
+ description: The stable, creation-ordered identity of the history item.
1184
+ agentInstanceKey:
1185
+ type: string
1186
+ description: The owning agent-instance key.
1187
+ loopIteration:
1188
+ type: integer
1189
+ description: The agent-loop counter (one LLM call + its tool dispatches + results share an iteration).
1190
+ role:
1191
+ type: string
1192
+ enum: [USER, ASSISTANT, TOOL_RESULT, CONFIGURATION, UNSPECIFIED]
1193
+ description: The conversation role of the turn.
1194
+ content:
1195
+ type: array
1196
+ description: The turn's typed content blocks.
1197
+ items:
1198
+ $ref: "#/components/schemas/AgentHistoryContentBlock"
1199
+ toolCalls:
1200
+ type: array
1201
+ description: The tool calls embedded in the turn.
1202
+ items:
1203
+ $ref: "#/components/schemas/AgentHistoryToolCall"
1204
+ metrics:
1205
+ $ref: "#/components/schemas/AgentHistoryTurnMetrics"
1206
+ commitStatus:
1207
+ type: string
1208
+ description: The engine's COMMITTED / PENDING / DISCARDED commit flag (a bare string).
1209
+ elementInstanceKey:
1210
+ type: string
1211
+ description: The element instance the item was produced under, when reported (best-effort).
1212
+ jobKey:
1213
+ type: string
1214
+ description: The job key the item was produced under, when reported (best-effort).
1215
+ producedAt:
1216
+ type: string
1217
+ description: When the item was produced, ISO-8601, when reported (best-effort).
1218
+ AgentHistory:
1219
+ type: object
1220
+ description: >-
1221
+ One agent instance's durable conversation history (turns + per-turn metrics) sourced
1222
+ from engine `searchAgentInstanceHistory` (issue #745/#747), in conversational order. The cockpit
1223
+ renders the HISTORICAL transcript + metrics from this; the token-granular relay stays the LIVE
1224
+ overlay only. Keyed by `agentInstanceKey` — never the slash-bearing relay stream id (#744 moot).
1225
+ required:
1226
+ - agentInstanceKey
1227
+ - count
1228
+ - records
1229
+ properties:
1230
+ agentInstanceKey:
1231
+ type: string
1232
+ description: The agent-instance key this history belongs to.
1233
+ count:
1234
+ type: integer
1235
+ description: The number of history records (turns) returned.
1236
+ generatedAt:
1237
+ type: string
1238
+ description: When this snapshot was taken, ISO-8601.
1239
+ instance:
1240
+ allOf:
1241
+ - $ref: "#/components/schemas/AgentInstance"
1242
+ description: The owning instance summary (rolled-up metrics + lifecycle), when the engine still
1243
+ reports it. Absent when the instance is unknown/aged out.
1244
+ records:
1245
+ type: array
1246
+ description: The history records (turns), ordered by loopIteration then creation-ordered key.
1247
+ items:
1248
+ $ref: "#/components/schemas/AgentHistoryRecord"
1005
1249
  VersionInfo:
1006
1250
  type: object
1007
1251
  description: The running app's identity (which code is actually live).
@@ -3945,6 +4189,122 @@ paths:
3945
4189
  application/json:
3946
4190
  schema:
3947
4191
  $ref: "#/components/schemas/ErrorBody"
4192
+ /agentic/agent-instances:
4193
+ get:
4194
+ operationId: listAgentInstances
4195
+ summary: >-
4196
+ List engine-native AgentInstances (issue #745/#747, umbrella #746) — the durable agent-task
4197
+ runs the worker harness minted against the `<zeebe:agentDefinition agentType="external"/>` marker,
4198
+ read back from the engine read model (`searchAgentInstances`), newest-created first. Keyed/filtered
4199
+ by process / element / status — NOT the slash-bearing relay stream id. Advisory read-only; never
4200
+ gates control flow. Feeds the cockpit "historical sessions" view (settled history = engine; live
4201
+ tail = relay overlay). Read-as-absence — an engine with no AgentInstance channel returns an empty list.
4202
+ security:
4203
+ - hookSecret: []
4204
+ - {}
4205
+ parameters:
4206
+ - name: processInstanceKey
4207
+ in: query
4208
+ required: false
4209
+ schema:
4210
+ type: string
4211
+ description: Return only agent instances owned by this process instance.
4212
+ - name: rootProcessInstanceKey
4213
+ in: query
4214
+ required: false
4215
+ schema:
4216
+ type: string
4217
+ description: Return only agent instances in this root process-instance hierarchy.
4218
+ - name: elementId
4219
+ in: query
4220
+ required: false
4221
+ schema:
4222
+ type: string
4223
+ description: Return only agent instances owned by this BPMN element (the AI-agent task).
4224
+ - name: status
4225
+ in: query
4226
+ required: false
4227
+ schema:
4228
+ type: string
4229
+ description: Return only agent instances in this lifecycle status (the engine's `AgentInstanceStatusEnum`).
4230
+ responses:
4231
+ "200":
4232
+ description: The engine-native agent instances matching the filters.
4233
+ content:
4234
+ application/json:
4235
+ schema:
4236
+ $ref: "#/components/schemas/AgentInstanceList"
4237
+ "401":
4238
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
4239
+ content:
4240
+ application/json:
4241
+ schema:
4242
+ $ref: "#/components/schemas/ErrorBody"
4243
+ "503":
4244
+ description: No engine read path available (no engine client configured).
4245
+ content:
4246
+ application/json:
4247
+ schema:
4248
+ $ref: "#/components/schemas/ErrorBody"
4249
+ /agentic/agent-instances/{agentInstanceKey}/history:
4250
+ get:
4251
+ operationId: getAgentInstanceHistory
4252
+ summary: >-
4253
+ Fetch one AgentInstance's durable conversation history (turns + per-turn metrics) from
4254
+ engine `searchAgentInstanceHistory` (issue #745/#747), in conversational order, with the owning
4255
+ instance's rolled-up metrics when still reported. The cockpit renders the HISTORICAL transcript +
4256
+ metrics from this — keyed by `agentInstanceKey`, never the slash-bearing relay stream id (#744
4257
+ moot). Advisory read-only; never gates control flow. Read-as-absence — an unknown key / an engine
4258
+ with no AgentHistory channel returns an empty history.
4259
+ security:
4260
+ - hookSecret: []
4261
+ - {}
4262
+ parameters:
4263
+ - name: agentInstanceKey
4264
+ in: path
4265
+ required: true
4266
+ schema:
4267
+ type: string
4268
+ description: The engine agent-instance key whose history to read (from `listAgentInstances`).
4269
+ - name: role
4270
+ in: query
4271
+ required: false
4272
+ schema:
4273
+ type: string
4274
+ enum: [USER, ASSISTANT, TOOL_RESULT, CONFIGURATION, UNSPECIFIED]
4275
+ description: Only turns with this conversation role. Any value from the enum is
4276
+ forwarded verbatim to the engine as the filter.
4277
+ - name: loopIteration
4278
+ in: query
4279
+ required: false
4280
+ schema:
4281
+ type: integer
4282
+ description: Only turns produced in this agent-loop iteration.
4283
+ - name: elementInstanceKey
4284
+ in: query
4285
+ required: false
4286
+ schema:
4287
+ type: string
4288
+ description: Only turns produced under this element instance.
4289
+ responses:
4290
+ "200":
4291
+ description: The agent instance's history (turns + metrics), possibly empty.
4292
+ content:
4293
+ application/json:
4294
+ schema:
4295
+ $ref: "#/components/schemas/AgentHistory"
4296
+ "401":
4297
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
4298
+ content:
4299
+ application/json:
4300
+ schema:
4301
+ $ref: "#/components/schemas/ErrorBody"
4302
+ "503":
4303
+ description: No engine read path available (no engine client configured).
4304
+ content:
4305
+ application/json:
4306
+ schema:
4307
+ $ref: "#/components/schemas/ErrorBody"
3948
4308
  /reconcile:
3949
4309
  post:
3950
4310
  operationId: reconcileEngineState
@@ -0,0 +1,106 @@
1
+ // Testkit-boot read-back tests for the engine agent-history endpoints (issue #745/#747):
2
+ // GET /agentic/agent-instances → listAgentInstances
3
+ // GET /agentic/agent-instances/{agentInstanceKey}/history → getAgentInstanceHistory
4
+ //
5
+ // These drive the REAL door through `bootTestApp`'s api driver against the WASM EngineClient double.
6
+ // The testkit engine implements the agent read methods as READ-AS-ABSENCE (it records no AgentInstance
7
+ // channel), so a booted app returns an empty list / empty history rather than an error — exactly the
8
+ // contract the production consumer relies on when an engine has no durable agent history yet.
9
+ // Behavioural parity (non-empty history) is validated against a LIVE engine, out of the testkit's scope.
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { test } from "node:test";
14
+ import type { AppApi } from "@nanobpm/urban";
15
+ import { assertEquals } from "#test-assert";
16
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
17
+ import type { AgentHistoryReader } from "../app/agentic/agent-history.ts";
18
+ import { noopLog } from "../test/log.ts";
19
+ import type { AgentHistory, AgentInstanceList } from "../nano-generated/api-io.d.ts";
20
+
21
+ const APP_ROOT = resolve(import.meta.dirname, "..");
22
+
23
+ async function withApp(fn: (app: TestApp) => Promise<void>): Promise<void> {
24
+ const dir = mkdtempSync(join(tmpdir(), "nwf-agenthist-"));
25
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
26
+ try {
27
+ await fn(app);
28
+ } finally {
29
+ await app.stop?.();
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ }
33
+
34
+ test("listAgentInstances: read-as-absence → 200 with an empty instance list", async () => {
35
+ await withApp(async (app) => {
36
+ const res = await app.api.call<AgentInstanceList>("listAgentInstances", {});
37
+ assertEquals(res.status, 200);
38
+ assertEquals(res.body.count, 0);
39
+ assertEquals(res.body.instances.length, 0);
40
+ });
41
+ });
42
+
43
+ test("getAgentInstanceHistory: an unknown key → 200 with an empty history (read-as-absence)", async () => {
44
+ await withApp(async (app) => {
45
+ const res = await app.api.call<AgentHistory>("getAgentInstanceHistory", {
46
+ params: { agentInstanceKey: "no-such-instance" },
47
+ });
48
+ assertEquals(res.status, 200);
49
+ assertEquals(res.body.agentInstanceKey, "no-such-instance");
50
+ assertEquals(res.body.count, 0);
51
+ assertEquals(res.body.records.length, 0);
52
+ });
53
+ });
54
+
55
+ // Shared-secret guard regression coverage. Both endpoints implement the same optional guard the other
56
+ // agentic reads pin (x-hook-secret when NANO_PR_WEBHOOK_SECRET is set; unset -> open). `SECRET` is
57
+ // captured at module load, so set the env and re-import with a cache-buster to re-capture it, exactly
58
+ // as the sibling operation guard tests do. A lightweight stub engine (read-as-absence) is enough for
59
+ // the authorised path — the point is to pin 401-without-header / non-401-with-header, so a future
60
+ // refactor can't silently invert the condition or rename the header.
61
+ const stubEngine: AgentHistoryReader = {
62
+ searchAgentInstances: async () => [],
63
+ searchAgentInstanceHistory: async () => [],
64
+ getAgentInstance: async () => null,
65
+ };
66
+ const guardApp = { log: noopLog(), engine: stubEngine } as unknown as AppApi;
67
+
68
+ function guardInput(headers: Record<string, string>, params: Record<string, string> = {}) {
69
+ return {
70
+ req: { method: "GET", headers: new Headers(headers), text: async () => "" } as never,
71
+ params,
72
+ query: {},
73
+ body: undefined,
74
+ };
75
+ }
76
+
77
+ test("listAgentInstances: shared-secret guard rejects a missing/invalid secret, admits the correct one", async () => {
78
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
79
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
80
+ try {
81
+ const mod = await import(`./listAgentInstances.ts?guard=${Date.now()}`);
82
+ const handler = mod.default as (i: ReturnType<typeof guardInput>, app: AppApi) => Promise<{ status: number }>;
83
+ assertEquals((await handler(guardInput({}), guardApp)).status, 401);
84
+ assertEquals((await handler(guardInput({ "x-hook-secret": "wrong" }), guardApp)).status, 401);
85
+ assertEquals((await handler(guardInput({ "x-hook-secret": "s3cr3t" }), guardApp)).status, 200);
86
+ } finally {
87
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
88
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
89
+ }
90
+ });
91
+
92
+ test("getAgentInstanceHistory: shared-secret guard rejects a missing/invalid secret, admits the correct one", async () => {
93
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
94
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
95
+ try {
96
+ const mod = await import(`./getAgentInstanceHistory.ts?guard=${Date.now()}`);
97
+ const handler = mod.default as (i: ReturnType<typeof guardInput>, app: AppApi) => Promise<{ status: number }>;
98
+ const params = { agentInstanceKey: "ai-1" };
99
+ assertEquals((await handler(guardInput({}, params), guardApp)).status, 401);
100
+ assertEquals((await handler(guardInput({ "x-hook-secret": "wrong" }, params), guardApp)).status, 401);
101
+ assertEquals((await handler(guardInput({ "x-hook-secret": "s3cr3t" }, params), guardApp)).status, 200);
102
+ } finally {
103
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
104
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
105
+ }
106
+ });
@@ -0,0 +1,37 @@
1
+ // GET /app/api/agentic/agent-instances/{agentInstanceKey}/history → operationId `getAgentInstanceHistory`
2
+ // (issue #745/#747, umbrella #746).
3
+ //
4
+ // Fetch ONE AgentInstance's durable conversation history (turns + per-turn metrics) from the engine read
5
+ // model through the single engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstanceHistory`
6
+ // / `getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563). The cockpit renders the HISTORICAL
7
+ // transcript + metrics from this, keyed by `agentInstanceKey` — NEVER the slash-bearing relay stream id
8
+ // (#744 moot). The token-granular relay stays the LIVE overlay only.
9
+ //
10
+ // Advisory read-only (ADR 0056): observes the engine read model, never gates control flow. Read-as-absence
11
+ // — a blank/unknown key, or an engine with no AgentHistory channel (the testkit WASM double), yields an
12
+ // empty history (200), never an error. Shared-secret guard mirrors the other agentic reads.
13
+
14
+ import { type AgentHistoryQuery, readAgentHistory } from "../app/agentic/agent-history.ts";
15
+ import { envVar } from "../app/version.ts";
16
+ import { defineOperation } from "../nano-generated/operations.ts";
17
+
18
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
19
+
20
+ export default defineOperation("getAgentInstanceHistory", async ({ params, query, req }, app) => {
21
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
22
+ app.log.warn("getAgentInstanceHistory rejected: missing/invalid shared secret");
23
+ return { status: 401, body: { error: "unauthorized" } };
24
+ }
25
+ if (!app.engine) {
26
+ app.log.warn("getAgentInstanceHistory: no engine client configured — no agent-history read path");
27
+ return { status: 503, body: { error: "no engine read path available" } };
28
+ }
29
+
30
+ const filter: AgentHistoryQuery = {
31
+ ...(query.role !== undefined ? { role: query.role } : {}),
32
+ ...(query.loopIteration !== undefined ? { loopIteration: query.loopIteration } : {}),
33
+ ...(query.elementInstanceKey !== undefined ? { elementInstanceKey: query.elementInstanceKey } : {}),
34
+ };
35
+ const body = await readAgentHistory(app.engine, params.agentInstanceKey, filter);
36
+ return { status: 200, body };
37
+ });
@@ -12,6 +12,7 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
12
12
  import type { SqliteDb } from "@nanobpm/agentic/presence";
13
13
  import type { AppApi, DataLayer } from "@nanobpm/urban";
14
14
  import { assert, assertEquals } from "#test-assert";
15
+ import { composeStreamId, parseStreamId } from "@nanobpm/agentic/emit";
15
16
  import { currentClaimRegistry } from "../app/agentic/claim-registry.ts";
16
17
  import { currentCorrelation } from "../app/agentic/correlation.ts";
17
18
  import { family as claimFamily } from "../app/agentic/families/claim.family.ts";
@@ -172,7 +173,7 @@ test("#713: a claim populates jobKeys and repoints the drill stream with ZERO tr
172
173
  assertEquals(res.status, 200);
173
174
  const w = res.body.workers[0];
174
175
  assertEquals(w.jobKeys, ["8420"], "the claim registry feeds the jobKeys seam");
175
- assertEquals(w.stream, "job:8420", "the drill stream repoints at the claimed job, keyed by the claim");
176
+ assertEquals(w.stream, composeStreamId("wk-a", "8420"), "the drill stream repoints at the claimed job, keyed by the claim");
176
177
  assertEquals(res.body.correlations.length, 0, "no correlation context until a terminal lands (drill-in only)");
177
178
  } finally {
178
179
  claimFamily.teardown?.();
@@ -210,7 +211,7 @@ test("#713: the correlation registry is demoted to drill-in context — a link a
210
211
  assertEquals(res.body.correlations.length, 1);
211
212
  const c = res.body.correlations[0];
212
213
  assertEquals(c.jobKey, "6494");
213
- assertEquals(c.stream, "job:6494");
214
+ assertEquals(c.stream, composeStreamId("wk-a", "6494"));
214
215
  assertEquals(c.processInstanceKey, "4612");
215
216
  assertEquals(c.bpmnProcessId, "plan-fanout");
216
217
  assertEquals(c.planKey, "o/r#142");
@@ -220,3 +221,41 @@ test("#713: the correlation registry is demoted to drill-in context — a link a
220
221
  await hub.close();
221
222
  }
222
223
  });
224
+
225
+ test("#738 drift: the supply advertises the producer's instance-scoped stream id, round-tripping the shared @nanobpm/agentic codec (not the retired job:<jobKey>)", async () => {
226
+ // The data-plane naming contract this bug (#738) restores: the producer (c8ctl-plugin-nano) writes a
227
+ // job's transcript under `composeStreamId(instance, jobKey)`, so the cockpit MUST advertise that exact
228
+ // id — both the worker drill stream (claim-keyed) and the correlation drill stream — or every
229
+ // transcript renders empty. This pins the consumer to the ONE shared codec: a regression back to
230
+ // `job:<jobKey>` (a slash-free id that never round-trips through parseStreamId) fails here.
231
+ const hub = await mountPresence(memSqlite());
232
+ const ctx: AgenticContext = { hub, registry: hub.registry, transport: undefined as never, data: undefined, log: noopLog() };
233
+ claimFamily.mount(ctx);
234
+ correlationFamily.mount(ctx);
235
+ const claims = currentClaimRegistry();
236
+ const correlation = currentCorrelation();
237
+ assert(claims !== undefined && correlation !== undefined, "both singletons install");
238
+ claims.claim("wk-a", "12559");
239
+ correlation.link("wk-a", "12559", { processInstanceKey: "4612", planKey: "o/r#142" });
240
+ try {
241
+ const res = (await handler(input(), app)) as {
242
+ status: number;
243
+ body: { workers: Array<Record<string, unknown>>; correlations: Array<Record<string, unknown>> };
244
+ };
245
+ assertEquals(res.status, 200);
246
+ const producerStream = composeStreamId("wk-a", "12559");
247
+ const w = res.body.workers[0];
248
+ assertEquals(w.stream, producerStream, "the cockpit drills the exact stream the producer writes");
249
+ const c = res.body.correlations[0];
250
+ assertEquals(c.stream, producerStream, "the correlation drill stream matches the producer stream");
251
+ // The shared codec round-trips the advertised id back to its {instance, stream} parts.
252
+ assertEquals(parseStreamId(producerStream), { instance: "wk-a", stream: "12559" });
253
+ // And it is emphatically NOT the retired job:<jobKey> scheme that left transcripts empty (#738).
254
+ assert(typeof w.stream === "string" && !w.stream.startsWith("job:"), "the retired job:<jobKey> data-plane scheme is gone");
255
+ } finally {
256
+ correlationFamily.teardown?.();
257
+ claimFamily.teardown?.();
258
+ family.teardown?.();
259
+ await hub.close();
260
+ }
261
+ });
@@ -6,8 +6,9 @@
6
6
  // H6/#713 closes the loop with an EXPLICIT claim registry (`app/agentic/claim-registry.ts`): it is the
7
7
  // AUTHORITATIVE source the presence snapshot's `jobKeysFor` seam resolves against, so each worker's
8
8
  // current jobKeys light up from `claim` frames — not inferred from the relay terminal — and appear even
9
- // with ZERO transcript. Each worker's drill `stream` is repointed at its claimed jobKey-scoped relay
10
- // stream (`job:<jobKey>`), keyed by the CLAIM (explicit instance+jobKey), not by a connection. The
9
+ // with ZERO transcript. Each worker's drill `stream` is repointed at its claimed instance-scoped relay
10
+ // stream (`composeStreamId(instance, jobKey)`, issue #738), keyed by the CLAIM (explicit
11
+ // instance+jobKey), not by a connection. The
11
12
  // relay correlation registry is DEMOTED to drill-in context only: it still supplies the `correlations`
12
13
  // — the process-instance / plan context for a job's terminal — so the cockpit lines a worker's terminal
13
14
  // up with "that process instance / this plan", but it is no longer the visibility source.
@@ -31,9 +32,10 @@ import { defineOperation } from "../nano-generated/operations.ts";
31
32
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
32
33
 
33
34
  // Project a presence-registry row to the wire worker. The drill `stream` defaults to the worker
34
- // instance (H5) but is repointed at the worker's claimed jobKey-scoped relay stream (`job:<jobKey>`)
35
- // when the claim registry knows a current claim for it (#713) — keyed by the CLAIM, not by the
36
- // connection — so drilling in opens the LIVE job's terminal even before any transcript lands.
35
+ // instance (H5) but is repointed at the worker's claimed instance-scoped relay stream
36
+ // (`composeStreamId(instance, jobKey)`, issue #738) when the claim registry knows a current claim for
37
+ // it (#713) — keyed by the CLAIM, not by the connection — so drilling in opens the LIVE job's terminal
38
+ // (the exact stream the producer writes) even before any transcript lands.
37
39
  function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
38
40
  const out: AgenticSupplyWorker = {
39
41
  instance: w.instance,
@@ -9,6 +9,7 @@ import type { Frame } from "@nanobpm/agentic/protocol";
9
9
  import type { SqliteDb } from "@nanobpm/agentic/transcript";
10
10
  import type { AppApi, DataLayer } from "@nanobpm/urban";
11
11
  import { assert, assertEquals } from "#test-assert";
12
+ import { composeStreamId } from "@nanobpm/agentic/emit";
12
13
  import { createRelayFamily, currentRelayTranscriptService } from "../app/agentic/families/relay.family.ts";
13
14
  import type { AgenticContext } from "../app/agentic/registry.ts";
14
15
  import { noopLog } from "../test/log.ts";
@@ -82,17 +83,17 @@ test("returns the whole transcript from offset 0, then a resume slice from a lat
82
83
  const store = currentRelayTranscriptService()?.store;
83
84
  assert(store !== undefined);
84
85
  store.flush(
85
- "job:6494",
86
+ composeStreamId("wk", "6494"),
86
87
  { since: () => ({ entries: [{ offset: 0, chunk: "aa" }, { offset: 1, chunk: "bb" }, { offset: 2, chunk: "cc" }] }), nextOffset: 3 },
87
88
  "ephemeral",
88
89
  );
89
90
  try {
90
- const whole = (await handler(input("job:6494"), app)) as {
91
+ const whole = (await handler(input(composeStreamId("wk", "6494")), app)) as {
91
92
  status: number;
92
93
  body: { stream: string; from: number; gap: boolean; nextOffset: number; chunkCount: number; byteLength: number; entries: Array<{ offset: number; chunk: string }>; jobKey?: string };
93
94
  };
94
95
  assertEquals(whole.status, 200);
95
- assertEquals(whole.body.stream, "job:6494");
96
+ assertEquals(whole.body.stream, composeStreamId("wk", "6494"));
96
97
  assertEquals(whole.body.jobKey, "6494");
97
98
  assertEquals(whole.body.from, 0);
98
99
  assertEquals(whole.body.gap, false);
@@ -101,7 +102,7 @@ test("returns the whole transcript from offset 0, then a resume slice from a lat
101
102
  assertEquals(whole.body.byteLength, 6);
102
103
  assertEquals(whole.body.entries.map((e) => e.offset), [0, 1, 2]);
103
104
 
104
- const resume = (await handler(input("job:6494", { from: 2 }), app)) as {
105
+ const resume = (await handler(input(composeStreamId("wk", "6494"), { from: 2 }), app)) as {
105
106
  body: { from: number; chunkCount: number; entries: Array<{ offset: number; chunk: string }> };
106
107
  };
107
108
  assertEquals(resume.body.from, 2);