@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.
- package/CHANGELOG.md +12 -0
- package/app/agentic/agent-history.test.ts +165 -0
- package/app/agentic/agent-history.ts +211 -0
- package/app/agentic/claim-registry.test.ts +3 -2
- package/app/agentic/claim-registry.ts +8 -6
- package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
- package/app/agentic/cockpit/agent-history-render.ts +168 -0
- package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
- package/app/agentic/cockpit/agent-history-view.ts +186 -0
- package/app/agentic/cockpit/index.ts +21 -0
- package/app/agentic/cockpit/mount.test.ts +133 -1
- package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
- package/app/agentic/cockpit/supply-boot.ts +134 -1
- package/app/agentic/cockpit/supply-view.ts +3 -3
- package/app/agentic/cockpit/transcript-view.ts +1 -1
- package/app/agentic/correlation-store.test.ts +14 -10
- package/app/agentic/correlation-store.ts +4 -3
- package/app/agentic/correlation.test.ts +24 -16
- package/app/agentic/correlation.ts +25 -12
- package/app/agentic/families/claim.family.test.ts +2 -1
- package/app/agentic/families/relay.family.test.ts +74 -73
- package/app/agentic/families/relay.family.ts +24 -21
- package/app/agentic/transcript-read.test.ts +108 -17
- package/app/agentic/transcript-read.ts +41 -7
- package/app/contracts.ts +10 -2
- package/app/mcpToolSurface.ts +8 -1
- package/db/migrations/101_agentic_history_read_expand.sql +34 -0
- package/openapi.yaml +360 -0
- package/operations/agentHistoryEndpoints.test.ts +106 -0
- package/operations/getAgentInstanceHistory.ts +37 -0
- package/operations/getAgenticSupply.test.ts +41 -2
- package/operations/getAgenticSupply.ts +7 -5
- package/operations/getAgenticTranscript.test.ts +5 -4
- package/operations/listAgentInstances.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +10 -9
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +104 -0
- package/pages/cockpit/mount.js +286 -3
- 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, "
|
|
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, "
|
|
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
|
|
10
|
-
// stream (`
|
|
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
|
|
35
|
-
// when the claim registry knows a current claim for
|
|
36
|
-
// connection — so drilling in opens the LIVE job's terminal
|
|
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
|
-
"
|
|
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("
|
|
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, "
|
|
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("
|
|
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);
|