@agentshouse/kit 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (150) hide show
  1. package/README.md +41 -1671
  2. package/bin/connect-linux.sh +501 -53
  3. package/bin/connect-windows.ps1 +430 -0
  4. package/bin/kit.js +200 -159
  5. package/bootstrap.json +28 -0
  6. package/docs/configuration.md +104 -0
  7. package/docs/google.md +33 -0
  8. package/docs/linux.md +77 -0
  9. package/docs/macos.md +26 -0
  10. package/docs/telegram.md +29 -0
  11. package/docs/windows.md +36 -0
  12. package/package.json +7 -2
  13. package/src/adapter-mcp.js +92 -12
  14. package/src/agents.js +63 -8
  15. package/src/binding.js +52 -0
  16. package/src/blocks.js +2 -26
  17. package/src/bridge.js +28 -14
  18. package/src/bytes.js +19 -8
  19. package/src/configuration.js +14 -211
  20. package/src/connector-login.js +3 -84
  21. package/src/contract.js +44 -12
  22. package/src/conversation-originals.js +1 -3
  23. package/src/delivery.js +4 -7
  24. package/src/directory.js +6 -26
  25. package/src/draft.js +4 -82
  26. package/src/git-main.js +138 -0
  27. package/src/git.js +62 -0
  28. package/src/google/calendar.js +39 -45
  29. package/src/google/drive.js +61 -186
  30. package/src/google/entries.js +349 -0
  31. package/src/google/gateway.js +3 -6
  32. package/src/google/gmail.js +33 -23
  33. package/src/google/oauth.js +106 -66
  34. package/src/google/source.js +21 -23
  35. package/src/host.js +6 -2
  36. package/src/house-main.js +65 -8
  37. package/src/house.js +446 -127
  38. package/src/how-we-work.md +25 -0
  39. package/src/inbound.js +2 -18
  40. package/src/kit-work-stream.js +120 -0
  41. package/src/launcher.js +46 -10
  42. package/src/local-files.js +3 -11
  43. package/src/login.js +27 -14
  44. package/src/mcp.js +18 -0
  45. package/src/mount.js +9 -18
  46. package/src/original.js +4 -4
  47. package/src/pacing.js +41 -0
  48. package/src/plan.js +37 -45
  49. package/src/repository.js +3 -61
  50. package/src/session-bin/git +2 -0
  51. package/src/setup.js +14 -15
  52. package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
  53. package/src/skill-set/ask-matt/SKILL.md +91 -0
  54. package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
  55. package/src/skill-set/code-review/LICENSE +21 -0
  56. package/src/skill-set/code-review/SKILL.md +89 -0
  57. package/src/skill-set/code-review/agents/openai.yaml +3 -0
  58. package/src/skill-set/codebase-design/DEEPENING.md +37 -0
  59. package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
  60. package/src/skill-set/codebase-design/LICENSE +21 -0
  61. package/src/skill-set/codebase-design/SKILL.md +116 -0
  62. package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
  63. package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
  64. package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
  65. package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
  66. package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  67. package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
  68. package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
  69. package/src/skill-set/domain-modeling/LICENSE +21 -0
  70. package/src/skill-set/domain-modeling/SKILL.md +76 -0
  71. package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
  72. package/src/skill-set/grill-me/LICENSE +21 -0
  73. package/src/skill-set/grill-me/SKILL.md +8 -0
  74. package/src/skill-set/grill-me/agents/openai.yaml +4 -0
  75. package/src/skill-set/grill-with-docs/LICENSE +21 -0
  76. package/src/skill-set/grill-with-docs/SKILL.md +8 -0
  77. package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
  78. package/src/skill-set/grilling/LICENSE +21 -0
  79. package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
  80. package/src/skill-set/handoff/LICENSE +21 -0
  81. package/src/skill-set/handoff/SKILL.md +17 -0
  82. package/src/skill-set/handoff/agents/openai.yaml +4 -0
  83. package/src/skill-set/implement/LICENSE +21 -0
  84. package/src/skill-set/implement/SKILL.md +16 -0
  85. package/src/skill-set/implement/agents/openai.yaml +4 -0
  86. package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
  87. package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
  88. package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
  89. package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
  90. package/src/skill-set/prototype/LICENSE +21 -0
  91. package/src/skill-set/prototype/LOGIC.md +67 -0
  92. package/src/skill-set/prototype/SKILL.md +28 -0
  93. package/src/skill-set/prototype/UI.md +112 -0
  94. package/src/skill-set/prototype/agents/openai.yaml +3 -0
  95. package/src/skill-set/research/LICENSE +21 -0
  96. package/src/skill-set/research/SKILL.md +14 -0
  97. package/src/skill-set/research/agents/openai.yaml +3 -0
  98. package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
  99. package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
  100. package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
  101. package/src/skill-set/tdd/LICENSE +21 -0
  102. package/src/skill-set/tdd/SKILL.md +40 -0
  103. package/src/skill-set/tdd/agents/openai.yaml +3 -0
  104. package/src/skill-set/tdd/mocking.md +59 -0
  105. package/src/skill-set/tdd/tests.md +77 -0
  106. package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
  107. package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
  108. package/src/skill-set/teach/LICENSE +21 -0
  109. package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
  110. package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
  111. package/src/skill-set/teach/SKILL.md +141 -0
  112. package/src/skill-set/teach/agents/openai.yaml +4 -0
  113. package/src/skill-set/to-questionnaire/LICENSE +21 -0
  114. package/src/skill-set/to-questionnaire/SKILL.md +55 -0
  115. package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
  116. package/src/skill-set/to-spec/LICENSE +21 -0
  117. package/src/skill-set/to-spec/SKILL.md +76 -0
  118. package/src/skill-set/to-spec/agents/openai.yaml +4 -0
  119. package/src/skill-set/to-tickets/LICENSE +21 -0
  120. package/src/skill-set/to-tickets/SKILL.md +106 -0
  121. package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
  122. package/src/skill-set/wait-what/LICENSE +21 -0
  123. package/src/skill-set/wait-what/SKILL.md +8 -0
  124. package/src/skill-set/wait-what/agents/openai.yaml +4 -0
  125. package/src/skill-set/wayfinder/LICENSE +21 -0
  126. package/src/skill-set/wayfinder/SKILL.md +129 -0
  127. package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
  128. package/src/skill-set/writing-for-agents/LICENSE +21 -0
  129. package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
  130. package/src/skill-set/writing-for-agents/SKILL.md +83 -0
  131. package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
  132. package/src/skill-set.js +85 -0
  133. package/src/source.js +93 -54
  134. package/src/state.js +19 -1
  135. package/src/sync-library.js +7 -11
  136. package/src/sync-state.js +12 -3
  137. package/src/sync.js +142 -43
  138. package/src/tasks.js +92 -0
  139. package/src/telegram-view.js +217 -0
  140. package/src/telegram.js +204 -69
  141. package/src/turn-frames.js +69 -0
  142. package/src/worker.js +738 -189
  143. package/src/working-copy.js +1274 -0
  144. package/src/workspace.js +299 -88
  145. package/src/google/declared.js +0 -146
  146. package/src/google/id.js +0 -1
  147. package/src/instance/google.js +0 -1
  148. package/src/starter-set.js +0 -17
  149. /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
  150. /package/src/{starter-set → skill-set}/grilling/agents/openai.yaml +0 -0
package/src/workspace.js CHANGED
@@ -1,22 +1,29 @@
1
1
  import { access, mkdir, readFile, rm } from "node:fs/promises";
2
+ import { createServer } from "node:net";
2
3
  import { join } from "node:path";
3
4
  import { readConfiguration } from "./configuration.js";
4
5
  import {
5
6
  CGROUP_CONTROLLERS,
6
7
  HOST_MOUNT_INFO,
8
+ LAUNCH_DIRECTORY_BASE,
7
9
  LOOP_CONTROL,
8
10
  WORKSPACE_ACQUIRE_MAX,
9
11
  WORKSPACE_COMMAND_TIMEOUT_MS,
12
+ WORKSPACE_DISK_HOUSE,
10
13
  WORKSPACE_DISK_IMAGE,
14
+ WORKSPACE_DISK_KIT_HOME,
11
15
  WORKSPACE_DISK_MOUNT,
12
16
  WORKSPACE_KIT_HOME,
13
17
  WORKSPACE_MOUNT_OPTIONS,
14
18
  WORKSPACE_LABEL,
15
- WORKSPACE_MOUNT,
19
+ WORKSPACE_LOCK_POLL_MS,
16
20
  WORKSPACE_OPERATION_WITHDRAWN,
17
21
  WORKSPACE_POLL_MS,
22
+ WORKSPACE_PREVIOUS_SUFFIX,
18
23
  WORKSPACE_READY_ATTEMPTS,
19
24
  WORKSPACE_READY_POLL_MS,
25
+ WORKSPACE_RELEASE_IMAGE,
26
+ WORKSPACE_SELECTED_IMAGE,
20
27
  WORKSPACE_STOP_TIMEOUT_SECONDS,
21
28
  WORKSPACE_SYSTEM_RESERVE_BYTES,
22
29
  } from "./contract.js";
@@ -27,10 +34,13 @@ import {
27
34
  openSession,
28
35
  releaseControl,
29
36
  reportWorkspaceOutcome,
37
+ updateDrain,
30
38
  } from "./house.js";
31
- import { KitRefusal } from "./refusal.js";
39
+ import { houseFailed, KitRefusal } from "./refusal.js";
32
40
 
33
41
  const REQUIRED_CONTROLLERS = ["cpu", "memory"];
42
+ const PROVISIONED_DISK = "/disk";
43
+ const STOPPED_STATES = ["created", "exited"];
34
44
  const MEMINFO = "/proc/meminfo";
35
45
  const MEM_AVAILABLE = /^MemAvailable:\s+(\d+)\s+kB$/m;
36
46
 
@@ -106,30 +116,35 @@ export function mountedPaths(document) {
106
116
  );
107
117
  }
108
118
 
109
- export function workspaceCreateArguments(identity, operation, image, house, root) {
119
+ export function workspaceCreateArguments(identity, workspace, image, house, root) {
120
+ const disk = workspaceMountPath(root, identity);
110
121
  return [
111
122
  "create",
112
123
  "--name",
113
124
  identity,
114
125
  "--label",
115
- `${WORKSPACE_LABEL}=${operation.environment}`,
126
+ `${WORKSPACE_LABEL}=${workspace.environment}`,
116
127
  "--cpus",
117
- (operation.maxima.cpu_millicores / 1000).toFixed(3),
128
+ (workspace.maxima.cpu_millicores / 1000).toFixed(3),
118
129
  "--memory",
119
- `${operation.maxima.memory_bytes}b`,
130
+ `${workspace.maxima.memory_bytes}b`,
120
131
  "--memory-swap",
121
- `${operation.maxima.memory_bytes}b`,
132
+ `${workspace.maxima.memory_bytes}b`,
122
133
  "--restart",
123
134
  "no",
135
+ ...(new URL(house).protocol === "https:" ? [] : ["--network", "host"]),
124
136
  "--mount",
125
- `type=bind,source=${workspaceMountPath(root, identity)},target=${WORKSPACE_MOUNT}`,
137
+ `type=bind,source=${disk}/${WORKSPACE_DISK_KIT_HOME},target=${WORKSPACE_KIT_HOME}`,
138
+ "--mount",
139
+ `type=bind,source=${disk}/${WORKSPACE_DISK_HOUSE},target=${LAUNCH_DIRECTORY_BASE}`,
140
+ "--env",
141
+ `HOME=${WORKSPACE_KIT_HOME}`,
126
142
  "--env",
127
143
  `HOUSE_KIT_HOME=${WORKSPACE_KIT_HOME}`,
128
144
  "--env",
129
- `HOUSE_KIT_HOUSE=${house}`,
130
- "--workdir",
131
- WORKSPACE_MOUNT,
145
+ "HOUSE_KIT_CONTAINER=1",
132
146
  image,
147
+ "resident",
133
148
  ];
134
149
  }
135
150
 
@@ -198,7 +213,7 @@ export async function workspacePrerequisites(host, image) {
198
213
  if (formatter.status !== 0) {
199
214
  return {
200
215
  supported: false,
201
- refusal: `the declared workspace image ${image} carries no mkfs.ext4, so no volume ceiling can be created`,
216
+ refusal: `the selected Kit image ${image} carries no mkfs.ext4, so no volume ceiling can be created`,
202
217
  };
203
218
  }
204
219
  return { supported: true, runtime: `docker ${version.stdout.trim()}` };
@@ -215,34 +230,45 @@ function bootstrapConfiguration() {
215
230
  });
216
231
  }
217
232
 
218
- function provisionArguments(root, identity, image, target, mode) {
233
+ function diskArguments(root, identity, image, script) {
219
234
  return [
220
235
  "run",
221
236
  "--rm",
222
237
  "--interactive",
223
238
  "--mount",
224
- `type=bind,source=${workspaceMountPath(root, identity)},target=${WORKSPACE_MOUNT}`,
239
+ `type=bind,source=${workspaceMountPath(root, identity)},target=${PROVISIONED_DISK}`,
225
240
  "--entrypoint",
226
241
  "sh",
227
242
  image,
228
243
  "-c",
229
- `set -e; mkdir -p ${WORKSPACE_KIT_HOME}; chmod 700 ${WORKSPACE_KIT_HOME}; cat > ${target}; chmod ${mode} ${target}`,
244
+ script,
230
245
  ];
231
246
  }
232
247
 
233
- async function provisionWorkspaceState(host, root, identity, image, authority) {
234
- for (const [target, mode, content] of [
235
- [`${WORKSPACE_KIT_HOME}/credential`, "600", `${authority.credential}\n`],
236
- [`${WORKSPACE_KIT_HOME}/kit.json`, "600", `${bootstrapConfiguration()}\n`],
237
- ]) {
238
- const written = await docker(
248
+ async function onDisk(host, settings, identity, script, stdin = "") {
249
+ const written = await docker(host, diskArguments(settings.root, identity, settings.image, script), stdin);
250
+ if (written.status !== 0) {
251
+ throw new KitRefusal("kit_workspace_unprovisioned", written.stderr.trim() || identity);
252
+ }
253
+ }
254
+
255
+ async function provisionWorkspaceState(host, settings, identity, operation) {
256
+ const home = `${PROVISIONED_DISK}/${WORKSPACE_DISK_KIT_HOME}`;
257
+ const house = `${PROVISIONED_DISK}/${WORKSPACE_DISK_HOUSE}`;
258
+ const files = [
259
+ ...(operation.authority === undefined ? [] : [["credential", operation.authority.credential]]),
260
+ ["kit.json", bootstrapConfiguration()],
261
+ ["house", settings.house],
262
+ ["environment", operation.environment],
263
+ ];
264
+ for (const [name, content] of files) {
265
+ await onDisk(
239
266
  host,
240
- provisionArguments(root, identity, image, target, mode),
241
- content,
267
+ settings,
268
+ identity,
269
+ `set -e; mkdir -p ${home} ${house}; chmod 700 ${home} ${house}; cat > ${home}/${name}; chmod 600 ${home}/${name}`,
270
+ `${content}\n`,
242
271
  );
243
- if (written.status !== 0) {
244
- throw new KitRefusal("kit_workspace_unprovisioned", written.stderr.trim() || identity);
245
- }
246
272
  }
247
273
  }
248
274
 
@@ -289,9 +315,7 @@ export async function createWorkspace(host, settings, operation) {
289
315
  }
290
316
  const mountRefusal = await mountWorkspace(host, settings, identity);
291
317
  if (mountRefusal !== null) return { outcome: "unsupported", refusal: mountRefusal };
292
- if (operation.authority !== undefined) {
293
- await provisionWorkspaceState(host, settings.root, identity, settings.image, operation.authority);
294
- }
318
+ await provisionWorkspaceState(host, settings, identity, operation);
295
319
  const created = await docker(
296
320
  host,
297
321
  workspaceCreateArguments(identity, operation, settings.image, settings.house, settings.root),
@@ -312,9 +336,97 @@ async function workspacePresent(host, identity) {
312
336
  return inspected.status === 0 ? inspected.stdout.trim() : null;
313
337
  }
314
338
 
339
+ async function heldWorkspace(host, identity) {
340
+ const inspected = await docker(host, [
341
+ "container",
342
+ "inspect",
343
+ "--format",
344
+ `{{.State.Status}} {{.Config.Image}} {{index .Config.Labels "${WORKSPACE_LABEL}"}} {{.HostConfig.NanoCpus}} {{.HostConfig.Memory}}`,
345
+ identity,
346
+ ]);
347
+ if (inspected.status !== 0) return null;
348
+ const [state, image, environment, nanoCpus, memory] = inspected.stdout.trim().split(/\s+/);
349
+ return {
350
+ state,
351
+ image,
352
+ environment,
353
+ maxima: { cpu_millicores: Number(nanoCpus) / 1_000_000, memory_bytes: Number(memory) },
354
+ };
355
+ }
356
+
357
+ function previousName(identity) {
358
+ return `${identity}${WORKSPACE_PREVIOUS_SUFFIX}`;
359
+ }
360
+
361
+ async function replaceWorkspace(host, settings, identity, held, running) {
362
+ const mountRefusal = await mountWorkspace(host, settings, identity);
363
+ if (mountRefusal !== null) return mountRefusal;
364
+ if (running) {
365
+ const stopped = await docker(host, [
366
+ "stop",
367
+ "--timeout",
368
+ String(WORKSPACE_STOP_TIMEOUT_SECONDS),
369
+ identity,
370
+ ]);
371
+ if (stopped.status !== 0) return `the workspace did not stop: ${stopped.stderr.trim()}`;
372
+ }
373
+ let cause;
374
+ try {
375
+ cause = await placeReplacement(host, settings, identity, held, running);
376
+ } catch (error) {
377
+ if (!(error instanceof KitRefusal)) throw error;
378
+ cause = error.message;
379
+ }
380
+ if (cause === null) {
381
+ const removed = await docker(host, ["rm", "--force", previousName(identity)]);
382
+ if (removed.status === 0) return null;
383
+ cause = `the previous workspace was not removed: ${removed.stderr.trim()}`;
384
+ }
385
+ return rollBack(host, identity, running, cause);
386
+ }
387
+
388
+ async function placeReplacement(host, settings, identity, held, running) {
389
+ await onDisk(host, settings, identity, `rm -f ${updateDrain(`${PROVISIONED_DISK}/${WORKSPACE_DISK_KIT_HOME}`)}`);
390
+ const setAside = await docker(host, ["rename", identity, previousName(identity)]);
391
+ if (setAside.status !== 0) return `the workspace was not set aside: ${setAside.stderr.trim()}`;
392
+ const created = await docker(
393
+ host,
394
+ workspaceCreateArguments(identity, held, settings.image, settings.house, settings.root),
395
+ );
396
+ if (created.status !== 0) return `the workspace was not recreated: ${created.stderr.trim()}`;
397
+ if (!running) return null;
398
+ const started = await docker(host, ["start", identity]);
399
+ if (started.status !== 0) return `the workspace did not start: ${started.stderr.trim()}`;
400
+ return null;
401
+ }
402
+
403
+ async function rollBack(host, identity, running, cause) {
404
+ const unrestored = await restorePrevious(host, identity);
405
+ if (unrestored !== null) return `${cause}, and ${unrestored}`;
406
+ if (!running) return cause;
407
+ const restarted = await docker(host, ["start", identity]);
408
+ if (restarted.status === 0) return cause;
409
+ return `${cause}, and the workspace did not start again: ${restarted.stderr.trim()}`;
410
+ }
411
+
412
+ async function restorePrevious(host, identity) {
413
+ const previous = previousName(identity);
414
+ if ((await workspacePresent(host, previous)) === null) return null;
415
+ if ((await workspacePresent(host, identity)) !== null) {
416
+ const discarded = await docker(host, ["rm", "--force", identity]);
417
+ if (discarded.status !== 0) {
418
+ return `the replacement was not removed: ${discarded.stderr.trim()}`;
419
+ }
420
+ }
421
+ const restored = await docker(host, ["rename", previous, identity]);
422
+ if (restored.status !== 0) return `the workspace was not restored: ${restored.stderr.trim()}`;
423
+ return null;
424
+ }
425
+
315
426
  export async function startWorkspace(host, settings, operation) {
316
427
  const identity = operation.workspace ?? workspaceIdentity(operation);
317
- if ((await workspacePresent(host, identity)) === null) return { outcome: "absent" };
428
+ const held = await heldWorkspace(host, identity);
429
+ if (held === null) return { outcome: "absent" };
318
430
  const available = memoryAvailable(await host.read(MEMINFO));
319
431
  if (available - operation.maxima.memory_bytes < WORKSPACE_SYSTEM_RESERVE_BYTES) {
320
432
  return {
@@ -324,6 +436,10 @@ export async function startWorkspace(host, settings, operation) {
324
436
  }
325
437
  const mountRefusal = await mountWorkspace(host, settings, identity);
326
438
  if (mountRefusal !== null) return { outcome: "unsupported", refusal: mountRefusal };
439
+ if (STOPPED_STATES.includes(held.state) && held.image !== settings.image) {
440
+ const refusal = await replaceWorkspace(host, settings, identity, held, false);
441
+ if (refusal !== null) return { outcome: "failed", refusal };
442
+ }
327
443
  const started = await docker(host, ["start", identity]);
328
444
  if (started.status !== 0) {
329
445
  return { outcome: "failed", refusal: `the workspace did not start: ${started.stderr.trim()}` };
@@ -409,14 +525,15 @@ export async function managedWorkspaces(host) {
409
525
  if (listed.status !== 0) {
410
526
  throw new KitRefusal("kit_workspace_inventory_unavailable", listed.stderr.trim() || null);
411
527
  }
412
- return listed.stdout
413
- .split("\n")
414
- .map((line) => line.trim())
415
- .filter((line) => line !== "")
416
- .map((line) => {
417
- const [name, environment, state] = line.split(/\s+/);
418
- return { name, environment, state };
419
- });
528
+ const managed = new Map();
529
+ for (const line of listed.stdout.split("\n").map((entry) => entry.trim()).filter((entry) => entry !== "")) {
530
+ const [container, environment, state] = line.split(/\s+/);
531
+ const name = container.endsWith(WORKSPACE_PREVIOUS_SUFFIX)
532
+ ? container.slice(0, -WORKSPACE_PREVIOUS_SUFFIX.length)
533
+ : container;
534
+ if (!managed.has(name)) managed.set(name, { name, environment, state });
535
+ }
536
+ return [...managed.values()];
420
537
  }
421
538
 
422
539
  export async function reconcileWorkspaces(host, authorized) {
@@ -425,76 +542,170 @@ export async function reconcileWorkspaces(host, authorized) {
425
542
  );
426
543
  const stopped = [];
427
544
  for (const managed of await managedWorkspaces(host)) {
428
- if (managed.state !== "running") continue;
429
545
  if (expected.get(managed.environment) === "running") continue;
430
- const settled = await docker(host, [
431
- "stop",
432
- "--timeout",
433
- String(WORKSPACE_STOP_TIMEOUT_SECONDS),
434
- managed.name,
435
- ]);
436
- if (settled.status === 0) stopped.push(managed.name);
546
+ const settled = await exclusively(host, managed.name, async () => {
547
+ if ((await restorePrevious(host, managed.name)) !== null) return false;
548
+ if ((await workspacePresent(host, managed.name)) !== "running") return false;
549
+ const stopping = await docker(host, [
550
+ "stop",
551
+ "--timeout",
552
+ String(WORKSPACE_STOP_TIMEOUT_SECONDS),
553
+ managed.name,
554
+ ]);
555
+ return stopping.status === 0;
556
+ });
557
+ if (settled) stopped.push(managed.name);
437
558
  }
438
559
  return stopped;
439
560
  }
440
561
 
562
+ function workspaceLock(identity) {
563
+ return new Promise((resolve, reject) => {
564
+ const lock = createServer();
565
+ lock.once("error", reject);
566
+ lock.listen({ path: `\0${WORKSPACE_LABEL}/${identity}` }, () => resolve(lock));
567
+ });
568
+ }
569
+
570
+ async function exclusively(host, identity, act) {
571
+ let lock = null;
572
+ while (lock === null) {
573
+ try {
574
+ lock = await workspaceLock(identity);
575
+ } catch (error) {
576
+ if (error.code !== "EADDRINUSE") throw error;
577
+ await host.wait(WORKSPACE_LOCK_POLL_MS);
578
+ }
579
+ }
580
+ try {
581
+ return await act();
582
+ } finally {
583
+ await new Promise((resolve) => lock.close(resolve));
584
+ }
585
+ }
586
+
441
587
  export async function performOperation(host, settings, operation) {
442
- if (operation.kind === "create") return createWorkspace(host, settings, operation);
443
- if (operation.kind === "start") return startWorkspace(host, settings, operation);
444
- if (operation.kind === "stop") return stopWorkspace(host, operation);
445
- return deleteWorkspace(host, settings, operation);
588
+ const selected = operation.kind === "stop" ? settings : { ...settings, image: await selectedImage(host) };
589
+ const identity = operation.workspace ?? workspaceIdentity(operation);
590
+ return exclusively(host, identity, async () => {
591
+ const unrestored = await restorePrevious(host, identity);
592
+ if (unrestored !== null) return { outcome: "failed", refusal: unrestored };
593
+ if (operation.kind === "stop") return stopWorkspace(host, operation);
594
+ if (operation.kind === "create") return createWorkspace(host, selected, operation);
595
+ if (operation.kind === "start") return startWorkspace(host, selected, operation);
596
+ return deleteWorkspace(host, selected, operation);
597
+ });
446
598
  }
447
599
 
448
- export async function serveWorkspaces(options, dependencies = {}) {
600
+ async function selectedImage(host) {
601
+ let image;
602
+ try {
603
+ image = (await host.read(WORKSPACE_SELECTED_IMAGE)).trim();
604
+ } catch {
605
+ image = null;
606
+ }
607
+ if (image === null || !WORKSPACE_RELEASE_IMAGE.test(image)) {
608
+ throw new KitRefusal("kit_workspace_host_unconfigured", WORKSPACE_SELECTED_IMAGE);
609
+ }
610
+ return image;
611
+ }
612
+
613
+ async function hostSettings(options) {
449
614
  const configuration = await readConfiguration(options.configuration);
450
- if (configuration.host === null || configuration.host.image === null) {
615
+ if (configuration.host === null) {
451
616
  throw new KitRefusal("kit_workspace_host_unconfigured", options.configuration);
452
617
  }
618
+ return { root: configuration.host.workspace, house: options.house };
619
+ }
620
+
621
+ async function updateWorkspace(host, settings, name) {
622
+ try {
623
+ const unrestored = await restorePrevious(host, name);
624
+ if (unrestored !== null) return { workspace: name, cause: unrestored };
625
+ const held = await heldWorkspace(host, name);
626
+ if (held === null || held.image === settings.image) return null;
627
+ const running = !STOPPED_STATES.includes(held.state);
628
+ if (running) {
629
+ const drained = await docker(host, ["exec", name, "kit", "resident", "drain"]);
630
+ if (drained.status !== 0) {
631
+ return { workspace: name, cause: drained.stdout.trim() || drained.stderr.trim() };
632
+ }
633
+ }
634
+ const refusal = await replaceWorkspace(host, settings, name, held, running);
635
+ if (refusal !== null) return { workspace: name, cause: refusal };
636
+ return { workspace: name, running };
637
+ } catch (error) {
638
+ if (!(error instanceof KitRefusal)) throw error;
639
+ return { workspace: name, cause: error.message };
640
+ }
641
+ }
642
+
643
+ export async function updateWorkspaces(options, dependencies = {}) {
644
+ const host = adapter(dependencies);
645
+ const updated = [];
646
+ const unchanged = [];
647
+ const managed = await managedWorkspaces(host);
648
+ if (managed.length === 0) return { updated, unchanged };
649
+ const settings = { ...(await hostSettings(options)), image: await selectedImage(host) };
650
+ for (const { name } of managed) {
651
+ const answer = await exclusively(host, name, () => updateWorkspace(host, settings, name));
652
+ if (answer === null) continue;
653
+ if (answer.cause === undefined) updated.push(answer);
654
+ else unchanged.push(answer);
655
+ }
656
+ return { updated, unchanged };
657
+ }
658
+
659
+ export async function serveWorkspaces(options, dependencies = {}) {
453
660
  const host = adapter(dependencies);
454
- const settings = {
455
- root: configuration.host.workspace,
456
- image: configuration.host.image,
457
- house: options.house,
458
- };
661
+ const settings = await hostSettings(options);
459
662
  const session = await openSession(options, dependencies);
460
663
  const settled = [];
461
664
  const withdrawn = [];
462
665
  let reconciled = [];
463
666
  const passes = options.passes ?? Number.POSITIVE_INFINITY;
464
- for (let pass = 0; pass < passes; pass += 1) {
465
- const work = await maintainControl(
466
- session,
467
- async (signal) => {
468
- const acquired = await acquireWorkspaces(session, WORKSPACE_ACQUIRE_MAX, signal);
469
- reconciled = await reconcileWorkspaces(host, acquired.workspaces);
470
- for (const operation of acquired.operations) {
471
- let report;
472
- try {
473
- report = await performOperation(host, settings, operation);
474
- } catch (error) {
475
- if (!(error instanceof KitRefusal)) throw error;
476
- report = { outcome: "failed", refusal: error.message };
477
- }
478
- try {
479
- await reportWorkspaceOutcome(session, operation.operation, report, signal);
480
- } catch (error) {
481
- if (!(error instanceof KitRefusal) || error.code !== WORKSPACE_OPERATION_WITHDRAWN) {
482
- throw error;
667
+ try {
668
+ for (let pass = 0; pass < passes; pass += 1) {
669
+ const work = await maintainControl(
670
+ session,
671
+ async (signal) => {
672
+ const acquired = await acquireWorkspaces(session, WORKSPACE_ACQUIRE_MAX, signal);
673
+ reconciled = await reconcileWorkspaces(host, acquired.workspaces);
674
+ for (const operation of acquired.operations) {
675
+ let report;
676
+ try {
677
+ report = await performOperation(host, settings, operation);
678
+ } catch (error) {
679
+ if (!(error instanceof KitRefusal)) throw error;
680
+ report = { outcome: "failed", refusal: error.message };
483
681
  }
484
- withdrawn.push({ operation: operation.operation, kind: operation.kind });
485
- continue;
682
+ try {
683
+ await reportWorkspaceOutcome(session, operation.operation, report, signal);
684
+ } catch (error) {
685
+ if (!(error instanceof KitRefusal) || error.code !== WORKSPACE_OPERATION_WITHDRAWN) {
686
+ throw error;
687
+ }
688
+ withdrawn.push({ operation: operation.operation, kind: operation.kind });
689
+ continue;
690
+ }
691
+ settled.push({ operation: operation.operation, kind: operation.kind, ...report });
486
692
  }
487
- settled.push({ operation: operation.operation, kind: operation.kind, ...report });
488
- }
489
- return acquired;
490
- },
491
- options.signal,
492
- { release: false },
493
- );
494
- if (pass + 1 < passes && work.operations.length === 0) {
495
- await host.wait(WORKSPACE_POLL_MS);
693
+ return acquired;
694
+ },
695
+ options.signal,
696
+ { release: false },
697
+ ).catch((error) => {
698
+ if (!houseFailed(error)) throw error;
699
+ return null;
700
+ });
701
+ if (pass + 1 < passes && (work === null || work.operations.length === 0)) {
702
+ await host.wait(WORKSPACE_POLL_MS);
703
+ }
496
704
  }
705
+ } catch (error) {
706
+ if (!(error instanceof KitRefusal) || error.code !== "kit_command_cancelled") throw error;
707
+ } finally {
708
+ await releaseControl(session);
497
709
  }
498
- await releaseControl(session);
499
710
  return { settled, reconciled, withdrawn };
500
711
  }
@@ -1,146 +0,0 @@
1
- import { ITEM_BODY_MAX_BYTES } from "../contract.js";
2
- import { KitRefusal } from "../refusal.js";
3
- import { googleJson } from "./gateway.js";
4
-
5
- function valueAt(value, path) {
6
- let current = value;
7
- for (const segment of path.split(".")) {
8
- if (current === null || current === undefined || typeof current !== "object") return undefined;
9
- current = current[segment];
10
- }
11
- return current;
12
- }
13
-
14
- function requiredText(value, declaration) {
15
- if (typeof value !== "string" || value.length === 0) {
16
- throw new KitRefusal("google_provider_response_invalid", declaration);
17
- }
18
- return value;
19
- }
20
-
21
- function optionalText(value, declaration) {
22
- if (value === undefined || value === null) return null;
23
- return requiredText(value, declaration);
24
- }
25
-
26
- function cursorOf(value) {
27
- if (value === null) return { pageToken: null, checkpoint: null };
28
- let cursor;
29
- try {
30
- cursor = JSON.parse(value);
31
- } catch {
32
- throw new KitRefusal("google_checkpoint_invalid", "declared service");
33
- }
34
- if (
35
- typeof cursor !== "object" ||
36
- cursor === null ||
37
- (cursor.pageToken !== null && typeof cursor.pageToken !== "string") ||
38
- (cursor.checkpoint !== null && typeof cursor.checkpoint !== "string")
39
- ) {
40
- throw new KitRefusal("google_checkpoint_invalid", "declared service");
41
- }
42
- return cursor;
43
- }
44
-
45
- function composedBody(item, paths) {
46
- const parts = paths.map((path) => valueAt(item, path));
47
- if (parts.some((part) => !["string", "number", "boolean"].includes(typeof part))) {
48
- throw new KitRefusal("google_provider_response_invalid", "declared body");
49
- }
50
- const body = parts.map((part) => `${part}`).join("\n\n");
51
- if (Buffer.byteLength(body, "utf8") > ITEM_BODY_MAX_BYTES) {
52
- throw new KitRefusal("google_provider_response_invalid", "declared body");
53
- }
54
- return body;
55
- }
56
-
57
- function normalizedItem(service, item) {
58
- const fields = service.declaration.fields;
59
- const observedAt = requiredText(valueAt(item, fields.observedAt), "declared observed time");
60
- if (Number.isNaN(Date.parse(observedAt))) {
61
- throw new KitRefusal("google_provider_response_invalid", "declared observed time");
62
- }
63
- const container = requiredText(valueAt(item, fields.container), "declared container");
64
- const nativeVersion =
65
- fields.nativeVersion === null
66
- ? null
67
- : optionalText(valueAt(item, fields.nativeVersion), "declared native version");
68
- const providerModifiedAt =
69
- fields.providerModifiedAt === null
70
- ? null
71
- : optionalText(
72
- valueAt(item, fields.providerModifiedAt),
73
- "declared provider modification time",
74
- );
75
- if (providerModifiedAt !== null && Number.isNaN(Date.parse(providerModifiedAt))) {
76
- throw new KitRefusal("google_provider_response_invalid", "declared provider modification time");
77
- }
78
- return {
79
- namespace: `google/${service.service}`,
80
- nativeObjectId: requiredText(valueAt(item, fields.nativeId), "declared native id"),
81
- nativeVersion,
82
- providerModifiedAt:
83
- providerModifiedAt === null ? null : new Date(providerModifiedAt).toISOString(),
84
- observedAt: new Date(observedAt).toISOString(),
85
- body: composedBody(item, fields.body),
86
- author:
87
- fields.author === null
88
- ? null
89
- : optionalText(valueAt(item, fields.author), "declared author"),
90
- url:
91
- fields.url === null ? null : optionalText(valueAt(item, fields.url), "declared url"),
92
- attachments: [],
93
- container: `google/${service.service}/${container}`,
94
- };
95
- }
96
-
97
- export async function declaredPage(options, dependencies = {}) {
98
- const declared = options.service.declaration;
99
- const cursor = cursorOf(options.cursor);
100
- const url = new URL(declared.path, `https://${declared.host}`);
101
- for (const [name, value] of Object.entries(declared.query)) url.searchParams.set(name, value);
102
- url.searchParams.set(declared.pageSize, `${options.limit}`);
103
- if (cursor.pageToken !== null) {
104
- url.searchParams.set(declared.pageToken.request, cursor.pageToken);
105
- }
106
- if (cursor.checkpoint !== null) {
107
- url.searchParams.set(declared.pageToken.checkpointRequest, cursor.checkpoint);
108
- }
109
- const answer = await googleJson(
110
- {
111
- directory: options.directory,
112
- instance: options.instance,
113
- client: options.client,
114
- requiredScopes: declared.scopes,
115
- url: url.toString(),
116
- signal: options.signal,
117
- },
118
- dependencies,
119
- );
120
- const entries = valueAt(answer, declared.items);
121
- if (!Array.isArray(entries) || entries.length > options.limit) {
122
- throw new KitRefusal("google_provider_response_invalid", "declared items");
123
- }
124
- const pageToken = valueAt(answer, declared.pageToken.response);
125
- let next;
126
- if (pageToken !== undefined && pageToken !== null) {
127
- next = {
128
- pageToken: requiredText(pageToken, "declared page token"),
129
- checkpoint: cursor.checkpoint,
130
- };
131
- } else {
132
- next = {
133
- pageToken: null,
134
- checkpoint: requiredText(
135
- valueAt(answer, declared.pageToken.checkpointResponse),
136
- "declared checkpoint",
137
- ),
138
- };
139
- }
140
- return {
141
- items: entries.map((entry) => normalizedItem(options.service, entry)),
142
- dropped: [],
143
- cursor: JSON.stringify(next),
144
- more: pageToken !== undefined && pageToken !== null,
145
- };
146
- }
package/src/google/id.js DELETED
@@ -1 +0,0 @@
1
- export const GOOGLE_DRIVE_ID = /^[A-Za-z0-9_-]{1,512}$/;
@@ -1 +0,0 @@
1
- export const GOOGLE_OAUTH_CLIENT = Object.freeze({"id":"911965338518-867gdvuddb7kdkjo3qvpv6gds369alb7.apps.googleusercontent.com","secret":"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"});