@jr2/orchestrator 0.3.0 → 0.4.1

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 (73) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/src/repo-fetch.ts CHANGED
@@ -17,8 +17,8 @@
17
17
  // would count a fetch that began BEFORE the ask. The same coalescing happens one hop earlier, in
18
18
  // this process: asks that raise the same bar for one Sandbox and key share one mark and one wait,
19
19
  // and past a cap on how many waits may be open the answer is the cache — the caller is inside an
20
- // untrusted pod (ADR-0013), and a `git fetch` loop must not cost this process a kubectl per
21
- // second per iteration.
20
+ // untrusted pod (ADR-0013), and a `git fetch` loop must not cost this process a write per
21
+ // iteration.
22
22
  //
23
23
  // Freshness degrades, absence does not (ADR-0051/0053): a remote fetch that failed, or one that
24
24
  // outran the budget, is answered as `stale` with what the cache holds — never as an error. The
@@ -26,13 +26,15 @@
26
26
  // still succeeds. Only a Repo the Sandbox does not mount is a refusal: the Sandbox token's scope
27
27
  // is the caches its own pod mounts (ADR-0013).
28
28
  //
29
- // Drives the Sandbox CR through `kubectl` exactly as the provision's Ready wait does
30
- // (sandbox-kubectl.ts) — same process seam, injectable, so the mapping is unit-testable without a
31
- // cluster; the kind e2e tier exercises the real thing.
29
+ // The mark is a merge patch through the Orchestrator's own client, and the wait is the one
30
+ // Sandbox watch (ADR-0063): the watch event that carries the landing answers the ask. Both are
31
+ // injectable, so the mapping is unit-testable against a fake API server; the kind e2e tier
32
+ // exercises the real thing.
32
33
 
34
+ import { SANDBOXES, kubeClient, type KubeClient } from "./kube-client.ts";
33
35
  import { askedAnnotation } from "./names.ts";
34
36
  import { repoKeyOfIdentity } from "./repo-identity.ts";
35
- import { defaultKubectlExec, type KubectlExec } from "./sandbox-kubectl.ts";
37
+ import { watchSandboxes, type SandboxObject, type SandboxRepoStatus, type SandboxWatch } from "./sandbox-watch.ts";
36
38
 
37
39
  /** What one ask settles as. `fetched` is the landing's timestamp; `stale` is git's own words (or
38
40
  * this port's, when the budget ran out) beside the time the cache's objects are as of — `null`
@@ -54,54 +56,49 @@ export class UnmountedRepoError extends Error {}
54
56
  const CACHE_BUDGET_MS = 60_000;
55
57
  /** How many asks this Orchestrator will hold open at once, counting one per Sandbox and key. The
56
58
  * caller is inside a pod (ADR-0013: untrusted), an Agent that loops on `git fetch` is a mode this
57
- * codebase has already seen, and every open ask costs a `kubectl` per second in the one process
59
+ * codebase has already seen, and every open ask costs a write and a waiter in the one process
58
60
  * that serves every run in the instance. Past the cap an ask is answered the way a failed one is —
59
61
  * the cache, said out loud — so a pod that floods degrades its own fetches and nobody else's. */
60
62
  const MAX_IN_FLIGHT = 16;
61
63
  /** Watch slack on top of it: the agent's verdict has to reach the Repo CR, the operator has to
62
- * compute the Sandbox entry from it, and this port has to read it. The ADR's "nothing else owns a
64
+ * compute the Sandbox entry from it, and the watch has to carry it here. The ADR's "nothing else owns a
63
65
  * timeout" holds — this is the agent's budget plus the round trip, not a second policy. */
64
66
  const WATCH_SLACK_MS = 15_000;
65
67
 
66
- export type KubectlRepoFetchesOptions = {
68
+ export type KubeRepoFetchesOptions = {
67
69
  /** The instance's namespace — the Sandboxes are here. */
68
70
  namespace: string;
69
- /** kubectl `--context` override. Default: the current context (ADR-0009). */
70
- context?: string;
71
- /** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
72
- exec?: KubectlExec;
73
- /** The clock the ask is stamped from, and the budget measured with. Injectable for tests. */
71
+ /** The Kubernetes client (ADR-0063), shared with the Sandbox port. Default: the in-cluster one. */
72
+ client?: KubeClient;
73
+ /** The one Sandbox watch, shared with the Sandbox port. Default: one started on first use. */
74
+ watch?: SandboxWatch;
75
+ /** The clock the ask is stamped from. Injectable for tests. */
74
76
  now?: () => Date;
75
- /** How often the Sandbox's status is re-read while waiting. Default 1s. */
76
- pollMs?: number;
77
77
  /** The whole wait. Default: the cache agent's on-demand budget plus watch slack. */
78
78
  budgetMs?: number;
79
79
  /** How many asks may be open at once. Default {@link MAX_IN_FLIGHT}. */
80
80
  maxInFlight?: number;
81
81
  };
82
82
 
83
- export function kubectlRepoFetches(opts: KubectlRepoFetchesOptions): RepoFetches {
84
- const exec = opts.exec ?? defaultKubectlExec;
83
+ export function kubeRepoFetches(opts: KubeRepoFetchesOptions): RepoFetches {
85
84
  const now = opts.now ?? (() => new Date());
86
- const pollMs = opts.pollMs ?? 1_000;
87
85
  const budgetMs = opts.budgetMs ?? CACHE_BUDGET_MS + WATCH_SLACK_MS;
88
86
  const maxInFlight = opts.maxInFlight ?? MAX_IN_FLIGHT;
89
- const base = ["--namespace", opts.namespace, ...(opts.context ? ["--context", opts.context] : [])];
87
+ let client: KubeClient | undefined = opts.client;
88
+ const kube = () => (client ??= kubeClient());
89
+ let watch: SandboxWatch | undefined = opts.watch;
90
+ const sandboxes = () => (watch ??= watchSandboxes(kube(), { namespace: opts.namespace }));
90
91
  // The asks this port is holding open, one per Sandbox and key, each with the second its mark was
91
92
  // raised to. The mark is a COALESCER on the node; this is the same coalescing one hop earlier,
92
93
  // and it is exact rather than approximate: every stamp that can answer an ask is kept at the
93
94
  // second (askedAt below), so two asks that raise the same bar CANNOT get different answers.
94
- // Sharing one wait between them spares the CR a second write and this process a second poll.
95
+ // Sharing one wait between them spares the CR a second write and this process a second waiter.
95
96
  const openAsks = new Map<string, { until: number; answer: Promise<FetchAnswer> }>();
96
97
 
97
- const get = async (sandbox: string): Promise<SandboxItem | undefined> => {
98
- try {
99
- const { stdout } = await exec(["get", "sandbox", sandbox, ...base, "-o", "json"]);
100
- return JSON.parse(stdout) as SandboxItem;
101
- } catch (err) {
102
- if (isNotFound(err)) return undefined;
103
- throw err;
104
- }
98
+ /** The Sandbox as the watch holds it; the API server itself for one the watch has not seen (or
99
+ * before its first list has landed). */
100
+ const get = async (sandbox: string): Promise<SandboxObject | undefined> => {
101
+ return sandboxes().get(sandbox) ?? (await kube().get<SandboxObject>(SANDBOXES, opts.namespace, sandbox));
105
102
  };
106
103
 
107
104
  return {
@@ -144,38 +141,37 @@ export function kubectlRepoFetches(opts: KubectlRepoFetchesOptions): RepoFetches
144
141
  /** The mark and the wait on it, for one Sandbox and key. Every ask that shares the mark's second
145
142
  * shares this one promise. */
146
143
  async function wait(sandbox: string, key: string, asked: string): Promise<FetchAnswer> {
147
- // The mark. ONE call writes it and prints the object as it stands afterwards (the lease's
148
- // trick, sandbox-kubectl.ts), so the first look at the status costs no second round trip —
149
- // and a fetch that already landed since an earlier ask answers immediately.
150
- const { stdout } = await exec([
151
- "annotate",
152
- "sandbox",
153
- sandbox,
154
- ...base,
155
- `${askedAnnotation(key)}=${asked}`,
156
- "--overwrite",
157
- "-o",
158
- "json",
159
- ]);
160
- const deadline = now().getTime() + budgetMs;
161
- let item = JSON.parse(stdout) as SandboxItem;
162
- for (;;) {
163
- const answer = verdict(item, key, asked);
164
- if (answer) return answer;
165
- if (now().getTime() >= deadline) {
166
- // The budget is the cache agent's, and this is what running past it looks like from
167
- // here: the agent may still be at it (its next interval will land), so what the caller
168
- // gets is the cache, said out loud.
169
- return { stale: "timed out waiting for the node cache", asOf: fetchedOf(item, key) };
170
- }
171
- await sleep(pollMs);
172
- const next = await get(sandbox);
173
- // The Sandbox went while we waited: the pod that asked is gone, so there is nothing left
174
- // to answer for. The caller is inside that pod, so this is nearly unreachable — and a
175
- // refusal beats inventing a verdict on a resource that no longer exists.
176
- if (!next) throw new UnmountedRepoError(`Sandbox "${sandbox}" is gone`);
177
- item = next;
178
- }
144
+ // The mark. The merge patch answers the object as it stands afterwards, so a fetch that
145
+ // already landed since an earlier ask answers at once, with no event to wait for.
146
+ const marked = await kube().patch<SandboxObject>(SANDBOXES, opts.namespace, sandbox, {
147
+ metadata: { annotations: { [askedAnnotation(key)]: asked } },
148
+ });
149
+ const first = verdict(marked, key, asked);
150
+ if (first) return first;
151
+ // Then the watch: the event that carries the landing is the answer (ADR-0063). The budget is
152
+ // the cache agent's, and running past it looks like this from here: the agent may still be at
153
+ // it (its next interval will land), so what the caller gets is the cache, said out loud.
154
+ return new Promise<FetchAnswer>((resolve, reject) => {
155
+ let last: SandboxObject = marked;
156
+ const done = (fn: () => void) => {
157
+ clearTimeout(timer);
158
+ unsubscribe();
159
+ fn();
160
+ };
161
+ const timer = setTimeout(
162
+ () => done(() => resolve({ stale: "timed out waiting for the node cache", asOf: fetchedOf(last, key) })),
163
+ budgetMs,
164
+ );
165
+ const unsubscribe = sandboxes().subscribe(sandbox, (item) => {
166
+ // The Sandbox went while we waited: the pod that asked is gone, so there is nothing left
167
+ // to answer for. The caller is inside that pod, so this is nearly unreachable — and a
168
+ // refusal beats inventing a verdict on a resource that no longer exists.
169
+ if (!item) return done(() => reject(new UnmountedRepoError(`Sandbox "${sandbox}" is gone`)));
170
+ last = item;
171
+ const answer = verdict(item, key, asked);
172
+ if (answer) done(() => resolve(answer));
173
+ });
174
+ });
179
175
  }
180
176
  }
181
177
 
@@ -194,7 +190,7 @@ export function kubectlRepoFetches(opts: KubectlRepoFetchesOptions): RepoFetches
194
190
  *
195
191
  * `error` is the other verdict, and the operator scopes it to an attempt made for that same ask.
196
192
  */
197
- function verdict(item: SandboxItem, key: string, asked: string): FetchAnswer | undefined {
193
+ function verdict(item: SandboxObject, key: string, asked: string): FetchAnswer | undefined {
198
194
  const entry = entryOf(item, key);
199
195
  if (!entry) return undefined;
200
196
  const entryAsked = at(entry.asked);
@@ -221,11 +217,11 @@ function askedAt(stamp: string): number {
221
217
  * fetch at all, which is what the program prints as "an unknown time". This is the `asOf` of a
222
218
  * stale answer: the fetch it names did NOT answer the ask, and saying when the objects are from is
223
219
  * the whole of what a degraded answer can offer (ADR-0053). */
224
- function fetchedOf(item: SandboxItem, key: string): string | null {
220
+ function fetchedOf(item: SandboxObject, key: string): string | null {
225
221
  return entryOf(item, key)?.fetched ?? null;
226
222
  }
227
223
 
228
- function entryOf(item: SandboxItem, key: string): SandboxRepoStatus | undefined {
224
+ function entryOf(item: SandboxObject, key: string): SandboxRepoStatus | undefined {
229
225
  return (item.status?.repos ?? []).find((r) => r.key === key);
230
226
  }
231
227
 
@@ -236,24 +232,3 @@ function at(stamp: string | undefined): number | undefined {
236
232
  const ms = Date.parse(stamp);
237
233
  return Number.isNaN(ms) ? undefined : ms;
238
234
  }
239
-
240
- /** A `Sandbox` resource as `kubectl get -o json` prints it — the fields this port reads. */
241
- type SandboxItem = {
242
- spec?: { repos?: Array<{ key?: string }> };
243
- status?: { repos?: SandboxRepoStatus[] };
244
- };
245
-
246
- /** The operator's standing per-key Repo entry on a Sandbox (ADR-0053). */
247
- type SandboxRepoStatus = {
248
- key: string;
249
- asked?: string;
250
- fetched?: string;
251
- attempted?: string;
252
- error?: string;
253
- };
254
-
255
- function isNotFound(err: unknown): boolean {
256
- return err instanceof Error && /NotFound|not found/i.test(err.message);
257
- }
258
-
259
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
package/src/repos.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  // on every node that needs the repository. What comes back is that agent's per-node status, which
7
7
  // is what `jr2 status` reports and what a provision waits on through the Sandbox's `Ready`.
8
8
  //
9
- // The port's kubectl implementation (`kubectlRepos`) drives the CRD the same way the Sandbox port
10
- // does (sandbox-kubectl.ts): shelling to `kubectl`, honoring the current kube context, with the
11
- // process seam injectable so the mapping is unit-testable without a cluster.
9
+ // The port's implementation (`kubeRepos`) drives the CRD the same way the Sandbox port does
10
+ // (sandbox-kube.ts): through the Orchestrator's own Kubernetes client (kube-client.ts, ADR-0063),
11
+ // injectable so the mapping is unit-testable against a fake API server.
12
12
  //
13
13
  // TWO SPELLINGS, ONE RESOURCE: the resource is named by the cache key, and its `spec.url` and
14
14
  // `secretRef` are ONE statement — the boot's. The walk collapses every Machine binding an
@@ -18,7 +18,7 @@
18
18
  // eviction clock — never the url. So a Machine that binds over ssh what another bound over https
19
19
  // borrows the cache the boot stated, and its runs never flip the resource between the two (each
20
20
  // flip is a generation the cache agent re-points origin and refetches on). The push url is each
21
- // Binding's own (attachScript), so nothing about the run is wrong; only the cache's transport is
21
+ // Binding's own (the Harness's attach, ADR-0063), so nothing about the run is wrong; only the cache's transport is
22
22
  // shared.
23
23
  //
24
24
  // A resource NOTHING binds has no boot to restate it, so the provision is its one writer: an
@@ -33,7 +33,7 @@
33
33
 
34
34
  import { credentialSecretFor, matchCredential, type GitCredential } from "./config.ts";
35
35
  import { ANNOTATION_REPO_IDENTITY, ANNOTATION_REPO_LAST_ATTACHED, LABEL_REPO_BOUND } from "./names.ts";
36
- import { defaultKubectlExec, type KubectlExec } from "./sandbox-kubectl.ts";
36
+ import { REPOS, isAlreadyExists, kubeClient, KubeError, type KubeClient } from "./kube-client.ts";
37
37
 
38
38
  /** One node's view of a Repo, as the cache agent reports it on the resource's status. */
39
39
  export type RepoNodeState = {
@@ -75,8 +75,8 @@ export interface RepoResources {
75
75
  * that moved the url or the credential reaches the cache. The only caller that writes a BOUND
76
76
  * resource's spec, and never the eviction clock: a boot is not an attach. Resolves the
77
77
  * `git.credentials` entry for the identity into the `secretRef` the cache agent reads: an https
78
- * entry's token materializes as a Secret in Flux's shape, an ssh entry names its deploy-key
79
- * Secret and the Orchestrator never reads it.
78
+ * entry names the clone Secret `jr2 up` wrote from its token, an ssh entry names its deploy-key
79
+ * Secret. The Orchestrator writes and reads neither (ADR-0068).
80
80
  */
81
81
  bind(repo: { url: string; identity: string; key: string }): Promise<void>;
82
82
  /**
@@ -95,63 +95,48 @@ export interface RepoResources {
95
95
  list(): Promise<RepoStatus[]>;
96
96
  }
97
97
 
98
- /** The CRD's fully qualified plural — unambiguous to kubectl whatever else calls itself a repo. */
99
- const REPO_RESOURCE = "repos.core.jr2.dev";
100
-
101
- export type KubectlReposOptions = {
98
+ export type KubeReposOptions = {
102
99
  /** The instance's namespace — the Repo resources live beside the Sandboxes that name them. */
103
100
  namespace: string;
104
- /** kubectl `--context` override. Default: the current context (ADR-0009). */
105
- context?: string;
106
- /** The instance's `git.credentials`, matched by prefix on the identity (config.ts). */
107
- credentials: readonly GitCredential[];
108
- /** Where a token entry's env var is read from (deployed: `process.env`, which the Instance
109
- * Secret's `envFrom` populated at `jr2 up`). Unset → the resource carries no `secretRef` and the
110
- * clone is anonymous. */
111
- env: Record<string, string | undefined>;
101
+ /** The instance's `git.credentials`, matched by prefix on the identity (config.ts). A function
102
+ * is read on every call: the config that applies is the current Assembly's, and a publish
103
+ * swaps it without a new port (ADR-0069). */
104
+ credentials: readonly GitCredential[] | (() => readonly GitCredential[]);
105
+ /** The clone Secrets `jr2 up` wrote (deployed: `git-clone.json` in `jr2-held`), by name. A token
106
+ * entry whose Secret is not among them — its variable was unset at `jr2 up` — gives the resource
107
+ * no `secretRef`, and the clone is anonymous. Default: none. */
108
+ cloneSecrets?: () => Promise<ReadonlySet<string>>;
112
109
  /** CR `spec.refreshInterval` — how often the cache agent fetches a warm cache. Default `5m`. */
113
110
  refreshInterval?: string;
114
- /** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
115
- exec?: KubectlExec;
111
+ /** The Kubernetes client (ADR-0063), shared with the Sandbox port. Default: the in-cluster one. */
112
+ client?: KubeClient;
116
113
  /** The clock `last-attached` is stamped from. Injectable for tests. */
117
114
  now?: () => Date;
118
115
  };
119
116
 
120
- export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
121
- const exec = opts.exec ?? defaultKubectlExec;
117
+ export function kubeRepos(opts: KubeReposOptions): RepoResources {
122
118
  const now = opts.now ?? (() => new Date());
123
- const base = ["--namespace", opts.namespace, ...(opts.context ? ["--context", opts.context] : [])];
124
-
125
- /**
126
- * The Secret the cache agent spends for an https url, in Flux's shape (`username`/`password`),
127
- * so a Flux or Argo user reuses the Secret they have. Its name is derived from the entry's
128
- * `match`, so a redeploy finds its own and two entries never share one. Applied (create-or-
129
- * update) on every ensure: a token rotated by `jr2 up` reaches the Secret at the next boot.
130
- */
131
- const applyTokenSecret = async (name: string, password: string): Promise<void> => {
132
- const secret = {
133
- apiVersion: "v1",
134
- kind: "Secret",
135
- metadata: { name, namespace: opts.namespace, labels: { "app.kubernetes.io/managed-by": "jr2" } },
136
- type: "Opaque",
137
- stringData: { username: "x-access-token", password },
138
- };
139
- await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(secret) });
140
- };
119
+ const ns = opts.namespace;
120
+ // Built on first use: a boot never needs the cluster to construct this port (ADR-0048).
121
+ let client: KubeClient | undefined = opts.client;
122
+ const kube = () => (client ??= kubeClient());
141
123
 
142
124
  /**
143
125
  * The `secretRef` for one url: the entry the identity matches, then the url's scheme picks
144
- * which of its fields applies (config.ts). A token entry whose env var is unset writes no ref —
145
- * the clone is anonymous, and `jr2 status` will show git's refusal if the host wanted one.
126
+ * which of its fields applies (config.ts). It only names the Secret (ADR-0068): `jr2 up` wrote the
127
+ * token's, in Flux's shape, and the cache agent reads it. A token entry whose Secret `jr2 up` did
128
+ * not write — its variable was unset — names none: the clone is anonymous, and `jr2 status` will
129
+ * show git's refusal if the host wanted one.
146
130
  */
147
131
  const secretRefFor = async (url: string, identity: string): Promise<{ name: string } | undefined> => {
148
- const cred = credentialSecretFor(url, matchCredential(identity, opts.credentials));
132
+ const cred = credentialSecretFor(
133
+ url,
134
+ matchCredential(identity, typeof opts.credentials === "function" ? opts.credentials() : opts.credentials),
135
+ );
149
136
  if (cred === undefined) return undefined;
150
137
  if (cred.kind === "ssh") return { name: cred.secret };
151
- const value = opts.env[cred.env];
152
- if (value === undefined || value === "") return undefined;
153
- await applyTokenSecret(cred.secret, value);
154
- return { name: cred.secret };
138
+ const written = (await opts.cloneSecrets?.()) ?? new Set<string>();
139
+ return written.has(cred.secret) ? { name: cred.secret } : undefined;
155
140
  };
156
141
 
157
142
  /** The resource as one statement writes it: `create` needs the whole, `bind` its spec again.
@@ -168,7 +153,7 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
168
153
  kind: "Repo",
169
154
  metadata: {
170
155
  name: repo.key,
171
- namespace: opts.namespace,
156
+ namespace: ns,
172
157
  ...(repo.bound ? { labels: { [LABEL_REPO_BOUND]: "true" } } : {}),
173
158
  annotations: {
174
159
  [ANNOTATION_REPO_IDENTITY]: repo.identity,
@@ -185,9 +170,9 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
185
170
  };
186
171
 
187
172
  /** Create, never apply — an existing resource keeps its spec. True when this call created it. */
188
- const create = async (cr: object): Promise<boolean> => {
173
+ const create = async (cr: RepoItem & { metadata: { name: string } }): Promise<boolean> => {
189
174
  try {
190
- await exec(["create", ...base, "-f", "-"], { input: JSON.stringify(cr) });
175
+ await kube().create(REPOS, ns, cr);
191
176
  return true;
192
177
  } catch (err) {
193
178
  if (!isAlreadyExists(err)) throw err;
@@ -196,13 +181,14 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
196
181
  };
197
182
 
198
183
  const patch = async (key: string, body: object): Promise<void> => {
199
- await exec(["patch", REPO_RESOURCE, key, ...base, "--type", "merge", "-p", JSON.stringify(body)]);
184
+ await kube().patch(REPOS, ns, key, body);
200
185
  };
201
186
 
202
187
  /** One resource as it stands — what an unbound `ensure` restates its credential against. */
203
188
  const get = async (key: string): Promise<RepoItem> => {
204
- const { stdout } = await exec(["get", REPO_RESOURCE, key, ...base, "-o", "json"]);
205
- return JSON.parse(stdout) as RepoItem;
189
+ const item = await kube().get<RepoItem>(REPOS, ns, key);
190
+ if (!item) throw new Error(`Repo "${key}" vanished between its create and its read`);
191
+ return item;
206
192
  };
207
193
 
208
194
  return {
@@ -246,9 +232,9 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
246
232
 
247
233
  async reconcileBound(keys) {
248
234
  const keep = new Set(keys);
249
- let stdout: string;
235
+ let items: RepoItem[];
250
236
  try {
251
- ({ stdout } = await exec(["get", REPO_RESOURCE, ...base, "-l", `${LABEL_REPO_BOUND}=true`, "-o", "json"]));
237
+ ({ items } = await kube().list<RepoItem>(REPOS, ns, { labelSelector: `${LABEL_REPO_BOUND}=true` }));
252
238
  } catch (err) {
253
239
  // A cluster with no `repos.core.jr2.dev` resource type holds no Repos, so "nothing to
254
240
  // unlabel" is the complete answer — the one read that may answer none. An instance that
@@ -258,23 +244,34 @@ export function kubectlRepos(opts: KubectlReposOptions): RepoResources {
258
244
  if (!isMissingResourceType(err)) throw err;
259
245
  return;
260
246
  }
261
- for (const item of itemsOf(stdout)) {
247
+ for (const item of items) {
262
248
  const name = item.metadata?.name;
263
249
  if (name === undefined || keep.has(name)) continue;
264
- // `<label>-` is kubectl's spelling for "remove the label".
265
- await exec(["label", REPO_RESOURCE, name, ...base, `${LABEL_REPO_BOUND}-`]);
250
+ // A merge patch's `null` removes the label.
251
+ await patch(name, { metadata: { labels: { [LABEL_REPO_BOUND]: null } } });
266
252
  }
267
253
  },
268
254
 
269
255
  async list() {
270
- const { stdout } = await exec(["get", REPO_RESOURCE, ...base, "-o", "json"]);
271
- return itemsOf(stdout).map(repoStatusOf);
256
+ let items: RepoItem[];
257
+ try {
258
+ ({ items } = await kube().list<RepoItem>(REPOS, ns));
259
+ } catch (err) {
260
+ // The data plane is wired on every deployed boot (ADR-0069), so `GET /repos` asks here on
261
+ // a cluster with no operator too. No Repo type holds no Repos: an empty list, the same
262
+ // answer `reconcileBound` gives. Any other refusal still throws.
263
+ if (!isMissingResourceType(err)) throw err;
264
+ return [];
265
+ }
266
+ return items.map(repoStatusOf);
272
267
  },
273
268
  };
274
269
  }
275
270
 
276
- /** A `Repo` resource as `kubectl get -o json` prints it — the fields this port reads. */
271
+ /** A `Repo` resource as the API server answers it — the fields this port reads. */
277
272
  type RepoItem = {
273
+ apiVersion?: string;
274
+ kind?: string;
278
275
  metadata?: { name?: string; labels?: Record<string, string>; annotations?: Record<string, string> };
279
276
  spec?: { url?: string };
280
277
  status?: {
@@ -290,11 +287,6 @@ type RepoItem = {
290
287
  };
291
288
  };
292
289
 
293
- function itemsOf(stdout: string): RepoItem[] {
294
- const parsed = JSON.parse(stdout) as { items?: RepoItem[] };
295
- return parsed.items ?? [];
296
- }
297
-
298
290
  /** The resource → what `GET /repos` reports: the Orchestrator's own metadata read back, and the
299
291
  * cache agent's per-node entries verbatim. Absent optional fields stay absent, not `undefined`
300
292
  * keys, so the JSON a client sees is exactly the type. */
@@ -319,12 +311,9 @@ export function repoStatusOf(item: RepoItem): RepoStatus {
319
311
  };
320
312
  }
321
313
 
322
- function isAlreadyExists(err: unknown): boolean {
323
- return err instanceof Error && /AlreadyExists|already exists/i.test(err.message);
324
- }
325
-
326
- /** kubectl's words for "this cluster has no such CRD" — the Repo type is the operator's to install
327
- * (ADR-0051), and an instance may be deployed where nothing installed it. */
314
+ /** The API server's answer for a kind it does not serve: 404 on the collection itself — the Repo
315
+ * type is the operator's to install (ADR-0051), and an instance may be deployed where nothing
316
+ * installed it. (An object that is absent is a 404 on the OBJECT, which a list never asks for.) */
328
317
  function isMissingResourceType(err: unknown): boolean {
329
- return /doesn't have a resource type/i.test(err instanceof Error ? err.message : String(err));
318
+ return err instanceof KubeError && err.status === 404;
330
319
  }
package/src/rest.ts ADDED
@@ -0,0 +1,133 @@
1
+ // Rest (ADR-0024, ADR-0029): the condition of an Agent's state under which a pick is answered.
2
+ //
3
+ // A pick that moves the workflow to a state inside the Agent's state is answered when the
4
+ // workflow is at rest there: no state below the Agent's state is running something (an Effect, a
5
+ // Gate, another Agent, a child Machine), and no pending timer will move the workflow to another
6
+ // state inside the Agent's state.
7
+ //
8
+ // It is read from the Machine, never kept: the snapshot (which states are active, which children
9
+ // run) and the timer table (`timers.ts`, the same table a restore re-arms). So a restored run gives
10
+ // the same answer as the run before the restart. Read it AFTER the mailbox drains — a microtask
11
+ // after a snapshot, or in a request of its own — never inside a subscriber: an event already queued
12
+ // (a `sendTo(self)` with no delay) moves the Machine before the next tick.
13
+ //
14
+ // Everything below is computed over the SET of active states below the Agent's state. Today that set
15
+ // is one path (a parallel region below an Agent's state is refused at load, ADR-0015), and nothing
16
+ // here assumes it.
17
+ //
18
+ // What the rule does not know: what an Effect is. It knows what waiting is — a live invoked child, a
19
+ // timer that holds. A settled child with no `onDone` is rest; a state named `rest` means nothing.
20
+ // Known limit: a delayed `sendTo` to a child whose reply then moves the Machine is not counted.
21
+
22
+ import type { AnyActorRef, AnyMachineSnapshot, AnyStateMachine } from "xstate";
23
+ import { seatOf, type PartySeat } from "./party.ts";
24
+ import { scheduledFor } from "./timers.ts";
25
+
26
+ type Node = AnyStateMachine["root"];
27
+
28
+ /** Is `node` active in `snapshot`? Read off the state value, a public shape. */
29
+ function isActive(snapshot: AnyMachineSnapshot, node: Node): boolean {
30
+ let value: unknown = snapshot.value;
31
+ for (const key of node.path) {
32
+ if (typeof value === "string") return value === key && node.path.at(-1) === key;
33
+ if (!value || typeof value !== "object" || !(key in value)) return false;
34
+ value = (value as Record<string, unknown>)[key];
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /** Every active state strictly below `s`: a set, which is one path today and may be more. */
40
+ export function activeBelow(snapshot: AnyMachineSnapshot, s: Node): Node[] {
41
+ const out: Node[] = [];
42
+ const visit = (node: Node, value: unknown): void => {
43
+ if (node.type === "parallel") {
44
+ for (const child of Object.values(node.states)) {
45
+ out.push(child);
46
+ visit(child, value && typeof value === "object" ? (value as Record<string, unknown>)[child.key] : undefined);
47
+ }
48
+ return;
49
+ }
50
+ if (typeof value === "string") {
51
+ const child = node.states[value];
52
+ if (child) {
53
+ out.push(child);
54
+ visit(child, undefined);
55
+ }
56
+ } else if (value && typeof value === "object") {
57
+ for (const [key, sub] of Object.entries(value)) {
58
+ const child = node.states[key];
59
+ if (!child) continue;
60
+ out.push(child);
61
+ visit(child, sub);
62
+ }
63
+ }
64
+ };
65
+ let value: unknown = snapshot.value;
66
+ for (const key of s.path)
67
+ value = value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
68
+ visit(s, value);
69
+ return out;
70
+ }
71
+
72
+ const isInside = (node: Node, s: Node): boolean => {
73
+ for (let n: Node | undefined = node; n; n = n.parent) if (n === s) return true;
74
+ return false;
75
+ };
76
+
77
+ /**
78
+ * The pending timers that hold Rest: a timer whose event, delivered to this Machine, is handled by a
79
+ * transition of an active state at or below `s` whose target is inside `s` (or `s` itself, from `s`,
80
+ * without re-entering it — re-entering ends the Turn). Guards are not run: a guarded handler holds
81
+ * until the timer fires, and then nothing is pending. A timer whose handler leaves `s` (a Turn
82
+ * timeout) does not hold: when it fires, the Turn ends (ADR-0024).
83
+ */
84
+ export function holdingTimers(machine: AnyActorRef, s: Node): string[] {
85
+ const snapshot = machine.getSnapshot() as AnyMachineSnapshot;
86
+ const active = [s, ...activeBelow(snapshot, s)];
87
+ return scheduledFor(machine)
88
+ .filter((t) =>
89
+ active.some((node) =>
90
+ (node.transitions.get(t.event.type) ?? []).some((tr) =>
91
+ ((tr.target ?? []) as Node[]).some((target) =>
92
+ target === s ? node === s && !tr.reenter : isInside(target, s),
93
+ ),
94
+ ),
95
+ ),
96
+ )
97
+ .map((t) => t.id);
98
+ }
99
+
100
+ /** The invoke ids of the live children of active states strictly below `s`. The Agent's own invoke
101
+ * is on `s` and does not count. */
102
+ export function runningBelow(machine: AnyActorRef, s: Node): string[] {
103
+ const snapshot = machine.getSnapshot() as AnyMachineSnapshot;
104
+ const children = snapshot.children as Record<string, AnyActorRef | undefined>;
105
+ const out: string[] = [];
106
+ for (const node of activeBelow(snapshot, s))
107
+ for (const inv of node.invoke) if (children[inv.id]?.getSnapshot().status === "active") out.push(inv.id);
108
+ return out;
109
+ }
110
+
111
+ /** Is the Machine at Rest in `s`? Nothing below it runs, and no pending timer holds. */
112
+ export function atRest(machine: AnyActorRef, s: Node): boolean {
113
+ return runningBelow(machine, s).length === 0 && holdingTimers(machine, s).length === 0;
114
+ }
115
+
116
+ /**
117
+ * Where an Agent's registration stands: `rest`, `busy` (its state is active and not at Rest), or
118
+ * `gone` (its state is no longer active: the Turn is over, ADR-0024). A registration with no seat to
119
+ * read (a plain `setup()` tree, a bare test entry) reads `rest` — today's behavior, never a false
120
+ * hold.
121
+ */
122
+ export type RestReading = "rest" | "busy" | "gone";
123
+
124
+ export function restOf(reg: PartySeat): RestReading {
125
+ if (!reg.invoker || !reg.invokeId) return "rest";
126
+ const snapshot = reg.invoker.getSnapshot() as AnyMachineSnapshot | undefined;
127
+ if (!snapshot || !(snapshot.machine as AnyStateMachine | undefined)?.root) return "rest";
128
+ if (snapshot.status !== "active") return "gone";
129
+ const seat = seatOf(snapshot, reg.invokeId);
130
+ if (!seat) return "rest";
131
+ if (!isActive(snapshot, seat)) return "gone";
132
+ return atRest(reg.invoker, seat) ? "rest" : "busy";
133
+ }