@junejs/server 0.1.2-dev.3 → 0.1.2-dev.4

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.
@@ -1,5 +1,5 @@
1
1
  import { Channel, ChannelFactory } from "@junejs/core/agent-config";
2
- import { InboundEvent, Model, Msg, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
2
+ import { InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
3
3
  import { Resources } from "@junejs/core/resources";
4
4
  //#region src/agent-durable.d.ts
5
5
  type SqlStorageValue = ArrayBuffer | string | number | null;
@@ -30,9 +30,10 @@ declare class DoSessionStore implements SessionStore {
30
30
  private get sql();
31
31
  appendMessage(m: Msg): void;
32
32
  messages(): Msg[];
33
- hasUserTurn(turnId: string): boolean;
33
+ hasOpeningMessage(turnId: string): boolean;
34
34
  getStep(id: string): unknown | undefined;
35
35
  putStep(id: string, output: unknown): void;
36
+ delStep(id: string): void;
36
37
  getStatus(): string;
37
38
  setStatus(s: string): void;
38
39
  tx<T>(fn: () => T): T;
@@ -58,6 +59,7 @@ declare class AgentDurableObject {
58
59
  turnId?: string;
59
60
  userText: string;
60
61
  event?: InboundEvent;
62
+ trigger?: ProactiveTrigger;
61
63
  }): Promise<string>;
62
64
  transcript(): import("@junejs/core/agent-runtime").Turn[];
63
65
  fetch(req: Request): Promise<Response>;
@@ -1,5 +1,5 @@
1
1
  import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
2
- import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
2
+ import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
3
3
  import { ensureScope, runInScope } from "@junejs/db";
4
4
  //#region src/agent-durable.ts
5
5
  var DoSessionStore = class {
@@ -20,8 +20,8 @@ var DoSessionStore = class {
20
20
  messages() {
21
21
  return this.sql.exec("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
22
22
  }
23
- hasUserTurn(turnId) {
24
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
23
+ hasOpeningMessage(turnId) {
24
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
25
25
  }
26
26
  getStep(id) {
27
27
  const rows = this.sql.exec("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
@@ -30,6 +30,9 @@ var DoSessionStore = class {
30
30
  putStep(id, output) {
31
31
  this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
32
32
  }
33
+ delStep(id) {
34
+ this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
35
+ }
33
36
  getStatus() {
34
37
  const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
35
38
  return rows.length ? rows[0].v : "new";
@@ -93,20 +96,40 @@ var AgentDurableObject = class {
93
96
  async fetch(req) {
94
97
  const url = new URL(req.url);
95
98
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
96
- const { userText, turnId, event } = await req.json();
99
+ const { userText, turnId, event, trigger } = await req.json();
97
100
  await ensureScope();
98
- const started = runInScope({
99
- resources: this.resources,
100
- services: this.services
101
- }, () => this.session.start({
102
- userText,
103
- turnId,
104
- event
105
- }));
101
+ let started;
102
+ try {
103
+ started = runInScope({
104
+ resources: this.resources,
105
+ services: this.services
106
+ }, () => this.session.start({
107
+ userText,
108
+ turnId,
109
+ event,
110
+ trigger
111
+ }));
112
+ } catch (err) {
113
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
114
+ }
106
115
  return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
107
116
  }
117
+ if (req.method === "POST" && url.pathname.endsWith("/resume")) {
118
+ const { turnId, inputId, input, by } = await req.json();
119
+ await ensureScope();
120
+ try {
121
+ runInScope({
122
+ resources: this.resources,
123
+ services: this.services
124
+ }, () => this.session.resume(turnId, inputId, input, { by }));
125
+ } catch (err) {
126
+ const message = err instanceof Error ? err.message : String(err);
127
+ return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
128
+ }
129
+ return new Response(sseTurnStream(this.session, turnId), { headers: SSE_HEADERS });
130
+ }
108
131
  if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
109
- return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
132
+ return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
110
133
  }
111
134
  };
112
135
  const SSE_HEADERS = {
@@ -132,7 +155,7 @@ function sseTurnStream(session, turnId) {
132
155
  }, 2e4);
133
156
  unsub = session.observe((e) => {
134
157
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
135
- if (e.type === "turn.completed" || e.type === "turn.failed") {
158
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") {
136
159
  stop();
137
160
  controller.close();
138
161
  }
@@ -235,14 +258,35 @@ function durableChannelSurface(getNamespace, opts) {
235
258
  method: "POST",
236
259
  body: serializeTurn(message, o)
237
260
  })));
261
+ },
262
+ resumeStream: async function* (o) {
263
+ const namespace = getNamespace();
264
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
265
+ yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume", {
266
+ method: "POST",
267
+ body: serializeResume(o)
268
+ })));
238
269
  }
239
270
  });
240
271
  }
272
+ function serializeResume(o) {
273
+ try {
274
+ return JSON.stringify({
275
+ turnId: o.turnId,
276
+ inputId: o.inputId,
277
+ input: o.input,
278
+ by: o.by
279
+ });
280
+ } catch (err) {
281
+ throw new Error(`resumeStream: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
282
+ }
283
+ }
241
284
  function serializeTurn(userText, o) {
242
285
  const payload = {
243
286
  userText,
244
287
  turnId: o?.turnId,
245
- event: o?.event
288
+ event: o?.event,
289
+ trigger: o?.trigger
246
290
  };
247
291
  try {
248
292
  return JSON.stringify(payload);
@@ -20,8 +20,8 @@ var SqliteSessionStore = class {
20
20
  messages() {
21
21
  return this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid).map((r) => JSON.parse(r.body));
22
22
  }
23
- hasUserTurn(turnId) {
24
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
23
+ hasOpeningMessage(turnId) {
24
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
25
25
  }
26
26
  getStep(id) {
27
27
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id);
@@ -30,6 +30,9 @@ var SqliteSessionStore = class {
30
30
  putStep(id, output) {
31
31
  this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
32
32
  }
33
+ delStep(id) {
34
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
35
+ }
33
36
  getStatus() {
34
37
  return this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid)?.status ?? "new";
35
38
  }
@@ -100,8 +103,8 @@ var MemorySessionStore = class {
100
103
  messages() {
101
104
  return this.msgs.slice();
102
105
  }
103
- hasUserTurn(turnId) {
104
- return this.msgs.some((m) => m.role === "user" && m.turnId === turnId);
106
+ hasOpeningMessage(turnId) {
107
+ return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
105
108
  }
106
109
  getStep(id) {
107
110
  return this.steps.has(id) ? this.steps.get(id) : void 0;
@@ -109,6 +112,9 @@ var MemorySessionStore = class {
109
112
  putStep(id, output) {
110
113
  this.steps.set(id, output);
111
114
  }
115
+ delStep(id) {
116
+ this.steps.delete(id);
117
+ }
112
118
  getStatus() {
113
119
  return this.status;
114
120
  }
@@ -156,7 +162,8 @@ function mountAgent(agent, runtime, opts = {}) {
156
162
  run: (message, o) => runtime.session(agent.name, o?.session ?? "default").turn({
157
163
  turnId: o?.turnId,
158
164
  userText: message,
159
- event: o?.event
165
+ event: o?.event,
166
+ trigger: o?.trigger
160
167
  })
161
168
  };
162
169
  const channels = channelFetch(agent, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.2-dev.3",
3
+ "version": "0.1.2-dev.4",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -125,7 +125,7 @@
125
125
  "build": "tsdown"
126
126
  },
127
127
  "dependencies": {
128
- "@junejs/core": ">=0.1.1-dev.5",
128
+ "@junejs/core": ">=0.1.1-dev.8",
129
129
  "@junejs/db": ">=0.0.34-dev.0",
130
130
  "@momiji-rs/sparkdown": "^0.0.4",
131
131
  "lightningcss": "^1.27.0",
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  AgentSession,
20
+ ResumeAuthorizationError,
20
21
  withSystem,
21
22
  type EventSink,
22
23
  type TurnEvent,
@@ -25,6 +26,7 @@ import {
25
26
  type Runtime,
26
27
  type InboundEvent,
27
28
  type SessionStore,
29
+ type ProactiveTrigger,
28
30
  type Tool,
29
31
  } from "@junejs/core/agent-runtime";
30
32
  import type { Resources } from "@junejs/core/resources";
@@ -95,8 +97,8 @@ export class DoSessionStore implements SessionStore {
95
97
  messages(): Msg[] {
96
98
  return this.sql.exec<{ body: string }>("SELECT body FROM agent_messages ORDER BY seq").toArray().map((r) => JSON.parse(r.body));
97
99
  }
98
- hasUserTurn(turnId: string): boolean {
99
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
100
+ hasOpeningMessage(turnId: string): boolean {
101
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
100
102
  }
101
103
  getStep(id: string): unknown | undefined {
102
104
  const rows = this.sql.exec<{ output: string }>("SELECT output FROM agent_steps WHERE id = ?", id).toArray();
@@ -105,6 +107,9 @@ export class DoSessionStore implements SessionStore {
105
107
  putStep(id: string, output: unknown) {
106
108
  this.sql.exec("INSERT INTO agent_steps (id, output) VALUES (?, ?)", id, JSON.stringify(output));
107
109
  }
110
+ delStep(id: string) {
111
+ this.sql.exec("DELETE FROM agent_steps WHERE id = ?", id);
112
+ }
108
113
  getStatus(): string {
109
114
  const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'status'").toArray();
110
115
  return rows.length ? rows[0]!.v : "new";
@@ -213,7 +218,7 @@ export class AgentDurableObject {
213
218
  // Juno's batch-loader registry) can't leak across turns on a long-lived DO.
214
219
  // ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
215
220
  // pipeline does; without it runInScope is a pass-through and ambient reads throw.
216
- async turn(input: { turnId?: string; userText: string; event?: InboundEvent }): Promise<string> {
221
+ async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger }): Promise<string> {
217
222
  await ensureScope();
218
223
  return runInScope({ resources: this.resources, services: this.services }, () => this.session.turn(input));
219
224
  }
@@ -226,16 +231,38 @@ export class AgentDurableObject {
226
231
  async fetch(req: Request): Promise<Response> {
227
232
  const url = new URL(req.url);
228
233
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
229
- const { userText, turnId, event } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent };
234
+ const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
230
235
  await ensureScope();
231
236
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
232
237
  // db/services (ALS propagates to the .then continuation registered here); subscribing
233
238
  // happens synchronously right after, before any event can emit.
234
- const started = runInScope({ resources: this.resources, services: this.services }, () => this.session.start({ userText, turnId, event }));
239
+ let started: { turnId: string };
240
+ try {
241
+ started = runInScope({ resources: this.resources, services: this.services }, () => this.session.start({ userText, turnId, event, trigger }));
242
+ } catch (err) {
243
+ // e.g. the session is suspended awaiting input — a client-resolvable conflict, not a crash
244
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
245
+ }
235
246
  return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
236
247
  }
248
+ // Provide the input a suspended turn is waiting on and stream its continuation as SSE.
249
+ // NOTE: this default surface passes `by` straight from the body — the engine treats it as a
250
+ // VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
251
+ // signature-checked Slack interaction payload), never expose this endpoint raw to clients.
252
+ if (req.method === "POST" && url.pathname.endsWith("/resume")) {
253
+ const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
254
+ await ensureScope();
255
+ try {
256
+ runInScope({ resources: this.resources, services: this.services }, () => this.session.resume(turnId, inputId, input, { by }));
257
+ } catch (err) {
258
+ const message = err instanceof Error ? err.message : String(err);
259
+ // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id
260
+ return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
261
+ }
262
+ return new Response(sseTurnStream(this.session, turnId), { headers: SSE_HEADERS });
263
+ }
237
264
  if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
238
- return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
265
+ return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
239
266
  }
240
267
  }
241
268
 
@@ -263,7 +290,9 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
263
290
  }, 20_000);
264
291
  unsub = session.observe((e) => {
265
292
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
266
- if (e.type === "turn.completed" || e.type === "turn.failed") { stop(); controller.close(); }
293
+ // terminal for the STREAM: completed, failed, OR suspended (input.requested → the turn
294
+ // parked; the stream ends and a later /resume opens a fresh continuation stream).
295
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
267
296
  }, { turnId });
268
297
  },
269
298
  cancel() { stop(); },
@@ -433,6 +462,18 @@ export function durableChannelSurface(
433
462
  );
434
463
  yield* sseTurnEvents(res);
435
464
  },
465
+ // Resume a parked turn on its session's DO and stream the continuation.
466
+ resumeStream: async function* (o) {
467
+ const namespace = getNamespace();
468
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
469
+ const res = await durableFetch(
470
+ namespace,
471
+ opts.agentName,
472
+ o.session ?? "default",
473
+ new Request("https://do/resume", { method: "POST", body: serializeResume(o) }),
474
+ );
475
+ yield* sseTurnEvents(res);
476
+ },
436
477
  };
437
478
  return channelDispatch(resolved, ctx);
438
479
  }
@@ -443,8 +484,22 @@ export function durableChannelSurface(
443
484
  // run the turn, so on a serialization failure we drop it rather than let an
444
485
  // unserializable payload take down turn forwarding entirely. (raw is optional on
445
486
  // InboundEvent precisely because it may not survive this boundary.)
446
- function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent }): string {
447
- const payload = { userText, turnId: o?.turnId, event: o?.event };
487
+ // Serialize a /resume RPC body. `input` is `unknown` — the human's answer — so a
488
+ // (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
489
+ // Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
490
+ // turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
491
+ function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
492
+ try {
493
+ return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
494
+ } catch (err) {
495
+ throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
496
+ }
497
+ }
498
+
499
+ // `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
500
+ // the stringify throw; the fallback that strips it stays sufficient.
501
+ function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
502
+ const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger };
448
503
  try {
449
504
  return JSON.stringify(payload);
450
505
  } catch {
@@ -46,8 +46,8 @@ class SqliteSessionStore implements SessionStore {
46
46
  return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
47
47
  .map((r) => JSON.parse(r.body));
48
48
  }
49
- hasUserTurn(turnId: string): boolean {
50
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
49
+ hasOpeningMessage(turnId: string): boolean {
50
+ return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
51
51
  }
52
52
  getStep(id: string): unknown | undefined {
53
53
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
@@ -56,6 +56,9 @@ class SqliteSessionStore implements SessionStore {
56
56
  putStep(id: string, output: unknown) {
57
57
  this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
58
58
  }
59
+ delStep(id: string) {
60
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
61
+ }
59
62
  getStatus(): string {
60
63
  return (this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid) as { status: string } | undefined)?.status ?? "new";
61
64
  }
@@ -136,9 +139,10 @@ class MemorySessionStore implements SessionStore {
136
139
  private status = "new";
137
140
  appendMessage(m: Msg) { this.msgs.push(m); }
138
141
  messages(): Msg[] { return this.msgs.slice(); }
139
- hasUserTurn(turnId: string): boolean { return this.msgs.some((m) => m.role === "user" && m.turnId === turnId); }
142
+ hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
140
143
  getStep(id: string): unknown | undefined { return this.steps.has(id) ? this.steps.get(id) : undefined; }
141
144
  putStep(id: string, output: unknown) { this.steps.set(id, output); }
145
+ delStep(id: string) { this.steps.delete(id); }
142
146
  getStatus(): string { return this.status; }
143
147
  setStatus(s: string) { this.status = s; }
144
148
  tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
@@ -209,7 +213,7 @@ export function mountAgent(
209
213
  agent,
210
214
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
211
215
  run: (message, o) =>
212
- runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event }),
216
+ runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
213
217
  };
214
218
  const channels = channelFetch(agent, ctx);
215
219
  const surface = async (req: Request): Promise<Response | null> => {