@dynamicagents/plugins 0.13.0 → 0.14.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.
@@ -1,6 +1,7 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import { type WorkspaceEgressPolicy, type WorkspaceStub } from "@cloudflare/computer";
3
3
  import { ContainerBackend, type ContainerInstanceSize } from "@cloudflare/computer/backends/container";
4
+ import { type DepsSnapshotRecord } from "./deps-snapshot.js";
4
5
  import { type WorkspaceAdvisory } from "./advisory.js";
5
6
  import type { InstallPlan, InstallState } from "./install.js";
6
7
  import type { RepoGitResult } from "../repo/index.js";
@@ -49,6 +50,7 @@ import type { RepoGitResult } from "../repo/index.js";
49
50
  * and each carries the reasoning for its own decisions:
50
51
  *
51
52
  * - `./install-job.ts` — when a dependency install runs, and every guard on it.
53
+ * - `./deps-snapshot.ts` — starting a container from one that already installed.
52
54
  * - `./sync.ts` — the host's half of syncing, which nothing else will do.
53
55
  * - `./ca-trust.ts` — making a container able to speak TLS.
54
56
  * - `./git-identity.ts` — who a container's commits belong to.
@@ -337,15 +339,33 @@ export declare abstract class WorkspaceObjectBase<TEnv extends Cloudflare.Env =
337
339
  dir: string;
338
340
  repo?: string;
339
341
  }): Promise<InstallState>;
342
+ /**
343
+ * This workspace's dependency snapshot, for another of the same class to start
344
+ * from — see {@link seedDepsSnapshot}. Starts no container and moves no
345
+ * deadline.
346
+ */
347
+ depsSnapshot(): Promise<DepsSnapshotRecord | undefined>;
348
+ /**
349
+ * Start this workspace's next container from `record`, another workspace's
350
+ * dependency snapshot, when it fits the checkout here — a worktree at the same
351
+ * path as the checkout it was cut from. Call it after the checkout and before
352
+ * the first command.
353
+ *
354
+ * A snapshot is that workspace's whole disk, so offer one only between
355
+ * workspaces of the same caller.
356
+ */
357
+ seedDepsSnapshot(record: DepsSnapshotRecord): Promise<{
358
+ seeded: boolean;
359
+ reason?: string;
360
+ }>;
340
361
  /**
341
362
  * Stop the container, and **keep everything on disk**.
342
363
  *
343
364
  * The counterpart to {@link reclaimIfIdle}, and the difference is the whole
344
365
  * reason both exist. A reclaim is for a workspace nobody wants again: it empties
345
- * storage, so the checkout and the dependency tree go with the container. This is
346
- * for a workspace that will be worked in again — the next task on the same
347
- * repository has to skip the clone and the install, which is the cost the whole
348
- * design is arranged around.
366
+ * storage, so the checkout and the dependency snapshot go with the container.
367
+ * This is for a workspace that will be worked in again — the next task on the
368
+ * same repository skips the clone, and the install by restoring the snapshot.
349
369
  *
350
370
  * **Why a caller needs this at all.** The idle deadline would eventually do it,
351
371
  * but it cannot be tuned down to meet a cost target: `containerIdleMs` must
@@ -9,6 +9,7 @@ import { createGitClient } from "@cloudflare/computer/git";
9
9
  import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare";
10
10
  import { ContainerTrust } from "./ca-trust.js";
11
11
  import { ContainerDeps } from "./container-deps.js";
12
+ import { DepsSnapshot } from "./deps-snapshot.js";
12
13
  import { ContainerGitIdentity } from "./git-identity.js";
13
14
  import { InstallJob } from "./install-job.js";
14
15
  import { WorkspaceGitHost } from "./git-host.js";
@@ -62,6 +63,7 @@ import { pathExists } from "./read.js";
62
63
  * and each carries the reasoning for its own decisions:
63
64
  *
64
65
  * - `./install-job.ts` — when a dependency install runs, and every guard on it.
66
+ * - `./deps-snapshot.ts` — starting a container from one that already installed.
65
67
  * - `./sync.ts` — the host's half of syncing, which nothing else will do.
66
68
  * - `./ca-trust.ts` — making a container able to speak TLS.
67
69
  * - `./git-identity.ts` — who a container's commits belong to.
@@ -135,6 +137,8 @@ const HOLDS_KEY = "container-holds";
135
137
  const RELEASE_ASKED_KEY = "container-release-asked";
136
138
  /** Where the container-warm wake keeps its current schedule id. */
137
139
  const CONTAINER_WARM_ID = "container-warm-id";
140
+ /** Where the dependency-snapshot wake keeps its current schedule id. */
141
+ const DEPS_SNAPSHOT_ID = "deps-snapshot-id";
138
142
  /**
139
143
  * How long a container stays up after the last command **started**.
140
144
  *
@@ -301,6 +305,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
301
305
  idleReclaim: () => this.#onIdleReclaim(),
302
306
  containerIdle: (payload) => this.#onContainerIdle(payload),
303
307
  containerWarm: () => this.#onContainerWarm(),
308
+ depsSnapshot: () => this.#onDepsSnapshot(),
304
309
  syncRetry: (payload) => this.#onSyncDrain(payload)
305
310
  },
306
311
  onError: (err) => {
@@ -337,6 +342,18 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
337
342
  key: CONTAINER_WARM_ID,
338
343
  callback: "containerWarm"
339
344
  });
345
+ /**
346
+ * Snapshot a container that just installed — see `./deps-snapshot.ts`.
347
+ *
348
+ * From the alarm rather than the install's drain, which a model turn's
349
+ * `waitUntil` may own.
350
+ */
351
+ #snapshotDue = namedDeadline({
352
+ storage: this.ctx.storage,
353
+ scheduler: this.#wake.scheduler,
354
+ key: DEPS_SNAPSHOT_ID,
355
+ callback: "depsSnapshot"
356
+ });
340
357
  /**
341
358
  * Come back and move the next block of an outstanding pull.
342
359
  *
@@ -369,6 +386,19 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
369
386
  ready: () => this.#ready(),
370
387
  waitUntil: (promise) => this.ctx.waitUntil(promise),
371
388
  containerGone: () => this.#containerGone(),
389
+ installedRecords: (tree) => this.#snapshot.dueEntries(tree),
390
+ installed: async () => void (await this.#snapshotDue.set(new Date())),
391
+ tag: () => this.#tag,
392
+ id: () => this.ctx.id.toString()
393
+ });
394
+ /** The next container's dependency trees — see `./deps-snapshot.ts`. */
395
+ #snapshot = new DepsSnapshot({
396
+ storage: this.ctx.storage,
397
+ container: () => this.ctx.container,
398
+ image: () => this.#image,
399
+ workspace: () => this.#workspace,
400
+ rootFingerprint: (dir) => this.#install.rootFingerprint(dir),
401
+ tree: () => this.#install.tree(),
372
402
  tag: () => this.#tag,
373
403
  id: () => this.ctx.id.toString()
374
404
  });
@@ -426,13 +456,21 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
426
456
  * The binding name, the egress policy and the instance size are the
427
457
  * subclass's — see {@link WorkspaceObjectConfig}, which carries the warnings
428
458
  * on each.
459
+ *
460
+ * `launch` is how a start restores a dependency snapshot instead of booting
461
+ * the image — see `./deps-snapshot.ts`. Never in the env: the env is part of
462
+ * what a running container is matched against.
429
463
  */
430
464
  #backendMemo;
431
465
  get backend() {
432
466
  return (this.#backendMemo ??= this.#newBackend());
433
467
  }
468
+ /** The image a start boots, as the deploy prepared it. */
469
+ get #image() {
470
+ return this.ctx.container?.images?.[IMAGE_NAME];
471
+ }
434
472
  #newBackend() {
435
- const image = this.ctx.container?.images?.[IMAGE_NAME];
473
+ const image = this.#image;
436
474
  return new ContainerBackend({
437
475
  container: () => this,
438
476
  workspace: {
@@ -442,7 +480,8 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
442
480
  egress: this.#cfg.egress,
443
481
  name: IMAGE_NAME,
444
482
  instance: this.#cfg.instance,
445
- containerEnv: image === undefined ? {} : { [IMAGE_ENV]: image }
483
+ containerEnv: image === undefined ? {} : { [IMAGE_ENV]: image },
484
+ launch: this.#snapshot.launch
446
485
  });
447
486
  }
448
487
  #workspaceMemo;
@@ -486,6 +525,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
486
525
  this.#gitIdentity.forget();
487
526
  this.#deps.forget();
488
527
  await this.#install.containerGone();
528
+ await this.#snapshot.forgetDue();
489
529
  }
490
530
  /**
491
531
  * Set once {@link reclaimIfIdle} has emptied storage, until the alarm it armed
@@ -538,19 +578,53 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
538
578
  });
539
579
  return this.#readying;
540
580
  }
541
- /** One start, run for whoever got there first — see {@link #ready}. */
581
+ /**
582
+ * One start, run for whoever got there first — see {@link #ready}.
583
+ *
584
+ * A cold start restores the dependency snapshot when one fits, and the marker
585
+ * the setup reads decides whether what came up is adopted as installed — see
586
+ * `./deps-snapshot.ts`.
587
+ */
542
588
  async #readyNow() {
543
589
  // Read **before** `ready()`, which starts a stopped container.
544
- if (!this.ctx.container?.running)
590
+ let restoring;
591
+ if (!this.ctx.container?.running) {
545
592
  await this.#containerGone();
546
- await this.#workspace.ready();
547
- // Before the first exec, which pushes the whole tree into a new container.
548
- await this.#purgeSyncedTrees();
549
- await this.#trust.ensure();
550
- await this.#gitIdentity.ensure();
551
- const found = await this.#deps.ensure(await this.#install.dir());
552
- if (found)
553
- await this.#install.reconcile(found.marker);
593
+ restoring = await this.#snapshot.arm(() => this.#expectedTree());
594
+ }
595
+ try {
596
+ await this.#workspace.ready();
597
+ // Before the first exec, which pushes the whole tree into a new container.
598
+ await this.#purgeSyncedTrees();
599
+ await this.#trust.ensure();
600
+ await this.#gitIdentity.ensure();
601
+ const found = await this.#deps.ensure(restoring?.dir ?? (await this.#install.dir()));
602
+ if (found)
603
+ await this.#install.reconcile(found.marker);
604
+ if (restoring &&
605
+ (await this.#snapshot.restored(restoring, found?.marker)))
606
+ await this.#install.adopt(restoring);
607
+ }
608
+ finally {
609
+ this.#snapshot.disarm();
610
+ }
611
+ }
612
+ /**
613
+ * The install a cold start would owe: where the last one ran, or the checkout
614
+ * when none has — a worktree seeded before its first install.
615
+ */
616
+ async #expectedTree() {
617
+ const target = (await this.#install.target()) ?? (await this.#checkoutTarget());
618
+ if (!target)
619
+ return undefined;
620
+ const fingerprint = await this.#install.fingerprintFor(target);
621
+ return fingerprint ? { dir: target.dir, fingerprint } : undefined;
622
+ }
623
+ async #checkoutTarget() {
624
+ const record = await this.ctx.storage.get(CHECKOUT_KEY);
625
+ if (!record?.dir)
626
+ return undefined;
627
+ return { dir: record.dir, ...(record.repo ? { repo: record.repo } : {}) };
554
628
  }
555
629
  /**
556
630
  * Delete every `node_modules` that was synced into this object, once.
@@ -834,6 +908,26 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
834
908
  async startInstall(req) {
835
909
  return await this.#install.start(req);
836
910
  }
911
+ /**
912
+ * This workspace's dependency snapshot, for another of the same class to start
913
+ * from — see {@link seedDepsSnapshot}. Starts no container and moves no
914
+ * deadline.
915
+ */
916
+ async depsSnapshot() {
917
+ return await this.#snapshot.get();
918
+ }
919
+ /**
920
+ * Start this workspace's next container from `record`, another workspace's
921
+ * dependency snapshot, when it fits the checkout here — a worktree at the same
922
+ * path as the checkout it was cut from. Call it after the checkout and before
923
+ * the first command.
924
+ *
925
+ * A snapshot is that workspace's whole disk, so offer one only between
926
+ * workspaces of the same caller.
927
+ */
928
+ async seedDepsSnapshot(record) {
929
+ return await this.#snapshot.seed(record, () => this.#expectedTree());
930
+ }
837
931
  // --- lifecycle ---------------------------------------------------------------
838
932
  /**
839
933
  * Mark this workspace as in use, and push its reclamation back.
@@ -868,10 +962,9 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
868
962
  *
869
963
  * The counterpart to {@link reclaimIfIdle}, and the difference is the whole
870
964
  * reason both exist. A reclaim is for a workspace nobody wants again: it empties
871
- * storage, so the checkout and the dependency tree go with the container. This is
872
- * for a workspace that will be worked in again — the next task on the same
873
- * repository has to skip the clone and the install, which is the cost the whole
874
- * design is arranged around.
965
+ * storage, so the checkout and the dependency snapshot go with the container.
966
+ * This is for a workspace that will be worked in again — the next task on the
967
+ * same repository skips the clone, and the install by restoring the snapshot.
875
968
  *
876
969
  * **Why a caller needs this at all.** The idle deadline would eventually do it,
877
970
  * but it cannot be tuned down to meet a cost target: `containerIdleMs` must
@@ -1041,6 +1134,12 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
1041
1134
  reason,
1042
1135
  running: this.ctx.container?.running ?? false
1043
1136
  });
1137
+ // An install's snapshot the wake has not taken yet would go with the disk
1138
+ // it is of, so it is taken here first. Not for a reclaim, which keeps
1139
+ // nothing. Either way, one in flight needs the container running.
1140
+ if (reason === "idle")
1141
+ await this.#snapshot.takeDue();
1142
+ await this.#snapshot.settled();
1044
1143
  // Hang up the session to computerd first: a destroy that cuts it live
1045
1144
  // surfaces as an uncaught `Network connection lost`. The workspace
1046
1145
  // reconnects on its next use, and `close()` swallows its own failures.
@@ -1305,6 +1404,21 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
1305
1404
  await this.#sync.arm((payload?.attempt ?? 0) + 1);
1306
1405
  }
1307
1406
  }
1407
+ /** An install finished in this container — see `./deps-snapshot.ts`. */
1408
+ async #onDepsSnapshot() {
1409
+ // The record is read from this object's storage — each root's inputs, and
1410
+ // which roots there are — so the install's own pull has to have landed: it
1411
+ // carries the submodules a bootstrap cloned. One still moving comes back;
1412
+ // one that failed is left to the stop, which drains before it takes.
1413
+ const synced = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
1414
+ if (synced === "incomplete") {
1415
+ await this.#snapshotDue.set(new Date(Date.now() + SYNC_DRAIN_RESUME_MS));
1416
+ return;
1417
+ }
1418
+ if (synced === "failed")
1419
+ return;
1420
+ await this.#snapshot.takeDue();
1421
+ }
1308
1422
  /** The idle-reclaim deadline came due. */
1309
1423
  async #onIdleReclaim() {
1310
1424
  const { reclaimed, idleMs } = await this.reclaimIfIdle();
@@ -14,6 +14,8 @@ export type WorkspaceWakeHandlers = {
14
14
  idleReclaim: () => Promise<void>;
15
15
  /** Start a cold container before a command needs it — see `./object.ts`. */
16
16
  containerWarm: () => Promise<void>;
17
+ /** Snapshot a container that just installed — see `./deps-snapshot.ts`. */
18
+ depsSnapshot: () => Promise<void>;
17
19
  containerIdle: (payload?: SyncDrainIntent) => Promise<void>;
18
20
  syncRetry: (payload?: SyncDrainIntent) => Promise<void>;
19
21
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamicagents/plugins",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Optional, composable capabilities for a Dynamic Agent: browser rendering, a Linux container, git and pull requests, a scratchpad, and Claude Code sessions. One subpath per plugin, so a bundle grows only with what it imports.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -75,11 +75,11 @@
75
75
  "prepare": "husky || true && npm run build"
76
76
  },
77
77
  "peerDependencies": {
78
- "@cloudflare/computer": "^0.4.0",
79
- "@cloudflare/think": ">=0.20.0 <0.21.0",
80
- "@dynamicagents/core": ">=0.13.0 <0.14.0",
81
- "@platformatic/vfs": "^0.4.0",
82
- "agents": ">=0.26.0 <0.27.0",
78
+ "@cloudflare/computer": "^0.5.0",
79
+ "@cloudflare/think": ">=0.20.2 <0.21.0",
80
+ "@dynamicagents/core": ">=0.14.0 <0.15.0",
81
+ "@platformatic/vfs": "^0.5.0",
82
+ "agents": ">=0.28.0 <0.29.0",
83
83
  "ai": "^7.0.88",
84
84
  "zod": "^4.4.3"
85
85
  },
@@ -92,13 +92,13 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@cloudflare/computer": "^0.4.0",
96
- "@cloudflare/think": "^0.20.0",
95
+ "@cloudflare/computer": "^0.5.0",
96
+ "@cloudflare/think": "^0.20.2",
97
97
  "@cloudflare/vitest-plugin": "^1.1.9",
98
- "@dynamicagents/core": "^0.13.1",
99
- "@platformatic/vfs": "^0.4.0",
98
+ "@dynamicagents/core": "^0.14.0",
99
+ "@platformatic/vfs": "^0.5.0",
100
100
  "@types/node": "^26.5.1",
101
- "agents": "^0.26.0",
101
+ "agents": "^0.28.0",
102
102
  "ai": "^7.0.101",
103
103
  "eslint": "^10.10.0",
104
104
  "husky": "^9.1.7",