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,15 +5,19 @@
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
  import { spawnSync } from "node:child_process";
11
13
  import { createHash, randomUUID } from "node:crypto";
12
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
14
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync, } from "node:fs";
13
15
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
16
  import { isDeepStrictEqual } from "node:util";
15
17
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.js";
16
- import { artifactAutoCleanEnabled, artifactAutoCleanIntervalHours, artifactDefaultRetentionDays, artifactMaxBytes, artifactMaxUnitBytes, coordFreshnessSeconds, resolveBinName, } from "../config.js";
18
+ import { artifactAutoCleanEnabled, artifactAutoCleanIntervalHours, artifactDefaultRetentionDays, artifactHoldDays, artifactIdleGraceHours, artifactMaxBytes, artifactMaxHeldBytes, artifactMaxUnitBytes, artifactMinFreeBytes, coordFreshnessSeconds, resolveBinName, } from "../config.js";
19
+ import { readCoordinationViewV3, requireAuthoritySafeCoordinationViewV3, } from "../events/v3/coordination-view.js";
20
+ import { liveInstanceIdV3 } from "../events/v3/live-route-observer.js";
17
21
  import { stateFileMode } from "../storage/modes.js";
18
22
  import { artifactRootEntries, readArtifactActivity, validArtifactActivity, } from "./activity.js";
19
23
  import { ARTIFACT_MANIFEST, ARTIFACT_SCHEMA_VERSION, ARTIFACTS_DIR } from "./constants.js";
@@ -29,6 +33,12 @@ export function artifactCapabilities() {
29
33
  explicit_v1_migration: true,
30
34
  minute_retention: true,
31
35
  discard_after_review: true,
36
+ owner_scoped_remove: true,
37
+ allow_big_after_create: true,
38
+ hold_expiry: true,
39
+ persistent_holds: true,
40
+ held_budget: true,
41
+ disk_free_report: true,
32
42
  };
33
43
  }
34
44
  export function artifactsRoot(repoRoot) {
@@ -57,7 +67,8 @@ function createArtifactUnlocked(repoRoot, input) {
57
67
  if (!isSafeId(artifactId)) {
58
68
  throw new Error("artifact id must use ASCII letters, digits, hyphens, or underscores");
59
69
  }
60
- const holds = (input.holds ?? []).map((hold) => makeHold(hold, input.actor, now));
70
+ const holdDays = artifactHoldDays(repoRoot);
71
+ const holds = (input.holds ?? []).map((hold) => makeHold(hold, input.actor, now, holdDays));
61
72
  if (new Set(holds.map((hold) => hold.id)).size !== holds.length) {
62
73
  throw new Error("duplicate hold id");
63
74
  }
@@ -102,7 +113,7 @@ export function inventoryArtifacts(repoRoot, opts = {}) {
102
113
  for (const name of names) {
103
114
  rows.push(classifyArtifactPath(repoRoot, join(root, name), now, freshnessSeconds));
104
115
  }
105
- return applyArtifactBudgets(repoRoot, rows);
116
+ return applyArtifactBudgets(repoRoot, rows, now);
106
117
  }
107
118
  export function showArtifact(repoRoot, ref, opts = {}) {
108
119
  const path = resolveArtifactRef(repoRoot, ref);
@@ -141,6 +152,24 @@ function renewArtifactUnlocked(repoRoot, ref, days, reason, input) {
141
152
  atomicWriteManifest(path, manifest, now);
142
153
  return manifest;
143
154
  }
155
+ /**
156
+ * Record the `--big` acknowledgement on an existing workspace, for work that
157
+ * turns out larger than expected. Unlike a hold, it leaves expiry in force and
158
+ * exempts the unit only from the per-bundle ceiling. Idempotent.
159
+ */
160
+ export function allowBigArtifact(repoRoot, ref, input = {}) {
161
+ return withArtifactLock(repoRoot, () => {
162
+ const path = resolveArtifactRef(repoRoot, ref);
163
+ const parsed = readManifest(path);
164
+ if (!parsed.ok)
165
+ throw new Error(parsed.reason);
166
+ if (parsed.manifest.oversize_acknowledged)
167
+ return parsed.manifest;
168
+ const manifest = { ...parsed.manifest, oversize_acknowledged: true };
169
+ atomicWriteManifest(path, manifest, input.now);
170
+ return manifest;
171
+ });
172
+ }
144
173
  export function releaseArtifact(repoRoot, ref, input = {}) {
145
174
  return withArtifactLock(repoRoot, () => releaseArtifactUnlocked(repoRoot, ref, input));
146
175
  }
@@ -186,6 +215,95 @@ export function discardArtifact(repoRoot, ref, reason, input = {}) {
186
215
  return updated;
187
216
  });
188
217
  }
218
+ /** Preview or immediately remove one reviewed workspace belonging to this actor. */
219
+ export function removeArtifact(repoRoot, ref, reason, input = {}) {
220
+ const why = reason.trim();
221
+ if (!why)
222
+ throw new Error("remove requires a reason confirming the files are no longer needed");
223
+ if (!validActor(input.actor))
224
+ throw new Error("remove requires a current artifact owner identity");
225
+ const actor = input.actor;
226
+ const now = input.now ?? new Date();
227
+ assertValidDate(now, "now");
228
+ const store = artifactsRoot(repoRoot);
229
+ const checkStore = () => {
230
+ for (const path of [resolve(repoRoot), dirname(store), store]) {
231
+ const stat = lstatSync(path);
232
+ if (!stat.isDirectory() || stat.isSymbolicLink())
233
+ throw new Error("artifact store must use real directories, not symlinks");
234
+ }
235
+ };
236
+ checkStore();
237
+ return withArtifactLock(repoRoot, () => {
238
+ checkStore();
239
+ const path = resolveArtifactRef(repoRoot, ref);
240
+ const inspect = () => {
241
+ const target = realpathSync(path);
242
+ const cwd = realpathSync(process.cwd());
243
+ if (cwd === target || cwd.startsWith(`${target}${sep}`))
244
+ throw new Error("cannot remove the working directory or its ancestor");
245
+ if (lstatSync(path).dev !== lstatSync(store).dev)
246
+ throw new Error("artifact removal cannot cross a mount boundary");
247
+ const entry = classifyArtifactPath(repoRoot, path, now, coordFreshnessSeconds(repoRoot));
248
+ if (!["managed-current", "managed-expired", "managed-active"].includes(entry.classification))
249
+ throw new Error(`cannot remove artifact: ${entry.reason}`);
250
+ if (entry.owner_instance_id !== actor.instance_id)
251
+ throw new Error("only the creating artifact owner may remove this workspace");
252
+ const view = requireAuthoritySafeCoordinationViewV3(readCoordinationViewV3(repoRoot));
253
+ for (const peer of Object.values(view.instances)) {
254
+ if (!peer.authority_eligible || peer.instance_id === liveInstanceIdV3(actor.instance_id))
255
+ continue;
256
+ for (const claim of peer.files_touched) {
257
+ const claimed = resolve(repoRoot, claim);
258
+ if (path === claimed ||
259
+ path.startsWith(`${claimed}${sep}`) ||
260
+ claimed.startsWith(`${path}${sep}`))
261
+ throw new Error(`artifact overlaps another agent's claim: ${claim}`);
262
+ }
263
+ }
264
+ return entry;
265
+ };
266
+ const entry = inspect();
267
+ const before = artifactRemovalSnapshot(path);
268
+ if (!input.yes)
269
+ return { entry, reason: why, deleted: false };
270
+ // Metadata mutations share this lock. Recheck payload, claims, and protections
271
+ // immediately before removal because ordinary file writers do not take it.
272
+ checkStore();
273
+ const current = inspect();
274
+ if (!isDeepStrictEqual(entry, current) ||
275
+ !isDeepStrictEqual(before, artifactRemovalSnapshot(path)))
276
+ throw new Error("artifact changed during removal inspection; preview it again");
277
+ rmSync(path, { recursive: true, force: false });
278
+ const removed = { ...current, action: "deleted", reason: why };
279
+ recordArtifactDeletion(repoRoot, removed, now, actor);
280
+ return { entry: removed, reason: why, deleted: true };
281
+ });
282
+ }
283
+ /** Refuse links, mounts, special files, and embedded stores; fingerprint every entry. */
284
+ function artifactRemovalSnapshot(path) {
285
+ const device = lstatSync(path).dev;
286
+ const entries = new Map();
287
+ const walk = (target) => {
288
+ const stat = lstatSync(target);
289
+ if (stat.isSymbolicLink() || stat.dev !== device || (!stat.isFile() && !stat.isDirectory()))
290
+ throw new Error(`unsafe artifact removal path: ${target}`);
291
+ if (target !== path && [".git", ".harnery"].includes(basename(target)))
292
+ throw new Error(`embedded repository or coordination state cannot be removed: ${target}`);
293
+ if (target !== join(path, ARTIFACT_MANIFEST) && basename(target) === ARTIFACT_MANIFEST)
294
+ throw new Error(`nested artifact must be managed separately: ${target}`);
295
+ entries.set(target, [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"));
296
+ if (stat.isDirectory()) {
297
+ const names = readdirSync(target).sort();
298
+ if (names.includes("HEAD") && names.includes("objects") && names.includes("config"))
299
+ throw new Error(`embedded Git metadata cannot be removed: ${target}`);
300
+ for (const name of names)
301
+ walk(join(target, name));
302
+ }
303
+ };
304
+ walk(path);
305
+ return entries;
306
+ }
189
307
  /** Advice only: a successful check does not establish that its evidence is disposable. */
190
308
  export function artifactReviewGuidance(repoRoot, ref) {
191
309
  const target = isAbsolute(ref) ? basename(ref) : ref;
@@ -209,7 +327,7 @@ function releaseArtifactUnlocked(repoRoot, ref, input) {
209
327
  return manifest;
210
328
  }
211
329
  export function holdArtifact(repoRoot, ref, input) {
212
- const hold = makeHold(input, input.actor, input.now ?? new Date());
330
+ const hold = makeHold(input, input.actor, input.now ?? new Date(), artifactHoldDays(repoRoot));
213
331
  return withArtifactLock(repoRoot, () => {
214
332
  const path = resolveArtifactRef(repoRoot, ref);
215
333
  const parsed = readManifest(path);
@@ -221,7 +339,18 @@ export function holdArtifact(repoRoot, ref, input) {
221
339
  previous.reason !== hold.reason) {
222
340
  throw new Error("hold id already exists with a different owner or reason");
223
341
  }
224
- return parsed.manifest;
342
+ // Repeating a hold renews it: the original owner and set_at stay, and the
343
+ // window restarts from now. A persistent hold stays persistent.
344
+ const { expires_at: _expires, persistent: _persistent, ...kept } = previous;
345
+ const renewed = previous.persistent || hold.persistent
346
+ ? { ...kept, persistent: true }
347
+ : { ...kept, expires_at: hold.expires_at };
348
+ const manifest = {
349
+ ...parsed.manifest,
350
+ holds: parsed.manifest.holds.map((item) => (item.id === hold.id ? renewed : item)),
351
+ };
352
+ atomicWriteManifest(path, manifest, input.now);
353
+ return manifest;
225
354
  }
226
355
  const manifest = { ...parsed.manifest, holds: [...parsed.manifest.holds, hold] };
227
356
  atomicWriteManifest(path, manifest, input.now);
@@ -388,7 +517,7 @@ function cleanArtifactsUnlocked(repoRoot, opts) {
388
517
  // per-unit size are local decisions; recheck all their guards on the target.
389
518
  const current = entry.classification === "managed-over-budget"
390
519
  ? inventoryArtifacts(repoRoot, { now, freshnessSeconds }).find((row) => row.path === entry.path)
391
- : applyArtifactUnitBudget(repoRoot, classifyArtifactPath(repoRoot, entry.path, now, freshnessSeconds));
520
+ : applyArtifactUnitBudget(repoRoot, classifyArtifactPath(repoRoot, entry.path, now, freshnessSeconds), now);
392
521
  if (!current) {
393
522
  return { ...entry, classification: "unknown", reason: "entry disappeared", action: "keep" };
394
523
  }
@@ -410,6 +539,7 @@ function cleanArtifactsUnlocked(repoRoot, opts) {
410
539
  };
411
540
  }
412
541
  rmSync(current.path, { recursive: true, force: false });
542
+ recordArtifactDeletion(repoRoot, current, now);
413
543
  return { ...current, action: "deleted" };
414
544
  }
415
545
  catch (error) {
@@ -422,6 +552,220 @@ function cleanArtifactsUnlocked(repoRoot, opts) {
422
552
  }
423
553
  });
424
554
  }
555
+ /** Sibling of the artifacts root, like the stamp, so it never enters the inventory. */
556
+ const DELETION_LOG = ".harnery/artifact-deletions.jsonl";
557
+ const DELETION_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
558
+ /**
559
+ * Append one deletion to the log, keeping 30 days. Callers hold the artifact
560
+ * lock. Best-effort: the directory is already gone, so a failed write must not
561
+ * turn a completed deletion into a reported failure.
562
+ */
563
+ function recordArtifactDeletion(repoRoot, row, now, removedBy) {
564
+ const record = {
565
+ deleted_at: now.toISOString(),
566
+ name: row.name,
567
+ relative_path: row.relative_path,
568
+ artifact_id: row.artifact_id,
569
+ slug: row.slug,
570
+ owner_instance_id: row.owner_instance_id,
571
+ classification: row.classification,
572
+ reason: row.reason,
573
+ bytes: row.bytes,
574
+ apparent_bytes: row.apparent_bytes,
575
+ expires_at: row.expires_at,
576
+ idle_since: row.idle_since,
577
+ ...(removedBy ? { removed_by: removedBy } : {}),
578
+ };
579
+ try {
580
+ const path = join(resolve(repoRoot), DELETION_LOG);
581
+ const kept = readArtifactDeletions(repoRoot, {
582
+ since: new Date(now.getTime() - DELETION_LOG_RETENTION_MS),
583
+ });
584
+ const temp = `${path}.${randomUUID()}.tmp`;
585
+ writeFileSync(temp, [...kept, record].map((item) => `${JSON.stringify(item)}\n`).join(""), {
586
+ mode: stateFileMode(),
587
+ });
588
+ renameSync(temp, path);
589
+ }
590
+ catch {
591
+ // See the doc comment: the deletion stands even if its record cannot.
592
+ }
593
+ }
594
+ /** Deletions the cleanup recorded, oldest first. Unreadable lines are skipped. */
595
+ export function readArtifactDeletions(repoRoot, opts = {}) {
596
+ let text;
597
+ try {
598
+ text = readFileSync(join(resolve(repoRoot), DELETION_LOG), "utf8");
599
+ }
600
+ catch {
601
+ return [];
602
+ }
603
+ const since = opts.since?.getTime() ?? -Infinity;
604
+ const records = [];
605
+ for (const line of text.split("\n")) {
606
+ if (!line.trim())
607
+ continue;
608
+ try {
609
+ const record = JSON.parse(line);
610
+ if (typeof record?.name === "string" && Date.parse(record.deleted_at) >= since)
611
+ records.push(record);
612
+ }
613
+ catch {
614
+ // A torn or foreign line does not hide the others.
615
+ }
616
+ }
617
+ return records;
618
+ }
619
+ /** Sibling of the artifacts root, like the deletion log, so it never enters the inventory. */
620
+ const USAGE_CACHE = ".harnery/artifact-usage.json";
621
+ const LARGEST_HOLDS = 5;
622
+ /** Bytes available to this user on the artifact store's filesystem, or null. */
623
+ export function artifactFreeBytes(repoRoot) {
624
+ try {
625
+ const root = artifactsRoot(repoRoot);
626
+ const stat = statfsSync(existsSync(root) ? root : resolve(repoRoot));
627
+ const free = Number(stat.bavail) * Number(stat.bsize);
628
+ return Number.isFinite(free) && free >= 0 ? free : null;
629
+ }
630
+ catch {
631
+ return null;
632
+ }
633
+ }
634
+ /**
635
+ * Held bytes, free disk, and warnings for one full inventory. Held work is
636
+ * never deleted: the held budget and the disk floor only report. Records the
637
+ * measurement in `.harnery/artifact-usage.json` so `create` and `hold` can
638
+ * warn without walking the store.
639
+ */
640
+ export function artifactUsageReport(repoRoot, rows, opts = {}) {
641
+ const now = opts.now ?? new Date();
642
+ const kept = rows.filter((row) => row.action !== "deleted");
643
+ const held = kept.filter((row) => row.classification === "managed-held");
644
+ const heldBytes = held.reduce((sum, row) => sum + (row.bytes ?? 0), 0);
645
+ const largest = [...held]
646
+ .sort((left, right) => (right.bytes ?? 0) - (left.bytes ?? 0))
647
+ .slice(0, LARGEST_HOLDS)
648
+ .map((row) => heldSummary(row));
649
+ const freeBytes = artifactFreeBytes(repoRoot);
650
+ if (existsSync(artifactsRoot(repoRoot))) {
651
+ writeUsageCache(repoRoot, {
652
+ measured_at: now.toISOString(),
653
+ bytes: kept.reduce((sum, row) => sum + (row.bytes ?? 0), 0),
654
+ held_bytes: heldBytes,
655
+ free_bytes: freeBytes,
656
+ largest_holds: largest,
657
+ });
658
+ }
659
+ return usageReport(repoRoot, heldBytes, largest, freeBytes, null);
660
+ }
661
+ /** The cached measurement, or null when none was recorded or it is unreadable. */
662
+ export function readArtifactUsageCache(repoRoot) {
663
+ try {
664
+ const value = JSON.parse(readFileSync(join(resolve(repoRoot), USAGE_CACHE), "utf8"));
665
+ if (!value ||
666
+ !validIso(value.measured_at) ||
667
+ typeof value.held_bytes !== "number" ||
668
+ typeof value.bytes !== "number")
669
+ return null;
670
+ return {
671
+ measured_at: value.measured_at,
672
+ bytes: value.bytes,
673
+ held_bytes: value.held_bytes,
674
+ free_bytes: typeof value.free_bytes === "number" ? value.free_bytes : null,
675
+ largest_holds: Array.isArray(value.largest_holds) ? value.largest_holds : [],
676
+ };
677
+ }
678
+ catch {
679
+ return null;
680
+ }
681
+ }
682
+ /**
683
+ * Usage for commands that must stay cheap (`create`, `hold`): held bytes from
684
+ * the last full inventory, free disk measured now. Never walks the store.
685
+ */
686
+ export function artifactQuickUsage(repoRoot) {
687
+ const cache = readArtifactUsageCache(repoRoot);
688
+ const freeBytes = artifactFreeBytes(repoRoot);
689
+ const report = usageReport(repoRoot, cache?.held_bytes ?? 0, cache?.largest_holds ?? [], freeBytes, cache?.measured_at ?? null);
690
+ return {
691
+ usage: {
692
+ held_bytes: cache?.held_bytes ?? null,
693
+ held_measured_at: cache?.measured_at ?? null,
694
+ free_bytes: freeBytes,
695
+ },
696
+ low_disk: report.low_disk,
697
+ min_free_bytes: report.min_free_bytes,
698
+ warnings: report.warnings,
699
+ };
700
+ }
701
+ /**
702
+ * Refuse new large work when the artifact store's disk is below its floor.
703
+ * `create --big` calls this; `--allow-low-disk` is the deliberate override.
704
+ */
705
+ export function assertArtifactDiskFloor(repoRoot) {
706
+ const minFree = artifactMinFreeBytes(repoRoot);
707
+ const freeBytes = artifactFreeBytes(repoRoot);
708
+ if (minFree === 0 || freeBytes === null || freeBytes >= minFree)
709
+ return;
710
+ const bin = resolveBinName(repoRoot);
711
+ throw new Error(`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.`);
712
+ }
713
+ function heldSummary(row) {
714
+ const parsed = readManifest(row.path);
715
+ return {
716
+ name: row.name,
717
+ bytes: row.bytes ?? 0,
718
+ holds: parsed.ok
719
+ ? parsed.manifest.holds.map((hold) => ({
720
+ id: hold.id,
721
+ set_by: hold.set_by.name ?? hold.set_by.instance_id,
722
+ expires_at: hold.expires_at ?? null,
723
+ persistent: hold.persistent === true,
724
+ }))
725
+ : [],
726
+ };
727
+ }
728
+ function usageReport(repoRoot, heldBytes, largest, freeBytes, measuredAt) {
729
+ const maxHeld = artifactMaxHeldBytes(repoRoot);
730
+ const minFree = artifactMinFreeBytes(repoRoot);
731
+ const heldOver = heldBytes > maxHeld;
732
+ const lowDisk = minFree > 0 && freeBytes !== null && freeBytes < minFree;
733
+ const bin = resolveBinName(repoRoot);
734
+ const warnings = [];
735
+ if (heldOver) {
736
+ const list = largest
737
+ .map((item) => `${item.name} (${gib(item.bytes)}${item.holds.length ? `, hold ${item.holds.map((hold) => hold.id).join(", ")}` : ""})`)
738
+ .join("; ");
739
+ warnings.push(`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>\`.`);
740
+ }
741
+ if (lowDisk) {
742
+ warnings.push(`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.`);
743
+ }
744
+ return {
745
+ held_bytes: heldBytes,
746
+ max_held_bytes: maxHeld,
747
+ held_over_budget: heldOver,
748
+ largest_holds: largest,
749
+ free_bytes: freeBytes,
750
+ min_free_bytes: minFree,
751
+ low_disk: lowDisk,
752
+ warnings,
753
+ };
754
+ }
755
+ function writeUsageCache(repoRoot, cache) {
756
+ try {
757
+ const path = join(resolve(repoRoot), USAGE_CACHE);
758
+ const temp = `${path}.${randomUUID()}.tmp`;
759
+ writeFileSync(temp, `${JSON.stringify(cache, null, 2)}\n`, { mode: stateFileMode() });
760
+ renameSync(temp, path);
761
+ }
762
+ catch {
763
+ // The cache only feeds warnings; a failed write must not fail the inventory.
764
+ }
765
+ }
766
+ function gib(bytes) {
767
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
768
+ }
425
769
  /** Sibling of the artifacts root so the stamp never appears in the inventory scan. */
426
770
  const AUTO_CLEAN_STAMP = ".harnery/artifacts-auto-clean.json";
427
771
  /**
@@ -429,9 +773,11 @@ const AUTO_CLEAN_STAMP = ".harnery/artifacts-auto-clean.json";
429
773
  *
430
774
  * Retention was previously enforced only when someone remembered to run
431
775
  * `artifacts clean --yes`, so expired workspaces accumulated indefinitely on
432
- * busy hosts. This runs the exact same guarded deletion (expired or over-budget
433
- * managed entries, each re-classified immediately before removal;
434
- * unmanaged and legacy directories are never touched) at most once per
776
+ * busy hosts. This runs the exact same guarded deletion as `artifacts clean
777
+ * --yes` (expired entries, plus oversize or over-budget entries idle past
778
+ * `artifacts.idle_grace_hours`, each re-classified immediately before removal
779
+ * and recorded in the deletion log; unmanaged and legacy directories are never
780
+ * touched) at most once per
435
781
  * interval (default 1h after completion, 1m between partial/failed slices).
436
782
  * The owner-aware lock serializes callers; interrupted attempts can retry.
437
783
  * Disable with `artifacts.auto_clean: false` or
@@ -495,6 +841,7 @@ function autoCleanArtifactsUnlocked(repoRoot, opts) {
495
841
  .filter((row) => row.classification === "unknown")
496
842
  .map((row) => ({ name: row.name, reason: row.reason }));
497
843
  const status = failures.length ? "failed" : remaining ? "partial" : "completed";
844
+ artifactUsageReport(repoRoot, rows, { now });
498
845
  writeStamp({
499
846
  ...attempt,
500
847
  status,
@@ -564,13 +911,18 @@ function classifyArtifactPath(repoRoot, path, now, freshnessSeconds) {
564
911
  return rowFor(path, name, repoRoot, classification, parsed.reason);
565
912
  }
566
913
  const manifest = parsed.manifest;
567
- const bytes = safeTreeSize(path);
914
+ const usage = safeTreeUsage(path);
568
915
  const lastModifiedMs = safeTreeLastModified(path, now, manifest.activity);
569
916
  const retentionAnchorMs = Date.parse(manifest.retention.renewed_at ?? manifest.created_at);
570
917
  const retentionWindowMs = Date.parse(manifest.retention.expires_at) - retentionAnchorMs;
571
918
  const effectiveLastModifiedMs = Math.max(retentionAnchorMs, lastModifiedMs ?? 0);
572
- const effectiveExpiresAt = new Date(effectiveLastModifiedMs + retentionWindowMs).toISOString();
573
- const base = rowFor(path, name, repoRoot, "managed-current", "retention has not expired", bytes);
919
+ const holds = artifactHoldState(manifest, now);
920
+ // A lapsed hold protected the files until its deadline, so ordinary expiry
921
+ // and the size-rule idle clock both count from the latest lapse.
922
+ const lapsedMs = Math.max(0, ...holds.lapsed.map((hold) => Date.parse(hold.expires_at)));
923
+ const effectiveExpiresAt = new Date(Math.max(effectiveLastModifiedMs + retentionWindowMs, lapsedMs)).toISOString();
924
+ const base = rowFor(path, name, repoRoot, "managed-current", "retention has not expired", usage);
925
+ const releasedMs = Math.max(manifest.released_at ? Date.parse(manifest.released_at) : 0, lapsedMs);
574
926
  Object.assign(base, {
575
927
  artifact_id: manifest.artifact_id,
576
928
  slug: manifest.slug,
@@ -579,13 +931,15 @@ function classifyArtifactPath(repoRoot, path, now, freshnessSeconds) {
579
931
  expires_at: effectiveExpiresAt,
580
932
  owner_instance_id: manifest.created_by?.instance_id ?? null,
581
933
  oversize_acknowledged: manifest.oversize_acknowledged === true,
934
+ idle_since: new Date(Math.max(effectiveLastModifiedMs, releasedMs)).toISOString(),
582
935
  });
583
- if (manifest.holds.length > 0) {
936
+ if (holds.active.length > 0) {
584
937
  return {
585
938
  ...base,
586
939
  classification: "managed-held",
587
- reason: `held: ${manifest.holds.map((hold) => hold.id).join(", ")}`,
940
+ reason: `held: ${holds.active.map((hold) => hold.id).join(", ")}`,
588
941
  action: "keep",
942
+ warning: holdWarning(repoRoot, name, holds.active, now),
589
943
  };
590
944
  }
591
945
  if (base.bytes === null || lastModifiedMs === null) {
@@ -605,7 +959,12 @@ function classifyArtifactPath(repoRoot, path, now, freshnessSeconds) {
605
959
  };
606
960
  }
607
961
  if (!manifest.released_at && manifest.created_by?.instance_id) {
608
- const live = ownerLiveness(repoRoot, manifest.created_by.instance_id, now, freshnessSeconds);
962
+ const { state: live, heartbeatMs } = ownerLiveness(repoRoot, manifest.created_by.instance_id, now, freshnessSeconds);
963
+ // A stale heartbeat only starts the idle clock. The owner may be waiting
964
+ // for a human reply, so size rules still wait out the grace from here.
965
+ if (heartbeatMs !== null && heartbeatMs > Date.parse(base.idle_since)) {
966
+ base.idle_since = new Date(Math.min(heartbeatMs, now.getTime())).toISOString();
967
+ }
609
968
  if (live === "live") {
610
969
  return {
611
970
  ...base,
@@ -625,44 +984,95 @@ function classifyArtifactPath(repoRoot, path, now, freshnessSeconds) {
625
984
  }
626
985
  if (Date.parse(effectiveExpiresAt) > now.getTime())
627
986
  return base;
987
+ const lapsedNote = holds.lapsed.length
988
+ ? `; hold ${holds.lapsed.map((hold) => `${hold.id} lapsed at ${hold.expires_at}`).join(", ")}`
989
+ : "";
628
990
  return {
629
991
  ...base,
630
992
  classification: "managed-expired",
631
- reason: `retention expired at ${effectiveExpiresAt}`,
993
+ reason: `retention expired at ${effectiveExpiresAt}${lapsedNote}`,
632
994
  action: "would-delete",
633
995
  };
634
996
  }
635
- function applyArtifactUnitBudget(repoRoot, row) {
997
+ /** Advice for held rows: an imminent lapse, or a hold that never expires. */
998
+ function holdWarning(repoRoot, name, active, now) {
999
+ const bin = resolveBinName(repoRoot);
1000
+ const notes = [];
1001
+ for (const hold of active) {
1002
+ if (hold.persistent)
1003
+ continue;
1004
+ const renew = `${bin} artifacts hold ${name} --id ${hold.id} --reason ${JSON.stringify(hold.reason)}`;
1005
+ if (!hold.expires_at) {
1006
+ notes.push(`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`);
1007
+ }
1008
+ else if (Date.parse(hold.expires_at) - now.getTime() <= HOLD_LAPSE_WARNING_MS) {
1009
+ notes.push(`hold ${hold.id} lapses at ${hold.expires_at}; renew with \`${renew}\``);
1010
+ }
1011
+ }
1012
+ return notes.length ? notes.join("; ") : null;
1013
+ }
1014
+ /** When a size rule may first delete this unit, or null when it never may. */
1015
+ function sizeEvictableAt(repoRoot, row) {
1016
+ if (!row.idle_since)
1017
+ return null;
1018
+ const idleSince = Date.parse(row.idle_since);
1019
+ if (!Number.isFinite(idleSince))
1020
+ return null;
1021
+ return idleSince + artifactIdleGraceHours(repoRoot) * 60 * 60 * 1000;
1022
+ }
1023
+ function applyArtifactUnitBudget(repoRoot, row, now) {
636
1024
  const maxUnitBytes = artifactMaxUnitBytes(repoRoot);
1025
+ if (!["managed-current", "managed-active"].includes(row.classification) ||
1026
+ row.bytes === null ||
1027
+ row.bytes <= maxUnitBytes ||
1028
+ row.oversize_acknowledged) {
1029
+ return row;
1030
+ }
1031
+ const evictableAt = sizeEvictableAt(repoRoot, row);
637
1032
  if (row.classification === "managed-current" &&
638
- row.bytes !== null &&
639
- row.bytes > maxUnitBytes &&
640
- !row.oversize_acknowledged) {
1033
+ evictableAt !== null &&
1034
+ now.getTime() >= evictableAt) {
641
1035
  return {
642
1036
  ...row,
643
1037
  classification: "managed-oversize",
644
1038
  action: "would-delete",
645
- reason: `bundle uses ${row.bytes} bytes, above the ${maxUnitBytes}-byte ceiling without --big`,
1039
+ reason: `bundle uses ${row.bytes} bytes on disk, above the ${maxUnitBytes}-byte ceiling without --big, and has been idle for ${artifactIdleGraceHours(repoRoot)}h`,
646
1040
  };
647
1041
  }
648
- return row;
1042
+ const when = row.classification === "managed-active" || evictableAt === null
1043
+ ? `${artifactIdleGraceHours(repoRoot)}h after its owner goes idle`
1044
+ : `after ${new Date(evictableAt).toISOString()} unless it changes first`;
1045
+ return {
1046
+ ...row,
1047
+ 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`,
1048
+ };
649
1049
  }
650
- function applyArtifactBudgets(repoRoot, inputRows) {
1050
+ function applyArtifactBudgets(repoRoot, inputRows, now) {
651
1051
  const maxBytes = artifactMaxBytes(repoRoot);
652
- const rows = inputRows.map((row) => applyArtifactUnitBudget(repoRoot, { ...row }));
653
- const managedBytes = rows.reduce((sum, row) => sum + (row.artifact_id && row.bytes !== null ? row.bytes : 0), 0);
1052
+ const rows = inputRows.map((row) => applyArtifactUnitBudget(repoRoot, { ...row }, now));
1053
+ // Held units can never be evicted, so counting them would push every other
1054
+ // unit out without bringing the store under budget.
1055
+ const managedBytes = rows.reduce((sum, row) => sum +
1056
+ (row.artifact_id && row.classification !== "managed-held" && row.bytes !== null
1057
+ ? row.bytes
1058
+ : 0), 0);
654
1059
  let retainedBytes = managedBytes -
655
1060
  rows.reduce((sum, row) => sum + (row.action === "would-delete" && row.bytes !== null ? row.bytes : 0), 0);
656
1061
  if (retainedBytes <= maxBytes)
657
1062
  return rows;
658
1063
  const candidates = rows
659
- .filter((row) => row.classification === "managed-current" && row.action === "keep" && row.bytes !== null)
1064
+ .filter((row) => {
1065
+ if (row.classification !== "managed-current" || row.action !== "keep" || row.bytes === null)
1066
+ return false;
1067
+ const evictableAt = sizeEvictableAt(repoRoot, row);
1068
+ return evictableAt !== null && now.getTime() >= evictableAt;
1069
+ })
660
1070
  .sort((left, right) => `${left.expires_at ?? ""}\0${left.created_at ?? ""}\0${left.name}`.localeCompare(`${right.expires_at ?? ""}\0${right.created_at ?? ""}\0${right.name}`));
661
1071
  for (const row of candidates) {
662
1072
  if (retainedBytes <= maxBytes)
663
1073
  break;
664
1074
  row.classification = "managed-over-budget";
665
- row.reason = `repository artifact budget is ${maxBytes} bytes; earliest-expiring inactive bundles are removed first`;
1075
+ row.reason = `repository artifact budget is ${maxBytes} bytes of disk use; earliest-expiring bundles idle for ${artifactIdleGraceHours(repoRoot)}h are removed first`;
666
1076
  row.action = "would-delete";
667
1077
  retainedBytes -= row.bytes ?? 0;
668
1078
  }
@@ -966,9 +1376,29 @@ function validHold(value) {
966
1376
  typeof hold.reason === "string" &&
967
1377
  !!hold.reason.trim() &&
968
1378
  validActor(hold.set_by) &&
969
- validIso(hold.set_at));
1379
+ validIso(hold.set_at) &&
1380
+ (hold.expires_at === undefined || validIso(hold.expires_at)) &&
1381
+ (hold.persistent === undefined || hold.persistent === true) &&
1382
+ !(hold.persistent && hold.expires_at !== undefined));
1383
+ }
1384
+ const HOLD_LAPSE_WARNING_MS = 48 * 60 * 60 * 1000;
1385
+ /**
1386
+ * Split holds into those still in force and those that lapsed. A persistent
1387
+ * hold never lapses. A hold recorded before holds expired has no `expires_at`
1388
+ * and stays in force until its owner removes or renews it.
1389
+ */
1390
+ export function artifactHoldState(manifest, now = new Date()) {
1391
+ const active = [];
1392
+ const lapsed = [];
1393
+ for (const hold of manifest.holds) {
1394
+ if (hold.persistent || !hold.expires_at || Date.parse(hold.expires_at) > now.getTime())
1395
+ active.push(hold);
1396
+ else
1397
+ lapsed.push(hold);
1398
+ }
1399
+ return { active, lapsed };
970
1400
  }
971
- function makeHold(input, actor, now) {
1401
+ function makeHold(input, actor, now, defaultDays) {
972
1402
  assertValidDate(now, "now");
973
1403
  if (!validActor(actor))
974
1404
  throw new Error("a valid hold actor is required");
@@ -976,25 +1406,48 @@ function makeHold(input, actor, now) {
976
1406
  throw new Error("invalid hold id");
977
1407
  if (typeof input.reason !== "string" || !input.reason.trim())
978
1408
  throw new Error("hold reason must not be empty");
979
- return {
1409
+ if (input.days !== undefined && input.minutes !== undefined)
1410
+ throw new Error("choose either hold days or hold minutes");
1411
+ if (input.persistent && (input.days !== undefined || input.minutes !== undefined))
1412
+ throw new Error("a persistent hold takes no duration");
1413
+ const hold = {
980
1414
  id: input.id,
981
1415
  reason: input.reason.trim(),
982
1416
  set_by: { ...actor },
983
1417
  set_at: now.toISOString(),
984
1418
  };
1419
+ if (input.persistent)
1420
+ return { ...hold, persistent: true };
1421
+ const minutes = input.minutes !== undefined
1422
+ ? holdMinutes(input.minutes)
1423
+ : holdDaysValue(input.days ?? defaultDays) * 24 * 60;
1424
+ return { ...hold, expires_at: addMinutes(now, minutes).toISOString() };
1425
+ }
1426
+ function holdDaysValue(value) {
1427
+ if (!Number.isInteger(value) || value <= 0 || value > 365)
1428
+ throw new Error("hold days must be between 1 and 365");
1429
+ return value;
1430
+ }
1431
+ function holdMinutes(value) {
1432
+ if (!Number.isInteger(value) || value <= 0 || value > 365 * 24 * 60)
1433
+ throw new Error("hold minutes must be between 1 and 525600");
1434
+ return value;
985
1435
  }
986
1436
  function ownerLiveness(repoRoot, instanceId, now, freshnessSeconds) {
987
1437
  try {
988
1438
  const row = readLiveCoordinationRow(repoRoot, instanceId);
989
1439
  if (!row)
990
- return "stale";
1440
+ return { state: "stale", heartbeatMs: null };
991
1441
  const ts = Date.parse(row.last_heartbeat);
992
1442
  if (!Number.isFinite(ts))
993
- return "unknown";
994
- return now.getTime() - ts <= freshnessSeconds * 1000 ? "live" : "stale";
1443
+ return { state: "unknown", heartbeatMs: null };
1444
+ return {
1445
+ state: now.getTime() - ts <= freshnessSeconds * 1000 ? "live" : "stale",
1446
+ heartbeatMs: ts,
1447
+ };
995
1448
  }
996
1449
  catch {
997
- return "unknown";
1450
+ return { state: "unknown", heartbeatMs: null };
998
1451
  }
999
1452
  }
1000
1453
  function containsTrackedPath(repoRoot, path) {
@@ -1008,21 +1461,35 @@ function containsTrackedPath(repoRoot, path) {
1008
1461
  // If Git itself cannot answer, fail closed.
1009
1462
  return result.status !== 0 || result.stdout.length > 0;
1010
1463
  }
1011
- function safeTreeSize(path) {
1464
+ /** Disk bytes for one entry. Windows reports no block count, so length stands in. */
1465
+ function allocatedBytes(st) {
1466
+ return process.platform !== "win32" && Number.isFinite(st.blocks) ? st.blocks * 512 : st.size;
1467
+ }
1468
+ /**
1469
+ * Measure a tree without following symlinks. Size rules read `disk`: a sparse
1470
+ * image or a hard-linked environment would otherwise count many times its real
1471
+ * footprint and push healthy workspaces over a budget they do not exceed.
1472
+ */
1473
+ function safeTreeUsage(path, seen = new Set()) {
1012
1474
  try {
1013
1475
  const st = lstatSync(path);
1014
- if (st.isSymbolicLink())
1015
- return st.size;
1016
- if (st.isFile())
1017
- return st.size;
1018
- if (!st.isDirectory())
1019
- return 0;
1020
- let total = st.size;
1476
+ if (!st.isDirectory()) {
1477
+ if (!st.isFile() && !st.isSymbolicLink())
1478
+ return { disk: 0, apparent: 0 };
1479
+ const key = `${st.dev}:${st.ino}`;
1480
+ if (st.nlink > 1 && seen.has(key))
1481
+ return { disk: 0, apparent: st.size };
1482
+ if (st.nlink > 1)
1483
+ seen.add(key);
1484
+ return { disk: allocatedBytes(st), apparent: st.size };
1485
+ }
1486
+ const total = { disk: allocatedBytes(st), apparent: st.size };
1021
1487
  for (const child of readdirSync(path)) {
1022
- const size = safeTreeSize(join(path, child));
1023
- if (size === null)
1488
+ const usage = safeTreeUsage(join(path, child), seen);
1489
+ if (usage === null)
1024
1490
  return null;
1025
- total += size;
1491
+ total.disk += usage.disk;
1492
+ total.apparent += usage.apparent;
1026
1493
  }
1027
1494
  return total;
1028
1495
  }
@@ -1030,6 +1497,9 @@ function safeTreeSize(path) {
1030
1497
  return null;
1031
1498
  }
1032
1499
  }
1500
+ function safeTreeSize(path) {
1501
+ return safeTreeUsage(path)?.disk ?? null;
1502
+ }
1033
1503
  /**
1034
1504
  * Return the newest filesystem change in a managed tree without following
1035
1505
  * symlinks. A small future tolerance protects a write racing the inventory
@@ -1043,7 +1513,7 @@ function safeTreeLastModified(path, now, activity) {
1043
1513
  return null;
1044
1514
  }
1045
1515
  }
1046
- function rowFor(path, name, repoRoot, classification, reason, bytes = safeTreeSize(path)) {
1516
+ function rowFor(path, name, repoRoot, classification, reason, usage = safeTreeUsage(path)) {
1047
1517
  return {
1048
1518
  name,
1049
1519
  path,
@@ -1051,7 +1521,8 @@ function rowFor(path, name, repoRoot, classification, reason, bytes = safeTreeSi
1051
1521
  classification,
1052
1522
  reason,
1053
1523
  action: classification === "managed-expired" ? "would-delete" : "keep",
1054
- bytes,
1524
+ bytes: usage?.disk ?? null,
1525
+ apparent_bytes: usage?.apparent ?? null,
1055
1526
  artifact_id: null,
1056
1527
  slug: null,
1057
1528
  created_at: null,
@@ -1059,6 +1530,8 @@ function rowFor(path, name, repoRoot, classification, reason, bytes = safeTreeSi
1059
1530
  expires_at: null,
1060
1531
  owner_instance_id: null,
1061
1532
  oversize_acknowledged: false,
1533
+ idle_since: null,
1534
+ warning: null,
1062
1535
  };
1063
1536
  }
1064
1537
  function normalizeSlug(value) {