@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
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
|
|
22
22
|
// relay/transcript never hard-lock or gate a BPMN sequence flow).
|
|
23
23
|
import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
24
|
+
import { parseStreamId } from "@nanobpm/agentic/emit";
|
|
24
25
|
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
25
26
|
import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
|
|
26
27
|
import {
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
type TranscriptVocab,
|
|
38
39
|
} from "@nanobpm/agentic/transcript";
|
|
39
40
|
import type { Logger } from "@nanobpm/urban";
|
|
40
|
-
import { currentCorrelation, type JobContext, type JobCorrelation
|
|
41
|
+
import { currentCorrelation, type JobContext, type JobCorrelation } from "../correlation.ts";
|
|
41
42
|
import { AgenticCorrelationStore } from "../correlation-store.ts";
|
|
42
43
|
import type { ElementInstanceResolver } from "../element-instance.ts";
|
|
43
44
|
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
@@ -194,7 +195,7 @@ interface StreamState {
|
|
|
194
195
|
/** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
|
|
195
196
|
completed: boolean;
|
|
196
197
|
/**
|
|
197
|
-
* Set once
|
|
198
|
+
* Set once an instance-scoped job stream has been linked into the correlation registry (H6, #149), so
|
|
198
199
|
* the link is attempted at most once per stream. It stays `false` while the producer's presence
|
|
199
200
|
* instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
|
|
200
201
|
*/
|
|
@@ -208,14 +209,14 @@ interface StreamState {
|
|
|
208
209
|
*/
|
|
209
210
|
createdAt: string;
|
|
210
211
|
/**
|
|
211
|
-
* The worker instance
|
|
212
|
+
* The worker instance an instance-scoped job stream was linked under (H6). Recorded so a stream's release
|
|
212
213
|
* (completion / disconnect) can tidy the {@link RelayTranscriptService.#jobStreamByInstance}
|
|
213
214
|
* supersede index, and so the "one job at a time per worker" supersede rule can identify the
|
|
214
215
|
* instance's prior job stream.
|
|
215
216
|
*/
|
|
216
217
|
instance?: string;
|
|
217
218
|
/**
|
|
218
|
-
* The engine element-instance key this
|
|
219
|
+
* The engine element-instance key this instance-scoped job stream's job occupies (#544), once the
|
|
219
220
|
* asynchronous link-time resolution ({@link RelayTranscriptService.#resolveElementInstance}) lands.
|
|
220
221
|
* Stashed on the stream so job completion can persist it even if the live correlation was already
|
|
221
222
|
* enriched-and-released, and so a resolution that returns after completion can still be recognised.
|
|
@@ -275,7 +276,7 @@ export interface RelayTranscriptServiceOptions {
|
|
|
275
276
|
readonly ensureSchema?: boolean;
|
|
276
277
|
/**
|
|
277
278
|
* The correlation write-side seam (H6, #149). When present, a first `produce` frame for a
|
|
278
|
-
*
|
|
279
|
+
* instance-scoped job stream links the producing worker instance → jobKey here, and the stream's
|
|
279
280
|
* completion / producer disconnect releases it. Defaults to the process-wide correlation registry
|
|
280
281
|
* ({@link currentCorrelation}); absent (`() => undefined`) → no linking (advisory, never an error).
|
|
281
282
|
*/
|
|
@@ -317,7 +318,7 @@ export interface RelayTranscriptServiceOptions {
|
|
|
317
318
|
*/
|
|
318
319
|
readonly correlationStore?: AgenticCorrelationStore;
|
|
319
320
|
/**
|
|
320
|
-
* Resolve the engine element-instance key
|
|
321
|
+
* Resolve the engine element-instance key an instance-scoped job stream's job occupies (#544). Called
|
|
321
322
|
* fire-and-forget on the first `produce` (while the job's JOB park is still live), and its result
|
|
322
323
|
* enriches the live correlation context / durable attribution so a captured session is keyed on the
|
|
323
324
|
* element INSTANCE (unambiguous across a looping / retried job), not just the static element id.
|
|
@@ -357,7 +358,7 @@ export class RelayTranscriptService {
|
|
|
357
358
|
readonly #log: Logger;
|
|
358
359
|
readonly #streams = new Map<string, StreamState>();
|
|
359
360
|
/**
|
|
360
|
-
* The
|
|
361
|
+
* The instance-scoped job relay stream each worker instance is CURRENTLY relaying (H6, #149). A worker
|
|
361
362
|
* relays every job it runs over one long-lived channel connection, one job at a time
|
|
362
363
|
* (`../correlation.ts`), so that connection never disconnects between jobs — the disconnect-driven
|
|
363
364
|
* `#reconcile` release never fires. This index lets a NEW job's first `produce` supersede the
|
|
@@ -609,17 +610,18 @@ export class RelayTranscriptService {
|
|
|
609
610
|
}
|
|
610
611
|
|
|
611
612
|
/**
|
|
612
|
-
* H6 write-side (#149): on the first `produce` for
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
613
|
+
* H6 write-side (#149): on the first `produce` for an instance-scoped job stream
|
|
614
|
+
* (`composeStreamId(instance, jobKey)`), link the producing worker instance → jobKey in the
|
|
615
|
+
* correlation registry, from data already crossing the wire (the jobKey is decoded from the stream
|
|
616
|
+
* id via `parseStreamId`; the instance is resolved from the producing connection). That lights up
|
|
617
|
+
* the worker's `jobKeys` in the supply feed and repoints its drill stream at the instance-scoped
|
|
618
|
+
* relay stream the producer actually writes under (issue #738). Idempotent per stream; retries on a
|
|
619
|
+
* later frame while the producer's presence instance is not yet resolvable (a register/produce
|
|
620
|
+
* race). Advisory — never throws into the frame handler.
|
|
619
621
|
*/
|
|
620
622
|
#link(stream: string, connectionId: string, state: StreamState): void {
|
|
621
623
|
if (state.linked) return;
|
|
622
|
-
const jobKey =
|
|
624
|
+
const jobKey = parseStreamId(stream)?.stream;
|
|
623
625
|
if (jobKey === undefined) return;
|
|
624
626
|
const instance = this.#instanceForConnection(connectionId);
|
|
625
627
|
if (instance === undefined || instance === "") return;
|
|
@@ -697,10 +699,11 @@ export class RelayTranscriptService {
|
|
|
697
699
|
});
|
|
698
700
|
}
|
|
699
701
|
|
|
700
|
-
/** H6 write-side (#149): release
|
|
702
|
+
/** H6 write-side (#149): release an instance-scoped job stream's (`composeStreamId(instance, jobKey)`)
|
|
703
|
+
* correlation on completion / disconnect. */
|
|
701
704
|
#unlink(stream: string, state: StreamState): void {
|
|
702
705
|
if (!state.linked) return;
|
|
703
|
-
const jobKey =
|
|
706
|
+
const jobKey = parseStreamId(stream)?.stream;
|
|
704
707
|
if (jobKey === undefined) return;
|
|
705
708
|
try {
|
|
706
709
|
// Persist the completed job's durable worker attribution + (best-effort) engine context BEFORE
|
|
@@ -791,7 +794,7 @@ export class RelayTranscriptService {
|
|
|
791
794
|
// relay-connection layer (the old presence heuristic, #690) conflates two independent liveness
|
|
792
795
|
// signals (engine lease vs. WS connection); the authoritative one is the engine job-state the
|
|
793
796
|
// poller already reconciles (#691).
|
|
794
|
-
if (this.#resolveElementInstance !== undefined &&
|
|
797
|
+
if (this.#resolveElementInstance !== undefined && parseStreamId(stream)?.stream !== undefined) {
|
|
795
798
|
// Engine/poller-owned completion (#691): drop the dead producer so `#reconcile` does not
|
|
796
799
|
// re-trigger on every subsequent frame, then reconcile THIS stream against the engine's view
|
|
797
800
|
// of its job (fire-and-forget — the sync frame handler must not await an engine read). A
|
|
@@ -858,7 +861,7 @@ export class RelayTranscriptService {
|
|
|
858
861
|
const streams: string[] = [];
|
|
859
862
|
for (const [stream, state] of this.#streams) {
|
|
860
863
|
if (state.completed) continue;
|
|
861
|
-
if (
|
|
864
|
+
if (parseStreamId(stream)?.stream === undefined) continue;
|
|
862
865
|
// Include UNLINKED job streams too (#708). The disconnect path (#691) routes an unlinked
|
|
863
866
|
// job stream through the engine reconcile and clears `state.producer`, so if the engine
|
|
864
867
|
// reports "still parked" at disconnect (or the read faults transiently) and the worker never
|
|
@@ -871,7 +874,7 @@ export class RelayTranscriptService {
|
|
|
871
874
|
}
|
|
872
875
|
|
|
873
876
|
/**
|
|
874
|
-
* Reconcile ONE
|
|
877
|
+
* Reconcile ONE instance-scoped job stream against the engine's view of its job (the poller-owned
|
|
875
878
|
* completion authority, #691). Asks the engine read model (the {@link ElementInstanceResolver} the
|
|
876
879
|
* #544 link path uses) whether the job is still parked: still parked → genuinely active, kept live;
|
|
877
880
|
* gone → ended (a clean completion whose terminal lifecycle event was missed, or an unclean exit) →
|
|
@@ -895,7 +898,7 @@ export class RelayTranscriptService {
|
|
|
895
898
|
if (resolve === undefined) return;
|
|
896
899
|
const before = this.#streams.get(stream);
|
|
897
900
|
if (before === undefined || before.completed) return;
|
|
898
|
-
const jobKey =
|
|
901
|
+
const jobKey = parseStreamId(stream)?.stream;
|
|
899
902
|
if (jobKey === undefined) return;
|
|
900
903
|
const processInstanceKey = this.#correlation()?.resolve?.(jobKey)?.processInstanceKey;
|
|
901
904
|
let activeKey: string | undefined;
|
|
@@ -8,9 +8,16 @@ import { test } from "node:test";
|
|
|
8
8
|
import { DatabaseSync } from "node:sqlite";
|
|
9
9
|
import type { SqliteDb, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
10
10
|
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import { composeStreamId } from "@nanobpm/agentic/emit";
|
|
11
12
|
import { AgenticCorrelationStore } from "./correlation-store.ts";
|
|
12
|
-
import { CorrelationRegistry } from "./correlation.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { CorrelationRegistry, jobStream } from "./correlation.ts";
|
|
14
|
+
import type { RelayTranscriptService } from "./families/relay.family.ts";
|
|
15
|
+
import { correlationFieldsFor, listTranscripts, readSingleTranscript, readTranscriptFrom } from "./transcript-read.ts";
|
|
16
|
+
|
|
17
|
+
/** The instance-scoped transcript stream id a job's terminal is stored under (issue #738); the jobKey
|
|
18
|
+
* is the stream part `parseStreamId` recovers. The worker instance is fixed here where it is immaterial
|
|
19
|
+
* to the assertion (the durable filters key off the recorded row's instance, not the stream's). */
|
|
20
|
+
const s = (jobKey: string): string => composeStreamId("w", jobKey);
|
|
14
21
|
|
|
15
22
|
/** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
|
|
16
23
|
function fakeStore(metas: TranscriptStream[]): TranscriptStore {
|
|
@@ -90,7 +97,7 @@ test("durable fallback: a released (past) job is attributed from the durable sto
|
|
|
90
97
|
const durable = new AgenticCorrelationStore(memoryStore());
|
|
91
98
|
durable.record({
|
|
92
99
|
jobKey: "k1",
|
|
93
|
-
stream: "
|
|
100
|
+
stream: s("k1"),
|
|
94
101
|
instance: "worker-A",
|
|
95
102
|
identity: "gpu-box-7",
|
|
96
103
|
host: "us-east-1a",
|
|
@@ -99,7 +106,7 @@ test("durable fallback: a released (past) job is attributed from the durable sto
|
|
|
99
106
|
completedAt: mid,
|
|
100
107
|
});
|
|
101
108
|
|
|
102
|
-
const fields = correlationFieldsFor("
|
|
109
|
+
const fields = correlationFieldsFor(s("k1"), undefined, durable);
|
|
103
110
|
assertEquals(fields.jobKey, "k1");
|
|
104
111
|
assertEquals(fields.instance, "worker-A");
|
|
105
112
|
assertEquals(fields.identity, "gpu-box-7");
|
|
@@ -111,16 +118,16 @@ test("durable fallback: a released (past) job is attributed from the durable sto
|
|
|
111
118
|
test("#544: the durable element-instance key surfaces on the read projection and its filter", () => {
|
|
112
119
|
const durable = new AgenticCorrelationStore(memoryStore());
|
|
113
120
|
// Two iterations of the SAME static element (`agent`) — distinct element instances, distinct jobKeys.
|
|
114
|
-
durable.record({ jobKey: "k1", stream: "
|
|
115
|
-
durable.record({ jobKey: "k2", stream: "
|
|
121
|
+
durable.record({ jobKey: "k1", stream: s("k1"), instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-1", completedAt: early });
|
|
122
|
+
durable.record({ jobKey: "k2", stream: s("k2"), instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-2", completedAt: late });
|
|
116
123
|
|
|
117
124
|
// The key surfaces on the correlation fields (durable fallback, live registry empty).
|
|
118
|
-
assertEquals(correlationFieldsFor("
|
|
125
|
+
assertEquals(correlationFieldsFor(s("k1"), undefined, durable).elementInstanceKey, "ei-1");
|
|
119
126
|
|
|
120
|
-
const store = fakeStore([meta("
|
|
127
|
+
const store = fakeStore([meta(s("k1"), early), meta(s("k2"), late)]);
|
|
121
128
|
// The elementInstanceKey filter resolves a session to ONE occupancy, where the elementId cannot.
|
|
122
129
|
const out = listTranscripts(store, undefined, { elementInstanceKey: "ei-2" }, durable);
|
|
123
|
-
assertEquals(out.map((t) => t.stream), ["
|
|
130
|
+
assertEquals(out.map((t) => t.stream), [s("k2")], "only the ei-2 occupancy matches");
|
|
124
131
|
assertEquals(out[0].elementInstanceKey, "ei-2", "the projection carries the element-instance key");
|
|
125
132
|
});
|
|
126
133
|
|
|
@@ -129,22 +136,22 @@ test("#544: the live correlation's element-instance key takes precedence over th
|
|
|
129
136
|
registry.link("worker-A", "k1", { elementId: "agent" });
|
|
130
137
|
registry.attachElementInstance("k1", "ei-live");
|
|
131
138
|
const durable = new AgenticCorrelationStore(memoryStore());
|
|
132
|
-
durable.record({ jobKey: "k1", stream: "
|
|
139
|
+
durable.record({ jobKey: "k1", stream: s("k1"), instance: "worker-A", elementInstanceKey: "ei-old", completedAt: mid });
|
|
133
140
|
|
|
134
|
-
assertEquals(correlationFieldsFor("
|
|
141
|
+
assertEquals(correlationFieldsFor(s("k1"), registry, durable).elementInstanceKey, "ei-live");
|
|
135
142
|
});
|
|
136
143
|
|
|
137
144
|
test("listTranscripts: the instance filter returns only sessions the durable store attributes to that worker", () => {
|
|
138
|
-
const store = fakeStore([meta("
|
|
145
|
+
const store = fakeStore([meta(s("k1"), early), meta(s("k2"), mid), meta(s("k3"), late)]);
|
|
139
146
|
const durable = new AgenticCorrelationStore(memoryStore());
|
|
140
|
-
durable.record({ jobKey: "k1", stream: "
|
|
141
|
-
durable.record({ jobKey: "k2", stream: "
|
|
142
|
-
durable.record({ jobKey: "k3", stream: "
|
|
147
|
+
durable.record({ jobKey: "k1", stream: s("k1"), instance: "worker-A", completedAt: early });
|
|
148
|
+
durable.record({ jobKey: "k2", stream: s("k2"), instance: "worker-B", completedAt: mid });
|
|
149
|
+
durable.record({ jobKey: "k3", stream: s("k3"), instance: "worker-A", completedAt: late });
|
|
143
150
|
|
|
144
151
|
const out = listTranscripts(store, undefined, { instance: "worker-A" }, durable);
|
|
145
152
|
assertEquals(
|
|
146
153
|
out.map((t) => t.stream),
|
|
147
|
-
["
|
|
154
|
+
[s("k3"), s("k1")],
|
|
148
155
|
"only worker-A's sessions, newest-first",
|
|
149
156
|
);
|
|
150
157
|
assert(out.every((t) => t.instance === "worker-A"), "each row is attributed to worker-A");
|
|
@@ -183,7 +190,7 @@ test("readTranscriptFrom: falls back to the live ring when the durable store has
|
|
|
183
190
|
{ offset: 0, chunk: "hello " },
|
|
184
191
|
{ offset: 1, chunk: "world" },
|
|
185
192
|
]);
|
|
186
|
-
const out = readTranscriptFrom("
|
|
193
|
+
const out = readTranscriptFrom(s("live1"), 0, getStore(undefined), undefined, undefined, {
|
|
187
194
|
ring,
|
|
188
195
|
createdAt: mid,
|
|
189
196
|
});
|
|
@@ -242,3 +249,87 @@ test("readTranscriptFrom: returns undefined when neither the store nor a live ri
|
|
|
242
249
|
const out = readTranscriptFrom("job:gone", 0, getStore(undefined), undefined, undefined, undefined);
|
|
243
250
|
assertEquals(out, undefined);
|
|
244
251
|
});
|
|
252
|
+
|
|
253
|
+
// --- #738: the Stage-0 `job:<jobKey>` transcript URL still resolves after the data-plane moved to the
|
|
254
|
+
// instance-scoped `composeStreamId(instance, jobKey)` stream ---
|
|
255
|
+
//
|
|
256
|
+
// The Explorer Stage-0 `transcriptUrl` (app/agentic/transcript-url.ts, #543) addresses a job by the bare
|
|
257
|
+
// `job:<jobKey>` alias — the worker cannot know its own instance at output-mapping time. The transcript is
|
|
258
|
+
// now STORED under the instance-scoped id, so a Stage-0 read must map the alias back to that id (via the
|
|
259
|
+
// live registry while linked, else the durable store) or it would 404 the URL it just emitted.
|
|
260
|
+
|
|
261
|
+
/** A keyed TranscriptStore double: `get`/`since` resolve only for the exact stream id seeded (unlike
|
|
262
|
+
* {@link getStore}, which returns its row for ANY id) — so a test can prove the alias was RESOLVED. */
|
|
263
|
+
function keyedStore(rows: Record<string, { meta: TranscriptStream; entries: { offset: number; chunk: string }[] }>): TranscriptStore {
|
|
264
|
+
return {
|
|
265
|
+
get: (stream: string) => rows[stream]?.meta,
|
|
266
|
+
since: (stream: string, from: number) => ({
|
|
267
|
+
entries: (rows[stream]?.entries ?? []).filter((e) => e.offset >= from),
|
|
268
|
+
gap: false,
|
|
269
|
+
nextOffset: rows[stream]?.meta.nextOffset ?? 0,
|
|
270
|
+
}),
|
|
271
|
+
list: () => Object.values(rows).map((r) => r.meta),
|
|
272
|
+
read: () => [],
|
|
273
|
+
} as unknown as TranscriptStore;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A RelayTranscriptService double exposing only the surface {@link readSingleTranscript} reads. */
|
|
277
|
+
function fakeService(
|
|
278
|
+
store: TranscriptStore | undefined,
|
|
279
|
+
correlationStore: AgenticCorrelationStore | undefined,
|
|
280
|
+
rings: Record<string, { ring: TranscriptRing; createdAt: string }> = {},
|
|
281
|
+
): RelayTranscriptService {
|
|
282
|
+
return {
|
|
283
|
+
store,
|
|
284
|
+
correlationStore,
|
|
285
|
+
liveFallback: (stream: string) => rings[stream],
|
|
286
|
+
} as unknown as RelayTranscriptService;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
test("readSingleTranscript: a Stage-0 job:<jobKey> alias resolves to the instance-scoped stored stream via the durable store (#738)", () => {
|
|
290
|
+
const durable = new AgenticCorrelationStore(memoryStore());
|
|
291
|
+
durable.record({ jobKey: "j1", stream: s("j1"), instance: "w", planKey: "acme/repo#1", completedAt: mid });
|
|
292
|
+
const store = keyedStore({
|
|
293
|
+
[s("j1")]: {
|
|
294
|
+
meta: { stream: s("j1"), lifecycle: "ephemeral", status: "completed", createdAt: early, completedAt: late, nextOffset: 1 },
|
|
295
|
+
entries: [{ offset: 0, chunk: "durable-bytes" }],
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
const res = readSingleTranscript(jobStream("j1"), 0, fakeService(store, durable), undefined);
|
|
299
|
+
assertEquals(res.status, 200, "the alias resolves to the instance-scoped row, not a 404");
|
|
300
|
+
assert(res.status === 200);
|
|
301
|
+
assertEquals(res.body.stream, s("j1"), "served under the instance-scoped id the bytes are stored on");
|
|
302
|
+
assertEquals(res.body.entries.map((e) => e.chunk).join(""), "durable-bytes");
|
|
303
|
+
assertEquals(res.body.jobKey, "j1");
|
|
304
|
+
assertEquals(res.body.planKey, "acme/repo#1", "durable attribution still surfaces through the alias read");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("readSingleTranscript: a Stage-0 job:<jobKey> alias resolves via the LIVE registry while the job is still linked (#738)", () => {
|
|
308
|
+
const registry = new CorrelationRegistry();
|
|
309
|
+
registry.link("w", "j2", { planKey: "acme/repo#2" });
|
|
310
|
+
const store = keyedStore({
|
|
311
|
+
[s("j2")]: {
|
|
312
|
+
meta: { stream: s("j2"), lifecycle: "ephemeral", status: "open", createdAt: mid, nextOffset: 1 },
|
|
313
|
+
entries: [{ offset: 0, chunk: "live-row" }],
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const res = readSingleTranscript(jobStream("j2"), 0, fakeService(store, undefined), registry);
|
|
317
|
+
assertEquals(res.status, 200);
|
|
318
|
+
assert(res.status === 200);
|
|
319
|
+
assertEquals(res.body.entries.map((e) => e.chunk).join(""), "live-row");
|
|
320
|
+
assertEquals(res.body.planKey, "acme/repo#2");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("readSingleTranscript: an unresolvable job:<jobKey> alias still 404s (no correlation anywhere)", () => {
|
|
324
|
+
const res = readSingleTranscript(jobStream("ghost"), 0, fakeService(keyedStore({}), undefined), undefined);
|
|
325
|
+
assertEquals(res.status, 404);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("correlationFieldsFor: decodes the jobKey from a bare job:<jobKey> Stage-0 alias too (#738)", () => {
|
|
329
|
+
const durable = new AgenticCorrelationStore(memoryStore());
|
|
330
|
+
durable.record({ jobKey: "j3", stream: s("j3"), instance: "worker-Z", planKey: "acme/repo#3", completedAt: mid });
|
|
331
|
+
const fields = correlationFieldsFor(jobStream("j3"), undefined, durable);
|
|
332
|
+
assertEquals(fields.jobKey, "j3", "the alias decodes its jobKey rather than yielding an empty projection");
|
|
333
|
+
assertEquals(fields.instance, "worker-Z");
|
|
334
|
+
assertEquals(fields.planKey, "acme/repo#3");
|
|
335
|
+
});
|
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
// Correlation is BEST-EFFORT and advisory: the correlation registry is in-memory and only holds
|
|
10
10
|
// currently-linked jobs, so a completed session's process-instance / plan context is present only
|
|
11
11
|
// while the job is still live. The jobKey itself is always recoverable — it is encoded in the stream
|
|
12
|
-
// id (`
|
|
12
|
+
// id (`composeStreamId(instance, jobKey)`, decoded with `parseStreamId`), so a past session is never
|
|
13
|
+
// anonymous even once its correlation has been released.
|
|
13
14
|
//
|
|
14
15
|
// Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
|
|
15
16
|
// unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
|
|
16
17
|
|
|
18
|
+
import { parseStreamId } from "@nanobpm/agentic/emit";
|
|
17
19
|
import type { TranscriptChunk, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
18
20
|
import type { AgenticTranscript, AgenticTranscriptData, ErrorBody } from "../../nano-generated/api-io.d.ts";
|
|
19
|
-
import {
|
|
21
|
+
import type { CorrelationRegistry } from "./correlation.ts";
|
|
22
|
+
import { jobKeyOfJobStream } from "./correlation.ts";
|
|
20
23
|
import type { AgenticCorrelationStore } from "./correlation-store.ts";
|
|
21
24
|
import type { RelayTranscriptService } from "./families/relay.family.ts";
|
|
22
25
|
import { utf8ByteLength } from "./transcript-events.ts";
|
|
@@ -46,8 +49,10 @@ interface CorrelationFields {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
|
-
* Resolve a stream id to its correlation fields. The jobKey is
|
|
50
|
-
* stream id
|
|
52
|
+
* Resolve a stream id to its correlation fields. The jobKey is decoded from the instance-scoped
|
|
53
|
+
* stream id (`composeStreamId(instance, jobKey)`) via {@link parseStreamId}, falling back to the bare
|
|
54
|
+
* `job:<jobKey>` Stage-0 alias ({@link jobKeyOfJobStream}) so a session addressed by either scheme stays
|
|
55
|
+
* attributable. Engine context (process instance / plan) + worker attribution (instance / identity /
|
|
51
56
|
* host) come from the LIVE registry while the job is still linked, and fall back to the DURABLE store
|
|
52
57
|
* (`AgenticCorrelationStore`) once the job has completed and its live correlation was released — so a
|
|
53
58
|
* PAST session stays attributable to its worker after the worker exits or the process restarts.
|
|
@@ -58,7 +63,7 @@ export function correlationFieldsFor(
|
|
|
58
63
|
correlation: CorrelationRegistry | undefined,
|
|
59
64
|
durable?: AgenticCorrelationStore | undefined,
|
|
60
65
|
): CorrelationFields {
|
|
61
|
-
const jobKey =
|
|
66
|
+
const jobKey = parseStreamId(stream)?.stream ?? jobKeyOfJobStream(stream);
|
|
62
67
|
if (jobKey === undefined) return {};
|
|
63
68
|
const fields: CorrelationFields = { jobKey };
|
|
64
69
|
const context = correlation?.resolve(jobKey);
|
|
@@ -251,6 +256,30 @@ export type SingleTranscriptResult =
|
|
|
251
256
|
| { status: 400; body: ErrorBody }
|
|
252
257
|
| { status: 404; body: ErrorBody };
|
|
253
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Resolve a requested stream id to the instance-scoped id the transcript is actually STORED under.
|
|
261
|
+
*
|
|
262
|
+
* The data plane keys every job transcript by `composeStreamId(instance, jobKey)` (issue #738), but the
|
|
263
|
+
* Explorer Stage-0 `transcriptUrl` (`app/agentic/transcript-url.ts`, #543) still addresses a job by the
|
|
264
|
+
* bare `job:<jobKey>` alias — the completing worker cannot know its own instance at output-mapping time.
|
|
265
|
+
* So a Stage-0 read arrives keyed by `job:<jobKey>` and would MISS the store (and 404) unless it is first
|
|
266
|
+
* mapped back to the instance-scoped id. The jobKey → instance-scoped stream is recovered from the LIVE
|
|
267
|
+
* correlation registry while the job is still linked, else from the DURABLE store (which persists the
|
|
268
|
+
* instance-scoped `stream` keyed by jobKey at completion). An already-instance-scoped id, a non-job
|
|
269
|
+
* stream, or an unknown jobKey is returned unchanged (the caller then reads it as-is, 404-ing if absent).
|
|
270
|
+
*/
|
|
271
|
+
export function canonicalStreamFor(
|
|
272
|
+
stream: string,
|
|
273
|
+
correlation: CorrelationRegistry | undefined,
|
|
274
|
+
durable: AgenticCorrelationStore | undefined,
|
|
275
|
+
): string {
|
|
276
|
+
// Already instance-scoped (or a non-alias id) — nothing to resolve.
|
|
277
|
+
if (parseStreamId(stream) !== undefined) return stream;
|
|
278
|
+
const jobKey = jobKeyOfJobStream(stream);
|
|
279
|
+
if (jobKey === undefined) return stream;
|
|
280
|
+
return correlation?.resolve(jobKey)?.stream ?? durable?.get(jobKey)?.stream ?? stream;
|
|
281
|
+
}
|
|
282
|
+
|
|
254
283
|
/**
|
|
255
284
|
* The ONE canonical single-stream transcript read, shared by BOTH routes that serve it (#744):
|
|
256
285
|
* `GET /agentic/transcripts?stream=<id>&from=<n>` (the proxy-safe QUERY form the cockpit clients
|
|
@@ -259,6 +288,10 @@ export type SingleTranscriptResult =
|
|
|
259
288
|
* `GET /agentic/transcripts/{stream}` (the legacy path form the worker-emitted `transcriptUrl`
|
|
260
289
|
* resolves — safe there because `job:<jobKey>` ids structurally never contain a slash). One
|
|
261
290
|
* implementation so the two addressings can never answer differently for the same stream/from.
|
|
291
|
+
*
|
|
292
|
+
* A Stage-0 `job:<jobKey>` alias is first resolved to the instance-scoped id the bytes are stored under
|
|
293
|
+
* ({@link canonicalStreamFor}), so both the durable store read AND the live-ring fallback address the
|
|
294
|
+
* stream the producer actually wrote (issue #738) rather than 404-ing the alias.
|
|
262
295
|
*/
|
|
263
296
|
export function readSingleTranscript(
|
|
264
297
|
stream: string,
|
|
@@ -274,13 +307,14 @@ export function readSingleTranscript(
|
|
|
274
307
|
// No relay/transcript service mounted at all - nothing to replay.
|
|
275
308
|
return { status: 404, body: { error: "no transcript for stream" } };
|
|
276
309
|
}
|
|
310
|
+
const readStream = canonicalStreamFor(stream, correlation, service.correlationStore);
|
|
277
311
|
const data = readTranscriptFrom(
|
|
278
|
-
|
|
312
|
+
readStream,
|
|
279
313
|
offset,
|
|
280
314
|
service.store,
|
|
281
315
|
correlation,
|
|
282
316
|
service.correlationStore,
|
|
283
|
-
service.liveFallback(
|
|
317
|
+
service.liveFallback(readStream),
|
|
284
318
|
);
|
|
285
319
|
if (data === undefined) {
|
|
286
320
|
return { status: 404, body: { error: "no transcript for stream" } };
|
package/app/contracts.ts
CHANGED
|
@@ -468,7 +468,7 @@ export const WIRE_CONTRACTS = {
|
|
|
468
468
|
name: "transcript.lifecycleClose",
|
|
469
469
|
owner: "app/agentic/families/relay.family.ts",
|
|
470
470
|
semantics:
|
|
471
|
-
"The harness's job-end `phase:\"close\"` transcript `lifecycle` event (issue #710, harness half jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:\"open\"` RELAY_OPEN_CHUNK the harness emits at relay-session open. The harness emits it on the `
|
|
471
|
+
"The harness's job-end `phase:\"close\"` transcript `lifecycle` event (issue #710, harness half jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:\"open\"` RELAY_OPEN_CHUNK the harness emits at relay-session open. The harness emits it on the job's instance-scoped `composeStreamId(instance, jobKey)` relay stream (issue #738) through agentic's own `encodeTranscriptEvent` (never hand-rolled), right before `job.complete`/`job.fail` and AFTER draining its outbound relay buffer, so it is the deterministic \"all this job's bytes are here, it is done\" signal. The app recognizes it in `isTerminalLifecycleChunk` (relay.family.ts) to `completeStream()` — flush the durable past-session transcript and release job⇄instance correlation — at job-completion time, fixing the truncated-tail defect where the flush waited for a supersede/disconnect. `close` is NOT a core agentic `LifecycleEvent` phase (the contract's phases are open|completed|exited); the app decodes it via an ADDITIVE `mergeTranscriptVocab` extension (`RELAY_TERMINAL_VOCAB`) that maps it onto the terminal `completed` phase — never a forked wire shape, and scoped to the relay job-end detector so the cockpit derive (CORE vocab) keeps the close chunk byte-faithful. The `close` trigger is ADDITIVE: supersede + disconnect remain as fallbacks and the `state.completed` guard keeps a close-then-disconnect (or duplicate close) idempotent. Consume this ONE marker — do not re-declare a synonym or a second job-end signal.",
|
|
472
472
|
shape: '{ nwfTranscriptEvent: 1, kind: "lifecycle", phase: "close" }',
|
|
473
473
|
},
|
|
474
474
|
"transcript.readUrl": {
|
|
@@ -484,9 +484,17 @@ export const WIRE_CONTRACTS = {
|
|
|
484
484
|
name: "agentTask.agentDefinition",
|
|
485
485
|
owner: "resources/processes/*.bpmn",
|
|
486
486
|
semantics:
|
|
487
|
-
"The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `<zeebe:agentDefinition agentType=\"external\" />` INSIDE its `<bpmn:extensionElements>`, COEXISTING with the existing `<zeebe:taskDefinition type=\"senior:*\"/>` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half
|
|
487
|
+
"The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `<zeebe:agentDefinition agentType=\"external\" />` INSIDE its `<bpmn:extensionElements>`, COEXISTING with the existing `<zeebe:taskDefinition type=\"senior:*\"/>` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half — see the `agentTask.historyRead` contract; the read path landed on `@nanobpm/urban`'s EngineClient in urban 0.93 / nanobpm/nano-ide#563). It is authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.",
|
|
488
488
|
shape: '<zeebe:agentDefinition agentType="external" /> (sibling of <zeebe:taskDefinition> in a senior:* service task\'s extensionElements)',
|
|
489
489
|
},
|
|
490
|
+
"agentTask.historyRead": {
|
|
491
|
+
category: "wire",
|
|
492
|
+
name: "agentTask.historyRead",
|
|
493
|
+
owner: "app/agentic/agent-history.ts",
|
|
494
|
+
semantics:
|
|
495
|
+
"The engine-native AgentInstance/AgentHistory READ path (issue #745/#747, umbrella #746 — the CONSUMER half of the PRODUCER `agentTask.agentDefinition` marker). The Cockpit HISTORICAL transcript + per-turn/instance metrics are sourced from the engine read model through the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstances`/`searchAgentInstanceHistory`/`getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563; the escalation's option (a) — NO `orchestration-cluster-api-js` fork / no second broker-REST client, so the read path is exercised by the testkit WASM double, which records read-as-absence — an empty list / null — while a live engine validates the behavioural parity). ONE narrow reader (`AgentHistoryReader`) + ONE projection (`listAgentInstances`/`readAgentHistory`) in app/agentic/agent-history.ts, served by `GET /agentic/agent-instances` + `GET /agentic/agent-instances/{agentInstanceKey}/history`. Correlation keys are the AGENT-INSTANCE / PROCESS-INSTANCE / ELEMENT-INSTANCE keys (the same #544 per-occupancy element-instance handle the relay correlation uses) — NEVER the slash-bearing `job:<jobKey>` relay stream id, so the #744 gateway-proxy bug class is moot for settled history. The token-granular relay stays the LIVE overlay only (settled history = engine, live tail = relay). Advisory read-only (ADR 0056): it observes the engine read model, never activates/completes a job or gates a sequence flow. The wire shapes (`AgentInstance`/`AgentInstanceList`/`AgentHistoryRecord`/`AgentHistory` + the metrics shapes) are declared in openapi.yaml and reuse the `@nanobpm/agentic/transcript` parity conversation grammar the transcript store already models. Consume this ONE read path — do not add a second agent-history client or a synonym endpoint.",
|
|
496
|
+
shape: "GET /agentic/agent-instances[?processInstanceKey&rootProcessInstanceKey&elementId&status] → AgentInstanceList; GET /agentic/agent-instances/{agentInstanceKey}/history[?role&loopIteration&elementInstanceKey] → AgentHistory",
|
|
497
|
+
},
|
|
490
498
|
} as const satisfies Record<string, WireContract>;
|
|
491
499
|
|
|
492
500
|
export const TYPE_CONTRACTS = {
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -98,8 +98,15 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
|
|
|
98
98
|
* each of the two transcript tools, keeping the `?stream=` form discoverable from the tool surface
|
|
99
99
|
* alone (measured +219 bytes serialized, 83,824 → 84,043 — over the old ceiling's 176-byte
|
|
100
100
|
* headroom). Deliberate, documented growth — not schema fat.
|
|
101
|
+
*
|
|
102
|
+
* RAISE PROVENANCE — 84_500 → 86_500 (#745/#747, umbrella #746): the engine-native agent-history
|
|
103
|
+
* read surface adds two GET tools — `listAgentInstances` and `getAgentInstanceHistory` — that read
|
|
104
|
+
* the durable AgentInstance / AgentHistory channels for wedge triage (kept on the surface per the
|
|
105
|
+
* `x-mcp` convention that read/orient doors stay exposed; only operator-only control doors are
|
|
106
|
+
* excluded, see app/mcpExclusions.test.ts). Measured +1,846 bytes serialized (84,043 → 85,889 —
|
|
107
|
+
* over the old ceiling's 457-byte headroom). Deliberate, documented growth — not schema fat.
|
|
101
108
|
*/
|
|
102
|
-
export const MCP_SURFACE_BYTES_BUDGET =
|
|
109
|
+
export const MCP_SURFACE_BYTES_BUDGET = 86_500;
|
|
103
110
|
|
|
104
111
|
/**
|
|
105
112
|
* The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Engine-native AgentInstance/AgentHistory read path — EXPAND phase (issue #745/#747, umbrella #746).
|
|
2
|
+
--
|
|
3
|
+
-- The CONSUMER half of the durable-agent-transcript work. The Cockpit HISTORICAL transcript + per-turn
|
|
4
|
+
-- and instance metrics are now sourced from the engine read model (`searchAgentInstances` /
|
|
5
|
+
-- `searchAgentInstanceHistory` / `getAgentInstance` on `@nanobpm/urban`'s EngineClient — urban 0.93 /
|
|
6
|
+
-- nanobpm/nano-ide#563), served by `GET /agentic/agent-instances` and
|
|
7
|
+
-- `GET /agentic/agent-instances/{agentInstanceKey}/history` (app/agentic/agent-history.ts). Settled
|
|
8
|
+
-- history now derives from engine truth, keyed by agent-instance / process-instance / element-instance
|
|
9
|
+
-- keys — never the slash-bearing `job:<jobKey>` relay stream id.
|
|
10
|
+
--
|
|
11
|
+
-- EXPAND / CONTRACT DISCIPLINE (AGENTS.md — forward-only, additive expand; destructive contract later):
|
|
12
|
+
--
|
|
13
|
+
-- • EXPAND (this phase, no schema change): begin sourcing HISTORICAL reads from engine AgentHistory.
|
|
14
|
+
-- The engine read path holds no app-side table (it reads the engine read model over the SDK/broker),
|
|
15
|
+
-- so there is NO new DDL and NOTHING is dropped here. This migration records the transition in the
|
|
16
|
+
-- schema history so the expand phase is an explicit, ordered ledger entry, mirroring the DDL-less
|
|
17
|
+
-- documented-meaning precedent in 013_merge_train_waiting_lane.sql.
|
|
18
|
+
--
|
|
19
|
+
-- • RETAIN (unchanged): the relay transcript tables `agentic_transcript_stream` /
|
|
20
|
+
-- `agentic_transcript_chunk` (024_agentic_transcript.sql) and the `transcript.readUrl` scheme STAY.
|
|
21
|
+
-- They remain the LIVE OVERLAY — the token-granular tail of a still-running agent — which the engine
|
|
22
|
+
-- settled-history read deliberately does NOT replace. Do not drop them here.
|
|
23
|
+
--
|
|
24
|
+
-- • CONTRACT (a LATER, separate phase — tracked, NOT executed here): once nothing reads the relay
|
|
25
|
+
-- tables / the transcript-URL scheme for SETTLED history (i.e. the live overlay is the only remaining
|
|
26
|
+
-- consumer, or it too has moved), a future forward-only migration DROPs `agentic_transcript_*` and
|
|
27
|
+
-- retires the URL scheme. That destructive drop is intentionally deferred behind a release that has
|
|
28
|
+
-- stopped reading the old shape (expand-and-contract), and is tracked as its own issue so the
|
|
29
|
+
-- remainder is a filed, claimable work item rather than invisible prose.
|
|
30
|
+
-- Deferred-to: nanobpm/nano-workforce#755
|
|
31
|
+
--
|
|
32
|
+
-- Additive and idempotent: no column/table is created, altered, or dropped, so it is safe to apply
|
|
33
|
+
-- forward over any earlier schema and re-runs are no-ops.
|
|
34
|
+
SELECT 1;
|