@junejs/server 1.0.0-dev.36 → 1.0.0-dev.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/agent-durable.d.ts +19 -2
  2. package/dist/agent-durable.js +118 -7
  3. package/dist/agent-native.d.ts +13 -2
  4. package/dist/agent-native.js +108 -6
  5. package/dist/app.d.ts +1 -0
  6. package/dist/app.js +10 -1
  7. package/dist/build.d.ts +3 -3
  8. package/dist/build.js +8 -2
  9. package/dist/{config-freeze-ZWC_Qd4h.d.ts → config-freeze-CyXvNqi2.d.ts} +1 -1
  10. package/dist/config-freeze.d.ts +1 -1
  11. package/dist/core-version.js +1 -1
  12. package/dist/dev.d.ts +6 -2
  13. package/dist/dev.js +40 -9
  14. package/dist/host.d.ts +1 -0
  15. package/dist/host.js +2 -1
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{manifest-DSNX4I-G.d.ts → manifest-CwTT1zVs.d.ts} +1 -1
  19. package/dist/manifest.d.ts +1 -1
  20. package/dist/manifest.js +3 -1
  21. package/dist/pipeline.js +15 -6
  22. package/dist/route-rank.d.ts +10 -0
  23. package/dist/route-rank.js +38 -0
  24. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  25. package/dist/route-scan.d.ts +2 -2
  26. package/dist/route-scan.js +47 -2
  27. package/dist/router.js +58 -44
  28. package/dist/{worker-Dv4l6Ybs.d.ts → worker-2qbQjj2c.d.ts} +1 -0
  29. package/dist/worker.d.ts +1 -1
  30. package/dist/worker.js +52 -76
  31. package/package.json +3 -3
  32. package/src/agent-durable.ts +164 -11
  33. package/src/agent-native.ts +161 -7
  34. package/src/app.ts +22 -1
  35. package/src/build.ts +11 -1
  36. package/src/core-version.ts +1 -1
  37. package/src/dev.ts +41 -8
  38. package/src/host.ts +4 -3
  39. package/src/manifest.ts +2 -0
  40. package/src/pipeline.ts +20 -4
  41. package/src/route-rank.ts +60 -0
  42. package/src/route-scan.ts +78 -1
  43. package/src/router.ts +75 -58
  44. package/src/worker.ts +66 -82
  45. package/dist/route-scan-DMlwoESy.d.ts +0 -13
package/dist/worker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { durableAgentSurface, durableChannelSurface } from "./agent-durable.js";
2
+ import { compareRoutePatterns, parseSegment } from "./route-rank.js";
2
3
  import { acceptTarget } from "./negotiate.js";
3
4
  import { createPipeline } from "./pipeline.js";
4
5
  import { RESERVED_PREFIX, contentTypeFor, safeRelativePath } from "./static-files.js";
@@ -8,34 +9,16 @@ function compilePattern(pattern) {
8
9
  let source = "";
9
10
  let allOptional = true;
10
11
  for (const seg of pattern.split("/").filter(Boolean)) {
11
- const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
12
- if (oca) {
13
- names.push(oca[1]);
14
- source += "(?:/(.+))?";
15
- continue;
16
- }
17
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
18
- if (ca) {
19
- names.push(ca[1]);
20
- source += "/(.+)";
21
- allOptional = false;
22
- continue;
23
- }
24
- const op = seg.match(/^\[\[(\w+)\]\]$/);
25
- if (op) {
26
- names.push(op[1]);
27
- source += "(?:/([^/]+))?";
28
- continue;
29
- }
30
- const p = seg.match(/^\[(\w+)\]$/);
31
- if (p) {
32
- names.push(p[1]);
33
- source += "/([^/]+)";
12
+ const { kind, name } = parseSegment(seg);
13
+ if (name) names.push(name);
14
+ if (kind === "optionalCatchAll") source += "(?:/(.+))?";
15
+ else if (kind === "optional") source += "(?:/([^/]+))?";
16
+ else {
17
+ if (kind === "catchAll") source += "/(.+)";
18
+ else if (kind === "param") source += "/([^/]+)";
19
+ else source += "/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
20
  allOptional = false;
35
- continue;
36
21
  }
37
- source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
38
- allOptional = false;
39
22
  }
40
23
  if (source === "") return {
41
24
  regex: /^\/$/,
@@ -47,20 +30,42 @@ function compilePattern(pattern) {
47
30
  };
48
31
  }
49
32
  function createWorker(manifest, opts = {}) {
50
- const dynamic = (manifest.dynamicRoutes ?? []).map((d) => ({
51
- ...compilePattern(d.pattern),
52
- def: d.def,
53
- pattern: d.pattern
54
- }));
55
- const resources = (manifest.resourceRoutes ?? []).map((r) => ({
56
- ...compilePattern(r.pattern),
57
- handler: r.handler,
58
- pattern: r.pattern
59
- }));
33
+ const generated = new Set(manifest.generatedRoutes ?? []);
34
+ const row = (pattern) => ({
35
+ ...compilePattern(pattern),
36
+ pattern,
37
+ generated: generated.has(pattern)
38
+ });
39
+ const table = [
40
+ ...Object.entries(manifest.routes).map(([pattern, def]) => ({
41
+ ...row(pattern),
42
+ def
43
+ })),
44
+ ...(manifest.dynamicRoutes ?? []).map((d) => ({
45
+ ...row(d.pattern),
46
+ def: d.def
47
+ })),
48
+ ...(manifest.resourceRoutes ?? []).map((r) => ({
49
+ ...row(r.pattern),
50
+ handler: r.handler
51
+ }))
52
+ ];
53
+ table.sort((a, b) => Number(a.generated) - Number(b.generated) || compareRoutePatterns(a.pattern, b.pattern) || Number(!!a.handler) - Number(!!b.handler));
60
54
  const routeList = [...Object.keys(manifest.routes), ...(manifest.dynamicRoutes ?? []).map((d) => d.pattern)].sort();
61
55
  const chainFor = (key) => manifest.layoutChains?.[key] ?? [];
62
56
  const loadingFor = (key) => manifest.loadings?.[key];
63
57
  const boundaryFor = (key) => manifest.layoutBoundaries?.[key] ?? null;
58
+ const resolvedPage = (def, key, params) => {
59
+ const b = boundaryFor(key);
60
+ return {
61
+ def,
62
+ params,
63
+ chain: chainFor(key),
64
+ boundaryIndex: b?.index ?? null,
65
+ boundaryKey: b?.key ?? null,
66
+ loading: loadingFor(key)
67
+ };
68
+ };
64
69
  let currentEnv;
65
70
  const requestLocals = /* @__PURE__ */ new WeakMap();
66
71
  const provider = manifest.resources;
@@ -102,47 +107,18 @@ function createWorker(manifest, opts = {}) {
102
107
  services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : void 0,
103
108
  resolve: async (pathname) => {
104
109
  const staticDef = manifest.routes[pathname];
105
- if (staticDef) {
106
- const b = boundaryFor(pathname);
107
- return {
108
- def: staticDef,
109
- params: {},
110
- chain: chainFor(pathname),
111
- boundaryIndex: b?.index ?? null,
112
- boundaryKey: b?.key ?? null,
113
- loading: loadingFor(pathname)
114
- };
115
- }
116
- for (const d of dynamic) {
117
- const m = pathname.match(d.regex);
118
- if (m) {
119
- const params = Object.fromEntries(d.names.flatMap((n, i) => {
120
- const v = m[i + 1];
121
- return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
122
- }));
123
- const b = boundaryFor(d.pattern);
124
- return {
125
- def: d.def,
126
- params,
127
- chain: chainFor(d.pattern),
128
- boundaryIndex: b?.index ?? null,
129
- boundaryKey: b?.key ?? null,
130
- loading: loadingFor(d.pattern)
131
- };
132
- }
133
- }
134
- for (const r of resources) {
110
+ if (staticDef && !generated.has(pathname)) return resolvedPage(staticDef, pathname, {});
111
+ for (const r of table) {
135
112
  const m = pathname.match(r.regex);
136
- if (m) {
137
- const params = Object.fromEntries(r.names.flatMap((n, i) => {
138
- const v = m[i + 1];
139
- return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
140
- }));
141
- return {
142
- handler: r.handler,
143
- params
144
- };
145
- }
113
+ if (!m) continue;
114
+ const params = Object.fromEntries(r.names.flatMap((n, i) => {
115
+ const v = m[i + 1];
116
+ return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
117
+ }));
118
+ return r.handler ? {
119
+ handler: r.handler,
120
+ params
121
+ } : resolvedPage(r.def, r.pattern, params);
146
122
  }
147
123
  return null;
148
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.36",
3
+ "version": "1.0.0-dev.45",
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",
@@ -136,11 +136,11 @@
136
136
  "@momiji-rs/sparkdown": "^0.0.4",
137
137
  "@resvg/resvg-wasm": "^2.6.2",
138
138
  "lightningcss": "^1.27.0",
139
- "oxc-parser": "^0.151.0",
139
+ "oxc-parser": "^0.152.0",
140
140
  "rolldown": "^1.1.0"
141
141
  },
142
142
  "peerDependencies": {
143
- "@junejs/core": ">=0.2.0-dev.57",
143
+ "@junejs/core": ">=0.2.0-dev.65",
144
144
  "react": "^19.2.0",
145
145
  "react-dom": "^19.2.0",
146
146
  "react-server-dom-webpack": "19.3.0",
@@ -22,6 +22,8 @@ import {
22
22
  withSystem,
23
23
  type Answerers,
24
24
  type AuthorizeAnswer,
25
+ type InputAnnouncement,
26
+ type NoteKind,
25
27
  type ChannelPolicy,
26
28
  type EventSink,
27
29
  type TurnEvent,
@@ -76,6 +78,10 @@ export interface SqlStorage {
76
78
  export interface DurableStorage {
77
79
  sql: SqlStorage;
78
80
  transactionSync<T>(fn: () => T): T;
81
+ // Schedules the object's alarm() — the watchdog and retry for input-announcement delivery
82
+ // (#260). Optional so a storage without alarms (a test double) still works; delivery then
83
+ // waits for the next announcement, settled turn or rebuild.
84
+ setAlarm?(scheduledTime: number): unknown;
79
85
  }
80
86
  export interface DurableObjectState {
81
87
  storage: DurableStorage;
@@ -151,7 +157,7 @@ export class DoSessionStore implements SessionStore {
151
157
  // tables + the generation counter are created lazily, so existing objects stay
152
158
  // untouched until their first reset. seq keeps counting across generations (DELETE
153
159
  // doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
154
- reset(): number {
160
+ reset(inTx?: () => void): number {
155
161
  return this.storage.transactionSync(() => {
156
162
  const sql = this.sql;
157
163
  sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
@@ -164,6 +170,7 @@ export class DoSessionStore implements SessionStore {
164
170
  sql.exec("DELETE FROM agent_steps");
165
171
  this.setStatus("new");
166
172
  sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
173
+ inTx?.(); // writes into the fresh generation, committed with the archive (#260)
167
174
  return generation;
168
175
  });
169
176
  }
@@ -245,6 +252,9 @@ export type DoAgentDef = {
245
252
  // Decides a { policy } answerer of a parked requestInput at /resume (#261) — the same hook
246
253
  // an AgentDefinition carries natively; assembleDurable passes agent.ts's through.
247
254
  authorizeAnswer?: AuthorizeAnswer;
255
+ // Receives this session's input announcements (#260) — run inside the request scope, so it
256
+ // can write the app's cross-session index with the ambient db.
257
+ onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
248
258
  };
249
259
 
250
260
  // The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
@@ -288,6 +298,10 @@ export class AgentDurableObject {
288
298
  private readonly doEnv: unknown;
289
299
  private readonly services: unknown;
290
300
  private readonly authorizeAnswer?: AuthorizeAnswer;
301
+ private readonly onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
302
+ private readonly storage: DurableStorage;
303
+ // Consecutive failed announcement deliveries (#260) — sets the alarm retry's backoff.
304
+ private announceFailures = 0;
291
305
  private resolveResources(): Promise<Resources> {
292
306
  return (this.resourcesOpened ??= Promise.resolve(
293
307
  typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
@@ -330,6 +344,8 @@ export class AgentDurableObject {
330
344
  this.doEnv = def.env;
331
345
  this.services = def.services;
332
346
  this.authorizeAnswer = def.authorizeAnswer;
347
+ this.onInputAnnouncement = def.onInputAnnouncement;
348
+ this.storage = state.storage;
333
349
  const name = def.name ?? "agent";
334
350
  // Failure observability (#76): a turn that dies after the fast-ACK has no other
335
351
  // observable surface on the edge — the webhook already 200'd and runBackground
@@ -339,6 +355,13 @@ export class AgentDurableObject {
339
355
  // over reporting; if IT throws, fall back to the default so nothing goes silent.
340
356
  // (InProcEventSink already guards emit against a throwing subscriber.)
341
357
  const sink = new InProcEventSink();
358
+ // Announcement watchdog (#260): a park is committed inside a turn, after turn.started, so
359
+ // arming here comes before its announcement can commit — whichever path started the turn,
360
+ // a held turn drained later included. Resume, reset and a held inbound turn arm at their
361
+ // call sites (armWatchdog).
362
+ sink.subscribe((e) => {
363
+ if (e.type === "turn.started") this.armWatchdog();
364
+ });
342
365
  sink.subscribe((e) => {
343
366
  if (e.type !== "turn.failed") return;
344
367
  // The stack already opens with the message; log it INSTEAD of the message line
@@ -436,8 +459,91 @@ export class AgentDurableObject {
436
459
  if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
437
460
  this.sessionKey = resolved;
438
461
  this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
462
+ // A held turn (#263) that asked for delivery is rendered through its source channel when
463
+ // it finally runs — possibly in a later life of this object, which is why the ask rides
464
+ // in the turn's hostContext and this hook is installed on every rebuild.
465
+ this.session.onDequeue = (heldTurnId, held) => {
466
+ if ((held.hostContext as { deliver?: boolean } | undefined)?.deliver && held.event) this.deliverHeld(this.session!, heldTurnId, held.event);
467
+ };
468
+ // Input announcements (#260): delivered in the request scope (the hook writes the app's
469
+ // index), and flushed on every rebuild — an earlier life may have committed a park and
470
+ // died before delivering it.
471
+ const announce = this.onInputAnnouncement;
472
+ if (announce) {
473
+ // ensureScope() here, not only in the request handlers: a session can first be rebuilt by
474
+ // a path that never wires async scope (/reset, /transcript, the direct API), and without it
475
+ // runInScope is a pass-through and the hook would see no ambient db.
476
+ this.session.onAnnounce = async (a) => {
477
+ await ensureScope();
478
+ const resources = await this.ready();
479
+ try {
480
+ await runInScope({ resources, services: this.services }, () => announce(a));
481
+ } catch (err) {
482
+ // The engine keeps the announcement and logs; retry it on the alarm, 5 s doubling
483
+ // to 5 min — a quiet session would otherwise hold it until its next activity.
484
+ this.scheduleAlarm(Math.min(5_000 * 2 ** this.announceFailures, 300_000));
485
+ this.announceFailures++;
486
+ throw err;
487
+ }
488
+ this.announceFailures = 0;
489
+ };
490
+ void this.session.flushAnnouncements();
491
+ }
439
492
  return this.session;
440
493
  }
494
+
495
+ // The Durable Object alarm (#260): delivers input announcements left undelivered — a hook
496
+ // that failed (its retry), or an object that died between committing one and delivering
497
+ // it (the watchdog). The shell forwards it — `alarm() { return this.agent.alarm(); }`;
498
+ // june build's does. Resolves the session like any key-less path (the persisted key),
499
+ // which rebuilds it after an eviction and flushes. Never rejects: a failed delivery
500
+ // reschedules itself through onAnnounce.
501
+ async alarm(): Promise<void> {
502
+ if (!this.onInputAnnouncement) return;
503
+ await this.resolveSession().flushAnnouncements();
504
+ }
505
+ // A watchdog alarm a minute out, armed BEFORE anything can commit an announcement (a
506
+ // turn starting, a resume, a reset, a held inbound turn): arming after the commit would
507
+ // leave exactly the window it exists for. A delivery that succeeds leaves it to find
508
+ // nothing.
509
+ private armWatchdog(): void {
510
+ if (this.onInputAnnouncement) this.scheduleAlarm(60_000);
511
+ }
512
+ // One alarm per object: the latest schedule wins.
513
+ private scheduleAlarm(inMs: number): void {
514
+ const failed = (err: unknown) => console.error(`[june] agent "${this.name}": could not schedule the announcement alarm — delivery waits for the next announcement, turn or rebuild:`, err);
515
+ try {
516
+ Promise.resolve(this.storage.setAlarm?.(Date.now() + inMs)).catch(failed);
517
+ } catch (err) {
518
+ failed(err);
519
+ }
520
+ }
521
+
522
+ // Render a held turn's reply through its source channel (#263), like /turn?deliver=1 does
523
+ // for a turn started now. Called from onDequeue, synchronously as the turn starts: the
524
+ // event subscription is taken HERE, before any event can emit, and buffers until the
525
+ // (async) resources are ready. Nothing may escape — there is no caller to report to; the
526
+ // log is the only surfacing path, as for any delivered render.
527
+ private deliverHeld(session: AgentSession, turnId: string, event: InboundEvent): void {
528
+ const logRenderFailure = (err: unknown) =>
529
+ console.error(`[june] agent "${this.name}": delivered render for held turn ${turnId} failed:`, err);
530
+ try {
531
+ const channel = this.channels.find((c) => c.name === event.source);
532
+ if (!channel?.deliver) {
533
+ logRenderFailure(new Error(`no deliver()-capable channel named "${event.source}" is wired into this DO — the held turn runs, but its reply is not rendered`));
534
+ return;
535
+ }
536
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
537
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
538
+ const target = { channelId: event.channelId, threadId: event.threadId, recipientUserId: event.user?.id, recipientTeamId: event.teamId };
539
+ this.ready()
540
+ .then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey })))
541
+ .catch(logRenderFailure)
542
+ .finally(() => { void it.return?.(); });
543
+ } catch (err) {
544
+ logRenderFailure(err);
545
+ }
546
+ }
441
547
  // Run the whole turn inside a request scope seeded from this DO's env, so ambient
442
548
  // `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
443
549
  // route loader. `locals` is intentionally NOT set here: a fresh scope object per
@@ -461,10 +567,18 @@ export class AgentDurableObject {
461
567
  const session = this.resolveSession(input.session);
462
568
  return runInScope({ resources, services: this.services }, () => session.start(input));
463
569
  }
570
+ // An attributed note (#262) for custom shells — the direct sibling of the /note route. No
571
+ // scope needed: a note touches only the store.
572
+ note(input: { by: string; kind: NoteKind; text: string; session?: string }): Promise<{ noteId: string }> {
573
+ const { session, ...note } = input;
574
+ return this.resolveSession(session).note(note);
575
+ }
464
576
  // Session reset (#129) for custom shells — the direct sibling of the /reset route. No
465
577
  // scope needed: reset touches only the store (no tools run).
466
578
  reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
467
- return this.resolveSession(opts?.session).reset();
579
+ const session = this.resolveSession(opts?.session);
580
+ this.armWatchdog(); // a reset can retire a park (#260)
581
+ return session.reset();
468
582
  }
469
583
  // Read-only: folds the durable log. When an identity exists (live, or persisted from a
470
584
  // prior life) the session resolves and caches like any other path. Only a read on a
@@ -484,7 +598,7 @@ export class AgentDurableObject {
484
598
  // hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
485
599
  const key = req.headers.get(SESSION_HEADER) ?? undefined;
486
600
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
487
- const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
601
+ const { userText, turnId, event, trigger, ifSuspended } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger; ifSuspended?: "reject" | "queue" };
488
602
  await ensureScope();
489
603
  // CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
490
604
  // queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
@@ -498,6 +612,15 @@ export class AgentDurableObject {
498
612
  // surface: on a pre-start rejection the caller may fall back to consumer-side rendering
499
613
  // without double-running the turn.
500
614
  const wantsDeliver = url.searchParams.get("deliver") === "1";
615
+ const detached = url.searchParams.get("detach") === "1";
616
+ // HOLD (#263): only a caller with no live consumer may ask to be held — a streaming
617
+ // caller wants the reply now, and a held turn's reply comes after the park resolves.
618
+ if (ifSuspended === "queue" && !wantsDeliver && !detached) {
619
+ return Response.json({ error: `ifSuspended "queue" needs deliver=1 or detach=1: a held turn runs later, when no one is streaming it` }, { status: 400 });
620
+ }
621
+ if (ifSuspended === "queue" && replace) {
622
+ return Response.json({ error: `ifSuspended "queue" and replace=1 cannot be combined — replacing would drop held turns that were accepted` }, { status: 400 });
623
+ }
501
624
  let deliverChannel: Channel | undefined;
502
625
  if (wantsDeliver) {
503
626
  if (!event?.channelId) {
@@ -517,15 +640,21 @@ export class AgentDurableObject {
517
640
  // BEFORE this block — the section from start() to the subscription stays synchronous.
518
641
  const resources = await this.ready();
519
642
  let session: AgentSession;
520
- let started: { turnId: string };
643
+ let started: { turnId: string; queued?: true };
521
644
  try {
522
645
  session = this.resolveSession(key);
523
- started = runInScope({ resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
646
+ // `hostContext` tells onDequeue how to finish a held turn (#263): a delivered one is
647
+ // rendered through its source channel when it runs, exactly as it would be now.
648
+ // start() itself runs turns a restart left held ahead of this one.
649
+ this.armWatchdog(); // a held turn commits a "held" announcement without starting (#260)
650
+ started = runInScope({ resources, services: this.services }, () =>
651
+ session.start({ userText, turnId, event, trigger, replace, ifSuspended, hostContext: wantsDeliver ? { deliver: true } : undefined }));
524
652
  } catch (err) {
525
653
  // e.g. the session is suspended awaiting input, or the key mis-matches this
526
654
  // object's identity — a client-resolvable conflict, not a crash
527
655
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
528
656
  }
657
+ if (started.queued) return Response.json({ turnId: started.turnId, queued: true }, { status: 202 });
529
658
  if (wantsDeliver) {
530
659
  // Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
531
660
  // the turn, no event can have emitted yet), then render under THIS DO's lifetime —
@@ -565,7 +694,7 @@ export class AgentDurableObject {
565
694
  // this DO's lifetime (alive while work is pending), so its duration is no longer
566
695
  // bounded by however long the CALLER can hold a connection (the edge waitUntil
567
696
  // ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
568
- if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
697
+ if (detached) return Response.json({ turnId: started.turnId }, { status: 202 });
569
698
  return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
570
699
  }
571
700
  // Provide the input a suspended turn is waiting on and stream its continuation as SSE.
@@ -619,6 +748,7 @@ export class AgentDurableObject {
619
748
  console.error(`[june] agent "${this.name}": authorizeAnswer failed for input "${inputId}" of turn ${turnId}:`, err);
620
749
  return Response.json({ error: `authorizeAnswer failed: ${err instanceof Error ? err.message : String(err)} — the answer was NOT applied` }, { status: 500 });
621
750
  }
751
+ this.armWatchdog(); // the answer commits a "resolved" announcement (#260)
622
752
  try {
623
753
  runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by, granted }));
624
754
  } catch (err) {
@@ -662,6 +792,7 @@ export class AgentDurableObject {
662
792
  } catch (err) {
663
793
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
664
794
  }
795
+ this.armWatchdog(); // a reset can retire a park (#260)
665
796
  try {
666
797
  return Response.json(await session.reset());
667
798
  } catch (err) {
@@ -669,6 +800,28 @@ export class AgentDurableObject {
669
800
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
670
801
  }
671
802
  }
803
+ // An attributed note (#262): body { by, kind, text } → { noteId }. A malformed body is a
804
+ // 400; a session-key conflict is a 409, like every other route.
805
+ if (req.method === "POST" && url.pathname.endsWith("/note")) {
806
+ let input: { by?: unknown; kind?: unknown; text?: unknown };
807
+ try {
808
+ input = (await req.json()) as typeof input;
809
+ } catch {
810
+ return Response.json({ error: "note: the body must be JSON { by, kind, text }" }, { status: 400 });
811
+ }
812
+ let session: AgentSession;
813
+ try {
814
+ session = this.resolveSession(key);
815
+ } catch (err) {
816
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
817
+ }
818
+ try {
819
+ return Response.json(await session.note(input as { by: string; kind: string; text: string }));
820
+ } catch (err) {
821
+ if (err instanceof TypeError) return Response.json({ error: err.message }, { status: 400 });
822
+ throw err;
823
+ }
824
+ }
672
825
  if (url.pathname.endsWith("/transcript")) {
673
826
  // A keyed read still conflict-checks (and may commit the key — it is authentic,
674
827
  // idFromName routed it here); a key-less read stays non-committal via transcript().
@@ -681,7 +834,7 @@ export class AgentDurableObject {
681
834
  }
682
835
  return Response.json({ transcript: this.transcript() });
683
836
  }
684
- return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
837
+ return new Response("agent DO — POST /turn, POST /resume, POST /reset, POST /note, or GET /transcript", { status: 404 });
685
838
  }
686
839
  }
687
840
 
@@ -964,7 +1117,7 @@ export function durableChannelSurface(
964
1117
  const detail = (await res.text()).slice(0, 200);
965
1118
  throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
966
1119
  }
967
- return (await res.json()) as { turnId: string };
1120
+ return (await res.json()) as { turnId: string; queued?: true };
968
1121
  },
969
1122
  // DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
970
1123
  // reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
@@ -988,7 +1141,7 @@ export function durableChannelSurface(
988
1141
  const detail = (await res.text()).slice(0, 200);
989
1142
  throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
990
1143
  }
991
- return (await res.json()) as { turnId: string };
1144
+ return (await res.json()) as { turnId: string; queued?: true };
992
1145
  },
993
1146
  // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
994
1147
  runStream: async function* (message, o) {
@@ -1086,8 +1239,8 @@ function serializeResume(o: { turnId: string; inputId: string; input: unknown; b
1086
1239
 
1087
1240
  // `trigger` is ProactiveTrigger — plain strings by construction — so only event.raw can make
1088
1241
  // the stringify throw; the fallback that strips it stays sufficient.
1089
- function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger }): string {
1090
- const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger };
1242
+ function serializeTurn(userText: string, o?: { turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger; ifSuspended?: "reject" | "queue" }): string {
1243
+ const payload = { userText, turnId: o?.turnId, event: o?.event, trigger: o?.trigger, ifSuspended: o?.ifSuspended };
1091
1244
  try {
1092
1245
  return JSON.stringify(payload);
1093
1246
  } catch {