@jr2/orchestrator 0.2.0 → 0.3.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.
@@ -10,7 +10,7 @@
10
10
  // socket-free through an injected `fetch`.
11
11
  //
12
12
  // Channel split (ADR-0002, refined by ADR-0016): the Harness wire carries **lifecycle only** —
13
- // domain events go up the MCP channel via the Adapter. The client's `send`/`wait` pair is exactly
13
+ // domain events go up the Menu, through the pod's Custodian. The client's `send`/`wait` pair is exactly
14
14
  // that lifecycle surface: `send` answers with a serializable Admission
15
15
  // (`{ streamUrl, offset, submissionId }` — jr2's durable re-attach handle, stored in the host
16
16
  // ledger), and `wait(admission)` follows the durable stream from the admission offset to the
@@ -80,6 +80,10 @@ export type HarnessClient = {
80
80
  export type HarnessClientOptions = {
81
81
  /** The Harness base URL — what a workspace publishes, or a stub's `url`. */
82
82
  baseUrl: string;
83
+ /** The Harness bearer for this placement (ADR-0058; `harnessToken` in tokens.ts) — sent on
84
+ * every call, the long-poll included. Absent only where no placement exists: an explicit-
85
+ * `endpoint` run against the stub Harness, which checks nothing. */
86
+ token?: string;
83
87
  /** Injectable for socket-free tests. Default: global `fetch`. */
84
88
  fetch?: typeof fetch;
85
89
  /** Reconnect backoff floor/ceiling (capped exponential, jittered per rung — see `jittered`;
@@ -125,6 +129,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
125
129
  const backoffMaxMs = options.backoffMaxMs ?? 5_000;
126
130
  const admitWindowMs = options.admitWindowMs ?? 90_000;
127
131
  const log = options.log ?? ((line: string) => console.warn(line));
132
+ const authorization: Record<string, string> = options.token ? { authorization: `Bearer ${options.token}` } : {};
128
133
 
129
134
  const conversationUrl = (agentName: string, instanceId: string) =>
130
135
  new URL(`/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, baseUrl).toString();
@@ -192,7 +197,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
192
197
  url,
193
198
  {
194
199
  method: "POST",
195
- headers: { "content-type": "application/json" },
200
+ headers: { "content-type": "application/json", ...authorization },
196
201
  body: JSON.stringify({
197
202
  message: sendOptions.message,
198
203
  // The other half of the Frame (ADR-0057): where this Turn works. Omitted only when
@@ -238,13 +243,21 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
238
243
  let events: StreamEvent[];
239
244
  let nextOffset: string | null;
240
245
  try {
241
- const res = await fetchImpl(url.toString(), { signal });
246
+ const res = await fetchImpl(url.toString(), { headers: authorization, signal });
242
247
  if (res.status === 404) {
243
248
  throw new SettlementFault(
244
249
  `conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
245
250
  `a conversation lives as long as its Harness process (ADR-0027)`,
246
251
  );
247
252
  }
253
+ // Refused, not unreachable: the bearer is derived, so a Harness that rejects it now
254
+ // rejects it on every retry — reconnecting would park the Turn forever, silently.
255
+ if (res.status === 401 || res.status === 403) {
256
+ throw new SettlementFault(
257
+ `harness refused the stream read (${res.status}) for submission "${admission.submissionId}": ` +
258
+ `${await errorDetail(res)} — the bearer is not this placement's (ADR-0058)`,
259
+ );
260
+ }
248
261
  if (!res.ok && res.status !== 204) {
249
262
  throw new ReconnectableError(`harness stream read failed (${res.status})`);
250
263
  }
@@ -274,6 +287,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
274
287
  async abort(agentName, instanceId, opts) {
275
288
  const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
276
289
  method: "POST",
290
+ headers: authorization,
277
291
  signal: opts?.signal,
278
292
  });
279
293
  if (!res.ok) {
@@ -334,16 +348,16 @@ export function createHarnessAgentRunClient(options: HarnessClientOptions): Agen
334
348
 
335
349
  /**
336
350
  * The run-narrative echo push (ADR-0023): `POST /echo { events }` against one Workspace Harness,
337
- * bearing the Instance token (the endpoint is instance-token-gated — the Harness verifies the
338
- * bearer against the token's sha-256, never holding the token itself). The payload is the
351
+ * bearing that placement's Harness bearer (ADR-0058 — the Harness verifies it against a sha-256,
352
+ * never holding it at rest). The payload is the
339
353
  * STRUCTURED feed events; the Harness renders. A non-OK answer rejects, and the CALLER treats
340
354
  * that as log-and-continue — fire-and-forget lives in the tee (run-host.ts), not here, so a test
341
355
  * can still assert a push failed.
342
356
  */
343
357
  export function createEchoPush(options: {
344
358
  baseUrl: string;
345
- /** The Instance token — the echo bearer. */
346
- token: string;
359
+ /** The Workspace's Harness bearer (ADR-0058). Absent only against the stub Harness. */
360
+ token?: string;
347
361
  /** Injectable for socket-free tests. Default: global `fetch`. */
348
362
  fetch?: typeof fetch;
349
363
  }): (events: EchoEvent[]) => Promise<void> {
@@ -356,7 +370,10 @@ export function createEchoPush(options: {
356
370
  // `fetch failed` in the pod log is what let that condition hide.
357
371
  const res = await fetchImpl(url, {
358
372
  method: "POST",
359
- headers: { "content-type": "application/json", authorization: `Bearer ${options.token}` },
373
+ headers: {
374
+ "content-type": "application/json",
375
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
376
+ },
360
377
  body: JSON.stringify({ events }),
361
378
  }).catch((err: unknown) => {
362
379
  throw new Error(`harness echo to ${url} failed: ${transportDetail(err)}`, { cause: err });
@@ -386,7 +403,10 @@ export function createEchoPush(options: {
386
403
  * still an `AgentDefinition`: the actor narrows on start and refuses an Open one there.
387
404
  */
388
405
  export function agent(declaration: AgentDeclaration): AgentLogic {
389
- return agentActorWith((endpoint) => createHarnessAgentRunClient({ baseUrl: endpoint }), declaration);
406
+ return agentActorWith(
407
+ (endpoint, bearer) => createHarnessAgentRunClient({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
408
+ declaration,
409
+ );
390
410
  }
391
411
 
392
412
  /** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
@@ -457,7 +477,7 @@ async function errorDetail(res: Response): Promise<string> {
457
477
 
458
478
  /**
459
479
  * The marker the `@kind` tier greps for, and therefore a CONTRACT — duplicated verbatim in
460
- * `@jr2/adapter` (the two packages share no runtime dependency) and matched in
480
+ * `@jr2/harness`'s Menu (the two packages share no runtime dependency) and matched in
461
481
  * `features/steps/kind.steps.ts`. Renaming it on one side does not break a build; it silently turns
462
482
  * the tier's routability budget into a check that passes because it matches nothing.
463
483
  */