harnery 0.41.0 → 0.42.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 (212) hide show
  1. package/dist/commander.js +3 -0
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +9 -1
  4. package/dist/commands/artifacts.d.ts.map +1 -1
  5. package/dist/commands/artifacts.js +113 -12
  6. package/dist/commands/browse.d.ts +164 -0
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +263 -51
  9. package/dist/commands/claude-desktop.d.ts.map +1 -1
  10. package/dist/commands/claude-desktop.js +52 -0
  11. package/dist/commands/disk.d.ts +6 -0
  12. package/dist/commands/disk.d.ts.map +1 -0
  13. package/dist/commands/disk.js +80 -0
  14. package/dist/commands/doctor.d.ts +8 -0
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +48 -1
  17. package/dist/commands/files.d.ts +16 -0
  18. package/dist/commands/files.d.ts.map +1 -1
  19. package/dist/commands/files.js +54 -2
  20. package/dist/commands/init.d.ts +2 -2
  21. package/dist/commands/init.d.ts.map +1 -1
  22. package/dist/commands/init.js +43 -9
  23. package/dist/commands/rm.d.ts +4 -0
  24. package/dist/commands/rm.d.ts.map +1 -0
  25. package/dist/commands/rm.js +87 -0
  26. package/dist/commands/servers.d.ts +7 -0
  27. package/dist/commands/servers.d.ts.map +1 -0
  28. package/dist/commands/servers.js +394 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +82 -2
  31. package/dist/core/adapters/profiles.d.ts +9 -0
  32. package/dist/core/adapters/profiles.d.ts.map +1 -1
  33. package/dist/core/adapters/profiles.js +22 -1
  34. package/dist/core/adapters/types.d.ts +9 -0
  35. package/dist/core/adapters/types.d.ts.map +1 -1
  36. package/dist/core/agents/cli.js +13 -6
  37. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/session-context.js +2 -0
  39. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  40. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  41. package/dist/core/agents/state/live-coordination-view.js +60 -0
  42. package/dist/core/artifacts/delivery-card.js +9 -2
  43. package/dist/core/artifacts/index.d.ts +140 -4
  44. package/dist/core/artifacts/index.d.ts.map +1 -1
  45. package/dist/core/artifacts/index.js +522 -49
  46. package/dist/core/config.d.ts +68 -11
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +128 -3
  49. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  50. package/dist/core/diagnostics/bundle.js +2 -2
  51. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  52. package/dist/core/events/v3/producers/recorder.js +16 -0
  53. package/dist/core/events/v3/projection.js +9 -1
  54. package/dist/core/hooks/adapter/events.d.ts +17 -0
  55. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  56. package/dist/core/hooks/adapter/events.js +5 -0
  57. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  58. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  59. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  60. package/dist/core/hooks/adapter/wiring.js +62 -4
  61. package/dist/core/hooks/cli.js +45 -6
  62. package/dist/core/hooks/run-markers.d.ts +37 -0
  63. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  64. package/dist/core/hooks/run-markers.js +109 -0
  65. package/dist/core/servers/index.d.ts +211 -0
  66. package/dist/core/servers/index.d.ts.map +1 -0
  67. package/dist/core/servers/index.js +560 -0
  68. package/dist/core/servers/net.d.ts +58 -0
  69. package/dist/core/servers/net.d.ts.map +1 -0
  70. package/dist/core/servers/net.js +270 -0
  71. package/dist/core/servers/tunnels.d.ts +14 -0
  72. package/dist/core/servers/tunnels.d.ts.map +1 -0
  73. package/dist/core/servers/tunnels.js +33 -0
  74. package/dist/core/storage/builtins.js +1 -0
  75. package/dist/core/workflow/engine.d.ts.map +1 -1
  76. package/dist/core/workflow/engine.js +30 -1
  77. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  78. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  79. package/dist/core/workflow/sandbox-projection.js +23 -0
  80. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  81. package/dist/core/workflow/spawn-claude.js +6 -1
  82. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  83. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  84. package/dist/core/workflow/spawn-codex.js +20 -1
  85. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  86. package/dist/core/workflow/spawn-cursor.js +6 -1
  87. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  88. package/dist/core/workflow/spawn-opencode.js +4 -1
  89. package/dist/core/workflow/types.d.ts +47 -2
  90. package/dist/core/workflow/types.d.ts.map +1 -1
  91. package/dist/core/workflow/worker-access.d.ts +92 -0
  92. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  93. package/dist/core/workflow/worker-access.js +450 -0
  94. package/dist/lib/browser/batch-steps.d.ts +18 -0
  95. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  96. package/dist/lib/browser/batch-steps.js +82 -0
  97. package/dist/lib/browser/collect/collector.d.ts +99 -0
  98. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  99. package/dist/lib/browser/collect/collector.js +202 -0
  100. package/dist/lib/browser/collect/fields.d.ts +31 -0
  101. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  102. package/dist/lib/browser/collect/fields.js +75 -0
  103. package/dist/lib/browser/collect/index.d.ts +6 -0
  104. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  105. package/dist/lib/browser/collect/index.js +5 -0
  106. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  107. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  108. package/dist/lib/browser/collect/page-driver.js +306 -0
  109. package/dist/lib/browser/collect/presets.d.ts +52 -0
  110. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  111. package/dist/lib/browser/collect/presets.js +176 -0
  112. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  113. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  114. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  115. package/dist/lib/browser/index.d.ts +1 -1
  116. package/dist/lib/browser/index.d.ts.map +1 -1
  117. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  118. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  119. package/dist/lib/browser/page-review-judge.js +13 -0
  120. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  121. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  122. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  123. package/dist/lib/browser/qa-run.js +1 -0
  124. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  125. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  126. package/dist/lib/browser/windows-chrome.js +179 -0
  127. package/dist/lib/claude-desktop-share.d.ts +136 -0
  128. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  129. package/dist/lib/claude-desktop-share.js +394 -0
  130. package/dist/lib/disk-usage.d.ts +50 -0
  131. package/dist/lib/disk-usage.d.ts.map +1 -0
  132. package/dist/lib/disk-usage.js +303 -0
  133. package/dist/lib/docs-lint.d.ts +6 -0
  134. package/dist/lib/docs-lint.d.ts.map +1 -1
  135. package/dist/lib/docs-lint.js +8 -3
  136. package/dist/lib/guarded-remove.d.ts +34 -0
  137. package/dist/lib/guarded-remove.d.ts.map +1 -0
  138. package/dist/lib/guarded-remove.js +310 -0
  139. package/dist/lib/instructions/templates.js +8 -8
  140. package/dist/lib/tunnel/error-page.d.ts +1 -1
  141. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  142. package/dist/lib/tunnel/error-page.js +19 -8
  143. package/dist/lib/tunnel/gate.js +20 -1
  144. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  145. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  146. package/dist/lib/tunnel/path-scope.js +62 -0
  147. package/dist/lib/tunnel/state.d.ts +5 -0
  148. package/dist/lib/tunnel/state.d.ts.map +1 -1
  149. package/dist/lib/tunnel/state.js +17 -3
  150. package/package.json +6 -1
  151. package/schemas/config.schema.json +91 -0
  152. package/src/commander.ts +19 -0
  153. package/src/commands/agents.ts +14 -0
  154. package/src/commands/artifacts.ts +171 -19
  155. package/src/commands/browse.ts +400 -57
  156. package/src/commands/claude-desktop.ts +61 -0
  157. package/src/commands/disk.ts +114 -0
  158. package/src/commands/doctor.ts +52 -1
  159. package/src/commands/files.ts +93 -2
  160. package/src/commands/init.ts +55 -5
  161. package/src/commands/rm.ts +115 -0
  162. package/src/commands/servers.ts +506 -0
  163. package/src/commands/tunnel.ts +102 -2
  164. package/src/core/adapters/profiles.ts +22 -1
  165. package/src/core/adapters/types.ts +10 -0
  166. package/src/core/agents/cli.ts +14 -6
  167. package/src/core/agents/render/session-context.ts +1 -0
  168. package/src/core/agents/state/live-coordination-view.ts +73 -0
  169. package/src/core/artifacts/delivery-card.ts +10 -2
  170. package/src/core/artifacts/index.ts +697 -44
  171. package/src/core/config.ts +183 -4
  172. package/src/core/diagnostics/bundle.ts +2 -1
  173. package/src/core/events/v3/producers/recorder.ts +16 -0
  174. package/src/core/events/v3/projection.ts +11 -1
  175. package/src/core/hooks/adapter/events.ts +19 -0
  176. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  177. package/src/core/hooks/adapter/wiring.ts +116 -8
  178. package/src/core/hooks/cli.ts +47 -6
  179. package/src/core/hooks/run-markers.ts +132 -0
  180. package/src/core/servers/index.ts +712 -0
  181. package/src/core/servers/net.ts +283 -0
  182. package/src/core/servers/tunnels.ts +39 -0
  183. package/src/core/storage/builtins.ts +1 -0
  184. package/src/core/workflow/engine.ts +38 -1
  185. package/src/core/workflow/sandbox-projection.ts +38 -2
  186. package/src/core/workflow/spawn-claude.ts +5 -1
  187. package/src/core/workflow/spawn-codex.ts +18 -1
  188. package/src/core/workflow/spawn-cursor.ts +5 -1
  189. package/src/core/workflow/spawn-opencode.ts +4 -1
  190. package/src/core/workflow/types.ts +62 -2
  191. package/src/core/workflow/worker-access.ts +525 -0
  192. package/src/lib/browser/batch-steps.ts +79 -0
  193. package/src/lib/browser/collect/collector.ts +306 -0
  194. package/src/lib/browser/collect/fields.ts +87 -0
  195. package/src/lib/browser/collect/index.ts +47 -0
  196. package/src/lib/browser/collect/page-driver.ts +327 -0
  197. package/src/lib/browser/collect/presets.ts +237 -0
  198. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  199. package/src/lib/browser/index.ts +1 -0
  200. package/src/lib/browser/page-review-judge.ts +16 -0
  201. package/src/lib/browser/qa-run-contracts.ts +11 -0
  202. package/src/lib/browser/qa-run.ts +1 -0
  203. package/src/lib/browser/windows-chrome.ts +227 -0
  204. package/src/lib/claude-desktop-share.ts +511 -0
  205. package/src/lib/disk-usage.ts +347 -0
  206. package/src/lib/docs-lint.ts +8 -3
  207. package/src/lib/guarded-remove.ts +376 -0
  208. package/src/lib/instructions/templates.ts +8 -8
  209. package/src/lib/tunnel/error-page.ts +25 -9
  210. package/src/lib/tunnel/gate.ts +22 -1
  211. package/src/lib/tunnel/path-scope.ts +73 -0
  212. package/src/lib/tunnel/state.ts +20 -3
@@ -5,7 +5,9 @@
5
5
  * become project records: screenshots, exports, audit dumps, rollback inputs,
6
6
  * and similar material. Each direct child of `.harnery/artifacts/` is one
7
7
  * managed unit with a small manifest. Cleanup fails closed: only a valid,
8
- * expired, inactive, untracked managed unit is deletable.
8
+ * unheld, inactive, untracked managed unit is deletable, and only when its
9
+ * retention expired or a size rule applies after its idle grace. Explicit
10
+ * removal lets a creating owner delete its reviewed, unheld workspace sooner.
9
11
  */
10
12
 
11
13
  import { spawnSync } from "node:child_process";
@@ -17,8 +19,10 @@ import {
17
19
  mkdirSync,
18
20
  readdirSync,
19
21
  readFileSync,
22
+ realpathSync,
20
23
  renameSync,
21
24
  rmSync,
25
+ statfsSync,
22
26
  writeFileSync,
23
27
  } from "node:fs";
24
28
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -28,11 +32,20 @@ import {
28
32
  artifactAutoCleanEnabled,
29
33
  artifactAutoCleanIntervalHours,
30
34
  artifactDefaultRetentionDays,
35
+ artifactHoldDays,
36
+ artifactIdleGraceHours,
31
37
  artifactMaxBytes,
38
+ artifactMaxHeldBytes,
32
39
  artifactMaxUnitBytes,
40
+ artifactMinFreeBytes,
33
41
  coordFreshnessSeconds,
34
42
  resolveBinName,
35
43
  } from "../config.ts";
44
+ import {
45
+ readCoordinationViewV3,
46
+ requireAuthoritySafeCoordinationViewV3,
47
+ } from "../events/v3/coordination-view.ts";
48
+ import { liveInstanceIdV3 } from "../events/v3/live-route-observer.ts";
36
49
  import { stateFileMode } from "../storage/modes.ts";
37
50
  import {
38
51
  type ArtifactActivity,
@@ -92,11 +105,23 @@ export interface ArtifactHold {
92
105
  reason: string;
93
106
  set_by: ArtifactActor;
94
107
  set_at: string;
108
+ /** When the hold lapses unless renewed. Absent only on a persistent hold or
109
+ * a hold recorded before holds expired; the latter stays in force. */
110
+ expires_at?: string;
111
+ /** Set by embedding hosts whose hold mirrors an external lease. Never lapses. */
112
+ persistent?: true;
95
113
  }
96
114
 
97
115
  export interface ArtifactHoldInput {
98
116
  id: string;
99
117
  reason: string;
118
+ /** Hold lifetime in days (1 to 365); defaults to `artifacts.hold_days`. */
119
+ days?: number;
120
+ /** Hold lifetime in minutes (1 to 525,600); replaces `days`. */
121
+ minutes?: number;
122
+ /** For a hold that mirrors an external lease, such as an open checkout with
123
+ * unsynchronized work. The hold never lapses; its owner must remove it. */
124
+ persistent?: boolean;
100
125
  }
101
126
 
102
127
  export function artifactCapabilities() {
@@ -108,6 +133,12 @@ export function artifactCapabilities() {
108
133
  explicit_v1_migration: true,
109
134
  minute_retention: true,
110
135
  discard_after_review: true,
136
+ owner_scoped_remove: true,
137
+ allow_big_after_create: true,
138
+ hold_expiry: true,
139
+ persistent_holds: true,
140
+ held_budget: true,
141
+ disk_free_report: true,
111
142
  } as const;
112
143
  }
113
144
 
@@ -131,7 +162,11 @@ export interface ArtifactInventoryEntry {
131
162
  classification: ArtifactClassification;
132
163
  reason: string;
133
164
  action: "keep" | "would-delete" | "deleted";
165
+ /** Disk use: allocated blocks, each hard-linked file counted once. Every
166
+ * size rule reads this figure. */
134
167
  bytes: number | null;
168
+ /** Sum of file lengths. A sparse file makes this larger than `bytes`. */
169
+ apparent_bytes: number | null;
135
170
  artifact_id: string | null;
136
171
  slug: string | null;
137
172
  created_at: string | null;
@@ -139,6 +174,11 @@ export interface ArtifactInventoryEntry {
139
174
  expires_at: string | null;
140
175
  owner_instance_id: string | null;
141
176
  oversize_acknowledged: boolean;
177
+ /** Latest file change, owner heartbeat, renewal, or release. Size rules wait
178
+ * `artifacts.idle_grace_hours` past this before they may delete. */
179
+ idle_since: string | null;
180
+ /** Advice for a unit that a size rule will delete once its grace ends. */
181
+ warning: string | null;
142
182
  }
143
183
 
144
184
  export interface ArtifactCreateInput {
@@ -214,7 +254,8 @@ function createArtifactUnlocked(
214
254
  if (!isSafeId(artifactId)) {
215
255
  throw new Error("artifact id must use ASCII letters, digits, hyphens, or underscores");
216
256
  }
217
- const holds = (input.holds ?? []).map((hold) => makeHold(hold, input.actor, now));
257
+ const holdDays = artifactHoldDays(repoRoot);
258
+ const holds = (input.holds ?? []).map((hold) => makeHold(hold, input.actor, now, holdDays));
218
259
  if (new Set(holds.map((hold) => hold.id)).size !== holds.length) {
219
260
  throw new Error("duplicate hold id");
220
261
  }
@@ -269,7 +310,7 @@ export function inventoryArtifacts(
269
310
  for (const name of names) {
270
311
  rows.push(classifyArtifactPath(repoRoot, join(root, name), now, freshnessSeconds));
271
312
  }
272
- return applyArtifactBudgets(repoRoot, rows);
313
+ return applyArtifactBudgets(repoRoot, rows, now);
273
314
  }
274
315
 
275
316
  export function showArtifact(
@@ -327,6 +368,27 @@ function renewArtifactUnlocked(
327
368
  return manifest;
328
369
  }
329
370
 
371
+ /**
372
+ * Record the `--big` acknowledgement on an existing workspace, for work that
373
+ * turns out larger than expected. Unlike a hold, it leaves expiry in force and
374
+ * exempts the unit only from the per-bundle ceiling. Idempotent.
375
+ */
376
+ export function allowBigArtifact(
377
+ repoRoot: string,
378
+ ref: string,
379
+ input: ArtifactMutationInput = {},
380
+ ): ArtifactManifestV2 {
381
+ return withArtifactLock(repoRoot, () => {
382
+ const path = resolveArtifactRef(repoRoot, ref);
383
+ const parsed = readManifest(path);
384
+ if (!parsed.ok) throw new Error(parsed.reason);
385
+ if (parsed.manifest.oversize_acknowledged) return parsed.manifest;
386
+ const manifest: ArtifactManifestV2 = { ...parsed.manifest, oversize_acknowledged: true };
387
+ atomicWriteManifest(path, manifest, input.now);
388
+ return manifest;
389
+ });
390
+ }
391
+
330
392
  export function releaseArtifact(
331
393
  repoRoot: string,
332
394
  ref: string,
@@ -385,6 +447,112 @@ export function discardArtifact(
385
447
  });
386
448
  }
387
449
 
450
+ export interface ArtifactRemovalResult {
451
+ entry: ArtifactInventoryEntry;
452
+ reason: string;
453
+ deleted: boolean;
454
+ }
455
+
456
+ /** Preview or immediately remove one reviewed workspace belonging to this actor. */
457
+ export function removeArtifact(
458
+ repoRoot: string,
459
+ ref: string,
460
+ reason: string,
461
+ input: ArtifactMutationInput & { yes?: boolean } = {},
462
+ ): ArtifactRemovalResult {
463
+ const why = reason.trim();
464
+ if (!why) throw new Error("remove requires a reason confirming the files are no longer needed");
465
+ if (!validActor(input.actor))
466
+ throw new Error("remove requires a current artifact owner identity");
467
+ const actor = input.actor;
468
+ const now = input.now ?? new Date();
469
+ assertValidDate(now, "now");
470
+ const store = artifactsRoot(repoRoot);
471
+ const checkStore = () => {
472
+ for (const path of [resolve(repoRoot), dirname(store), store]) {
473
+ const stat = lstatSync(path);
474
+ if (!stat.isDirectory() || stat.isSymbolicLink())
475
+ throw new Error("artifact store must use real directories, not symlinks");
476
+ }
477
+ };
478
+ checkStore();
479
+ return withArtifactLock(repoRoot, () => {
480
+ checkStore();
481
+ const path = resolveArtifactRef(repoRoot, ref);
482
+ const inspect = () => {
483
+ const target = realpathSync(path);
484
+ const cwd = realpathSync(process.cwd());
485
+ if (cwd === target || cwd.startsWith(`${target}${sep}`))
486
+ throw new Error("cannot remove the working directory or its ancestor");
487
+ if (lstatSync(path).dev !== lstatSync(store).dev)
488
+ throw new Error("artifact removal cannot cross a mount boundary");
489
+ const entry = classifyArtifactPath(repoRoot, path, now, coordFreshnessSeconds(repoRoot));
490
+ if (!["managed-current", "managed-expired", "managed-active"].includes(entry.classification))
491
+ throw new Error(`cannot remove artifact: ${entry.reason}`);
492
+ if (entry.owner_instance_id !== actor.instance_id)
493
+ throw new Error("only the creating artifact owner may remove this workspace");
494
+ const view = requireAuthoritySafeCoordinationViewV3(readCoordinationViewV3(repoRoot));
495
+ for (const peer of Object.values(view.instances)) {
496
+ if (!peer.authority_eligible || peer.instance_id === liveInstanceIdV3(actor.instance_id))
497
+ continue;
498
+ for (const claim of peer.files_touched) {
499
+ const claimed = resolve(repoRoot, claim);
500
+ if (
501
+ path === claimed ||
502
+ path.startsWith(`${claimed}${sep}`) ||
503
+ claimed.startsWith(`${path}${sep}`)
504
+ )
505
+ throw new Error(`artifact overlaps another agent's claim: ${claim}`);
506
+ }
507
+ }
508
+ return entry;
509
+ };
510
+ const entry = inspect();
511
+ const before = artifactRemovalSnapshot(path);
512
+ if (!input.yes) return { entry, reason: why, deleted: false };
513
+ // Metadata mutations share this lock. Recheck payload, claims, and protections
514
+ // immediately before removal because ordinary file writers do not take it.
515
+ checkStore();
516
+ const current = inspect();
517
+ if (
518
+ !isDeepStrictEqual(entry, current) ||
519
+ !isDeepStrictEqual(before, artifactRemovalSnapshot(path))
520
+ )
521
+ throw new Error("artifact changed during removal inspection; preview it again");
522
+ rmSync(path, { recursive: true, force: false });
523
+ const removed = { ...current, action: "deleted" as const, reason: why };
524
+ recordArtifactDeletion(repoRoot, removed, now, actor);
525
+ return { entry: removed, reason: why, deleted: true };
526
+ });
527
+ }
528
+
529
+ /** Refuse links, mounts, special files, and embedded stores; fingerprint every entry. */
530
+ function artifactRemovalSnapshot(path: string): Map<string, string> {
531
+ const device = lstatSync(path).dev;
532
+ const entries = new Map<string, string>();
533
+ const walk = (target: string) => {
534
+ const stat = lstatSync(target);
535
+ if (stat.isSymbolicLink() || stat.dev !== device || (!stat.isFile() && !stat.isDirectory()))
536
+ throw new Error(`unsafe artifact removal path: ${target}`);
537
+ if (target !== path && [".git", ".harnery"].includes(basename(target)))
538
+ throw new Error(`embedded repository or coordination state cannot be removed: ${target}`);
539
+ if (target !== join(path, ARTIFACT_MANIFEST) && basename(target) === ARTIFACT_MANIFEST)
540
+ throw new Error(`nested artifact must be managed separately: ${target}`);
541
+ entries.set(
542
+ target,
543
+ [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"),
544
+ );
545
+ if (stat.isDirectory()) {
546
+ const names = readdirSync(target).sort();
547
+ if (names.includes("HEAD") && names.includes("objects") && names.includes("config"))
548
+ throw new Error(`embedded Git metadata cannot be removed: ${target}`);
549
+ for (const name of names) walk(join(target, name));
550
+ }
551
+ };
552
+ walk(path);
553
+ return entries;
554
+ }
555
+
388
556
  /** Advice only: a successful check does not establish that its evidence is disposable. */
389
557
  export function artifactReviewGuidance(repoRoot: string, ref: string): string {
390
558
  const target = isAbsolute(ref) ? basename(ref) : ref;
@@ -417,7 +585,7 @@ export function holdArtifact(
417
585
  ref: string,
418
586
  input: ArtifactHoldInput & { actor: ArtifactActor; now?: Date },
419
587
  ): ArtifactManifestV2 {
420
- const hold = makeHold(input, input.actor, input.now ?? new Date());
588
+ const hold = makeHold(input, input.actor, input.now ?? new Date(), artifactHoldDays(repoRoot));
421
589
  return withArtifactLock(repoRoot, () => {
422
590
  const path = resolveArtifactRef(repoRoot, ref);
423
591
  const parsed = readManifest(path);
@@ -430,7 +598,19 @@ export function holdArtifact(
430
598
  ) {
431
599
  throw new Error("hold id already exists with a different owner or reason");
432
600
  }
433
- return parsed.manifest;
601
+ // Repeating a hold renews it: the original owner and set_at stay, and the
602
+ // window restarts from now. A persistent hold stays persistent.
603
+ const { expires_at: _expires, persistent: _persistent, ...kept } = previous;
604
+ const renewed: ArtifactHold =
605
+ previous.persistent || hold.persistent
606
+ ? { ...kept, persistent: true }
607
+ : { ...kept, expires_at: hold.expires_at! };
608
+ const manifest = {
609
+ ...parsed.manifest,
610
+ holds: parsed.manifest.holds.map((item) => (item.id === hold.id ? renewed : item)),
611
+ };
612
+ atomicWriteManifest(path, manifest, input.now);
613
+ return manifest;
434
614
  }
435
615
  const manifest = { ...parsed.manifest, holds: [...parsed.manifest.holds, hold] };
436
616
  atomicWriteManifest(path, manifest, input.now);
@@ -641,6 +821,7 @@ function cleanArtifactsUnlocked(
641
821
  : applyArtifactUnitBudget(
642
822
  repoRoot,
643
823
  classifyArtifactPath(repoRoot, entry.path, now, freshnessSeconds),
824
+ now,
644
825
  );
645
826
  if (!current) {
646
827
  return { ...entry, classification: "unknown", reason: "entry disappeared", action: "keep" };
@@ -665,6 +846,7 @@ function cleanArtifactsUnlocked(
665
846
  };
666
847
  }
667
848
  rmSync(current.path, { recursive: true, force: false });
849
+ recordArtifactDeletion(repoRoot, current, now);
668
850
  return { ...current, action: "deleted" };
669
851
  } catch (error) {
670
852
  return {
@@ -677,6 +859,314 @@ function cleanArtifactsUnlocked(
677
859
  });
678
860
  }
679
861
 
862
+ /** Sibling of the artifacts root, like the stamp, so it never enters the inventory. */
863
+ const DELETION_LOG = ".harnery/artifact-deletions.jsonl";
864
+ const DELETION_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
865
+
866
+ export interface ArtifactDeletionRecord {
867
+ deleted_at: string;
868
+ name: string;
869
+ relative_path: string;
870
+ artifact_id: string | null;
871
+ slug: string | null;
872
+ owner_instance_id: string | null;
873
+ classification: ArtifactClassification;
874
+ reason: string;
875
+ bytes: number | null;
876
+ apparent_bytes: number | null;
877
+ expires_at: string | null;
878
+ idle_since: string | null;
879
+ removed_by?: ArtifactActor;
880
+ }
881
+
882
+ /**
883
+ * Append one deletion to the log, keeping 30 days. Callers hold the artifact
884
+ * lock. Best-effort: the directory is already gone, so a failed write must not
885
+ * turn a completed deletion into a reported failure.
886
+ */
887
+ function recordArtifactDeletion(
888
+ repoRoot: string,
889
+ row: ArtifactInventoryEntry,
890
+ now: Date,
891
+ removedBy?: ArtifactActor,
892
+ ): void {
893
+ const record: ArtifactDeletionRecord = {
894
+ deleted_at: now.toISOString(),
895
+ name: row.name,
896
+ relative_path: row.relative_path,
897
+ artifact_id: row.artifact_id,
898
+ slug: row.slug,
899
+ owner_instance_id: row.owner_instance_id,
900
+ classification: row.classification,
901
+ reason: row.reason,
902
+ bytes: row.bytes,
903
+ apparent_bytes: row.apparent_bytes,
904
+ expires_at: row.expires_at,
905
+ idle_since: row.idle_since,
906
+ ...(removedBy ? { removed_by: removedBy } : {}),
907
+ };
908
+ try {
909
+ const path = join(resolve(repoRoot), DELETION_LOG);
910
+ const kept = readArtifactDeletions(repoRoot, {
911
+ since: new Date(now.getTime() - DELETION_LOG_RETENTION_MS),
912
+ });
913
+ const temp = `${path}.${randomUUID()}.tmp`;
914
+ writeFileSync(temp, [...kept, record].map((item) => `${JSON.stringify(item)}\n`).join(""), {
915
+ mode: stateFileMode(),
916
+ });
917
+ renameSync(temp, path);
918
+ } catch {
919
+ // See the doc comment: the deletion stands even if its record cannot.
920
+ }
921
+ }
922
+
923
+ /** Deletions the cleanup recorded, oldest first. Unreadable lines are skipped. */
924
+ export function readArtifactDeletions(
925
+ repoRoot: string,
926
+ opts: { since?: Date } = {},
927
+ ): ArtifactDeletionRecord[] {
928
+ let text: string;
929
+ try {
930
+ text = readFileSync(join(resolve(repoRoot), DELETION_LOG), "utf8");
931
+ } catch {
932
+ return [];
933
+ }
934
+ const since = opts.since?.getTime() ?? -Infinity;
935
+ const records: ArtifactDeletionRecord[] = [];
936
+ for (const line of text.split("\n")) {
937
+ if (!line.trim()) continue;
938
+ try {
939
+ const record = JSON.parse(line) as ArtifactDeletionRecord;
940
+ if (typeof record?.name === "string" && Date.parse(record.deleted_at) >= since)
941
+ records.push(record);
942
+ } catch {
943
+ // A torn or foreign line does not hide the others.
944
+ }
945
+ }
946
+ return records;
947
+ }
948
+
949
+ /** Sibling of the artifacts root, like the deletion log, so it never enters the inventory. */
950
+ const USAGE_CACHE = ".harnery/artifact-usage.json";
951
+ const LARGEST_HOLDS = 5;
952
+
953
+ export interface ArtifactHeldSummary {
954
+ name: string;
955
+ bytes: number;
956
+ holds: Array<{
957
+ id: string;
958
+ /** The hold owner's name when recorded, otherwise its instance id. */
959
+ set_by: string;
960
+ expires_at: string | null;
961
+ persistent: boolean;
962
+ }>;
963
+ }
964
+
965
+ /** Last full-inventory measurement, read by commands that must not walk the store. */
966
+ export interface ArtifactUsageCache {
967
+ measured_at: string;
968
+ bytes: number;
969
+ held_bytes: number;
970
+ free_bytes: number | null;
971
+ largest_holds: ArtifactHeldSummary[];
972
+ }
973
+
974
+ export interface ArtifactUsageReport {
975
+ held_bytes: number;
976
+ max_held_bytes: number;
977
+ held_over_budget: boolean;
978
+ largest_holds: ArtifactHeldSummary[];
979
+ free_bytes: number | null;
980
+ min_free_bytes: number;
981
+ low_disk: boolean;
982
+ /** Plain sentences for a human; empty when nothing needs attention. */
983
+ warnings: string[];
984
+ }
985
+
986
+ /** Bytes available to this user on the artifact store's filesystem, or null. */
987
+ export function artifactFreeBytes(repoRoot: string): number | null {
988
+ try {
989
+ const root = artifactsRoot(repoRoot);
990
+ const stat = statfsSync(existsSync(root) ? root : resolve(repoRoot));
991
+ const free = Number(stat.bavail) * Number(stat.bsize);
992
+ return Number.isFinite(free) && free >= 0 ? free : null;
993
+ } catch {
994
+ return null;
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Held bytes, free disk, and warnings for one full inventory. Held work is
1000
+ * never deleted: the held budget and the disk floor only report. Records the
1001
+ * measurement in `.harnery/artifact-usage.json` so `create` and `hold` can
1002
+ * warn without walking the store.
1003
+ */
1004
+ export function artifactUsageReport(
1005
+ repoRoot: string,
1006
+ rows: ArtifactInventoryEntry[],
1007
+ opts: { now?: Date } = {},
1008
+ ): ArtifactUsageReport {
1009
+ const now = opts.now ?? new Date();
1010
+ const kept = rows.filter((row) => row.action !== "deleted");
1011
+ const held = kept.filter((row) => row.classification === "managed-held");
1012
+ const heldBytes = held.reduce((sum, row) => sum + (row.bytes ?? 0), 0);
1013
+ const largest = [...held]
1014
+ .sort((left, right) => (right.bytes ?? 0) - (left.bytes ?? 0))
1015
+ .slice(0, LARGEST_HOLDS)
1016
+ .map((row) => heldSummary(row));
1017
+ const freeBytes = artifactFreeBytes(repoRoot);
1018
+ if (existsSync(artifactsRoot(repoRoot))) {
1019
+ writeUsageCache(repoRoot, {
1020
+ measured_at: now.toISOString(),
1021
+ bytes: kept.reduce((sum, row) => sum + (row.bytes ?? 0), 0),
1022
+ held_bytes: heldBytes,
1023
+ free_bytes: freeBytes,
1024
+ largest_holds: largest,
1025
+ });
1026
+ }
1027
+ return usageReport(repoRoot, heldBytes, largest, freeBytes, null);
1028
+ }
1029
+
1030
+ /** The cached measurement, or null when none was recorded or it is unreadable. */
1031
+ export function readArtifactUsageCache(repoRoot: string): ArtifactUsageCache | null {
1032
+ try {
1033
+ const value = JSON.parse(readFileSync(join(resolve(repoRoot), USAGE_CACHE), "utf8"));
1034
+ if (
1035
+ !value ||
1036
+ !validIso(value.measured_at) ||
1037
+ typeof value.held_bytes !== "number" ||
1038
+ typeof value.bytes !== "number"
1039
+ )
1040
+ return null;
1041
+ return {
1042
+ measured_at: value.measured_at,
1043
+ bytes: value.bytes,
1044
+ held_bytes: value.held_bytes,
1045
+ free_bytes: typeof value.free_bytes === "number" ? value.free_bytes : null,
1046
+ largest_holds: Array.isArray(value.largest_holds) ? value.largest_holds : [],
1047
+ };
1048
+ } catch {
1049
+ return null;
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Usage for commands that must stay cheap (`create`, `hold`): held bytes from
1055
+ * the last full inventory, free disk measured now. Never walks the store.
1056
+ */
1057
+ export function artifactQuickUsage(repoRoot: string): {
1058
+ usage: { held_bytes: number | null; held_measured_at: string | null; free_bytes: number | null };
1059
+ low_disk: boolean;
1060
+ min_free_bytes: number;
1061
+ warnings: string[];
1062
+ } {
1063
+ const cache = readArtifactUsageCache(repoRoot);
1064
+ const freeBytes = artifactFreeBytes(repoRoot);
1065
+ const report = usageReport(
1066
+ repoRoot,
1067
+ cache?.held_bytes ?? 0,
1068
+ cache?.largest_holds ?? [],
1069
+ freeBytes,
1070
+ cache?.measured_at ?? null,
1071
+ );
1072
+ return {
1073
+ usage: {
1074
+ held_bytes: cache?.held_bytes ?? null,
1075
+ held_measured_at: cache?.measured_at ?? null,
1076
+ free_bytes: freeBytes,
1077
+ },
1078
+ low_disk: report.low_disk,
1079
+ min_free_bytes: report.min_free_bytes,
1080
+ warnings: report.warnings,
1081
+ };
1082
+ }
1083
+
1084
+ /**
1085
+ * Refuse new large work when the artifact store's disk is below its floor.
1086
+ * `create --big` calls this; `--allow-low-disk` is the deliberate override.
1087
+ */
1088
+ export function assertArtifactDiskFloor(repoRoot: string): void {
1089
+ const minFree = artifactMinFreeBytes(repoRoot);
1090
+ const freeBytes = artifactFreeBytes(repoRoot);
1091
+ if (minFree === 0 || freeBytes === null || freeBytes >= minFree) return;
1092
+ const bin = resolveBinName(repoRoot);
1093
+ throw new Error(
1094
+ `only ${gib(freeBytes)} is free on the artifact store's disk, below the ${gib(minFree)} floor (artifacts.min_free_bytes), so a --big workspace was not created. Free space with \`${bin} artifacts clean --yes\`, \`${bin} artifacts discard <ref> --reason <text>\` for reviewed work, or \`${bin} artifacts unhold <ref> --id <id>\` for finished holds, then retry. Pass --allow-low-disk to create it anyway.`,
1095
+ );
1096
+ }
1097
+
1098
+ function heldSummary(row: ArtifactInventoryEntry): ArtifactHeldSummary {
1099
+ const parsed = readManifest(row.path);
1100
+ return {
1101
+ name: row.name,
1102
+ bytes: row.bytes ?? 0,
1103
+ holds: parsed.ok
1104
+ ? parsed.manifest.holds.map((hold) => ({
1105
+ id: hold.id,
1106
+ set_by: hold.set_by.name ?? hold.set_by.instance_id,
1107
+ expires_at: hold.expires_at ?? null,
1108
+ persistent: hold.persistent === true,
1109
+ }))
1110
+ : [],
1111
+ };
1112
+ }
1113
+
1114
+ function usageReport(
1115
+ repoRoot: string,
1116
+ heldBytes: number,
1117
+ largest: ArtifactHeldSummary[],
1118
+ freeBytes: number | null,
1119
+ measuredAt: string | null,
1120
+ ): ArtifactUsageReport {
1121
+ const maxHeld = artifactMaxHeldBytes(repoRoot);
1122
+ const minFree = artifactMinFreeBytes(repoRoot);
1123
+ const heldOver = heldBytes > maxHeld;
1124
+ const lowDisk = minFree > 0 && freeBytes !== null && freeBytes < minFree;
1125
+ const bin = resolveBinName(repoRoot);
1126
+ const warnings: string[] = [];
1127
+ if (heldOver) {
1128
+ const list = largest
1129
+ .map(
1130
+ (item) =>
1131
+ `${item.name} (${gib(item.bytes)}${item.holds.length ? `, hold ${item.holds.map((hold) => hold.id).join(", ")}` : ""})`,
1132
+ )
1133
+ .join("; ");
1134
+ warnings.push(
1135
+ `Held artifacts use ${gib(heldBytes)}${measuredAt ? ` as of ${measuredAt}` : ""}, above the ${gib(maxHeld)} held budget (artifacts.max_held_bytes). Holds are never deleted automatically; release or prune the largest${list ? `: ${list}` : ""}. Remove a finished hold with \`${bin} artifacts unhold <name> --id <id>\`.`,
1136
+ );
1137
+ }
1138
+ if (lowDisk) {
1139
+ warnings.push(
1140
+ `Only ${gib(freeBytes!)} is free on the artifact store's disk, below the ${gib(minFree)} floor (artifacts.min_free_bytes). Free space with \`${bin} artifacts clean --yes\`, discard reviewed workspaces, or unhold finished work; \`${bin} artifacts create --big\` refuses until then.`,
1141
+ );
1142
+ }
1143
+ return {
1144
+ held_bytes: heldBytes,
1145
+ max_held_bytes: maxHeld,
1146
+ held_over_budget: heldOver,
1147
+ largest_holds: largest,
1148
+ free_bytes: freeBytes,
1149
+ min_free_bytes: minFree,
1150
+ low_disk: lowDisk,
1151
+ warnings,
1152
+ };
1153
+ }
1154
+
1155
+ function writeUsageCache(repoRoot: string, cache: ArtifactUsageCache): void {
1156
+ try {
1157
+ const path = join(resolve(repoRoot), USAGE_CACHE);
1158
+ const temp = `${path}.${randomUUID()}.tmp`;
1159
+ writeFileSync(temp, `${JSON.stringify(cache, null, 2)}\n`, { mode: stateFileMode() });
1160
+ renameSync(temp, path);
1161
+ } catch {
1162
+ // The cache only feeds warnings; a failed write must not fail the inventory.
1163
+ }
1164
+ }
1165
+
1166
+ function gib(bytes: number): string {
1167
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
1168
+ }
1169
+
680
1170
  /** Sibling of the artifacts root so the stamp never appears in the inventory scan. */
681
1171
  const AUTO_CLEAN_STAMP = ".harnery/artifacts-auto-clean.json";
682
1172
 
@@ -692,9 +1182,11 @@ export interface ArtifactAutoCleanResult {
692
1182
  *
693
1183
  * Retention was previously enforced only when someone remembered to run
694
1184
  * `artifacts clean --yes`, so expired workspaces accumulated indefinitely on
695
- * busy hosts. This runs the exact same guarded deletion (expired or over-budget
696
- * managed entries, each re-classified immediately before removal;
697
- * unmanaged and legacy directories are never touched) at most once per
1185
+ * busy hosts. This runs the exact same guarded deletion as `artifacts clean
1186
+ * --yes` (expired entries, plus oversize or over-budget entries idle past
1187
+ * `artifacts.idle_grace_hours`, each re-classified immediately before removal
1188
+ * and recorded in the deletion log; unmanaged and legacy directories are never
1189
+ * touched) at most once per
698
1190
  * interval (default 1h after completion, 1m between partial/failed slices).
699
1191
  * The owner-aware lock serializes callers; interrupted attempts can retry.
700
1192
  * Disable with `artifacts.auto_clean: false` or
@@ -774,6 +1266,7 @@ function autoCleanArtifactsUnlocked(
774
1266
  .filter((row) => row.classification === "unknown")
775
1267
  .map((row) => ({ name: row.name, reason: row.reason }));
776
1268
  const status = failures.length ? "failed" : remaining ? "partial" : "completed";
1269
+ artifactUsageReport(repoRoot, rows, { now });
777
1270
  writeStamp({
778
1271
  ...attempt,
779
1272
  status,
@@ -847,13 +1340,23 @@ function classifyArtifactPath(
847
1340
  return rowFor(path, name, repoRoot, classification, parsed.reason);
848
1341
  }
849
1342
  const manifest = parsed.manifest;
850
- const bytes = safeTreeSize(path);
1343
+ const usage = safeTreeUsage(path);
851
1344
  const lastModifiedMs = safeTreeLastModified(path, now, manifest.activity);
852
1345
  const retentionAnchorMs = Date.parse(manifest.retention.renewed_at ?? manifest.created_at);
853
1346
  const retentionWindowMs = Date.parse(manifest.retention.expires_at) - retentionAnchorMs;
854
1347
  const effectiveLastModifiedMs = Math.max(retentionAnchorMs, lastModifiedMs ?? 0);
855
- const effectiveExpiresAt = new Date(effectiveLastModifiedMs + retentionWindowMs).toISOString();
856
- const base = rowFor(path, name, repoRoot, "managed-current", "retention has not expired", bytes);
1348
+ const holds = artifactHoldState(manifest, now);
1349
+ // A lapsed hold protected the files until its deadline, so ordinary expiry
1350
+ // and the size-rule idle clock both count from the latest lapse.
1351
+ const lapsedMs = Math.max(0, ...holds.lapsed.map((hold) => Date.parse(hold.expires_at!)));
1352
+ const effectiveExpiresAt = new Date(
1353
+ Math.max(effectiveLastModifiedMs + retentionWindowMs, lapsedMs),
1354
+ ).toISOString();
1355
+ const base = rowFor(path, name, repoRoot, "managed-current", "retention has not expired", usage);
1356
+ const releasedMs = Math.max(
1357
+ manifest.released_at ? Date.parse(manifest.released_at) : 0,
1358
+ lapsedMs,
1359
+ );
857
1360
  Object.assign(base, {
858
1361
  artifact_id: manifest.artifact_id,
859
1362
  slug: manifest.slug,
@@ -862,14 +1365,16 @@ function classifyArtifactPath(
862
1365
  expires_at: effectiveExpiresAt,
863
1366
  owner_instance_id: manifest.created_by?.instance_id ?? null,
864
1367
  oversize_acknowledged: manifest.oversize_acknowledged === true,
1368
+ idle_since: new Date(Math.max(effectiveLastModifiedMs, releasedMs)).toISOString(),
865
1369
  });
866
1370
 
867
- if (manifest.holds.length > 0) {
1371
+ if (holds.active.length > 0) {
868
1372
  return {
869
1373
  ...base,
870
1374
  classification: "managed-held",
871
- reason: `held: ${manifest.holds.map((hold) => hold.id).join(", ")}`,
1375
+ reason: `held: ${holds.active.map((hold) => hold.id).join(", ")}`,
872
1376
  action: "keep",
1377
+ warning: holdWarning(repoRoot, name, holds.active, now),
873
1378
  };
874
1379
  }
875
1380
  if (base.bytes === null || lastModifiedMs === null) {
@@ -889,7 +1394,17 @@ function classifyArtifactPath(
889
1394
  };
890
1395
  }
891
1396
  if (!manifest.released_at && manifest.created_by?.instance_id) {
892
- const live = ownerLiveness(repoRoot, manifest.created_by.instance_id, now, freshnessSeconds);
1397
+ const { state: live, heartbeatMs } = ownerLiveness(
1398
+ repoRoot,
1399
+ manifest.created_by.instance_id,
1400
+ now,
1401
+ freshnessSeconds,
1402
+ );
1403
+ // A stale heartbeat only starts the idle clock. The owner may be waiting
1404
+ // for a human reply, so size rules still wait out the grace from here.
1405
+ if (heartbeatMs !== null && heartbeatMs > Date.parse(base.idle_since!)) {
1406
+ base.idle_since = new Date(Math.min(heartbeatMs, now.getTime())).toISOString();
1407
+ }
893
1408
  if (live === "live") {
894
1409
  return {
895
1410
  ...base,
@@ -908,44 +1423,101 @@ function classifyArtifactPath(
908
1423
  }
909
1424
  }
910
1425
  if (Date.parse(effectiveExpiresAt) > now.getTime()) return base;
1426
+ const lapsedNote = holds.lapsed.length
1427
+ ? `; hold ${holds.lapsed.map((hold) => `${hold.id} lapsed at ${hold.expires_at}`).join(", ")}`
1428
+ : "";
911
1429
  return {
912
1430
  ...base,
913
1431
  classification: "managed-expired",
914
- reason: `retention expired at ${effectiveExpiresAt}`,
1432
+ reason: `retention expired at ${effectiveExpiresAt}${lapsedNote}`,
915
1433
  action: "would-delete",
916
1434
  };
917
1435
  }
918
1436
 
1437
+ /** Advice for held rows: an imminent lapse, or a hold that never expires. */
1438
+ function holdWarning(
1439
+ repoRoot: string,
1440
+ name: string,
1441
+ active: ArtifactHold[],
1442
+ now: Date,
1443
+ ): string | null {
1444
+ const bin = resolveBinName(repoRoot);
1445
+ const notes: string[] = [];
1446
+ for (const hold of active) {
1447
+ if (hold.persistent) continue;
1448
+ const renew = `${bin} artifacts hold ${name} --id ${hold.id} --reason ${JSON.stringify(hold.reason)}`;
1449
+ if (!hold.expires_at) {
1450
+ notes.push(
1451
+ `hold ${hold.id} has no expiry because it predates hold expiry; re-holding with \`${renew}\` starts one, or unhold it when the work is done`,
1452
+ );
1453
+ } else if (Date.parse(hold.expires_at) - now.getTime() <= HOLD_LAPSE_WARNING_MS) {
1454
+ notes.push(`hold ${hold.id} lapses at ${hold.expires_at}; renew with \`${renew}\``);
1455
+ }
1456
+ }
1457
+ return notes.length ? notes.join("; ") : null;
1458
+ }
1459
+
1460
+ /** When a size rule may first delete this unit, or null when it never may. */
1461
+ function sizeEvictableAt(repoRoot: string, row: ArtifactInventoryEntry): number | null {
1462
+ if (!row.idle_since) return null;
1463
+ const idleSince = Date.parse(row.idle_since);
1464
+ if (!Number.isFinite(idleSince)) return null;
1465
+ return idleSince + artifactIdleGraceHours(repoRoot) * 60 * 60 * 1000;
1466
+ }
1467
+
919
1468
  function applyArtifactUnitBudget(
920
1469
  repoRoot: string,
921
1470
  row: ArtifactInventoryEntry,
1471
+ now: Date,
922
1472
  ): ArtifactInventoryEntry {
923
1473
  const maxUnitBytes = artifactMaxUnitBytes(repoRoot);
1474
+ if (
1475
+ !["managed-current", "managed-active"].includes(row.classification) ||
1476
+ row.bytes === null ||
1477
+ row.bytes <= maxUnitBytes ||
1478
+ row.oversize_acknowledged
1479
+ ) {
1480
+ return row;
1481
+ }
1482
+ const evictableAt = sizeEvictableAt(repoRoot, row);
924
1483
  if (
925
1484
  row.classification === "managed-current" &&
926
- row.bytes !== null &&
927
- row.bytes > maxUnitBytes &&
928
- !row.oversize_acknowledged
1485
+ evictableAt !== null &&
1486
+ now.getTime() >= evictableAt
929
1487
  ) {
930
1488
  return {
931
1489
  ...row,
932
1490
  classification: "managed-oversize",
933
1491
  action: "would-delete",
934
- reason: `bundle uses ${row.bytes} bytes, above the ${maxUnitBytes}-byte ceiling without --big`,
1492
+ reason: `bundle uses ${row.bytes} bytes on disk, above the ${maxUnitBytes}-byte ceiling without --big, and has been idle for ${artifactIdleGraceHours(repoRoot)}h`,
935
1493
  };
936
1494
  }
937
- return row;
1495
+ const when =
1496
+ row.classification === "managed-active" || evictableAt === null
1497
+ ? `${artifactIdleGraceHours(repoRoot)}h after its owner goes idle`
1498
+ : `after ${new Date(evictableAt).toISOString()} unless it changes first`;
1499
+ return {
1500
+ ...row,
1501
+ warning: `uses ${row.bytes} bytes on disk, above the ${maxUnitBytes}-byte per-workspace ceiling; cleanup will delete it ${when}. If it is meant to be this large, run ${resolveBinName(repoRoot)} artifacts allow-big ${row.name}; otherwise move rebuildable content out of the artifact store`,
1502
+ };
938
1503
  }
939
1504
 
940
1505
  function applyArtifactBudgets(
941
1506
  repoRoot: string,
942
1507
  inputRows: ArtifactInventoryEntry[],
1508
+ now: Date,
943
1509
  ): ArtifactInventoryEntry[] {
944
1510
  const maxBytes = artifactMaxBytes(repoRoot);
945
- const rows = inputRows.map((row) => applyArtifactUnitBudget(repoRoot, { ...row }));
1511
+ const rows = inputRows.map((row) => applyArtifactUnitBudget(repoRoot, { ...row }, now));
946
1512
 
1513
+ // Held units can never be evicted, so counting them would push every other
1514
+ // unit out without bringing the store under budget.
947
1515
  const managedBytes = rows.reduce(
948
- (sum, row) => sum + (row.artifact_id && row.bytes !== null ? row.bytes : 0),
1516
+ (sum, row) =>
1517
+ sum +
1518
+ (row.artifact_id && row.classification !== "managed-held" && row.bytes !== null
1519
+ ? row.bytes
1520
+ : 0),
949
1521
  0,
950
1522
  );
951
1523
  let retainedBytes =
@@ -957,10 +1529,12 @@ function applyArtifactBudgets(
957
1529
  if (retainedBytes <= maxBytes) return rows;
958
1530
 
959
1531
  const candidates = rows
960
- .filter(
961
- (row) =>
962
- row.classification === "managed-current" && row.action === "keep" && row.bytes !== null,
963
- )
1532
+ .filter((row) => {
1533
+ if (row.classification !== "managed-current" || row.action !== "keep" || row.bytes === null)
1534
+ return false;
1535
+ const evictableAt = sizeEvictableAt(repoRoot, row);
1536
+ return evictableAt !== null && now.getTime() >= evictableAt;
1537
+ })
964
1538
  .sort((left, right) =>
965
1539
  `${left.expires_at ?? ""}\0${left.created_at ?? ""}\0${left.name}`.localeCompare(
966
1540
  `${right.expires_at ?? ""}\0${right.created_at ?? ""}\0${right.name}`,
@@ -969,7 +1543,7 @@ function applyArtifactBudgets(
969
1543
  for (const row of candidates) {
970
1544
  if (retainedBytes <= maxBytes) break;
971
1545
  row.classification = "managed-over-budget";
972
- row.reason = `repository artifact budget is ${maxBytes} bytes; earliest-expiring inactive bundles are removed first`;
1546
+ row.reason = `repository artifact budget is ${maxBytes} bytes of disk use; earliest-expiring bundles idle for ${artifactIdleGraceHours(repoRoot)}h are removed first`;
973
1547
  row.action = "would-delete";
974
1548
  retainedBytes -= row.bytes ?? 0;
975
1549
  }
@@ -1319,26 +1893,73 @@ function validHold(value: unknown): value is ArtifactHold {
1319
1893
  typeof hold.reason === "string" &&
1320
1894
  !!hold.reason.trim() &&
1321
1895
  validActor(hold.set_by) &&
1322
- validIso(hold.set_at)
1896
+ validIso(hold.set_at) &&
1897
+ (hold.expires_at === undefined || validIso(hold.expires_at)) &&
1898
+ (hold.persistent === undefined || hold.persistent === true) &&
1899
+ !(hold.persistent && hold.expires_at !== undefined)
1323
1900
  );
1324
1901
  }
1325
1902
 
1903
+ const HOLD_LAPSE_WARNING_MS = 48 * 60 * 60 * 1000;
1904
+
1905
+ /**
1906
+ * Split holds into those still in force and those that lapsed. A persistent
1907
+ * hold never lapses. A hold recorded before holds expired has no `expires_at`
1908
+ * and stays in force until its owner removes or renews it.
1909
+ */
1910
+ export function artifactHoldState(
1911
+ manifest: Pick<ArtifactManifestV2, "holds">,
1912
+ now: Date = new Date(),
1913
+ ): { active: ArtifactHold[]; lapsed: ArtifactHold[] } {
1914
+ const active: ArtifactHold[] = [];
1915
+ const lapsed: ArtifactHold[] = [];
1916
+ for (const hold of manifest.holds) {
1917
+ if (hold.persistent || !hold.expires_at || Date.parse(hold.expires_at) > now.getTime())
1918
+ active.push(hold);
1919
+ else lapsed.push(hold);
1920
+ }
1921
+ return { active, lapsed };
1922
+ }
1923
+
1326
1924
  function makeHold(
1327
1925
  input: ArtifactHoldInput,
1328
1926
  actor: ArtifactActor | undefined,
1329
1927
  now: Date,
1928
+ defaultDays: number,
1330
1929
  ): ArtifactHold {
1331
1930
  assertValidDate(now, "now");
1332
1931
  if (!validActor(actor)) throw new Error("a valid hold actor is required");
1333
1932
  if (!validHoldId(input.id)) throw new Error("invalid hold id");
1334
1933
  if (typeof input.reason !== "string" || !input.reason.trim())
1335
1934
  throw new Error("hold reason must not be empty");
1336
- return {
1935
+ if (input.days !== undefined && input.minutes !== undefined)
1936
+ throw new Error("choose either hold days or hold minutes");
1937
+ if (input.persistent && (input.days !== undefined || input.minutes !== undefined))
1938
+ throw new Error("a persistent hold takes no duration");
1939
+ const hold: ArtifactHold = {
1337
1940
  id: input.id,
1338
1941
  reason: input.reason.trim(),
1339
1942
  set_by: { ...actor },
1340
1943
  set_at: now.toISOString(),
1341
1944
  };
1945
+ if (input.persistent) return { ...hold, persistent: true };
1946
+ const minutes =
1947
+ input.minutes !== undefined
1948
+ ? holdMinutes(input.minutes)
1949
+ : holdDaysValue(input.days ?? defaultDays) * 24 * 60;
1950
+ return { ...hold, expires_at: addMinutes(now, minutes).toISOString() };
1951
+ }
1952
+
1953
+ function holdDaysValue(value: number): number {
1954
+ if (!Number.isInteger(value) || value <= 0 || value > 365)
1955
+ throw new Error("hold days must be between 1 and 365");
1956
+ return value;
1957
+ }
1958
+
1959
+ function holdMinutes(value: number): number {
1960
+ if (!Number.isInteger(value) || value <= 0 || value > 365 * 24 * 60)
1961
+ throw new Error("hold minutes must be between 1 and 525600");
1962
+ return value;
1342
1963
  }
1343
1964
 
1344
1965
  function ownerLiveness(
@@ -1346,15 +1967,18 @@ function ownerLiveness(
1346
1967
  instanceId: string,
1347
1968
  now: Date,
1348
1969
  freshnessSeconds: number,
1349
- ): "live" | "stale" | "unknown" {
1970
+ ): { state: "live" | "stale" | "unknown"; heartbeatMs: number | null } {
1350
1971
  try {
1351
1972
  const row = readLiveCoordinationRow(repoRoot, instanceId);
1352
- if (!row) return "stale";
1973
+ if (!row) return { state: "stale", heartbeatMs: null };
1353
1974
  const ts = Date.parse(row.last_heartbeat);
1354
- if (!Number.isFinite(ts)) return "unknown";
1355
- return now.getTime() - ts <= freshnessSeconds * 1000 ? "live" : "stale";
1975
+ if (!Number.isFinite(ts)) return { state: "unknown", heartbeatMs: null };
1976
+ return {
1977
+ state: now.getTime() - ts <= freshnessSeconds * 1000 ? "live" : "stale",
1978
+ heartbeatMs: ts,
1979
+ };
1356
1980
  } catch {
1357
- return "unknown";
1981
+ return { state: "unknown", heartbeatMs: null };
1358
1982
  }
1359
1983
  }
1360
1984
 
@@ -1369,17 +1993,39 @@ function containsTrackedPath(repoRoot: string, path: string): boolean {
1369
1993
  return result.status !== 0 || result.stdout.length > 0;
1370
1994
  }
1371
1995
 
1372
- function safeTreeSize(path: string): number | null {
1996
+ interface TreeUsage {
1997
+ /** Allocated disk bytes, counting each hard-linked inode once. */
1998
+ disk: number;
1999
+ /** Sum of file lengths, as `ls -l` reports them. */
2000
+ apparent: number;
2001
+ }
2002
+
2003
+ /** Disk bytes for one entry. Windows reports no block count, so length stands in. */
2004
+ function allocatedBytes(st: Stats): number {
2005
+ return process.platform !== "win32" && Number.isFinite(st.blocks) ? st.blocks * 512 : st.size;
2006
+ }
2007
+
2008
+ /**
2009
+ * Measure a tree without following symlinks. Size rules read `disk`: a sparse
2010
+ * image or a hard-linked environment would otherwise count many times its real
2011
+ * footprint and push healthy workspaces over a budget they do not exceed.
2012
+ */
2013
+ function safeTreeUsage(path: string, seen = new Set<string>()): TreeUsage | null {
1373
2014
  try {
1374
2015
  const st = lstatSync(path);
1375
- if (st.isSymbolicLink()) return st.size;
1376
- if (st.isFile()) return st.size;
1377
- if (!st.isDirectory()) return 0;
1378
- let total = st.size;
2016
+ if (!st.isDirectory()) {
2017
+ if (!st.isFile() && !st.isSymbolicLink()) return { disk: 0, apparent: 0 };
2018
+ const key = `${st.dev}:${st.ino}`;
2019
+ if (st.nlink > 1 && seen.has(key)) return { disk: 0, apparent: st.size };
2020
+ if (st.nlink > 1) seen.add(key);
2021
+ return { disk: allocatedBytes(st), apparent: st.size };
2022
+ }
2023
+ const total: TreeUsage = { disk: allocatedBytes(st), apparent: st.size };
1379
2024
  for (const child of readdirSync(path)) {
1380
- const size = safeTreeSize(join(path, child));
1381
- if (size === null) return null;
1382
- total += size;
2025
+ const usage = safeTreeUsage(join(path, child), seen);
2026
+ if (usage === null) return null;
2027
+ total.disk += usage.disk;
2028
+ total.apparent += usage.apparent;
1383
2029
  }
1384
2030
  return total;
1385
2031
  } catch {
@@ -1387,6 +2033,10 @@ function safeTreeSize(path: string): number | null {
1387
2033
  }
1388
2034
  }
1389
2035
 
2036
+ function safeTreeSize(path: string): number | null {
2037
+ return safeTreeUsage(path)?.disk ?? null;
2038
+ }
2039
+
1390
2040
  /**
1391
2041
  * Return the newest filesystem change in a managed tree without following
1392
2042
  * symlinks. A small future tolerance protects a write racing the inventory
@@ -1406,7 +2056,7 @@ function rowFor(
1406
2056
  repoRoot: string,
1407
2057
  classification: ArtifactClassification,
1408
2058
  reason: string,
1409
- bytes: number | null = safeTreeSize(path),
2059
+ usage: TreeUsage | null = safeTreeUsage(path),
1410
2060
  ): ArtifactInventoryEntry {
1411
2061
  return {
1412
2062
  name,
@@ -1415,7 +2065,8 @@ function rowFor(
1415
2065
  classification,
1416
2066
  reason,
1417
2067
  action: classification === "managed-expired" ? "would-delete" : "keep",
1418
- bytes,
2068
+ bytes: usage?.disk ?? null,
2069
+ apparent_bytes: usage?.apparent ?? null,
1419
2070
  artifact_id: null,
1420
2071
  slug: null,
1421
2072
  created_at: null,
@@ -1423,6 +2074,8 @@ function rowFor(
1423
2074
  expires_at: null,
1424
2075
  owner_instance_id: null,
1425
2076
  oversize_acknowledged: false,
2077
+ idle_since: null,
2078
+ warning: null,
1426
2079
  };
1427
2080
  }
1428
2081