@tanstack/ai-sandbox 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ //#region src/testkit/durable-run-fields-conformance.ts
3
+ /**
4
+ * Conformance for the DURABLE-RUN fields on a `RunStore`.
5
+ *
6
+ * These four fields (`sandboxKey`, `detachedSince`, `cancelRequested`,
7
+ * `driverEpoch`) exist for durable sandboxed runs: detach on disconnect, takeover
8
+ * by a later host, and the reaper. A chat-only app never writes them, so proving
9
+ * them is NOT part of `runPersistenceConformance` in `@tanstack/ai-persistence`.
10
+ * They live here, next to the takeover and reaper suites that depend on them.
11
+ *
12
+ * Run this when your app wires `withSandbox(sandbox, { runs, durability })`. The
13
+ * fields round-trip through the REQUIRED `update`/`get` pair, so a backend can
14
+ * pass every persistence case while silently dropping one of them, and the
15
+ * failure then shows up as a run that looks permanently detached or a takeover
16
+ * that cannot fence a superseded host.
17
+ *
18
+ * ```ts
19
+ * import { runDurableRunFieldsConformance } from '@tanstack/ai-sandbox/testkit'
20
+ * import { myPersistence } from './persistence'
21
+ *
22
+ * runDurableRunFieldsConformance('my postgres runs', () => myPersistence().stores.runs)
23
+ * ```
24
+ */
25
+ function runDurableRunFieldsConformance(name, makeStore) {
26
+ describe(`durable run fields conformance: ${name}`, () => {
27
+ it("round-trips the durable run fields, overwrites driverEpoch, and clears every one of them on explicit undefined", async () => {
28
+ const store = await makeStore();
29
+ await store.createOrResume({
30
+ runId: "fc-1",
31
+ threadId: "fc-t",
32
+ startedAt: 1
33
+ });
34
+ const fresh = await store.get("fc-1");
35
+ expect(fresh?.cancelRequested).toBeUndefined();
36
+ expect(fresh?.detachedSince).toBeUndefined();
37
+ expect(fresh?.sandboxKey).toBeUndefined();
38
+ expect(fresh?.driverEpoch).toBeUndefined();
39
+ await store.update("fc-1", {
40
+ sandboxKey: "sandbox-abc",
41
+ detachedSince: 500,
42
+ cancelRequested: true,
43
+ driverEpoch: 1
44
+ });
45
+ const afterFirstUpdate = await store.get("fc-1");
46
+ expect(afterFirstUpdate?.sandboxKey).toBe("sandbox-abc");
47
+ expect(afterFirstUpdate?.detachedSince).toBe(500);
48
+ expect(afterFirstUpdate?.cancelRequested).toBe(true);
49
+ expect(afterFirstUpdate?.driverEpoch).toBe(1);
50
+ await store.update("fc-1", { driverEpoch: 2 });
51
+ const afterEpochBump = await store.get("fc-1");
52
+ expect(afterEpochBump?.driverEpoch).toBe(2);
53
+ expect(afterEpochBump?.sandboxKey).toBe("sandbox-abc");
54
+ expect(afterEpochBump?.cancelRequested).toBe(true);
55
+ await store.update("fc-1", { detachedSince: void 0 });
56
+ const afterClear = await store.get("fc-1");
57
+ expect(afterClear?.detachedSince).toBeUndefined();
58
+ expect(afterClear?.sandboxKey).toBe("sandbox-abc");
59
+ expect(afterClear?.cancelRequested).toBe(true);
60
+ expect(afterClear?.driverEpoch).toBe(2);
61
+ await store.update("fc-1", { cancelRequested: false });
62
+ const afterExplicitFalse = await store.get("fc-1");
63
+ expect(afterExplicitFalse?.cancelRequested).toBe(false);
64
+ expect(afterExplicitFalse?.cancelRequested).not.toBeUndefined();
65
+ await store.update("fc-1", {
66
+ sandboxKey: "sandbox-xyz",
67
+ detachedSince: 900,
68
+ cancelRequested: true,
69
+ driverEpoch: 3
70
+ });
71
+ const beforeFullClear = await store.get("fc-1");
72
+ expect(beforeFullClear?.sandboxKey).toBe("sandbox-xyz");
73
+ expect(beforeFullClear?.detachedSince).toBe(900);
74
+ expect(beforeFullClear?.cancelRequested).toBe(true);
75
+ expect(beforeFullClear?.driverEpoch).toBe(3);
76
+ await store.update("fc-1", {
77
+ sandboxKey: void 0,
78
+ detachedSince: void 0,
79
+ cancelRequested: void 0,
80
+ driverEpoch: void 0
81
+ });
82
+ const afterFullClear = await store.get("fc-1");
83
+ expect(afterFullClear?.sandboxKey).toBeUndefined();
84
+ expect(afterFullClear?.detachedSince).toBeUndefined();
85
+ expect(afterFullClear?.cancelRequested).toBeUndefined();
86
+ expect(afterFullClear?.driverEpoch).toBeUndefined();
87
+ expect(afterFullClear?.status).toBe("running");
88
+ expect(afterFullClear?.startedAt).toBe(1);
89
+ });
90
+ });
91
+ }
92
+ //#endregion
93
+ export { runDurableRunFieldsConformance };
94
+
95
+ //# sourceMappingURL=durable-run-fields-conformance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable-run-fields-conformance.js","names":[],"sources":["../../../src/testkit/durable-run-fields-conformance.ts"],"sourcesContent":["/**\n * Conformance for the DURABLE-RUN fields on a `RunStore`.\n *\n * These four fields (`sandboxKey`, `detachedSince`, `cancelRequested`,\n * `driverEpoch`) exist for durable sandboxed runs: detach on disconnect, takeover\n * by a later host, and the reaper. A chat-only app never writes them, so proving\n * them is NOT part of `runPersistenceConformance` in `@tanstack/ai-persistence`.\n * They live here, next to the takeover and reaper suites that depend on them.\n *\n * Run this when your app wires `withSandbox(sandbox, { runs, durability })`. The\n * fields round-trip through the REQUIRED `update`/`get` pair, so a backend can\n * pass every persistence case while silently dropping one of them, and the\n * failure then shows up as a run that looks permanently detached or a takeover\n * that cannot fence a superseded host.\n *\n * ```ts\n * import { runDurableRunFieldsConformance } from '@tanstack/ai-sandbox/testkit'\n * import { myPersistence } from './persistence'\n *\n * runDurableRunFieldsConformance('my postgres runs', () => myPersistence().stores.runs)\n * ```\n */\nimport { describe, expect, it } from 'vitest'\nimport type { RunStore } from '@tanstack/ai'\n\n/** Factory for the store under test. A fresh one per case keeps them isolated. */\nexport type MakeRunStore = () => RunStore | Promise<RunStore>\n\nexport function runDurableRunFieldsConformance(\n name: string,\n makeStore: MakeRunStore,\n): void {\n describe(`durable run fields conformance: ${name}`, () => {\n // One case, because the four fields share one failure mode: a backend that\n // filters `undefined` out of its `SET` clause, or coerces an absent column to\n // a falsy default, passes every other assertion while breaking detach and\n // takeover. Splitting it per field would hide that they must all behave the\n // same way through one `update`.\n it('round-trips the durable run fields, overwrites driverEpoch, and clears every one of them on explicit undefined', async () => {\n const store = await makeStore()\n\n await store.createOrResume({\n runId: 'fc-1',\n threadId: 'fc-t',\n startedAt: 1,\n })\n\n // 0. A fresh run that was never patched with these fields must read\n // back as undefined -- not null, not false, not 0. A backend that\n // coerces a NULL/absent column to a falsy default (e.g.\n // `cancelRequested: false`) is claiming knowledge (\"explicitly not\n // cancelled\") it does not have, and `toBeFalsy()` would not catch\n // it since `false` is falsy too.\n const fresh = await store.get('fc-1')\n expect(fresh?.cancelRequested).toBeUndefined()\n expect(fresh?.detachedSince).toBeUndefined()\n expect(fresh?.sandboxKey).toBeUndefined()\n expect(fresh?.driverEpoch).toBeUndefined()\n\n // 1. All four fields round-trip through update -> get.\n await store.update('fc-1', {\n sandboxKey: 'sandbox-abc',\n detachedSince: 500,\n cancelRequested: true,\n driverEpoch: 1,\n })\n const afterFirstUpdate = await store.get('fc-1')\n expect(afterFirstUpdate?.sandboxKey).toBe('sandbox-abc')\n expect(afterFirstUpdate?.detachedSince).toBe(500)\n expect(afterFirstUpdate?.cancelRequested).toBe(true)\n expect(afterFirstUpdate?.driverEpoch).toBe(1)\n\n // 2. A monotonic driverEpoch bump overwrites, it is not ignored (a\n // takeover host bumping the fencing token must actually stick).\n await store.update('fc-1', { driverEpoch: 2 })\n const afterEpochBump = await store.get('fc-1')\n expect(afterEpochBump?.driverEpoch).toBe(2)\n // Sibling fields untouched by an update that only names driverEpoch.\n expect(afterEpochBump?.sandboxKey).toBe('sandbox-abc')\n expect(afterEpochBump?.cancelRequested).toBe(true)\n\n // 3. update({ detachedSince: undefined }) actually CLEARS the field.\n // A backend whose SQL adapter filters `undefined` out of its `SET`\n // clause leaves the old value, and every re-attached run then looks\n // permanently detached to the reaper.\n await store.update('fc-1', { detachedSince: undefined })\n const afterClear = await store.get('fc-1')\n expect(afterClear?.detachedSince).toBeUndefined()\n // Clearing detachedSince must not clobber the other durable fields.\n expect(afterClear?.sandboxKey).toBe('sandbox-abc')\n expect(afterClear?.cancelRequested).toBe(true)\n expect(afterClear?.driverEpoch).toBe(2)\n\n // 4. cancelRequested: false written EXPLICITLY must round-trip as\n // `false`, distinct from the fresh-run `undefined` checked in step 0.\n // A backend storing this boolean in an integer/NULL-able column has\n // to preserve the false/undefined distinction in both directions,\n // not just collapse both to falsy.\n await store.update('fc-1', { cancelRequested: false })\n const afterExplicitFalse = await store.get('fc-1')\n expect(afterExplicitFalse?.cancelRequested).toBe(false)\n expect(afterExplicitFalse?.cancelRequested).not.toBeUndefined()\n\n // 5. An explicit `undefined` clears EVERY durable field, not just\n // `detachedSince`. Step 3 only exercised one of the four, so a backend\n // half-converted to `'field' in patch` -- `in` for `detachedSince`,\n // still `patch.field !== undefined` for the rest -- passed the whole\n // suite while its clears silently no-opped. Step 4's explicit `false`\n // also survives a `!== undefined` guard, so nothing else here bites\n // either. Re-populate first, so each clear has a value to remove and\n // an assertion that fails when the clear is dropped.\n await store.update('fc-1', {\n sandboxKey: 'sandbox-xyz',\n detachedSince: 900,\n cancelRequested: true,\n driverEpoch: 3,\n })\n const beforeFullClear = await store.get('fc-1')\n expect(beforeFullClear?.sandboxKey).toBe('sandbox-xyz')\n expect(beforeFullClear?.detachedSince).toBe(900)\n expect(beforeFullClear?.cancelRequested).toBe(true)\n expect(beforeFullClear?.driverEpoch).toBe(3)\n\n await store.update('fc-1', {\n sandboxKey: undefined,\n detachedSince: undefined,\n cancelRequested: undefined,\n driverEpoch: undefined,\n })\n const afterFullClear = await store.get('fc-1')\n expect(afterFullClear?.sandboxKey).toBeUndefined()\n expect(afterFullClear?.detachedSince).toBeUndefined()\n expect(afterFullClear?.cancelRequested).toBeUndefined()\n expect(afterFullClear?.driverEpoch).toBeUndefined()\n // Clearing the durable fields is not a delete: the run row survives,\n // and the fields the patch never named keep their values.\n expect(afterFullClear?.status).toBe('running')\n expect(afterFullClear?.startedAt).toBe(1)\n })\n\n // `findActiveRun` is REQUIRED on the RunStore contract — every backend that\n // provides a `runs` store must satisfy these invariants (most-recent-running\n // wins, thread-scoped, null when idle). Reconnect is built on it, and a\n // backend that always answers `null` disables reconnect indistinguishably\n // from one that is merely idle, so this must never degrade to a skip.\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,+BACd,MACA,WACM;CACN,SAAS,mCAAmC,cAAc;EAMxD,GAAG,kHAAkH,YAAY;GAC/H,MAAM,QAAQ,MAAM,UAAU;GAE9B,MAAM,MAAM,eAAe;IACzB,OAAO;IACP,UAAU;IACV,WAAW;GACb,CAAC;GAQD,MAAM,QAAQ,MAAM,MAAM,IAAI,MAAM;GACpC,OAAO,OAAO,eAAe,CAAC,CAAC,cAAc;GAC7C,OAAO,OAAO,aAAa,CAAC,CAAC,cAAc;GAC3C,OAAO,OAAO,UAAU,CAAC,CAAC,cAAc;GACxC,OAAO,OAAO,WAAW,CAAC,CAAC,cAAc;GAGzC,MAAM,MAAM,OAAO,QAAQ;IACzB,YAAY;IACZ,eAAe;IACf,iBAAiB;IACjB,aAAa;GACf,CAAC;GACD,MAAM,mBAAmB,MAAM,MAAM,IAAI,MAAM;GAC/C,OAAO,kBAAkB,UAAU,CAAC,CAAC,KAAK,aAAa;GACvD,OAAO,kBAAkB,aAAa,CAAC,CAAC,KAAK,GAAG;GAChD,OAAO,kBAAkB,eAAe,CAAC,CAAC,KAAK,IAAI;GACnD,OAAO,kBAAkB,WAAW,CAAC,CAAC,KAAK,CAAC;GAI5C,MAAM,MAAM,OAAO,QAAQ,EAAE,aAAa,EAAE,CAAC;GAC7C,MAAM,iBAAiB,MAAM,MAAM,IAAI,MAAM;GAC7C,OAAO,gBAAgB,WAAW,CAAC,CAAC,KAAK,CAAC;GAE1C,OAAO,gBAAgB,UAAU,CAAC,CAAC,KAAK,aAAa;GACrD,OAAO,gBAAgB,eAAe,CAAC,CAAC,KAAK,IAAI;GAMjD,MAAM,MAAM,OAAO,QAAQ,EAAE,eAAe,KAAA,EAAU,CAAC;GACvD,MAAM,aAAa,MAAM,MAAM,IAAI,MAAM;GACzC,OAAO,YAAY,aAAa,CAAC,CAAC,cAAc;GAEhD,OAAO,YAAY,UAAU,CAAC,CAAC,KAAK,aAAa;GACjD,OAAO,YAAY,eAAe,CAAC,CAAC,KAAK,IAAI;GAC7C,OAAO,YAAY,WAAW,CAAC,CAAC,KAAK,CAAC;GAOtC,MAAM,MAAM,OAAO,QAAQ,EAAE,iBAAiB,MAAM,CAAC;GACrD,MAAM,qBAAqB,MAAM,MAAM,IAAI,MAAM;GACjD,OAAO,oBAAoB,eAAe,CAAC,CAAC,KAAK,KAAK;GACtD,OAAO,oBAAoB,eAAe,CAAC,CAAC,IAAI,cAAc;GAU9D,MAAM,MAAM,OAAO,QAAQ;IACzB,YAAY;IACZ,eAAe;IACf,iBAAiB;IACjB,aAAa;GACf,CAAC;GACD,MAAM,kBAAkB,MAAM,MAAM,IAAI,MAAM;GAC9C,OAAO,iBAAiB,UAAU,CAAC,CAAC,KAAK,aAAa;GACtD,OAAO,iBAAiB,aAAa,CAAC,CAAC,KAAK,GAAG;GAC/C,OAAO,iBAAiB,eAAe,CAAC,CAAC,KAAK,IAAI;GAClD,OAAO,iBAAiB,WAAW,CAAC,CAAC,KAAK,CAAC;GAE3C,MAAM,MAAM,OAAO,QAAQ;IACzB,YAAY,KAAA;IACZ,eAAe,KAAA;IACf,iBAAiB,KAAA;IACjB,aAAa,KAAA;GACf,CAAC;GACD,MAAM,iBAAiB,MAAM,MAAM,IAAI,MAAM;GAC7C,OAAO,gBAAgB,UAAU,CAAC,CAAC,cAAc;GACjD,OAAO,gBAAgB,aAAa,CAAC,CAAC,cAAc;GACpD,OAAO,gBAAgB,eAAe,CAAC,CAAC,cAAc;GACtD,OAAO,gBAAgB,WAAW,CAAC,CAAC,cAAc;GAGlD,OAAO,gBAAgB,MAAM,CAAC,CAAC,KAAK,SAAS;GAC7C,OAAO,gBAAgB,SAAS,CAAC,CAAC,KAAK,CAAC;EAC1C,CAAC;CAOH,CAAC;AACH"}
@@ -0,0 +1,51 @@
1
+ import { JournalPaths } from '../journal.js';
2
+ import { SandboxHandle } from '../contracts.js';
3
+ export interface JournalConformanceConfig {
4
+ /** Provider name, used in the describe title. */
5
+ name: string;
6
+ /** Create a live sandbox plus its teardown. */
7
+ createHandle: () => Promise<{
8
+ handle: SandboxHandle;
9
+ dispose: () => Promise<void>;
10
+ }>;
11
+ /**
12
+ * Declare that this provider cannot journal, with the reason. Registers a
13
+ * skipped case whose title carries the reason. Omit it and the suite runs.
14
+ */
15
+ unsupported?: {
16
+ reason: string;
17
+ };
18
+ /**
19
+ * Declare that this provider's reads take the POLL strategy rather than the
20
+ * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles,
21
+ * because it lacks `backgroundProcesses` or `killableProcesses`. The two follow
22
+ * cases then register as NAMED skips carrying the reason.
23
+ *
24
+ * Declare this ONLY when the provider really cannot follow. It is checked
25
+ * against a live handle in a case that always runs
26
+ * ({@link expectDeclaredStrategy}), so a wrong declaration fails the suite in
27
+ * either direction rather than quietly removing coverage.
28
+ */
29
+ followUnsupported?: {
30
+ reason: string;
31
+ };
32
+ }
33
+ /**
34
+ * Block until the run's journal file exists in the sandbox.
35
+ *
36
+ * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see
37
+ * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp`
38
+ * differently, so an `fs` probe would report the wrong file.
39
+ *
40
+ * Exported for `./reaper-conformance.ts`, which needs the same bounded,
41
+ * shell-only wait before probing a still-producing run. Internal to the testkit;
42
+ * not part of the `./testkit` public surface.
43
+ */
44
+ export declare function waitForJournal(handle: SandboxHandle, paths: JournalPaths): Promise<void>;
45
+ /**
46
+ * Assert `createHandle` satisfies the journal conformance contract. Each `it`
47
+ * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may
48
+ * share process state across calls without cross-test bleed only if
49
+ * `createHandle` returns an isolated sandbox.
50
+ */
51
+ export declare function runJournalConformance(config: JournalConformanceConfig): void;
@@ -0,0 +1,378 @@
1
+ import { exitSentinelLine, journalExistsCommand, journalPaths, journalReadCommand, journaledCommand } from "../journal.js";
2
+ import { journalReadStrategy, readJournal } from "../journal-reader.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { describe, expect, it } from "vitest";
5
+ //#region src/testkit/journal-conformance.ts
6
+ /**
7
+ * Provider conformance for the agent output journal.
8
+ *
9
+ * The journal design rests on two provider-level claims: a command string is
10
+ * framed through a POSIX shell (so `>>` redirection works), and `tail -c +N -f`
11
+ * is available. Both are asserted here against a real sandbox rather than
12
+ * assumed from the audit.
13
+ *
14
+ * A provider that cannot satisfy them MUST declare `unsupported.reason`. There
15
+ * is deliberately no silent-skip path: a conformance case that quietly returns
16
+ * prints as a pass, which is how an unimplemented capability ships green. The
17
+ * three FOLLOW cases obey the same rule through a second declaration,
18
+ * {@link JournalConformanceConfig.followUnsupported} — see {@link itFollows} for
19
+ * why the strategy has to be declared rather than detected at registration time,
20
+ * and {@link expectDeclaredStrategy} for what keeps the declaration honest.
21
+ *
22
+ * THE THIRD FOLLOW CASE TESTS THE OTHER SIDE OF THE BOUNDARY, and it is here
23
+ * because the first two do not. `killableProcesses` is what selects `'follow'`
24
+ * over `'poll'`, and a wrong `true` means `tail -f` is spawned on the assumption
25
+ * it can be reclaimed — leaking one follower per run when it cannot. The two
26
+ * follow cases only ever asserted that the READER stops, which
27
+ * `journal-reader.ts`'s `untilAborted` guarantees on its own by abandoning the
28
+ * pipe the moment the signal fires. So both of them pass a provider whose
29
+ * `kill()` is `() => Promise.resolve()`, and three of the four `true`
30
+ * declarations in this repo were in fact false: Docker's `stream.destroy()` only
31
+ * detached the client, local-process's `sh -c` forks so signalling the shell left
32
+ * the command alive, and Vercel's `kill()` never called the SDK's real
33
+ * `Command.kill` at all. Every one of them shipped green through this suite.
34
+ * "kills the sandbox-side process, not just the host's view of it" is the case
35
+ * that fails them — see its own comment for how it probes.
36
+ *
37
+ * Vitest is an OPTIONAL peer dependency: this module is imported only from test
38
+ * files, which already run under Vitest.
39
+ */
40
+ /**
41
+ * Per-case timeout. Every case here spawns a real sandbox and a real agent.
42
+ *
43
+ * 180s, not the 60s this used to be, and it matches the ceiling
44
+ * `takeover-conformance.ts` already gives its heaviest cases. It is the one
45
+ * wall-clock number left in the file and it is deliberately far outside the range
46
+ * any healthy run needs: a case here makes half a dozen provider round-trips, and
47
+ * ONE `docker exec` on a loaded daemon has been measured at 9.6s (see
48
+ * `takeover-conformance.ts`'s `countingExec`) and at 20–45s on a saturated one, so
49
+ * a 60s budget put the timeout itself in the same load-sensitive class as the
50
+ * assertions that were removed from these cases — measured going red on cases that
51
+ * pass in 7–13s each on a quiet machine.
52
+ *
53
+ * This bound exists only so a genuine hang FAILS instead of parking CI; it is not
54
+ * an assertion about speed, and nothing here should be tuned to sit near it.
55
+ */
56
+ var CASE_TIMEOUT_MS = 18e4;
57
+ /**
58
+ * Register a case that only means anything on a provider whose reads FOLLOW.
59
+ *
60
+ * `journalReadStrategy` needs a live handle and a live handle needs the async
61
+ * `createHandle`, so the strategy is not knowable when the cases are registered.
62
+ * It is therefore DECLARED, and the declaration selects `it` or `it.skip` here.
63
+ *
64
+ * This exists because the alternative — checking the strategy inside the case and
65
+ * returning early — is the silent-skip the module doc forbids. Such a case prints
66
+ * `✓` with a duration and a title claiming a property was verified while every
67
+ * real assertion in it (including the incremental-delivery handshake, which is
68
+ * the entire reason the follow path exists) was skipped. A named `it.skip` prints
69
+ * `↓` with the reason instead.
70
+ */
71
+ function itFollows(config, title, fn) {
72
+ const unsupported = config.followUnsupported;
73
+ if (unsupported === void 0) {
74
+ it(title, fn, CASE_TIMEOUT_MS);
75
+ return;
76
+ }
77
+ it.skip(`${title} — follow strategy unsupported: ${unsupported.reason}`, fn, CASE_TIMEOUT_MS);
78
+ }
79
+ /**
80
+ * Assert the live handle's read strategy is the one the config DECLARED.
81
+ *
82
+ * BOTH directions are defects, and neither is a skip. A provider that declared
83
+ * `followUnsupported` but whose handles do follow silently loses the two cases it
84
+ * could pass. One that declared nothing but polls would reach the follow
85
+ * assertions and fail them for a reason unrelated to journaling — which is what
86
+ * the previous `expect(handle.capabilities.killableProcesses).toBe(false)` branch
87
+ * did to a provider with `backgroundProcesses: false, killableProcesses: true`.
88
+ * Either way the config does not describe the provider, and that is worth
89
+ * failing.
90
+ */
91
+ function expectDeclaredStrategy(handle, config) {
92
+ expect(journalReadStrategy(handle)).toBe(config.followUnsupported === void 0 ? "follow" : "poll");
93
+ }
94
+ /** Decode the base64 frame a journal read command produces into raw text. */
95
+ function decodeJournalRead(stdout) {
96
+ return Buffer.from(stdout.replace(/\s+/g, ""), "base64").toString("utf8");
97
+ }
98
+ /**
99
+ * Block until the run's journal file exists in the sandbox.
100
+ *
101
+ * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see
102
+ * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp`
103
+ * differently, so an `fs` probe would report the wrong file.
104
+ *
105
+ * Exported for `./reaper-conformance.ts`, which needs the same bounded,
106
+ * shell-only wait before probing a still-producing run. Internal to the testkit;
107
+ * not part of the `./testkit` public surface.
108
+ */
109
+ async function waitForJournal(handle, paths) {
110
+ const deadline = Date.now() + 15e3;
111
+ for (;;) {
112
+ if ((await handle.process.exec(journalExistsCommand(paths))).exitCode === 0) return;
113
+ if (Date.now() > deadline) throw new Error(`journal conformance: ${paths.journal} never appeared`);
114
+ await sleep(100);
115
+ }
116
+ }
117
+ function sleep(ms) {
118
+ return new Promise((resolve) => setTimeout(resolve, ms));
119
+ }
120
+ /**
121
+ * An absolute path inside the sandbox that no other case, suite, or machine will
122
+ * touch.
123
+ *
124
+ * Every character is in `[A-Za-z0-9./-]`, so these interpolate into the shell
125
+ * commands below as a single word without quoting. `/tmp` and not the workspace:
126
+ * on local-process a shell redirect reaches the host's real `/tmp` while
127
+ * `handle.fs` resolves under the sandbox root (see rule 3 in `../journal.ts`),
128
+ * and everything here is written AND read through the shell so the two never have
129
+ * to agree.
130
+ */
131
+ function noncePath(label) {
132
+ return `/tmp/tanstack-journal-conformance-${label}-${randomUUID()}`;
133
+ }
134
+ /** Iteration cap on the kill probe's loop, so nothing can outlive the suite. */
135
+ var PROBE_MAX_TICKS = 600;
136
+ /**
137
+ * Bound on a journal read, so a reader that delivers nothing FAILS instead of
138
+ * parking CI.
139
+ *
140
+ * Never an assertion, and deliberately far above anything a healthy read needs
141
+ * (measured: 10–18s for the follow cases on both providers). Each case that uses
142
+ * it proves its property some other way — a causal handshake, or
143
+ * `backstop.aborted` — so this number can be raised freely and must never be the
144
+ * thing a case is tuned against.
145
+ */
146
+ var READ_BACKSTOP_MS = 9e4;
147
+ /**
148
+ * How long to let an asynchronous kill land before the quiet window opens.
149
+ *
150
+ * A kill is asynchronous on every provider here — Docker signals through a
151
+ * second `exec`, local-process signals a process group and lets the OS reap — so
152
+ * one more heartbeat tick immediately after `kill()` resolves is not a survivor.
153
+ */
154
+ var KILL_SETTLE_MS = 5e3;
155
+ /**
156
+ * The quiet window: how long the heartbeat must stay frozen.
157
+ *
158
+ * This is NOT a load-sensitive bound, and the asymmetry is the point. A dead
159
+ * process can never write again, so a slow or busy machine can only make this
160
+ * window MORE reliable, never less — unlike a "must happen within Nms" ceiling,
161
+ * which fails on load. Only a live survivor can end this window, and a live
162
+ * survivor writes once a second.
163
+ */
164
+ var HEARTBEAT_QUIET_MS = 6e3;
165
+ /**
166
+ * Byte count of `path`, according to the SANDBOX'S OWN shell, or `null` when it
167
+ * cannot be read.
168
+ *
169
+ * `wc -c` through the shell, never `handle.fs`: on local-process the two resolve
170
+ * `/tmp` differently (rule 3), so an `fs` probe would answer about a file the
171
+ * sandbox never wrote and the growth below would look frozen from the first
172
+ * sample — a vacuous pass. Parsed strictly rather than coerced, so a shell
173
+ * diagnostic cannot become `NaN` and compare unequal to itself.
174
+ */
175
+ async function fileSize(handle, path) {
176
+ const text = (await handle.process.exec(`wc -c < ${path} 2>/dev/null`)).stdout.trim();
177
+ return /^\d+$/.test(text) ? Number(text) : null;
178
+ }
179
+ /**
180
+ * Wait until `path` has grown to at least `bytes`, i.e. the probe process is
181
+ * provably DOING WORK inside the sandbox, and answer whether it got there.
182
+ *
183
+ * Returning the observation rather than throwing keeps the verdict inside the
184
+ * case's own `expect`: this is the "before" half of the assertion, and it is what
185
+ * makes the "after" half a live detector instead of a formality.
186
+ */
187
+ async function waitForTicks(handle, path, bytes) {
188
+ const deadline = Date.now() + 3e4;
189
+ for (;;) {
190
+ const size = await fileSize(handle, path);
191
+ if (size !== null && size >= bytes) return true;
192
+ if (Date.now() > deadline) return false;
193
+ await sleep(1e3);
194
+ }
195
+ }
196
+ /**
197
+ * Assert `createHandle` satisfies the journal conformance contract. Each `it`
198
+ * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may
199
+ * share process state across calls without cross-test bleed only if
200
+ * `createHandle` returns an isolated sandbox.
201
+ */
202
+ function runJournalConformance(config) {
203
+ describe(`journal conformance — ${config.name}`, () => {
204
+ if (config.unsupported) {
205
+ it.skip(`unsupported: ${config.unsupported.reason}`, () => {
206
+ expect(true).toBe(true);
207
+ });
208
+ return;
209
+ }
210
+ it("redirects a command's stdout into the journal and appends the exit sentinel", async () => {
211
+ const { handle, dispose } = await config.createHandle();
212
+ try {
213
+ expectDeclaredStrategy(handle, config);
214
+ const paths = journalPaths(`conf-${Date.now()}`);
215
+ const command = journaledCommand(`printf '{"a":1}\\n{"b":2}\\n'`, paths);
216
+ expect(await (await handle.process.spawn(command)).wait()).toBe(0);
217
+ expect(decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout)).toBe(`{"a":1}\n{"b":2}\n${exitSentinelLine(paths, 0)}\n`);
218
+ } finally {
219
+ await dispose();
220
+ }
221
+ }, CASE_TIMEOUT_MS);
222
+ it("records the agent's non-zero exit in the sentinel", async () => {
223
+ const { handle, dispose } = await config.createHandle();
224
+ try {
225
+ const paths = journalPaths(`conf-exit-${Date.now()}`);
226
+ await (await handle.process.spawn(journaledCommand("exit 7", paths))).wait();
227
+ expect(decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout)).toBe(`${exitSentinelLine(paths, 7)}\n`);
228
+ } finally {
229
+ await dispose();
230
+ }
231
+ }, CASE_TIMEOUT_MS);
232
+ it("keeps the agent's stderr out of the journal", async () => {
233
+ const { handle, dispose } = await config.createHandle();
234
+ try {
235
+ const paths = journalPaths(`conf-err-${Date.now()}`);
236
+ await (await handle.process.spawn(journaledCommand(`printf '{"a":1}\\n'; printf 'a warning\\n' 1>&2`, paths))).wait();
237
+ const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout);
238
+ expect(text).toBe(`{"a":1}\n${exitSentinelLine(paths, 0)}\n`);
239
+ expect(text).not.toContain("a warning");
240
+ } finally {
241
+ await dispose();
242
+ }
243
+ }, CASE_TIMEOUT_MS);
244
+ it("reads incrementally from a byte offset with absolute positions", async () => {
245
+ const { handle, dispose } = await config.createHandle();
246
+ try {
247
+ const paths = journalPaths(`conf-seek-${Date.now()}`);
248
+ await (await handle.process.spawn(journaledCommand(`printf '{"a":1}\\n{"b":2}\\n'`, paths))).wait();
249
+ const all = [];
250
+ for await (const line of readJournal(handle, {
251
+ paths,
252
+ fromByte: 0,
253
+ strategy: "poll",
254
+ pollIntervalMs: 0,
255
+ signal: AbortSignal.timeout(READ_BACKSTOP_MS)
256
+ })) {
257
+ all.push(line);
258
+ if (all.length === 3) break;
259
+ }
260
+ expect(all.map((l) => l.line)).toEqual([
261
+ "{\"a\":1}",
262
+ "{\"b\":2}",
263
+ exitSentinelLine(paths, 0)
264
+ ]);
265
+ const resumed = [];
266
+ for await (const line of readJournal(handle, {
267
+ paths,
268
+ fromByte: all[0]?.endPosition ?? 0,
269
+ strategy: "poll",
270
+ pollIntervalMs: 0,
271
+ signal: AbortSignal.timeout(READ_BACKSTOP_MS)
272
+ })) {
273
+ resumed.push(line);
274
+ if (resumed.length === 2) break;
275
+ }
276
+ expect(resumed.map((l) => l.line)).toEqual(["{\"b\":2}", exitSentinelLine(paths, 0)]);
277
+ expect(resumed[0]?.endPosition).toBe(all[1]?.endPosition);
278
+ } finally {
279
+ await dispose();
280
+ }
281
+ }, CASE_TIMEOUT_MS);
282
+ itFollows(config, "follows a journal that is still being written, delivering each line before the next is produced", async () => {
283
+ expect.hasAssertions();
284
+ const { handle, dispose } = await config.createHandle();
285
+ const gate = noncePath("follow-gate");
286
+ try {
287
+ expectDeclaredStrategy(handle, config);
288
+ const paths = journalPaths(`conf-follow-${Date.now()}`);
289
+ const agentCommand = `printf '{"a":1}\\n'; i=0; while [ ! -f ${gate} ]; do i=$((i+1)); if [ $i -gt 30 ]; then printf '{"gate":"never"}\\n'; break; fi; sleep 1; done; printf '{"b":2}\\n'`;
290
+ handle.process.spawn(journaledCommand(agentCommand, paths));
291
+ await waitForJournal(handle, paths);
292
+ expect((await handle.process.exec(`test -e ${gate}`)).exitCode).not.toBe(0);
293
+ const seen = [];
294
+ for await (const line of readJournal(handle, {
295
+ paths,
296
+ fromByte: 0,
297
+ signal: AbortSignal.timeout(READ_BACKSTOP_MS)
298
+ })) {
299
+ seen.push(line.line);
300
+ if (seen.length === 1) await handle.process.exec(`: >> ${gate}`);
301
+ if (seen.length === 3) break;
302
+ }
303
+ expect(seen).toEqual([
304
+ "{\"a\":1}",
305
+ "{\"b\":2}",
306
+ exitSentinelLine(paths, 0)
307
+ ]);
308
+ } finally {
309
+ await handle.process.exec(`: >> ${gate}`).catch(() => void 0);
310
+ await dispose();
311
+ }
312
+ });
313
+ itFollows(config, "stops a follow read when its signal aborts, without a consumer break", async () => {
314
+ expect.hasAssertions();
315
+ const { handle, dispose } = await config.createHandle();
316
+ try {
317
+ expectDeclaredStrategy(handle, config);
318
+ const paths = journalPaths(`conf-abort-${Date.now()}`);
319
+ const agent = await handle.process.spawn(journaledCommand(`printf '{"a":1}\\n'; sleep 30`, paths));
320
+ try {
321
+ await waitForJournal(handle, paths);
322
+ const seen = [];
323
+ const stop = new AbortController();
324
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
325
+ for await (const line of readJournal(handle, {
326
+ paths,
327
+ fromByte: 0,
328
+ signal: AbortSignal.any([stop.signal, backstop])
329
+ })) {
330
+ seen.push(line.line);
331
+ stop.abort();
332
+ }
333
+ expect({
334
+ seen,
335
+ backstopped: backstop.aborted
336
+ }).toEqual({
337
+ seen: ["{\"a\":1}"],
338
+ backstopped: false
339
+ });
340
+ } finally {
341
+ await agent.kill();
342
+ }
343
+ } finally {
344
+ await dispose();
345
+ }
346
+ });
347
+ itFollows(config, "kills the sandbox-side process, not just the host's view of it", async () => {
348
+ expect.hasAssertions();
349
+ const { handle, dispose } = await config.createHandle();
350
+ const heartbeat = noncePath("killprobe-hb");
351
+ const stop = noncePath("killprobe-stop");
352
+ try {
353
+ expectDeclaredStrategy(handle, config);
354
+ const probe = await handle.process.spawn(`( i=0; while [ ! -f ${stop} ] && [ $i -lt ${PROBE_MAX_TICKS} ]; do printf '.' >> ${heartbeat}; i=$((i+1)); sleep 1; done ) & wait`);
355
+ const tickedBeforeKill = await waitForTicks(handle, heartbeat, 2);
356
+ await probe.kill();
357
+ await sleep(KILL_SETTLE_MS);
358
+ const atSettle = await fileSize(handle, heartbeat);
359
+ await sleep(HEARTBEAT_QUIET_MS);
360
+ const afterQuietWindow = await fileSize(handle, heartbeat);
361
+ expect({
362
+ tickedBeforeKill,
363
+ tickedAfterKill: atSettle === null || afterQuietWindow === null || atSettle !== afterQuietWindow
364
+ }).toEqual({
365
+ tickedBeforeKill: true,
366
+ tickedAfterKill: false
367
+ });
368
+ } finally {
369
+ await handle.process.exec(`: >> ${stop}; rm -f ${heartbeat}`).catch(() => void 0);
370
+ await dispose();
371
+ }
372
+ });
373
+ });
374
+ }
375
+ //#endregion
376
+ export { runJournalConformance, waitForJournal };
377
+
378
+ //# sourceMappingURL=journal-conformance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journal-conformance.js","names":[],"sources":["../../../src/testkit/journal-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for the agent output journal.\n *\n * The journal design rests on two provider-level claims: a command string is\n * framed through a POSIX shell (so `>>` redirection works), and `tail -c +N -f`\n * is available. Both are asserted here against a real sandbox rather than\n * assumed from the audit.\n *\n * A provider that cannot satisfy them MUST declare `unsupported.reason`. There\n * is deliberately no silent-skip path: a conformance case that quietly returns\n * prints as a pass, which is how an unimplemented capability ships green. The\n * three FOLLOW cases obey the same rule through a second declaration,\n * {@link JournalConformanceConfig.followUnsupported} — see {@link itFollows} for\n * why the strategy has to be declared rather than detected at registration time,\n * and {@link expectDeclaredStrategy} for what keeps the declaration honest.\n *\n * THE THIRD FOLLOW CASE TESTS THE OTHER SIDE OF THE BOUNDARY, and it is here\n * because the first two do not. `killableProcesses` is what selects `'follow'`\n * over `'poll'`, and a wrong `true` means `tail -f` is spawned on the assumption\n * it can be reclaimed — leaking one follower per run when it cannot. The two\n * follow cases only ever asserted that the READER stops, which\n * `journal-reader.ts`'s `untilAborted` guarantees on its own by abandoning the\n * pipe the moment the signal fires. So both of them pass a provider whose\n * `kill()` is `() => Promise.resolve()`, and three of the four `true`\n * declarations in this repo were in fact false: Docker's `stream.destroy()` only\n * detached the client, local-process's `sh -c` forks so signalling the shell left\n * the command alive, and Vercel's `kill()` never called the SDK's real\n * `Command.kill` at all. Every one of them shipped green through this suite.\n * \"kills the sandbox-side process, not just the host's view of it\" is the case\n * that fails them — see its own comment for how it probes.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { randomUUID } from 'node:crypto'\nimport { describe, expect, it } from 'vitest'\nimport {\n exitSentinelLine,\n journalExistsCommand,\n journalPaths,\n journalReadCommand,\n journaledCommand,\n} from '../journal'\nimport { journalReadStrategy, readJournal } from '../journal-reader'\nimport type { JournalPaths } from '../journal'\nimport type { SandboxHandle } from '../contracts'\n\nexport interface JournalConformanceConfig {\n /** Provider name, used in the describe title. */\n name: string\n /** Create a live sandbox plus its teardown. */\n createHandle: () => Promise<{\n handle: SandboxHandle\n dispose: () => Promise<void>\n }>\n /**\n * Declare that this provider cannot journal, with the reason. Registers a\n * skipped case whose title carries the reason. Omit it and the suite runs.\n */\n unsupported?: { reason: string }\n /**\n * Declare that this provider's reads take the POLL strategy rather than the\n * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles,\n * because it lacks `backgroundProcesses` or `killableProcesses`. The two follow\n * cases then register as NAMED skips carrying the reason.\n *\n * Declare this ONLY when the provider really cannot follow. It is checked\n * against a live handle in a case that always runs\n * ({@link expectDeclaredStrategy}), so a wrong declaration fails the suite in\n * either direction rather than quietly removing coverage.\n */\n followUnsupported?: { reason: string }\n}\n\n/**\n * Per-case timeout. Every case here spawns a real sandbox and a real agent.\n *\n * 180s, not the 60s this used to be, and it matches the ceiling\n * `takeover-conformance.ts` already gives its heaviest cases. It is the one\n * wall-clock number left in the file and it is deliberately far outside the range\n * any healthy run needs: a case here makes half a dozen provider round-trips, and\n * ONE `docker exec` on a loaded daemon has been measured at 9.6s (see\n * `takeover-conformance.ts`'s `countingExec`) and at 20–45s on a saturated one, so\n * a 60s budget put the timeout itself in the same load-sensitive class as the\n * assertions that were removed from these cases — measured going red on cases that\n * pass in 7–13s each on a quiet machine.\n *\n * This bound exists only so a genuine hang FAILS instead of parking CI; it is not\n * an assertion about speed, and nothing here should be tuned to sit near it.\n */\nconst CASE_TIMEOUT_MS = 180_000\n\n/**\n * Register a case that only means anything on a provider whose reads FOLLOW.\n *\n * `journalReadStrategy` needs a live handle and a live handle needs the async\n * `createHandle`, so the strategy is not knowable when the cases are registered.\n * It is therefore DECLARED, and the declaration selects `it` or `it.skip` here.\n *\n * This exists because the alternative — checking the strategy inside the case and\n * returning early — is the silent-skip the module doc forbids. Such a case prints\n * `✓` with a duration and a title claiming a property was verified while every\n * real assertion in it (including the incremental-delivery handshake, which is\n * the entire reason the follow path exists) was skipped. A named `it.skip` prints\n * `↓` with the reason instead.\n */\nfunction itFollows(\n config: JournalConformanceConfig,\n title: string,\n fn: () => Promise<void>,\n): void {\n const unsupported = config.followUnsupported\n if (unsupported === undefined) {\n it(title, fn, CASE_TIMEOUT_MS)\n return\n }\n it.skip(\n `${title} — follow strategy unsupported: ${unsupported.reason}`,\n fn,\n CASE_TIMEOUT_MS,\n )\n}\n\n/**\n * Assert the live handle's read strategy is the one the config DECLARED.\n *\n * BOTH directions are defects, and neither is a skip. A provider that declared\n * `followUnsupported` but whose handles do follow silently loses the two cases it\n * could pass. One that declared nothing but polls would reach the follow\n * assertions and fail them for a reason unrelated to journaling — which is what\n * the previous `expect(handle.capabilities.killableProcesses).toBe(false)` branch\n * did to a provider with `backgroundProcesses: false, killableProcesses: true`.\n * Either way the config does not describe the provider, and that is worth\n * failing.\n */\nfunction expectDeclaredStrategy(\n handle: SandboxHandle,\n config: JournalConformanceConfig,\n): void {\n expect(journalReadStrategy(handle)).toBe(\n config.followUnsupported === undefined ? 'follow' : 'poll',\n )\n}\n\n/** Decode the base64 frame a journal read command produces into raw text. */\nfunction decodeJournalRead(stdout: string): string {\n return Buffer.from(stdout.replace(/\\s+/g, ''), 'base64').toString('utf8')\n}\n\n/**\n * Block until the run's journal file exists in the sandbox.\n *\n * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see\n * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp`\n * differently, so an `fs` probe would report the wrong file.\n *\n * Exported for `./reaper-conformance.ts`, which needs the same bounded,\n * shell-only wait before probing a still-producing run. Internal to the testkit;\n * not part of the `./testkit` public surface.\n */\nexport async function waitForJournal(\n handle: SandboxHandle,\n paths: JournalPaths,\n): Promise<void> {\n const deadline = Date.now() + 15_000\n for (;;) {\n const probe = await handle.process.exec(journalExistsCommand(paths))\n if (probe.exitCode === 0) return\n if (Date.now() > deadline) {\n throw new Error(`journal conformance: ${paths.journal} never appeared`)\n }\n await sleep(100)\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * An absolute path inside the sandbox that no other case, suite, or machine will\n * touch.\n *\n * Every character is in `[A-Za-z0-9./-]`, so these interpolate into the shell\n * commands below as a single word without quoting. `/tmp` and not the workspace:\n * on local-process a shell redirect reaches the host's real `/tmp` while\n * `handle.fs` resolves under the sandbox root (see rule 3 in `../journal.ts`),\n * and everything here is written AND read through the shell so the two never have\n * to agree.\n */\nfunction noncePath(label: string): string {\n return `/tmp/tanstack-journal-conformance-${label}-${randomUUID()}`\n}\n\n/** Iteration cap on the kill probe's loop, so nothing can outlive the suite. */\nconst PROBE_MAX_TICKS = 600\n\n/**\n * Bound on a journal read, so a reader that delivers nothing FAILS instead of\n * parking CI.\n *\n * Never an assertion, and deliberately far above anything a healthy read needs\n * (measured: 10–18s for the follow cases on both providers). Each case that uses\n * it proves its property some other way — a causal handshake, or\n * `backstop.aborted` — so this number can be raised freely and must never be the\n * thing a case is tuned against.\n */\nconst READ_BACKSTOP_MS = 90_000\n\n/**\n * How long to let an asynchronous kill land before the quiet window opens.\n *\n * A kill is asynchronous on every provider here — Docker signals through a\n * second `exec`, local-process signals a process group and lets the OS reap — so\n * one more heartbeat tick immediately after `kill()` resolves is not a survivor.\n */\nconst KILL_SETTLE_MS = 5_000\n\n/**\n * The quiet window: how long the heartbeat must stay frozen.\n *\n * This is NOT a load-sensitive bound, and the asymmetry is the point. A dead\n * process can never write again, so a slow or busy machine can only make this\n * window MORE reliable, never less — unlike a \"must happen within Nms\" ceiling,\n * which fails on load. Only a live survivor can end this window, and a live\n * survivor writes once a second.\n */\nconst HEARTBEAT_QUIET_MS = 6_000\n\n/**\n * Byte count of `path`, according to the SANDBOX'S OWN shell, or `null` when it\n * cannot be read.\n *\n * `wc -c` through the shell, never `handle.fs`: on local-process the two resolve\n * `/tmp` differently (rule 3), so an `fs` probe would answer about a file the\n * sandbox never wrote and the growth below would look frozen from the first\n * sample — a vacuous pass. Parsed strictly rather than coerced, so a shell\n * diagnostic cannot become `NaN` and compare unequal to itself.\n */\nasync function fileSize(\n handle: SandboxHandle,\n path: string,\n): Promise<number | null> {\n const probe = await handle.process.exec(`wc -c < ${path} 2>/dev/null`)\n const text = probe.stdout.trim()\n return /^\\d+$/.test(text) ? Number(text) : null\n}\n\n/**\n * Wait until `path` has grown to at least `bytes`, i.e. the probe process is\n * provably DOING WORK inside the sandbox, and answer whether it got there.\n *\n * Returning the observation rather than throwing keeps the verdict inside the\n * case's own `expect`: this is the \"before\" half of the assertion, and it is what\n * makes the \"after\" half a live detector instead of a formality.\n */\nasync function waitForTicks(\n handle: SandboxHandle,\n path: string,\n bytes: number,\n): Promise<boolean> {\n const deadline = Date.now() + 30_000\n for (;;) {\n const size = await fileSize(handle, path)\n if (size !== null && size >= bytes) return true\n if (Date.now() > deadline) return false\n // Matched to the heartbeat's own 1s period on purpose. Every poll is a\n // provider round-trip, so a 250ms interval spent three of them per tick it\n // could not possibly observe — pure pressure on the very `exec` path the rest\n // of the case depends on.\n await sleep(1_000)\n }\n}\n\n/**\n * Assert `createHandle` satisfies the journal conformance contract. Each `it`\n * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may\n * share process state across calls without cross-test bleed only if\n * `createHandle` returns an isolated sandbox.\n */\nexport function runJournalConformance(config: JournalConformanceConfig): void {\n describe(`journal conformance — ${config.name}`, () => {\n if (config.unsupported) {\n it.skip(`unsupported: ${config.unsupported.reason}`, () => {\n expect(true).toBe(true)\n })\n return\n }\n\n it(\n \"redirects a command's stdout into the journal and appends the exit sentinel\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n // Checked HERE, in a case that always runs, because\n // `followUnsupported` gates the two follow cases below: a declaration\n // that does not match the live handle must fail the suite rather than\n // remove coverage from it. This is the only place a `poll` declaration\n // can be caught, since the cases it skips never execute.\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-${Date.now()}`)\n const command = journaledCommand(\n `printf '{\"a\":1}\\\\n{\"b\":2}\\\\n'`,\n paths,\n )\n const proc = await handle.process.spawn(command)\n expect(await proc.wait()).toBe(0)\n\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`{\"a\":1}\\n{\"b\":2}\\n${exitSentinelLine(paths, 0)}\\n`)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n \"records the agent's non-zero exit in the sentinel\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-exit-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand('exit 7', paths),\n )\n await proc.wait()\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`${exitSentinelLine(paths, 7)}\\n`)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n \"keeps the agent's stderr out of the journal\",\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-err-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand(\n `printf '{\"a\":1}\\\\n'; printf 'a warning\\\\n' 1>&2`,\n paths,\n ),\n )\n await proc.wait()\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n expect(text).toBe(`{\"a\":1}\\n${exitSentinelLine(paths, 0)}\\n`)\n expect(text).not.toContain('a warning')\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n it(\n 'reads incrementally from a byte offset with absolute positions',\n async () => {\n const { handle, dispose } = await config.createHandle()\n try {\n const paths = journalPaths(`conf-seek-${Date.now()}`)\n const proc = await handle.process.spawn(\n journaledCommand(`printf '{\"a\":1}\\\\n{\"b\":2}\\\\n'`, paths),\n )\n await proc.wait()\n\n const all = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n strategy: 'poll',\n pollIntervalMs: 0,\n // Not an assertion — see {@link READ_BACKSTOP_MS}. This was\n // `AbortSignal.timeout(5_000)`, and it is a POLL read, so it costs one\n // provider round-trip per line: measured going red on a saturated\n // Docker daemon where a single `exec` took ~20s, while the lines it\n // asserts were perfectly correct.\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n all.push(line)\n if (all.length === 3) break\n }\n expect(all.map((l) => l.line)).toEqual([\n '{\"a\":1}',\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n\n const resumed = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: all[0]?.endPosition ?? 0,\n strategy: 'poll',\n pollIntervalMs: 0,\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n resumed.push(line)\n if (resumed.length === 2) break\n }\n expect(resumed.map((l) => l.line)).toEqual([\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n expect(resumed[0]?.endPosition).toBe(all[1]?.endPosition)\n } finally {\n await dispose()\n }\n },\n CASE_TIMEOUT_MS,\n )\n\n // This case is the reason `journalFollowCommand` pipes into nothing.\n // `tail -f journal | base64` delivers ZERO bytes while the agent is still\n // running — measured on GNU coreutils 8.32 `base64` and on busybox 1.36.1\n // `base64` in Alpine — because the encoder buffers its stdout until its\n // stdin closes, which only happens when the reader kills `tail`.\n //\n // INCREMENTAL, NOT MERELY EVENTUAL, AND PROVED CAUSALLY RATHER THAN BY A\n // STOPWATCH. The agent writes its first line and then BLOCKS on a gate file\n // that only this reader can create, and it creates it only on receiving that\n // first line. So the agent cannot reach its second line until the first was\n // delivered — receiving `{\"b\":2}` at all IS the proof of incremental\n // delivery, and the `toEqual` below is the whole assertion. A buffering\n // filter reintroduced onto the follow path deadlocks instead: nothing is\n // delivered, the gate is never created, the agent never writes its second\n // line, the read ends on its signal and `seen` is empty.\n //\n // This replaced `expect(firstLineMs).toBeLessThan(3_000)`, which measured\n // MACHINE LOAD as much as behavior: it was observed failing in whole-suite\n // fleet runs while passing 5/5 in isolation, because one `exec`/`spawn` is a\n // provider round-trip whose latency this suite does not control (a\n // `docker exec` on a loaded daemon has been measured at 9.6s). Same instinct\n // as `takeover-conformance.ts`'s `countingExec`: anchor on the property, not\n // on the clock. A bound that goes red on a busy machine teaches people to\n // ignore the suite. Do NOT \"fix\" a failure here by widening a window —\n // there is no window left to widen.\n itFollows(\n config,\n 'follows a journal that is still being written, delivering each line before the next is produced',\n async () => {\n // Every assertion below sits after an `await`, so a case that threw its way\n // out of the loop early would report an unrelated failure; this one reports\n // \"nothing was asserted\", which is the failure this case used to HIDE.\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n const gate = noncePath('follow-gate')\n try {\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-follow-${Date.now()}`)\n // The wait is bounded in the SANDBOX too, and on timeout it emits a\n // line that names what went wrong instead of the expected one — so a\n // gate that never arrives fails the `toEqual` with `{\"gate\":\"never\"}`\n // rather than eventually satisfying it. 30 ticks so that diagnostic\n // lands INSIDE `CASE_TIMEOUT_MS`; a longer cap would just time the case\n // out and lose the message.\n const agentCommand =\n `printf '{\"a\":1}\\\\n'; ` +\n `i=0; while [ ! -f ${gate} ]; do ` +\n `i=$((i+1)); ` +\n `if [ $i -gt 30 ]; then printf '{\"gate\":\"never\"}\\\\n'; break; fi; ` +\n `sleep 1; done; ` +\n `printf '{\"b\":2}\\\\n'`\n // Not awaited anywhere: reading the `__exit` sentinel below IS the\n // proof it finished. (`SpawnHandle.wait()` is not safe to call after the\n // fact on every provider — local-process registers a `close` listener at\n // call time, so a `wait()` issued after the process already exited never\n // resolves.)\n void handle.process.spawn(journaledCommand(agentCommand, paths))\n // `tail` on a file that does not exist yet exits immediately, and the\n // agent's spawn and the reader's spawn race. Waiting removes that race\n // WITHOUT touching the property under test: with a buffering filter on\n // the follow path the journal still exists, `tail` still runs, and the\n // reader still receives nothing.\n await waitForJournal(handle, paths)\n // The premise, pinned: the gate is genuinely absent, so the agent\n // really is blocked and its second line really is downstream of this\n // reader. Without this a pre-existing gate path would make the case\n // pass without following anything.\n expect(\n (await handle.process.exec(`test -e ${gate}`)).exitCode,\n ).not.toBe(0)\n const seen: Array<string> = []\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n // Not the assertion — see {@link READ_BACKSTOP_MS}. The gate's own\n // 30-tick cap fires well inside it, so the `{\"gate\":\"never\"}`\n // diagnostic still reaches this reader.\n signal: AbortSignal.timeout(READ_BACKSTOP_MS),\n })) {\n seen.push(line.line)\n // Releases the agent, and only from inside the stream. `touch` is\n // not portable to every BusyBox build with these flags, so this\n // creates the file with a redirect, through the shell — `handle.fs`\n // would write a path the agent's `test -f` cannot see on\n // local-process (rule 3).\n if (seen.length === 1) {\n await handle.process.exec(`: >> ${gate}`)\n }\n if (seen.length === 3) break\n }\n expect(seen).toEqual([\n '{\"a\":1}',\n '{\"b\":2}',\n exitSentinelLine(paths, 0),\n ])\n } finally {\n // Unblocks the agent even when the case failed, so no `while` loop\n // outlives it on a provider whose sandbox teardown does not reap.\n await handle.process.exec(`: >> ${gate}`).catch(() => undefined)\n await dispose()\n }\n },\n )\n\n // The follow read must obey its own AbortSignal rather than waiting for the\n // provider's `kill` to close the stream — a provider whose kill misses a\n // grandchild would otherwise hang the reader past its deadline.\n itFollows(\n config,\n 'stops a follow read when its signal aborts, without a consumer break',\n async () => {\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n try {\n expectDeclaredStrategy(handle, config)\n const paths = journalPaths(`conf-abort-${Date.now()}`)\n // Outlives the read on purpose: the journal must still be open, and the\n // agent still running, when the signal fires.\n const agent = await handle.process.spawn(\n journaledCommand(`printf '{\"a\":1}\\\\n'; sleep 30`, paths),\n )\n try {\n await waitForJournal(handle, paths)\n const seen: Array<string> = []\n // The signal fires ON the first line rather than on a stopwatch. The\n // old form was `AbortSignal.timeout(3_000)`, which asked the provider\n // to spawn a `tail` AND deliver a line inside 3s — measured failing on\n // a loaded Windows machine (git-bash `sh` + `tail`, `seen` came back\n // empty) while passing in isolation, the same load-sensitivity as the\n // `firstLineMs` bound the case above replaced. Aborting from inside the\n // stream keeps the property exactly: the agent is still running, the\n // journal is still open, and the reader must stop because its SIGNAL\n // said so.\n const stop = new AbortController()\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. It is not the assertion — `backstopped` below proves it\n // was not what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n signal: AbortSignal.any([stop.signal, backstop]),\n })) {\n seen.push(line.line)\n // No `break`, ever: the pre-fix reader honored a consumer break but\n // rode straight past its signal, so a `break` here would pass it.\n stop.abort()\n }\n expect({ seen, backstopped: backstop.aborted }).toEqual({\n seen: ['{\"a\":1}'],\n // The loop ended, and NOT because the backstop timed out — which is\n // the causal witness that \"the signal ends it at all\", with no clock\n // in the assertion.\n backstopped: false,\n })\n } finally {\n await agent.kill()\n }\n } finally {\n await dispose()\n }\n },\n )\n\n // THE CAPABILITY, not the reader's reaction to it.\n //\n // `killableProcesses` is the flag `journalReadStrategy` reads to choose\n // `'follow'`, and the promise it makes is about the SANDBOX-SIDE process:\n // `tail -f` may be spawned because the caller can reclaim it. Every other\n // case in this file asserts only that the READER stopped, which\n // `untilAborted` delivers unilaterally by abandoning the pipe — so all of\n // them pass a provider whose `kill()` is `() => Promise.resolve()`. This one\n // asks the sandbox itself.\n //\n // WHAT IT SPAWNS, AND WHY THAT SHAPE. The heartbeat loop is BACKGROUNDED\n // (`( … ) & wait`), so the long-lived work is a grandchild of the wrapper\n // shell rather than the wrapper itself. That is deliberate: it is the shape\n // of the defects this bites on. `sh -c '<cmd>'` does not reliably exec its\n // command, so a provider that signals only the wrapper leaves the real work\n // running — measured on local-process POSIX, where `sh -c 'sleep 987654321'`\n // survived `child.kill('SIGKILL')` — and Docker's `kill -SIG -\"$pid\"` group\n // form exists precisely so a backgrounded grandchild is not orphaned. A probe\n // that `exec`ed itself into the wrapper would be reclaimed by the correct and\n // the broken implementation alike, and prove nothing.\n //\n // WHY IT MEASURES WORK AND NOT EXISTENCE. `kill -0 <pid>` was the obvious\n // probe and it is WRONG here, measured: alpine's PID 1 under this provider is\n // `tail -f /dev/null`, which never `wait()`s, so a correctly killed child\n // lingers as an unreaped `[sleep]` forever and `kill -0` answers 0 for it.\n // That fails a healthy provider. `ps` is no better and is why the identity\n // here is a file rather than a nonce in the command line: on local-process\n // under Windows the shell is git-bash, whose MSYS `ps` prints only the process\n // IMAGE PATH (`/usr/bin/sleep`) and never argv — verified, `ps`, `ps -ef` and\n // `ps -W` all omit it — so `ps | grep <nonce>` would match nothing there, and\n // \"no match\" is indistinguishable from \"it is gone\": a vacuous pass on\n // exactly the provider whose kill was broken. (`pgrep` is worse: BusyBox has\n // it, git-bash does not.) A host-side census is not portable at all — Docker's\n // container-side process has no host process, and a remote provider has none\n // either.\n //\n // So the probe is a heartbeat: the process appends one byte per second to a\n // nonce-named file, through the shell. Only a RUNNING process can do that. A\n // zombie cannot, a killed process cannot, and no other test on the machine\n // writes to that path.\n //\n // BOTH OBSERVATIONS ARE ASSERTED, in one object, and the \"before\" one is not\n // decoration: a frozen-file check passes trivially against a file that never\n // grew at all, which is the exact failure shape this whole review keeps\n // turning up. `tickedBeforeKill` is what makes the detector live.\n itFollows(\n config,\n \"kills the sandbox-side process, not just the host's view of it\",\n async () => {\n expect.hasAssertions()\n const { handle, dispose } = await config.createHandle()\n const heartbeat = noncePath('killprobe-hb')\n const stop = noncePath('killprobe-stop')\n try {\n expectDeclaredStrategy(handle, config)\n // The `stop` file is how a SURVIVOR is reclaimed in teardown, since a\n // provider that fails this case cannot be trusted to kill it and the\n // suite must not leak a spinner either way. The tick cap is the second\n // net, for a teardown that never ran at all.\n const probe = await handle.process.spawn(\n `( i=0; while [ ! -f ${stop} ] && [ $i -lt ${PROBE_MAX_TICKS} ]; do ` +\n `printf '.' >> ${heartbeat}; i=$((i+1)); sleep 1; ` +\n `done ) & wait`,\n )\n // Two bytes, not one: one byte is \"it started\", two is \"it is looping\".\n const tickedBeforeKill = await waitForTicks(handle, heartbeat, 2)\n\n await probe.kill()\n await sleep(KILL_SETTLE_MS)\n const atSettle = await fileSize(handle, heartbeat)\n await sleep(HEARTBEAT_QUIET_MS)\n const afterQuietWindow = await fileSize(handle, heartbeat)\n\n expect({\n tickedBeforeKill,\n // An UNREADABLE sample counts as a tick, i.e. fails: the file was\n // provably readable a moment ago (`tickedBeforeKill`), so `null` here\n // means the probe itself broke and the quiet window proves nothing.\n // Two `null`s compare equal, which would otherwise read as \"frozen\".\n tickedAfterKill:\n atSettle === null ||\n afterQuietWindow === null ||\n atSettle !== afterQuietWindow,\n }).toEqual({ tickedBeforeKill: true, tickedAfterKill: false })\n } finally {\n await handle.process\n .exec(`: >> ${stop}; rm -f ${heartbeat}`)\n .catch(() => undefined)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}