@jr2/orchestrator 0.5.1 → 0.6.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.
Files changed (185) hide show
  1. package/docs/agents-block.md +2 -2
  2. package/docs/concepts/agents-and-turns.md +56 -20
  3. package/docs/concepts/architecture.md +30 -17
  4. package/docs/concepts/charts.md +6 -3
  5. package/docs/concepts/converge.md +3 -0
  6. package/docs/concepts/effects.md +86 -31
  7. package/docs/concepts/machines-and-workflows.md +9 -0
  8. package/docs/concepts/pools-and-sources.md +6 -6
  9. package/docs/concepts/secrets-and-trust.md +126 -42
  10. package/docs/concepts/the-menu.md +3 -0
  11. package/docs/concepts/workspaces.md +129 -51
  12. package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
  13. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  14. package/docs/get-started/install.md +4 -0
  15. package/docs/guides/add-tools-to-the-sandbox.md +17 -4
  16. package/docs/guides/bind-open-parts.md +5 -4
  17. package/docs/guides/cut-a-worktree.md +560 -0
  18. package/docs/guides/fix-a-failed-converge.md +1 -1
  19. package/docs/guides/let-an-agent-write-a-chart.md +5 -1
  20. package/docs/guides/look-inside-a-sandbox.md +18 -15
  21. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  22. package/docs/guides/push-and-sign-a-branch.md +67 -21
  23. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  24. package/docs/guides/run-one-worker-per-item.md +19 -18
  25. package/docs/guides/set-an-agents-access-and-dials.md +67 -22
  26. package/docs/guides/size-a-workspace.md +19 -6
  27. package/docs/guides/use-a-hosted-provider.md +6 -0
  28. package/docs/guides/watch-a-run.md +47 -24
  29. package/docs/guides/write-a-workflow.md +1 -1
  30. package/docs/llms.txt +33 -12
  31. package/docs/reference/authoring-surface/agent.md +34 -11
  32. package/docs/reference/authoring-surface/answer.md +19 -3
  33. package/docs/reference/authoring-surface/arg.md +27 -5
  34. package/docs/reference/authoring-surface/branchname.md +15 -0
  35. package/docs/reference/authoring-surface/command.md +36 -21
  36. package/docs/reference/authoring-surface/fail.md +17 -4
  37. package/docs/reference/authoring-surface/file.md +36 -16
  38. package/docs/reference/authoring-surface/http.md +108 -8
  39. package/docs/reference/authoring-surface/isready.md +14 -0
  40. package/docs/reference/authoring-surface/jr2setup.md +1 -1
  41. package/docs/reference/authoring-surface/open.md +36 -5
  42. package/docs/reference/authoring-surface/paramsof.md +22 -4
  43. package/docs/reference/authoring-surface/pool.md +4 -4
  44. package/docs/reference/authoring-surface/push.md +30 -12
  45. package/docs/reference/authoring-surface/readyof.md +21 -0
  46. package/docs/reference/authoring-surface/secret.md +25 -4
  47. package/docs/reference/authoring-surface/source.md +3 -3
  48. package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
  49. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  50. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  51. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  52. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  53. package/docs/reference/authoring-surface/type-binding.md +5 -6
  54. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  55. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  56. package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
  57. package/docs/reference/authoring-surface/type-customize.md +4 -2
  58. package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
  59. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  60. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  61. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  62. package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
  63. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  64. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  65. package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
  66. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  67. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  68. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  69. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  70. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  71. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  72. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  73. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  74. package/docs/reference/authoring-surface/type-poolspec.md +2 -2
  75. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  76. package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
  77. package/docs/reference/authoring-surface/type-reposlot.md +5 -5
  78. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  79. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  80. package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
  81. package/docs/reference/authoring-surface/type-workspaced.md +29 -8
  82. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  83. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  84. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  85. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  86. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  87. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  88. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  89. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  90. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  91. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  92. package/docs/reference/authoring-surface/workspace.md +65 -37
  93. package/docs/reference/authoring-surface/worktree.md +71 -0
  94. package/docs/reference/authoring-surface.md +30 -10
  95. package/docs/reference/cli.md +1 -1
  96. package/docs/reference/config.md +13 -4
  97. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  98. package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
  99. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  100. package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
  101. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  102. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
  103. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
  104. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
  105. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  106. package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
  107. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
  108. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
  109. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
  110. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
  111. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
  112. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
  113. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
  114. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
  115. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
  116. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
  117. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  118. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  119. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  120. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
  121. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
  122. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
  123. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
  124. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
  125. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
  126. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
  127. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
  128. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
  129. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  130. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
  131. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
  132. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  133. package/docs/reference/decisions.md +3 -0
  134. package/docs/reference/glossary.md +51 -12
  135. package/docs/reference/http-api.md +45 -7
  136. package/docs/reference/machines.md +0 -1
  137. package/docs/why.md +5 -4
  138. package/package.json +3 -3
  139. package/src/actor.ts +161 -35
  140. package/src/agent.ts +6 -6
  141. package/src/ambient.ts +3 -5
  142. package/src/chart.ts +186 -19
  143. package/src/charts.ts +9 -2
  144. package/src/config.ts +16 -6
  145. package/src/custodian.lua +17 -5
  146. package/src/custodian.ts +251 -77
  147. package/src/customize.ts +11 -3
  148. package/src/effect-checks.ts +50 -3
  149. package/src/effect.ts +343 -127
  150. package/src/effector-wire.ts +6 -3
  151. package/src/fail.ts +17 -4
  152. package/src/harness-client.ts +74 -31
  153. package/src/harness-env.ts +33 -0
  154. package/src/held-secrets.ts +125 -16
  155. package/src/holds.ts +3 -0
  156. package/src/http-effect.ts +88 -32
  157. package/src/http.ts +56 -17
  158. package/src/index.ts +15 -1
  159. package/src/internal.ts +1 -0
  160. package/src/kube-client.ts +6 -3
  161. package/src/machine-doc.ts +3 -4
  162. package/src/model-keys.ts +24 -0
  163. package/src/names.ts +19 -6
  164. package/src/notices.ts +443 -54
  165. package/src/open-body.ts +85 -19
  166. package/src/open.ts +36 -5
  167. package/src/parts.ts +40 -28
  168. package/src/party.ts +33 -0
  169. package/src/pick.ts +41 -7
  170. package/src/pool.ts +44 -23
  171. package/src/registration.ts +53 -11
  172. package/src/repo-identity.ts +7 -3
  173. package/src/repos.ts +20 -2
  174. package/src/run-host.ts +106 -20
  175. package/src/sandbox-effect.ts +217 -66
  176. package/src/sandbox-kube.ts +63 -16
  177. package/src/secret-placement.ts +9 -2
  178. package/src/setup.ts +86 -6
  179. package/src/tokens.ts +107 -32
  180. package/src/vocabulary.ts +23 -4
  181. package/src/wire.ts +10 -10
  182. package/src/workspace.ts +264 -253
  183. package/src/worktree.ts +249 -0
  184. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  185. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
package/src/chart.ts CHANGED
@@ -19,10 +19,11 @@
19
19
  // (open-body.ts), so that `jr2Setup`'s own refusals and load warnings are lines of the same report.
20
20
 
21
21
  import { createHash } from "node:crypto";
22
+ import { posix } from "node:path";
22
23
  import { z } from "zod";
23
24
  import { defineEvent, type EventAudience, type EventDef } from "@jr2/agent-protocol";
24
25
  import type { AgentDefinition, ThinkingLevel, WorkspaceAccess } from "./agent.ts";
25
- import { inputKeysOf, isEffect } from "./effect.ts";
26
+ import { inputKeysOf, inputShapeOf, isEffect, type InputShape } from "./effect.ts";
26
27
  import { FAILURE_GUARD } from "./fail.ts";
27
28
  import { compileChart, durationMs, type LentParts, type OpenBodyDeclaration } from "./open-body.ts";
28
29
  import { SENT_BY } from "./party.ts";
@@ -140,6 +141,10 @@ const MECHANISM_EVENTS: Record<string, { fields: Record<string, boolean> }> = {
140
141
  * placement; see {@link checkAgentInput}). */
141
142
  const AGENT_INPUT_KEYS = ["prompt", "cwd", "continue", "model", "thinkingLevel"] as const;
142
143
 
144
+ /** The kit's test for a cut that settled `ready`, in every Chart beside `jr2.failure`: a cut's own
145
+ * outcomes are output, so a Chart routes on them, and a Chart has no other way to read one. */
146
+ export const READY_GUARD = "jr2.ready";
147
+
143
148
  /**
144
149
  * The Chart briefing (ADR-0070): jr2's text on how a Chart runs, for whoever writes one. It is the
145
150
  * grammar's root description, so it rides `GET /workflows/<wrapper>/chart-schema` and a pick's tool
@@ -164,6 +169,16 @@ export function chartBriefing(narrow?: Narrow): string {
164
169
  ? ' `src: "fail"` with `input: { reason }` ends the run as a Failure: work that did not succeed, with ' +
165
170
  `the reason for a person; an \`onError\` with the guard \`${FAILURE_GUARD}\` catches it. A final state means done.`
166
171
  : "";
172
+ // A declaration is names alone, so this says "a lent Effect that cuts".
173
+ const cut =
174
+ narrow === undefined || narrow.lendsEffects
175
+ ? ' In a Workspace with Repo Slots, work happens in a Worktree. A lent Effect that cuts one takes `input: { path: "/work/<slot>/<name>", ' +
176
+ 'branch: "<branch>" }`: `<name>` is one folder name that you choose, and it is not the branch. The Open body sets the cut\'s `expect`, ' +
177
+ "so its input has no `expect`. Give its `onDone` two edges: the guard " +
178
+ `\`${READY_GUARD}\` on the one that goes on, and one with no guard for a refused cut. Then write that same literal path as \`cwd\` in an ` +
179
+ "Agent's `input` and as `worktree` in an Effect's `input`: each Turn and each Effect there must say where it works. " +
180
+ "The slot's folder `/work/<slot>` is not a Worktree."
181
+ : "";
167
182
  const agents = mints
168
183
  ? `A state runs an Agent with \`invoke: { src, input: { prompt } }\`: \`src\` is a lent Agent's name${lent}. ` +
169
184
  "This Chart mints no Agent of its own: there is no `agents`."
@@ -171,7 +186,7 @@ export function chartBriefing(narrow?: Narrow): string {
171
186
  "a key of `agents`; a lent Agent needs no entry in `agents`, which mints new ones.";
172
187
  return (
173
188
  `A Chart is a workflow written as data: states, the events its Agents pick${mints ? "" : ", and the Agents it mints"}. ` +
174
- `${agents}${gate}${fail} An Agent's Turn ends only when it ` +
189
+ `${agents}${gate}${fail}${cut} An Agent's Turn ends only when it ` +
175
190
  "picks one of the Chart's `events`: declare each pick in `events` with an `input` schema " +
176
191
  '(`{ "type": "object", "properties": { "<field>": { "type": "string" } }, "required": ["<field>"] }`; a field not in ' +
177
192
  "`required` may be absent), and " +
@@ -238,6 +253,7 @@ export function pathSegments(path: string): Array<string | number> {
238
253
  type Narrow = {
239
254
  srcs: string[];
240
255
  lentAgents: string[];
256
+ lendsEffects: boolean;
241
257
  gate: boolean;
242
258
  fail: boolean;
243
259
  guards: string[];
@@ -363,16 +379,16 @@ function grammar(narrow?: Narrow): z.ZodType<ChartText> {
363
379
  // `jr2.sentBy` is the kit's routing guard (ADR-0015), and `jr2.failure` its test for a Failure on
364
380
  // an `onError` (ADR-0073): every Chart has both, and neither is lent.
365
381
  const sentByArm = z.strictObject({ type: z.literal(SENT_BY), params: z.strictObject({ id: z.string() }) });
366
- const failureArm = z.strictObject({ type: z.literal(FAILURE_GUARD) });
382
+ const failureArm = z.strictObject({ type: z.union([z.literal(FAILURE_GUARD), z.literal(READY_GUARD)]) });
367
383
  const lentGuardName = narrow
368
- ? enumOf([...narrow.guards, FAILURE_GUARD])
384
+ ? enumOf([...narrow.guards, FAILURE_GUARD, READY_GUARD])
369
385
  : z.string().refine((t) => t !== SENT_BY, { message: `"${SENT_BY}" takes params { id }` });
370
386
  const paramGuardName = narrow
371
387
  ? enumOf(narrow.guards)
372
388
  : z
373
389
  .string()
374
390
  .refine((t) => t !== SENT_BY, { message: `"${SENT_BY}" takes params { id }` })
375
- .refine((t) => t !== FAILURE_GUARD, { message: `"${FAILURE_GUARD}" takes no params` });
391
+ .refine((t) => t !== FAILURE_GUARD && t !== READY_GUARD, { message: "that guard takes no params" });
376
392
  const guard: z.ZodType<ChartGuard> = z
377
393
  .union([
378
394
  lentGuardName,
@@ -397,7 +413,7 @@ function grammar(narrow?: Narrow): z.ZodType<ChartText> {
397
413
  ])
398
414
  .meta({
399
415
  id: `${ID_PREFIX}ChartGuard`,
400
- description: `A lent guard by name, \`jr2.sentBy\`, \`${FAILURE_GUARD}\`, or not/and/or of guards.`,
416
+ description: `A lent guard by name, \`jr2.sentBy\`, \`${FAILURE_GUARD}\`, \`${READY_GUARD}\`, or not/and/or of guards.`,
401
417
  }) as never;
402
418
 
403
419
  const lentActionName = narrow
@@ -681,6 +697,7 @@ function narrowOf(decl: OpenBodyDeclaration): Narrow {
681
697
  return {
682
698
  srcs: [...l.agents, ...l.effects, ...l.machines, ...(l.gate ? ["gate"] : []), ...(l.fail ? ["fail"] : [])],
683
699
  lentAgents: l.agents,
700
+ lendsEffects: l.effects.length > 0,
684
701
  gate: l.gate,
685
702
  fail: l.fail,
686
703
  guards: l.guards,
@@ -838,6 +855,17 @@ export type AdmittedChart = {
838
855
  size: number;
839
856
  };
840
857
 
858
+ /**
859
+ * Why a Chart's `cwd` is not a place to work, or undefined when it can be: a Repo Slot's folder is no
860
+ * Worktree. There, an Agent's search hits `default/`, and a relative write lands outside each
861
+ * Worktree (ADR-0057, ADR-0075).
862
+ */
863
+ export function slotFolderFault(cwd: string, slots: readonly string[]): string | undefined {
864
+ const slot = posix.normalize(cwd).match(/^\/work\/([^/]+)\/?$/)?.[1];
865
+ if (slot === undefined || !slots.includes(slot)) return undefined;
866
+ return `"${cwd}" is a Repo Slot's folder, not a Worktree; \`cwd\` is a Worktree that a cut made, "/work/${slot}/<name>", or "/work/${slot}/default" to read, or "/work"`;
867
+ }
868
+
841
869
  /** What the door knows beyond the declaration and the lent set. */
842
870
  export type AdmitOptions = {
843
871
  /**
@@ -846,6 +874,11 @@ export type AdmitOptions = {
846
874
  * `input.<key>` reference is checked against it; absent, `input` references are not checked.
847
875
  */
848
876
  inputKeys?: readonly string[];
877
+ /** The Repo Slots of the Workspace around the Open body, where the door can read them. Given and
878
+ * not empty, a Turn's `cwd` and an Effect's `worktree` are required, a literal path's slot is
879
+ * checked, and a literal `cwd` cannot be a slot's folder. Absent: the start of a Turn or an Effect
880
+ * says the same of a missing `cwd`, of a Worktree path, and of a `cwd` that is a slot's folder. */
881
+ repoSlots?: readonly string[];
849
882
  };
850
883
 
851
884
  const MAX_STATES = 200;
@@ -991,6 +1024,13 @@ export function admitChart(
991
1024
  return new Door(chart, decl, lent, options).admit(size);
992
1025
  }
993
1026
 
1027
+ /** What an Effect's input takes, for a line of the door. */
1028
+ function shapeText(shape: InputShape): string {
1029
+ const need = [...shape.required, ...(shape.oneOf ? [`exactly one of ${shape.oneOf.join(", ")}`] : [])].join(", ");
1030
+ const may = shape.optional.length ? `${need ? ", and " : ""}optionally ${shape.optional.join(", ")}` : "";
1031
+ return need || may ? `${need}${may}` : "nothing";
1032
+ }
1033
+
994
1034
  /** The checks from rule 3 on, over a Chart the grammar admitted. */
995
1035
  class Door {
996
1036
  private readonly lines: Line[] = [];
@@ -1171,8 +1211,8 @@ class Door {
1171
1211
  this.sentBys.push({ id: (guard.params as { id: string }).id, path: `${path}.params.id` });
1172
1212
  return;
1173
1213
  }
1174
- if (guard.type === FAILURE_GUARD) {
1175
- if (guard.params !== undefined) this.fail(3, `${path}.params: "${FAILURE_GUARD}" takes no params`);
1214
+ if (guard.type === FAILURE_GUARD || guard.type === READY_GUARD) {
1215
+ if (guard.params !== undefined) this.fail(3, `${path}.params: "${guard.type}" takes no params`);
1176
1216
  return;
1177
1217
  }
1178
1218
  this.checkGuardName(guard.type, `${path}.type`);
@@ -1180,7 +1220,7 @@ class Door {
1180
1220
  }
1181
1221
 
1182
1222
  private checkGuardName(name: string, path: string): void {
1183
- if (name === FAILURE_GUARD) return;
1223
+ if (name === FAILURE_GUARD || name === READY_GUARD) return;
1184
1224
  const lent = this.lends.guards.filter((g) => g in this.lent.guards);
1185
1225
  if (this.lends.guards.includes(name) && !(name in this.lent.guards)) {
1186
1226
  this.fail(
@@ -1190,7 +1230,7 @@ class Door {
1190
1230
  } else if (!this.lends.guards.includes(name)) {
1191
1231
  this.fail(
1192
1232
  3,
1193
- `${path}: "${name}" is not a lent guard; lent guards: ${names(lent)} (and ${SENT_BY} with params { id }, and ${FAILURE_GUARD})`,
1233
+ `${path}: "${name}" is not a lent guard; lent guards: ${names(lent)} (and ${SENT_BY} with params { id }, ${FAILURE_GUARD} and ${READY_GUARD})`,
1194
1234
  );
1195
1235
  }
1196
1236
  }
@@ -1680,6 +1720,7 @@ class Door {
1680
1720
  private checkParties(): void {
1681
1721
  const agents = new Set<string>();
1682
1722
  const gates = new Set<string>();
1723
+ const slotStates = new Map<string, Node[]>();
1683
1724
  for (const node of this.nodes) {
1684
1725
  const invokes = listAt(node.state.invoke, `${node.doc}.invoke`);
1685
1726
  // A Gate's invoke id, as jr2Setup names an unnamed one: the state's key path.
@@ -1694,6 +1735,7 @@ class Door {
1694
1735
  if (kind === "gate") gates.add(inv.id ?? (unnamed > 1 ? `${key}.${ordinal++}` : key));
1695
1736
  if (kind !== "agent" && kind !== "minted") continue;
1696
1737
  agents.add(inv.src);
1738
+ slotStates.set(inv.src, [...(slotStates.get(inv.src) ?? []), node]);
1697
1739
  if (inv.onDone !== undefined) {
1698
1740
  this.fail(
1699
1741
  6,
@@ -1715,6 +1757,25 @@ class Door {
1715
1757
  }
1716
1758
  }
1717
1759
  }
1760
+ // One Agent, two Turns at once: one sender and one conversation cannot hold both.
1761
+ // One line for each Agent: every state of it that can be active with another of them.
1762
+ for (const [slot, states] of slotStates) {
1763
+ const clash = new Set<Node>();
1764
+ for (let i = 0; i < states.length; i++) {
1765
+ for (let j = i + 1; j < states.length; j++) {
1766
+ const [a, b] = [states[i]!, states[j]!];
1767
+ const together =
1768
+ a === b || isAncestorOrSelf(a, b) || isAncestorOrSelf(b, a) || nearestCommon(a, b)?.kind === "parallel";
1769
+ if (together) clash.add(a).add(b);
1770
+ }
1771
+ }
1772
+ if (clash.size === 0) continue;
1773
+ const docs = [...clash].map((n) => n.doc).join(", ");
1774
+ this.fail(
1775
+ 6,
1776
+ `${docs}: Agent "${slot}" would run two Turns at once: these states invoke it and can be active together (in parallel regions, one inside the other, or one state twice); an Agent runs one Turn at a time: invoke another Agent in one of them`,
1777
+ );
1778
+ }
1718
1779
  for (const { id, path } of this.sentBys) {
1719
1780
  if (id.includes("{{")) {
1720
1781
  this.fail(3, `${path}: a sentBy id is a literal party name, never a reference`);
@@ -1933,8 +1994,9 @@ class Door {
1933
1994
  }
1934
1995
  if (kind === "effect") {
1935
1996
  const logic = this.lent.actors[inv.src];
1936
- const keys = isEffect(logic) ? inputKeysOf(logic.effect) : undefined;
1937
- if (keys === undefined && isEffect(logic) && logic.effect.kind === "dispatch") {
1997
+ const parts = isEffect(logic) ? logic.effect : undefined;
1998
+ const keys = parts ? inputKeysOf(parts) : undefined;
1999
+ if (keys === undefined && parts?.kind === "dispatch") {
1938
2000
  // A dispatch with no input schema has no keys to hold the Chart to: its input is the run's
1939
2001
  // body, and the callee's door is the check (ADR-0071). Its values are what any Effect's
1940
2002
  // input slot takes.
@@ -1957,31 +2019,114 @@ class Door {
1957
2019
  `${ipath}: the lent Effect "${inv.src}" has no input keys the door can read (an Open argv nobody bound)`,
1958
2020
  );
1959
2021
  }
1960
- if (input === undefined && keys.length === 0) return;
1961
- if (!isObject) {
1962
- return this.fail(8, `${ipath}: the lent Effect "${inv.src}" takes an object of exactly ${names(keys)}`);
2022
+ const shape = this.shapeHere(parts!);
2023
+ // A cut's `expect` is static, and a Chart cannot state it: an unstated one would let a planner's
2024
+ // name land on someone else's branch.
2025
+ if (parts?.kind === "command" && parts.cut && parts.cut.expect === undefined) {
2026
+ const fresh = this.lentNewCuts();
2027
+ this.fail(
2028
+ 8,
2029
+ `${ipath}: the lent cut "${inv.src}" does not state \`expect\`, so a Chart cannot use it; ` +
2030
+ (fresh.length
2031
+ ? `use a lent cut with \`expect: "new"\`: ${names(fresh)}`
2032
+ : `the Open body must state it (\`worktree({ expect: "new" })\` would); ${this.lentText()}`),
2033
+ );
1963
2034
  }
2035
+ // A push publishes the branch of a cut's row, which stays in the run's ledger, so a Chart that
2036
+ // cuts and a later Chart that pushes are the same pair: the test is what is lent, not what this
2037
+ // Chart invokes.
2038
+ const force = this.lentForcePush();
2039
+ if (parts?.kind === "command" && parts.cut?.expect === "existing" && force !== undefined) {
2040
+ const fresh = this.lentNewCuts();
2041
+ this.fail(
2042
+ 8,
2043
+ `${ipath}: the lent cut "${inv.src}" states \`expect: "existing"\` beside the lent force push "${force}", and together they can force-push any existing branch; ` +
2044
+ (fresh.length
2045
+ ? `use a lent cut with \`expect: "new"\`: ${names(fresh)}`
2046
+ : `this Open body lends no cut with \`expect: "new"\`, so a Chart cannot cut here (\`worktree({ expect: "new" })\` would); ${this.lentText()}`),
2047
+ );
2048
+ }
2049
+ if (input === undefined && shape.required.length === 0 && !shape.oneOf) return;
2050
+ const takes = shapeText(shape);
2051
+ if (!isObject) return this.fail(8, `${ipath}: the lent Effect "${inv.src}" takes an object: ${takes}`);
1964
2052
  const given = Object.keys(input);
1965
- const missing = keys.filter((k) => !given.includes(k));
2053
+ const missing = shape.required.filter((k) => !given.includes(k));
1966
2054
  const extra = given.filter((k) => !keys.includes(k));
1967
- if (missing.length || extra.length) {
2055
+ const ofOne = shape.oneOf?.filter((k) => given.includes(k)).length ?? 1;
2056
+ if (missing.length || extra.length || ofOne !== 1) {
1968
2057
  this.fail(
1969
2058
  8,
1970
- `${ipath}: the lent Effect "${inv.src}" takes exactly ${names(keys)}` +
2059
+ `${ipath}: the lent Effect "${inv.src}" takes ${takes}` +
1971
2060
  (missing.length ? `; missing: ${missing.join(", ")}` : "") +
1972
- (extra.length ? `; not its input: ${extra.join(", ")}` : ""),
2061
+ (extra.length ? `; not its input: ${extra.join(", ")}` : "") +
2062
+ (ofOne !== 1 ? `; give one of ${shape.oneOf!.join(", ")}` : ""),
1973
2063
  );
1974
2064
  }
2065
+ if (parts?.kind === "command" && parts.cut && given.includes("detach") && given.includes("from")) {
2066
+ this.fail(8, `${ipath}: a detached cut takes no \`from\``);
2067
+ }
1975
2068
  for (const k of given.filter((g) => keys.includes(g))) {
1976
2069
  const v = (input as Record<string, unknown>)[k];
1977
2070
  if (typeof v !== "string" && !isRef(v))
1978
2071
  this.fail(8, `${ipath}.${k}: an Effect's input slot takes a string or a reference`);
2072
+ else if ((k === "worktree" || k === "path") && typeof v === "string") this.checkLiteralPath(v, `${ipath}.${k}`);
1979
2073
  }
1980
2074
  return;
1981
2075
  }
1982
2076
  this.checkAgentInput(inv.src, kind, input, isObject, ipath);
1983
2077
  }
1984
2078
 
2079
+ /** An Effect's shape here: where the door knows the Workspace has Repo Slots, `worktree` is
2080
+ * required on what can name one. Elsewhere the start says so (`drive`). */
2081
+ private shapeHere(parts: Parameters<typeof inputShapeOf>[0]): InputShape {
2082
+ const shape = inputShapeOf(parts)!;
2083
+ if (!this.options.repoSlots?.length || !shape.optional.includes("worktree")) return shape;
2084
+ return {
2085
+ ...shape,
2086
+ required: [...shape.required, "worktree"],
2087
+ optional: shape.optional.filter((k) => k !== "worktree"),
2088
+ };
2089
+ }
2090
+
2091
+ /** The lent Effects, each with its parts. */
2092
+ private lentEffects(): Array<{ name: string; parts: Parameters<typeof inputShapeOf>[0] }> {
2093
+ return this.lends.effects.flatMap((name) => {
2094
+ const logic = this.lent.actors[name];
2095
+ return isEffect(logic) ? [{ name, parts: logic.effect }] : [];
2096
+ });
2097
+ }
2098
+
2099
+ /** The name of a lent push that forces, if one is lent. */
2100
+ private lentForcePush(): string | undefined {
2101
+ return this.lentEffects().find(({ parts }) => parts.kind === "push" && parts.force)?.name;
2102
+ }
2103
+
2104
+ /** The names of the lent cuts that make only a new branch. */
2105
+ private lentNewCuts(): string[] {
2106
+ return this.lentEffects()
2107
+ .filter(({ parts }) => parts.kind === "command" && parts.cut?.expect === "new")
2108
+ .map(({ name }) => name);
2109
+ }
2110
+
2111
+ /** A literal Worktree path: a wrong slot costs no pod. A path with a reference waits for its start. */
2112
+ private checkLiteralPath(value: string, path: string): void {
2113
+ if (referencesOf(value).length) return;
2114
+ const match = value.match(/^\/work\/([^/]+)\/([^/.-][^/]*)$/);
2115
+ const slots = this.options.repoSlots;
2116
+ if (match === null) this.fail(8, `${path}: "${value}" is not a Worktree path: /work/<slot>/<name>`);
2117
+ else if (slots !== undefined && !slots.includes(match[1]!)) {
2118
+ this.fail(8, `${path}: "${value}" names no Repo Slot of this Workspace; its Repo Slots: ${names([...slots])}`);
2119
+ }
2120
+ }
2121
+
2122
+ /** A literal `cwd`, see {@link slotFolderFault}. A `cwd` with a reference waits for its value: the
2123
+ * Open body asks the same question when the Turn starts. */
2124
+ private checkCwd(value: string, path: string): void {
2125
+ if (referencesOf(value).length) return;
2126
+ const fault = slotFolderFault(value, this.options.repoSlots ?? []);
2127
+ if (fault !== undefined) this.fail(8, `${path}: ${fault}`);
2128
+ }
2129
+
1985
2130
  /** The input of `fail` (ADR-0073): `{ reason, detail? }`, `reason` a text for a person (a string
1986
2131
  * or a reference), `detail` any value. */
1987
2132
  private checkFailInput(input: unknown, isObject: boolean, ipath: string): void {
@@ -2000,6 +2145,15 @@ class Door {
2000
2145
  }
2001
2146
  }
2002
2147
 
2148
+ /** Does this Agent read `/work`? A `workspace: "none"` one frames nothing. */
2149
+ private worksInWorkspace(src: string, kind: "agent" | "minted"): boolean {
2150
+ if (kind === "agent") {
2151
+ const logic = this.lent.actors[src] as { definition?: { workspace?: unknown } } | undefined;
2152
+ return logic?.definition?.workspace !== "none";
2153
+ }
2154
+ return (this.chart.agents?.[src] as { workspace?: unknown } | undefined)?.workspace !== "none";
2155
+ }
2156
+
2003
2157
  /**
2004
2158
  * An Agent invoke's input is a Turn's Frame and Dials (ADR-0057): `prompt` (a string), and
2005
2159
  * optionally `cwd`, `continue: true`, a `model` the Open body allows, and a `thinkingLevel`.
@@ -2033,6 +2187,13 @@ class Door {
2033
2187
  else if (typeof obj.prompt !== "string")
2034
2188
  this.fail(8, `${ipath}.prompt: a prompt is a string (placeholders inside it are fine)`);
2035
2189
  if (obj.cwd !== undefined && typeof obj.cwd !== "string") this.fail(8, `${ipath}.cwd: a cwd is a string`);
2190
+ if (obj.cwd === undefined && this.options.repoSlots?.length && this.worksInWorkspace(src, kind)) {
2191
+ this.fail(
2192
+ 8,
2193
+ `${ipath}: ${who} works under a Workspace with Repo Slots, and a Turn there says where: \`cwd\` is a Worktree, or "/work"`,
2194
+ );
2195
+ }
2196
+ if (typeof obj.cwd === "string" && this.worksInWorkspace(src, kind)) this.checkCwd(obj.cwd, `${ipath}.cwd`);
2036
2197
  if (obj.continue !== undefined && obj.continue !== true)
2037
2198
  this.fail(8, `${ipath}.continue: \`continue\` is true or absent`);
2038
2199
  if (obj.model !== undefined) {
@@ -2164,6 +2325,12 @@ function wholeRefOf(v: unknown): string | undefined {
2164
2325
  return isRef(v) ? (v as { $ref: string }).$ref : undefined;
2165
2326
  }
2166
2327
 
2328
+ /** The nearest state at or above both `a` and `b`. */
2329
+ function nearestCommon(a: Node, b: Node): Node | undefined {
2330
+ for (let n: Node | undefined = a; n; n = n.parent) if (isAncestorOrSelf(n, b)) return n;
2331
+ return undefined;
2332
+ }
2333
+
2167
2334
  /** Is `a` the state `b` or a state above it? */
2168
2335
  function isAncestorOrSelf(a: Node, b: Node): boolean {
2169
2336
  for (let n: Node | undefined = b; n; n = n.parent) if (n === a) return true;
package/src/charts.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  isOpenBody,
26
26
  lentIn,
27
27
  openBodyDeclarationOf,
28
+ repoSlotsAround,
28
29
  type LentParts,
29
30
  type OpenBodyDeclaration,
30
31
  type OpenBodyLogic,
@@ -305,6 +306,8 @@ export type ChartWrapper = {
305
306
  chartFields: string[];
306
307
  /** The door's other fields: the Chart's `input` keys the door can see (ADR-0070 rule 7). */
307
308
  inputKeys: string[];
309
+ /** The Repo Slots of the `workspace()` around the wrapper, where the door can read them. */
310
+ repoSlots?: readonly string[];
308
311
  };
309
312
 
310
313
  /**
@@ -333,7 +336,8 @@ export function chartWrapperOf(machine: AnyStateMachine): ChartWrapper | undefin
333
336
  const chartFields = Object.keys(shape).filter((k) => isChartSchema(shape[k]));
334
337
  // The door's other fields, less the body's placement: a Chart reads no url (open-body.ts).
335
338
  const inputKeys = Object.keys(chartInputOf(shape)).filter((k) => !chartFields.includes(k));
336
- return { body: bodies[0]!.slot, bodies, chartFields, inputKeys };
339
+ const repoSlots = repoSlotsAround(machine);
340
+ return { body: bodies[0]!.slot, bodies, chartFields, inputKeys, ...(repoSlots ? { repoSlots } : {}) };
337
341
  }
338
342
 
339
343
  /**
@@ -347,7 +351,10 @@ export function admitUnder(wrapper: ChartWrapper, text: unknown): AdmittedChart
347
351
  const report: string[] = [];
348
352
  for (const body of wrapper.bodies) {
349
353
  try {
350
- return admitBodyChart(text, body.declaration, body.lent, { inputKeys: wrapper.inputKeys });
354
+ return admitBodyChart(text, body.declaration, body.lent, {
355
+ inputKeys: wrapper.inputKeys,
356
+ ...(wrapper.repoSlots ? { repoSlots: wrapper.repoSlots } : {}),
357
+ });
351
358
  } catch (err) {
352
359
  if (!(err instanceof ChartRefusal)) throw err;
353
360
  const prefix = wrapper.bodies.length > 1 ? `body "${body.slot}": ` : "";
package/src/config.ts CHANGED
@@ -257,7 +257,7 @@ export type HarnessProvider = {
257
257
  // Harness sends the wire library's placeholder, which vLLM/Ollama ignore).
258
258
  /** The API key, when the endpoint needs one. Read it from `process.env`. It is a Held secret,
259
259
  * `JR2_PROVIDER_API_KEY`: `jr2 up` gives the value to the Custodian only, bound to the host of
260
- * `baseUrl`, and the Harness container holds only its Stand-in. Thus `baseUrl` must be
260
+ * `baseUrl`, and the Harness process holds only its secret Stand-in. Thus `baseUrl` must be
261
261
  * `https://` when you set it:
262
262
  * `harness.provider.apiKey is set, and harness.provider.baseUrl is not https://`. Default: none,
263
263
  * for an endpoint that needs no key. */
@@ -302,8 +302,9 @@ export type CatalogReach = {
302
302
  *
303
303
  * - It is a Held secret in each Harness pod that names it. A pod names it when its `workspace()`
304
304
  * lists it under `secrets`, or when it is the model key of a provider that an Agent there uses. The Harness
305
- * container's env var then holds only its Stand-in, `jr2-held-<name>`, and the pod's Custodian
306
- * puts the value into requests toward `hosts` only.
305
+ * process then holds only its Stand-in, a secret value that starts `jr2-held-<name>.`, and the
306
+ * pod's Custodian puts the value into requests toward `hosts` only. The Agent's programs get the
307
+ * Stand-in, in their env under this name, only when the `workspace()` lists it under `secrets`.
307
308
  * - It is an Effect secret where an Effect names it with `secret("name")`: a file in that
308
309
  * Effect's Effector.
309
310
  *
@@ -312,8 +313,9 @@ export type CatalogReach = {
312
313
  * `secrets[<i>] (<NAME>) is named by nothing; goes nowhere`.
313
314
  */
314
315
  export type SecretEntry = {
315
- /** As a Held secret, the name of the env var that the Harness container sees, which holds the
316
- * Stand-in. As an Effect secret, the name that `secret("<name>")` names, and the name of the
316
+ /** As a Held secret, the name of its Stand-in: the file the Harness reads it from, the env var
317
+ * that the engine reads a model key from, and the env var of the Agent's programs when the
318
+ * `workspace()` lists it under `secrets`. As an Effect secret, the name that `secret("<name>")` names, and the name of the
317
319
  * Effector's file. For a Held secret, it must match `[A-Z_][A-Z0-9_]*`:
318
320
  * `secrets[<i>].name "<name>" is not an env var name ([A-Z_][A-Z0-9_]*)`. It must not start with
319
321
  * `JR2_` and must not be a proxy or trust variable of the kit:
@@ -386,12 +388,20 @@ export type HarnessConfig = {
386
388
  // `jr2 up`. A known model key here is refused (ADR-0059).
387
389
  /** Env vars for the Harness container. The Agent can read every one of them, so do not put a
388
390
  * credential here. A known model key is refused here. Put it in the top-level `secrets` list.
391
+ * The Harness process gets each one, except a name that loads code from a path, such as
392
+ * `NODE_OPTIONS` or `LD_PRELOAD`, and `HOME`, which is its own; the Agent's programs get each
393
+ * one. A name that starts with `JR2_` is jr2's own, and is refused. A credential file that the
394
+ * Harness process reads, such as `AWS_CONFIG_FILE`, is refused in a folder that the Agent's code
395
+ * writes (`/work`, `/tmp`, `/home/jr2`), and gives a warning anywhere else.
389
396
  * See {@link HarnessEnvVar}. */
390
397
  env?: readonly HarnessEnvVar[];
391
398
  // `jr2 up` preflights that each referenced Secret exists (ADR-0019), and refuses one that
392
399
  // carries a known model key (ADR-0059).
393
400
  /** Secrets and ConfigMaps that you manage, whose keys all become env vars of the Harness
394
- * container. See {@link HarnessEnvFromSource}. */
401
+ * process and of the Agent's programs, as `env` does. In the container's spec each key has the
402
+ * prefix `JR2_ENVFROM_`, so that the Harness can tell jr2's names from the image's; the Harness
403
+ * removes it. A key that starts with `JR2_` is refused: `jr2 up` refuses it, and the Harness does
404
+ * not start with one. See {@link HarnessEnvFromSource}. */
395
405
  envFrom?: readonly HarnessEnvFromSource[];
396
406
  // Only `jr2 up` reads it (host-side): it materializes the `jr2-ca` ConfigMap and runs the
397
407
  // provider preflight with the same trust (ADR-0020, ADR-0059).
package/src/custodian.lua CHANGED
@@ -10,15 +10,18 @@
10
10
  --
11
11
  -- Four roles, one per route family, read from the route's metadata:
12
12
  --
13
- -- control the Harness's Menu and the ask, plain HTTP on loopback, toward the Orchestrator
13
+ -- control toward the Orchestrator, plain HTTP: the agent routes on the control socket, and
14
+ -- the ask on the loopback fetch listener (ADR-0076). Each listener has only its own
15
+ -- routes, so a route of the other one gets the deny route's 404
14
16
  -- egress the CONNECT listener: dial guard, then tunnel or hand to an intercept listener
15
17
  -- intercept one bound host, TLS already ended: swap the Stand-in, strip the rest
16
18
  -- health the kubelet's probe
17
19
 
18
20
  local VALUES = "/etc/jr2/custodian/values/"
19
21
 
20
- -- The value of one held secret, read once. One trailing line end is taken off, because
21
- -- `kubectl create secret --from-file` writes one; anything else a header cannot carry stops us.
22
+ -- The value of one held secret, or its Stand-in, read once. One trailing line end is taken off,
23
+ -- because `kubectl create secret --from-file` writes one; anything else a header cannot carry stops
24
+ -- us. The line names the file, never what it holds.
22
25
  local function load(name)
23
26
  local f = io.open(VALUES .. name, "rb")
24
27
  if f == nil then
@@ -36,13 +39,20 @@ local function load(name)
36
39
  return v
37
40
  end
38
41
 
42
+ -- Every Stand-in is secret but the fetch Stand-in (ADR-0059, ADR-0076): the pod's maker minted it,
43
+ -- and it is a value too, read from `<NAME>.standin` beside the secret's own. The one public Stand-in
44
+ -- rides the table (`standIn`), because `jr2-upload-pack` holds it as a constant.
39
45
  local STAND_IN = {}
40
46
  local VALUE = {}
41
47
  for _, s in ipairs(HELD.secrets) do
42
- STAND_IN[s.name] = "jr2-held-" .. s.name
48
+ STAND_IN[s.name] = s.standIn or load(s.name .. ".standin")
43
49
  VALUE[s.name] = load(s.name)
44
50
  end
45
51
 
52
+ -- The slot the Harness states on a control request (ADR-0076). It goes to the Orchestrator on a
53
+ -- control route, unchanged, and to no other host.
54
+ local SLOT_HEADER = "x-jr2-slot"
55
+
46
56
  -- The headers every bound target treats as a credential (`CREDENTIAL_HEADERS`, held-secrets.ts).
47
57
  local CREDENTIAL = {}
48
58
  for _, h in ipairs(HELD.credential) do CREDENTIAL[h] = true end
@@ -258,7 +268,7 @@ local function intercept(handle, headers, target)
258
268
  if path:lower():find("%%2[ef]") then
259
269
  return refuse(handle, 400, "path", "an encoded / or . in the path")
260
270
  end
261
- for _, h in ipairs({ "te", "trailer", "keep-alive", "proxy-connection" }) do headers:remove(h) end
271
+ for _, h in ipairs({ "te", "trailer", "keep-alive", "proxy-connection", SLOT_HEADER }) do headers:remove(h) end
262
272
  return hold(handle, headers, HELD.targets[target], target, path)
263
273
  end
264
274
 
@@ -273,6 +283,8 @@ local function control(handle, headers, route)
273
283
  '{"error":"this pod serves no Sandbox, so it mounts no Repo to fetch (ADR-0053)"}')
274
284
  end
275
285
  headers:replace(":path", "/sandboxes/" .. sandbox .. "/fetch")
286
+ -- The fetch acts for the Sandbox, not for an Agent: no slot rides it.
287
+ headers:remove(SLOT_HEADER)
276
288
  end
277
289
  return hold(handle, headers, HELD.control, "orchestrator", headers:get(":path"))
278
290
  end