harnery 0.37.0 → 0.38.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.
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +7 -2
- package/dist/commands/admission.d.ts +21 -0
- package/dist/commands/admission.d.ts.map +1 -0
- package/dist/commands/admission.js +565 -0
- package/dist/commands/agents.d.ts +7 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +61 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +48 -2
- package/dist/commands/browse-ai.d.ts +2 -2
- package/dist/commands/browse-ai.d.ts.map +1 -1
- package/dist/commands/browse-ai.js +6 -4
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +349 -21
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/qa-record.d.ts +144 -0
- package/dist/commands/qa-record.d.ts.map +1 -0
- package/dist/commands/qa-record.js +0 -0
- package/dist/commands/qa-run.d.ts +7 -3
- package/dist/commands/qa-run.d.ts.map +1 -1
- package/dist/commands/qa-run.js +268 -13
- package/dist/commands/qa-status.d.ts +71 -0
- package/dist/commands/qa-status.d.ts.map +1 -0
- package/dist/commands/qa-status.js +490 -0
- package/dist/commands/qa-verify.d.ts +40 -0
- package/dist/commands/qa-verify.d.ts.map +1 -0
- package/dist/commands/qa-verify.js +180 -0
- package/dist/commands/review-pack.d.ts +4 -0
- package/dist/commands/review-pack.d.ts.map +1 -0
- package/dist/commands/review-pack.js +1001 -0
- package/dist/core/agents/qa-signal.d.ts +111 -0
- package/dist/core/agents/qa-signal.d.ts.map +1 -0
- package/dist/core/agents/qa-signal.js +231 -0
- package/dist/core/agents/session-name-display.d.ts +20 -5
- package/dist/core/agents/session-name-display.d.ts.map +1 -1
- package/dist/core/agents/session-name-display.js +67 -7
- package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
- package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +17 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +1 -0
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/artifacts/constants.d.ts +1 -1
- package/dist/core/artifacts/constants.js +1 -1
- package/dist/core/artifacts/index.d.ts +57 -6
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +265 -10
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +16 -0
- package/dist/core/diagnostics/bundle.d.ts +16 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +101 -7
- package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
- package/dist/core/events/v3/bootstrap.js +10 -0
- package/dist/core/events/v3/coordination-view.d.ts +3 -0
- package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
- package/dist/core/events/v3/coordination-view.js +69 -8
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +9 -2
- package/dist/core/events/v3/producers/recorder.d.ts +23 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +264 -18
- package/dist/core/hooks/cli.js +120 -27
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +10 -3
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
- package/dist/core/hooks/session-name-presence.js +4 -1
- package/dist/core/qa-artifacts.d.ts +20 -0
- package/dist/core/qa-artifacts.d.ts.map +1 -0
- package/dist/core/qa-artifacts.js +110 -0
- package/dist/core/resources/contract.d.ts +6 -0
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/sampler.d.ts +7 -0
- package/dist/core/resources/sampler.d.ts.map +1 -1
- package/dist/core/resources/sampler.js +106 -5
- package/dist/lib/admission.d.ts +71 -0
- package/dist/lib/admission.d.ts.map +1 -0
- package/dist/lib/admission.js +264 -0
- package/dist/lib/agent-browser/client.d.ts +1 -1
- package/dist/lib/agent-browser/client.d.ts.map +1 -1
- package/dist/lib/agent-browser/client.js +1 -5
- package/dist/lib/browser/capture-fidelity.d.ts +39 -0
- package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
- package/dist/lib/browser/capture-fidelity.js +84 -0
- package/dist/lib/browser/client.d.ts +41 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +167 -9
- package/dist/lib/browser/critique.d.ts +38 -1
- package/dist/lib/browser/critique.d.ts.map +1 -1
- package/dist/lib/browser/critique.js +34 -6
- package/dist/lib/browser/index.d.ts +4 -2
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +2 -0
- package/dist/lib/browser/page-review-judge.d.ts +64 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
- package/dist/lib/browser/page-review-judge.js +270 -0
- package/dist/lib/browser/page-review-pack.d.ts +613 -0
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
- package/dist/lib/browser/page-review-pack.js +1751 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run-contracts.js +136 -1
- package/dist/lib/browser/qa-run.d.ts +100 -10
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +768 -169
- package/dist/lib/browser/request-diagnostics.d.ts +13 -0
- package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
- package/dist/lib/browser/request-diagnostics.js +18 -0
- package/dist/lib/browser/tiling.d.ts +19 -0
- package/dist/lib/browser/tiling.d.ts.map +1 -1
- package/dist/lib/browser/tiling.js +28 -0
- package/dist/lib/cookies/client.d.ts +9 -0
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +197 -44
- package/dist/lib/cookies/extra.d.ts +18 -0
- package/dist/lib/cookies/extra.d.ts.map +1 -0
- package/dist/lib/cookies/extra.js +14 -0
- package/dist/lib/cookies/index.d.ts +2 -1
- package/dist/lib/cookies/index.d.ts.map +1 -1
- package/dist/lib/cookies/index.js +2 -1
- package/dist/lib/durable-job.d.ts +124 -0
- package/dist/lib/durable-job.d.ts.map +1 -0
- package/dist/lib/durable-job.js +296 -0
- package/dist/lib/http/client.d.ts +7 -1
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +2 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +5 -2
- package/package.json +8 -2
- package/src/commander.ts +50 -2
- package/src/commands/admission.ts +699 -0
- package/src/commands/agents.ts +87 -1
- package/src/commands/artifacts.ts +97 -21
- package/src/commands/browse-ai.ts +10 -5
- package/src/commands/browse.ts +481 -20
- package/src/commands/fetch.ts +1 -0
- package/src/commands/qa-record.ts +682 -0
- package/src/commands/qa-run.ts +335 -16
- package/src/commands/qa-status.ts +608 -0
- package/src/commands/qa-verify.ts +238 -0
- package/src/commands/review-pack.ts +1281 -0
- package/src/core/agents/qa-signal.ts +261 -0
- package/src/core/agents/session-name-display.ts +78 -7
- package/src/core/agents/state/heartbeat-reader.ts +7 -0
- package/src/core/agents/state/heartbeat-writer.ts +23 -0
- package/src/core/agents/state/live-coordination-view.ts +1 -0
- package/src/core/agents/state/live-coordination-writer.ts +5 -0
- package/src/core/artifacts/constants.ts +1 -1
- package/src/core/artifacts/index.ts +370 -21
- package/src/core/config.ts +23 -0
- package/src/core/diagnostics/bundle.ts +119 -11
- package/src/core/events/v3/bootstrap.ts +10 -0
- package/src/core/events/v3/coordination-view.ts +100 -11
- package/src/core/events/v3/producers/intake.ts +9 -2
- package/src/core/events/v3/producers/recorder.ts +312 -18
- package/src/core/hooks/cli.ts +140 -32
- package/src/core/hooks/resolve/transcript.ts +10 -3
- package/src/core/hooks/session-name-presence.ts +6 -1
- package/src/core/qa-artifacts.ts +126 -0
- package/src/core/resources/contract.ts +7 -0
- package/src/core/resources/sampler.ts +138 -6
- package/src/lib/admission.ts +347 -0
- package/src/lib/agent-browser/client.ts +2 -10
- package/src/lib/browser/capture-fidelity.ts +98 -0
- package/src/lib/browser/client.ts +206 -10
- package/src/lib/browser/critique.ts +62 -7
- package/src/lib/browser/index.ts +36 -0
- package/src/lib/browser/page-review-judge.ts +360 -0
- package/src/lib/browser/page-review-pack.ts +2384 -0
- package/src/lib/browser/qa-run-contracts.ts +366 -3
- package/src/lib/browser/qa-run.ts +868 -190
- package/src/lib/browser/request-diagnostics.ts +27 -0
- package/src/lib/browser/tiling.ts +32 -0
- package/src/lib/cookies/client.ts +228 -42
- package/src/lib/cookies/extra.ts +28 -0
- package/src/lib/cookies/index.ts +2 -0
- package/src/lib/durable-job.ts +407 -0
- package/src/lib/http/client.ts +13 -1
- package/src/lib/instructions/templates.ts +5 -2
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import type { Stats } from "node:fs";
|
|
14
14
|
import {
|
|
15
15
|
existsSync,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
readdirSync,
|
|
19
19
|
readFileSync,
|
|
20
20
|
renameSync,
|
|
21
|
+
rmdirSync,
|
|
21
22
|
rmSync,
|
|
22
23
|
writeFileSync,
|
|
23
24
|
} from "node:fs";
|
|
@@ -40,8 +41,9 @@ export interface ArtifactActor {
|
|
|
40
41
|
name?: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export interface
|
|
44
|
+
export interface ArtifactManifestV2 {
|
|
44
45
|
schema_version: typeof ARTIFACT_SCHEMA_VERSION;
|
|
46
|
+
holds: ArtifactHold[];
|
|
45
47
|
artifact_id: string;
|
|
46
48
|
slug: string;
|
|
47
49
|
purpose: string;
|
|
@@ -57,7 +59,30 @@ export interface ArtifactManifestV1 {
|
|
|
57
59
|
oversize_acknowledged?: boolean;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
export interface ArtifactHold {
|
|
63
|
+
id: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
set_by: ArtifactActor;
|
|
66
|
+
set_at: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ArtifactHoldInput {
|
|
70
|
+
id: string;
|
|
71
|
+
reason: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function artifactCapabilities() {
|
|
75
|
+
return {
|
|
76
|
+
schema_version: ARTIFACT_SCHEMA_VERSION,
|
|
77
|
+
holds: true,
|
|
78
|
+
atomic_create_holds: true,
|
|
79
|
+
owner_scoped_unhold: true,
|
|
80
|
+
explicit_v1_migration: true,
|
|
81
|
+
} as const;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
export type ArtifactClassification =
|
|
85
|
+
| "managed-held"
|
|
61
86
|
| "managed-active"
|
|
62
87
|
| "managed-current"
|
|
63
88
|
| "managed-expired"
|
|
@@ -90,7 +115,12 @@ export interface ArtifactCreateInput {
|
|
|
90
115
|
slug: string;
|
|
91
116
|
purpose: string;
|
|
92
117
|
retentionDays: number;
|
|
118
|
+
/** Sub-day retention. When set, it replaces `retentionDays` for this unit
|
|
119
|
+
* (a page review pack expires in minutes, not days). 1 to 5,256,000. */
|
|
120
|
+
retentionMinutes?: number;
|
|
93
121
|
actor?: ArtifactActor;
|
|
122
|
+
/** Holds are persisted with the first manifest; a valid actor is required. */
|
|
123
|
+
holds?: ArtifactHoldInput[];
|
|
94
124
|
now?: Date;
|
|
95
125
|
id?: string;
|
|
96
126
|
big?: boolean;
|
|
@@ -112,7 +142,7 @@ export interface ArtifactAdoptionResult {
|
|
|
112
142
|
|
|
113
143
|
interface ParsedManifest {
|
|
114
144
|
ok: true;
|
|
115
|
-
manifest:
|
|
145
|
+
manifest: ArtifactManifestV2;
|
|
116
146
|
}
|
|
117
147
|
|
|
118
148
|
interface ManifestError {
|
|
@@ -132,18 +162,32 @@ export function configuredArtifactRetentionDays(repoRoot: string): number {
|
|
|
132
162
|
export function createArtifact(
|
|
133
163
|
repoRoot: string,
|
|
134
164
|
input: ArtifactCreateInput,
|
|
135
|
-
): { path: string; manifest:
|
|
165
|
+
): { path: string; manifest: ArtifactManifestV2 } {
|
|
166
|
+
return withArtifactLock(repoRoot, () => createArtifactUnlocked(repoRoot, input));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createArtifactUnlocked(
|
|
170
|
+
repoRoot: string,
|
|
171
|
+
input: ArtifactCreateInput,
|
|
172
|
+
): { path: string; manifest: ArtifactManifestV2 } {
|
|
136
173
|
const now = input.now ?? new Date();
|
|
137
174
|
assertValidDate(now, "now");
|
|
138
175
|
const slug = normalizeSlug(input.slug);
|
|
139
176
|
if (!slug) throw new Error("slug must contain at least one ASCII letter or digit");
|
|
140
177
|
const purpose = input.purpose.trim();
|
|
141
178
|
if (!purpose) throw new Error("purpose must not be empty");
|
|
142
|
-
const
|
|
179
|
+
const expiresAt =
|
|
180
|
+
input.retentionMinutes !== undefined
|
|
181
|
+
? addMinutes(now, positiveMinutes(input.retentionMinutes))
|
|
182
|
+
: addDays(now, positiveDays(input.retentionDays));
|
|
143
183
|
const artifactId = input.id ?? randomUUID();
|
|
144
184
|
if (!isSafeId(artifactId)) {
|
|
145
185
|
throw new Error("artifact id must use ASCII letters, digits, hyphens, or underscores");
|
|
146
186
|
}
|
|
187
|
+
const holds = (input.holds ?? []).map((hold) => makeHold(hold, input.actor, now));
|
|
188
|
+
if (new Set(holds.map((hold) => hold.id)).size !== holds.length) {
|
|
189
|
+
throw new Error("duplicate hold id");
|
|
190
|
+
}
|
|
147
191
|
|
|
148
192
|
const root = artifactsRoot(repoRoot);
|
|
149
193
|
mkdirSync(root, { recursive: true });
|
|
@@ -151,15 +195,16 @@ export function createArtifact(
|
|
|
151
195
|
const path = join(root, `${date}_${slug}_${artifactId.slice(0, 8)}`);
|
|
152
196
|
mkdirSync(path);
|
|
153
197
|
|
|
154
|
-
const manifest:
|
|
198
|
+
const manifest: ArtifactManifestV2 = {
|
|
155
199
|
schema_version: ARTIFACT_SCHEMA_VERSION,
|
|
200
|
+
holds,
|
|
156
201
|
artifact_id: artifactId,
|
|
157
202
|
slug,
|
|
158
203
|
purpose,
|
|
159
204
|
created_at: now.toISOString(),
|
|
160
205
|
created_by: input.actor,
|
|
161
206
|
retention: {
|
|
162
|
-
expires_at:
|
|
207
|
+
expires_at: expiresAt.toISOString(),
|
|
163
208
|
},
|
|
164
209
|
...(input.big ? { oversize_acknowledged: true } : {}),
|
|
165
210
|
};
|
|
@@ -201,7 +246,7 @@ export function showArtifact(
|
|
|
201
246
|
repoRoot: string,
|
|
202
247
|
ref: string,
|
|
203
248
|
opts: { now?: Date; freshnessSeconds?: number } = {},
|
|
204
|
-
): { entry: ArtifactInventoryEntry; manifest:
|
|
249
|
+
): { entry: ArtifactInventoryEntry; manifest: ArtifactManifestV2 } {
|
|
205
250
|
const path = resolveArtifactRef(repoRoot, ref);
|
|
206
251
|
const entry = inventoryArtifacts(repoRoot, opts).find((row) => row.path === path);
|
|
207
252
|
if (!entry) throw new Error(`artifact "${ref}" was not found`);
|
|
@@ -216,7 +261,19 @@ export function renewArtifact(
|
|
|
216
261
|
days: number,
|
|
217
262
|
reason: string,
|
|
218
263
|
input: ArtifactMutationInput = {},
|
|
219
|
-
):
|
|
264
|
+
): ArtifactManifestV2 {
|
|
265
|
+
return withArtifactLock(repoRoot, () =>
|
|
266
|
+
renewArtifactUnlocked(repoRoot, ref, days, reason, input),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renewArtifactUnlocked(
|
|
271
|
+
repoRoot: string,
|
|
272
|
+
ref: string,
|
|
273
|
+
days: number,
|
|
274
|
+
reason: string,
|
|
275
|
+
input: ArtifactMutationInput,
|
|
276
|
+
): ArtifactManifestV2 {
|
|
220
277
|
const now = input.now ?? new Date();
|
|
221
278
|
assertValidDate(now, "now");
|
|
222
279
|
const retentionDays = positiveDays(days);
|
|
@@ -225,7 +282,7 @@ export function renewArtifact(
|
|
|
225
282
|
const path = resolveArtifactRef(repoRoot, ref);
|
|
226
283
|
const parsed = readManifest(path);
|
|
227
284
|
if (!parsed.ok) throw new Error(parsed.reason);
|
|
228
|
-
const manifest:
|
|
285
|
+
const manifest: ArtifactManifestV2 = {
|
|
229
286
|
...parsed.manifest,
|
|
230
287
|
retention: {
|
|
231
288
|
expires_at: addDays(now, retentionDays).toISOString(),
|
|
@@ -241,13 +298,21 @@ export function releaseArtifact(
|
|
|
241
298
|
repoRoot: string,
|
|
242
299
|
ref: string,
|
|
243
300
|
input: ArtifactMutationInput = {},
|
|
244
|
-
):
|
|
301
|
+
): ArtifactManifestV2 {
|
|
302
|
+
return withArtifactLock(repoRoot, () => releaseArtifactUnlocked(repoRoot, ref, input));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function releaseArtifactUnlocked(
|
|
306
|
+
repoRoot: string,
|
|
307
|
+
ref: string,
|
|
308
|
+
input: ArtifactMutationInput,
|
|
309
|
+
): ArtifactManifestV2 {
|
|
245
310
|
const now = input.now ?? new Date();
|
|
246
311
|
assertValidDate(now, "now");
|
|
247
312
|
const path = resolveArtifactRef(repoRoot, ref);
|
|
248
313
|
const parsed = readManifest(path);
|
|
249
314
|
if (!parsed.ok) throw new Error(parsed.reason);
|
|
250
|
-
const manifest:
|
|
315
|
+
const manifest: ArtifactManifestV2 = {
|
|
251
316
|
...parsed.manifest,
|
|
252
317
|
released_at: now.toISOString(),
|
|
253
318
|
released_by: input.actor,
|
|
@@ -256,6 +321,58 @@ export function releaseArtifact(
|
|
|
256
321
|
return manifest;
|
|
257
322
|
}
|
|
258
323
|
|
|
324
|
+
export function holdArtifact(
|
|
325
|
+
repoRoot: string,
|
|
326
|
+
ref: string,
|
|
327
|
+
input: ArtifactHoldInput & { actor: ArtifactActor; now?: Date },
|
|
328
|
+
): ArtifactManifestV2 {
|
|
329
|
+
const hold = makeHold(input, input.actor, input.now ?? new Date());
|
|
330
|
+
return withArtifactLock(repoRoot, () => {
|
|
331
|
+
const path = resolveArtifactRef(repoRoot, ref);
|
|
332
|
+
const parsed = readManifest(path);
|
|
333
|
+
if (!parsed.ok) throw new Error(parsed.reason);
|
|
334
|
+
const previous = parsed.manifest.holds.find((item) => item.id === hold.id);
|
|
335
|
+
if (previous) {
|
|
336
|
+
if (
|
|
337
|
+
previous.set_by.instance_id !== input.actor.instance_id ||
|
|
338
|
+
previous.reason !== hold.reason
|
|
339
|
+
) {
|
|
340
|
+
throw new Error("hold id already exists with a different owner or reason");
|
|
341
|
+
}
|
|
342
|
+
return parsed.manifest;
|
|
343
|
+
}
|
|
344
|
+
const manifest = { ...parsed.manifest, holds: [...parsed.manifest.holds, hold] };
|
|
345
|
+
atomicWriteManifest(path, manifest);
|
|
346
|
+
return manifest;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function unholdArtifact(
|
|
351
|
+
repoRoot: string,
|
|
352
|
+
ref: string,
|
|
353
|
+
id: string,
|
|
354
|
+
input: { actor: ArtifactActor; now?: Date },
|
|
355
|
+
): ArtifactManifestV2 {
|
|
356
|
+
if (!validHoldId(id)) throw new Error("invalid hold id");
|
|
357
|
+
if (!validActor(input.actor)) throw new Error("a valid hold actor is required");
|
|
358
|
+
return withArtifactLock(repoRoot, () => {
|
|
359
|
+
const path = resolveArtifactRef(repoRoot, ref);
|
|
360
|
+
const parsed = readManifest(path);
|
|
361
|
+
if (!parsed.ok) throw new Error(parsed.reason);
|
|
362
|
+
const hold = parsed.manifest.holds.find((item) => item.id === id);
|
|
363
|
+
if (!hold) return parsed.manifest;
|
|
364
|
+
if (hold.set_by.instance_id !== input.actor.instance_id) {
|
|
365
|
+
throw new Error("only the hold owner may remove this hold");
|
|
366
|
+
}
|
|
367
|
+
const manifest = {
|
|
368
|
+
...parsed.manifest,
|
|
369
|
+
holds: parsed.manifest.holds.filter((item) => item.id !== id),
|
|
370
|
+
};
|
|
371
|
+
atomicWriteManifest(path, manifest);
|
|
372
|
+
return manifest;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
259
376
|
/** Adopt untracked loose files and legacy directories without changing directory paths. */
|
|
260
377
|
export function adoptUnmanagedArtifactFiles(
|
|
261
378
|
repoRoot: string,
|
|
@@ -267,6 +384,22 @@ export function adoptUnmanagedArtifactFiles(
|
|
|
267
384
|
actor?: ArtifactActor;
|
|
268
385
|
now?: Date;
|
|
269
386
|
},
|
|
387
|
+
): ArtifactAdoptionResult {
|
|
388
|
+
if (input.yes)
|
|
389
|
+
return withArtifactLock(repoRoot, () => adoptUnmanagedArtifactFilesUnlocked(repoRoot, input));
|
|
390
|
+
return adoptUnmanagedArtifactFilesUnlocked(repoRoot, input);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function adoptUnmanagedArtifactFilesUnlocked(
|
|
394
|
+
repoRoot: string,
|
|
395
|
+
input: {
|
|
396
|
+
yes?: boolean;
|
|
397
|
+
big?: boolean;
|
|
398
|
+
purpose: string;
|
|
399
|
+
retentionDays: number;
|
|
400
|
+
actor?: ArtifactActor;
|
|
401
|
+
now?: Date;
|
|
402
|
+
},
|
|
270
403
|
): ArtifactAdoptionResult {
|
|
271
404
|
const now = input.now ?? new Date();
|
|
272
405
|
const candidates: ArtifactAdoptionResult["candidates"] = [];
|
|
@@ -326,7 +459,7 @@ export function adoptUnmanagedArtifactFiles(
|
|
|
326
459
|
}
|
|
327
460
|
const files = candidates.filter((candidate) => candidate.kind === "file");
|
|
328
461
|
const created = files.length
|
|
329
|
-
?
|
|
462
|
+
? createArtifactUnlocked(repoRoot, {
|
|
330
463
|
slug: "adopted-unmanaged",
|
|
331
464
|
purpose: input.purpose,
|
|
332
465
|
retentionDays: input.retentionDays,
|
|
@@ -341,6 +474,7 @@ export function adoptUnmanagedArtifactFiles(
|
|
|
341
474
|
for (const candidate of directories) {
|
|
342
475
|
atomicWriteManifest(candidate.path, {
|
|
343
476
|
schema_version: ARTIFACT_SCHEMA_VERSION,
|
|
477
|
+
holds: [],
|
|
344
478
|
artifact_id: randomUUID(),
|
|
345
479
|
slug: normalizeSlug(candidate.name),
|
|
346
480
|
purpose: `${input.purpose}: ${candidate.name}`,
|
|
@@ -361,6 +495,27 @@ export function adoptUnmanagedArtifactFiles(
|
|
|
361
495
|
export function cleanArtifacts(
|
|
362
496
|
repoRoot: string,
|
|
363
497
|
opts: { yes?: boolean; now?: Date; freshnessSeconds?: number } = {},
|
|
498
|
+
): ArtifactInventoryEntry[] {
|
|
499
|
+
if (!opts.yes) return inventoryArtifacts(repoRoot, opts);
|
|
500
|
+
try {
|
|
501
|
+
return withArtifactLock(repoRoot, () => cleanArtifactsUnlocked(repoRoot, opts));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
return inventoryArtifacts(repoRoot, opts).map((row) =>
|
|
504
|
+
row.action === "would-delete"
|
|
505
|
+
? {
|
|
506
|
+
...row,
|
|
507
|
+
classification: "unknown",
|
|
508
|
+
action: "keep",
|
|
509
|
+
reason: errorMessage("cleanup refused", error),
|
|
510
|
+
}
|
|
511
|
+
: row,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function cleanArtifactsUnlocked(
|
|
517
|
+
repoRoot: string,
|
|
518
|
+
opts: { yes?: boolean; now?: Date; freshnessSeconds?: number },
|
|
364
519
|
): ArtifactInventoryEntry[] {
|
|
365
520
|
const now = opts.now ?? new Date();
|
|
366
521
|
const freshnessSeconds = opts.freshnessSeconds ?? 600;
|
|
@@ -535,6 +690,14 @@ function classifyArtifactPath(
|
|
|
535
690
|
oversize_acknowledged: manifest.oversize_acknowledged === true,
|
|
536
691
|
});
|
|
537
692
|
|
|
693
|
+
if (manifest.holds.length > 0) {
|
|
694
|
+
return {
|
|
695
|
+
...base,
|
|
696
|
+
classification: "managed-held",
|
|
697
|
+
reason: `held: ${manifest.holds.map((hold) => hold.id).join(", ")}`,
|
|
698
|
+
action: "keep",
|
|
699
|
+
};
|
|
700
|
+
}
|
|
538
701
|
if (base.bytes === null || lastModifiedMs === null) {
|
|
539
702
|
return {
|
|
540
703
|
...base,
|
|
@@ -639,17 +802,40 @@ function readManifest(path: string): ParsedManifest | ManifestError {
|
|
|
639
802
|
}
|
|
640
803
|
let value: unknown;
|
|
641
804
|
try {
|
|
805
|
+
const unit = lstatSync(path);
|
|
806
|
+
const stat = lstatSync(manifestPath);
|
|
807
|
+
if (
|
|
808
|
+
!unit.isDirectory() ||
|
|
809
|
+
unit.isSymbolicLink() ||
|
|
810
|
+
!stat.isFile() ||
|
|
811
|
+
stat.isSymbolicLink() ||
|
|
812
|
+
stat.size > 1024 * 1024
|
|
813
|
+
) {
|
|
814
|
+
return { ok: false, reason: "manifest must be a bounded regular file in a direct directory" };
|
|
815
|
+
}
|
|
642
816
|
value = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
643
817
|
} catch (error) {
|
|
644
818
|
return { ok: false, reason: errorMessage("manifest is unreadable", error) };
|
|
645
819
|
}
|
|
820
|
+
return parseArtifactManifest(value);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Validate only the current schema. Legacy conversion belongs to migrateArtifacts. */
|
|
824
|
+
export function parseArtifactManifest(value: unknown): ParsedManifest | ManifestError {
|
|
646
825
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
647
826
|
return { ok: false, reason: "manifest must be a JSON object" };
|
|
648
827
|
}
|
|
649
|
-
const m = value as Partial<
|
|
828
|
+
const m = value as Partial<ArtifactManifestV2>;
|
|
650
829
|
if (m.schema_version !== ARTIFACT_SCHEMA_VERSION) {
|
|
651
830
|
return { ok: false, reason: `unsupported schema_version ${String(m.schema_version)}` };
|
|
652
831
|
}
|
|
832
|
+
if (
|
|
833
|
+
!Array.isArray(m.holds) ||
|
|
834
|
+
m.holds.some((hold) => !validHold(hold)) ||
|
|
835
|
+
new Set(m.holds.map((hold) => hold.id)).size !== m.holds.length
|
|
836
|
+
) {
|
|
837
|
+
return { ok: false, reason: "invalid holds" };
|
|
838
|
+
}
|
|
653
839
|
if (!isSafeId(m.artifact_id)) return { ok: false, reason: "invalid artifact_id" };
|
|
654
840
|
if (typeof m.slug !== "string" || !m.slug || normalizeSlug(m.slug) !== m.slug) {
|
|
655
841
|
return { ok: false, reason: "invalid slug" };
|
|
@@ -680,17 +866,169 @@ function readManifest(path: string): ParsedManifest | ManifestError {
|
|
|
680
866
|
if (m.oversize_acknowledged !== undefined && typeof m.oversize_acknowledged !== "boolean") {
|
|
681
867
|
return { ok: false, reason: "invalid oversize_acknowledged" };
|
|
682
868
|
}
|
|
683
|
-
return { ok: true, manifest: m as
|
|
869
|
+
return { ok: true, manifest: m as ArtifactManifestV2 };
|
|
684
870
|
}
|
|
685
871
|
|
|
686
|
-
function atomicWriteManifest(path: string, manifest:
|
|
872
|
+
function atomicWriteManifest(path: string, manifest: ArtifactManifestV2): void {
|
|
687
873
|
const target = join(path, ARTIFACT_MANIFEST);
|
|
688
874
|
const tmp = `${target}.tmp.${process.pid}.${randomUUID().slice(0, 8)}`;
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
875
|
+
try {
|
|
876
|
+
writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
877
|
+
encoding: "utf8",
|
|
878
|
+
mode: 0o600,
|
|
879
|
+
flag: "wx",
|
|
880
|
+
});
|
|
881
|
+
renameSync(tmp, target);
|
|
882
|
+
} finally {
|
|
883
|
+
rmSync(tmp, { force: true });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export interface ArtifactMigrationEntry {
|
|
888
|
+
path: string;
|
|
889
|
+
action: "keep" | "would-migrate" | "migrated";
|
|
890
|
+
reason: string;
|
|
891
|
+
preimage_path?: string;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Explicit, bounded v1 cutover. Preview never writes; every applied unit keeps its exact preimage. */
|
|
895
|
+
export function migrateArtifacts(
|
|
896
|
+
repoRoot: string,
|
|
897
|
+
opts: { yes?: boolean } = {},
|
|
898
|
+
): ArtifactMigrationEntry[] {
|
|
899
|
+
const migrate = (): ArtifactMigrationEntry[] => {
|
|
900
|
+
const root = artifactsRoot(repoRoot);
|
|
901
|
+
if (!existsSync(root)) return [];
|
|
902
|
+
return readdirSync(root)
|
|
903
|
+
.sort()
|
|
904
|
+
.map((name): ArtifactMigrationEntry => {
|
|
905
|
+
const path = join(root, name);
|
|
906
|
+
try {
|
|
907
|
+
const stat = lstatSync(path);
|
|
908
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
909
|
+
throw new Error("not a direct directory");
|
|
910
|
+
if (containsTrackedPath(repoRoot, path))
|
|
911
|
+
throw new Error("artifact contains tracked files");
|
|
912
|
+
const target = join(path, ARTIFACT_MANIFEST);
|
|
913
|
+
const manifestStat = lstatSync(target);
|
|
914
|
+
if (
|
|
915
|
+
!manifestStat.isFile() ||
|
|
916
|
+
manifestStat.isSymbolicLink() ||
|
|
917
|
+
manifestStat.size > 1024 * 1024
|
|
918
|
+
) {
|
|
919
|
+
throw new Error("manifest is not a bounded regular file");
|
|
920
|
+
}
|
|
921
|
+
const preimage = readFileSync(target, "utf8");
|
|
922
|
+
const legacy = JSON.parse(preimage);
|
|
923
|
+
if (legacy?.schema_version !== 1) {
|
|
924
|
+
return {
|
|
925
|
+
path,
|
|
926
|
+
action: "keep",
|
|
927
|
+
reason: `schema_version ${String(legacy?.schema_version)} is not a migration source`,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
if (Object.hasOwn(legacy, "holds"))
|
|
931
|
+
throw new Error("v1 manifest unexpectedly contains holds");
|
|
932
|
+
const parsed = parseArtifactManifest({
|
|
933
|
+
...legacy,
|
|
934
|
+
schema_version: ARTIFACT_SCHEMA_VERSION,
|
|
935
|
+
holds: [],
|
|
936
|
+
});
|
|
937
|
+
if (!parsed.ok) throw new Error(parsed.reason);
|
|
938
|
+
const digest = createHash("sha256").update(preimage).digest("hex");
|
|
939
|
+
const preimagePath = join(
|
|
940
|
+
resolve(repoRoot),
|
|
941
|
+
".harnery/artifact-migrations",
|
|
942
|
+
`${digest}.v1.json`,
|
|
943
|
+
);
|
|
944
|
+
if (!opts.yes)
|
|
945
|
+
return {
|
|
946
|
+
path,
|
|
947
|
+
action: "would-migrate",
|
|
948
|
+
reason: "valid v1 manifest",
|
|
949
|
+
preimage_path: preimagePath,
|
|
950
|
+
};
|
|
951
|
+
mkdirSync(dirname(preimagePath), { recursive: true });
|
|
952
|
+
try {
|
|
953
|
+
writeFileSync(preimagePath, preimage, { flag: "wx", mode: 0o600 });
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if (
|
|
956
|
+
(error as NodeJS.ErrnoException).code !== "EEXIST" ||
|
|
957
|
+
lstatSync(preimagePath).isSymbolicLink() ||
|
|
958
|
+
readFileSync(preimagePath, "utf8") !== preimage
|
|
959
|
+
)
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
962
|
+
if (readFileSync(target, "utf8") !== preimage)
|
|
963
|
+
throw new Error("manifest changed before migration");
|
|
964
|
+
atomicWriteManifest(path, parsed.manifest);
|
|
965
|
+
return {
|
|
966
|
+
path,
|
|
967
|
+
action: "migrated",
|
|
968
|
+
reason: "v1 preimage preserved; identity and retention unchanged",
|
|
969
|
+
preimage_path: preimagePath,
|
|
970
|
+
};
|
|
971
|
+
} catch (error) {
|
|
972
|
+
return { path, action: "keep", reason: errorMessage("migration refused", error) };
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
};
|
|
976
|
+
return opts.yes ? withArtifactLock(repoRoot, migrate) : migrate();
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** A persistent directory lock fails closed on contention or a crashed owner. */
|
|
980
|
+
function withArtifactLock<T>(repoRoot: string, action: () => T): T {
|
|
981
|
+
const lock = join(resolve(repoRoot), ".harnery/artifacts-mutation.lock");
|
|
982
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
983
|
+
try {
|
|
984
|
+
mkdirSync(lock);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
throw new Error(
|
|
987
|
+
errorMessage(
|
|
988
|
+
"artifact store mutation lock unavailable; retry after its owner finishes",
|
|
989
|
+
error,
|
|
990
|
+
),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
return action();
|
|
995
|
+
} finally {
|
|
996
|
+
rmdirSync(lock);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function validHoldId(value: unknown): value is string {
|
|
1001
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function validHold(value: unknown): value is ArtifactHold {
|
|
1005
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1006
|
+
const hold = value as Partial<ArtifactHold>;
|
|
1007
|
+
return (
|
|
1008
|
+
validHoldId(hold.id) &&
|
|
1009
|
+
typeof hold.reason === "string" &&
|
|
1010
|
+
!!hold.reason.trim() &&
|
|
1011
|
+
validActor(hold.set_by) &&
|
|
1012
|
+
validIso(hold.set_at)
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function makeHold(
|
|
1017
|
+
input: ArtifactHoldInput,
|
|
1018
|
+
actor: ArtifactActor | undefined,
|
|
1019
|
+
now: Date,
|
|
1020
|
+
): ArtifactHold {
|
|
1021
|
+
assertValidDate(now, "now");
|
|
1022
|
+
if (!validActor(actor)) throw new Error("a valid hold actor is required");
|
|
1023
|
+
if (!validHoldId(input.id)) throw new Error("invalid hold id");
|
|
1024
|
+
if (typeof input.reason !== "string" || !input.reason.trim())
|
|
1025
|
+
throw new Error("hold reason must not be empty");
|
|
1026
|
+
return {
|
|
1027
|
+
id: input.id,
|
|
1028
|
+
reason: input.reason.trim(),
|
|
1029
|
+
set_by: { ...actor },
|
|
1030
|
+
set_at: now.toISOString(),
|
|
1031
|
+
};
|
|
694
1032
|
}
|
|
695
1033
|
|
|
696
1034
|
function ownerLiveness(
|
|
@@ -815,6 +1153,17 @@ function addDays(date: Date, days: number): Date {
|
|
|
815
1153
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
816
1154
|
}
|
|
817
1155
|
|
|
1156
|
+
function positiveMinutes(value: number): number {
|
|
1157
|
+
if (!Number.isInteger(value) || value <= 0 || value > 3650 * 24 * 60) {
|
|
1158
|
+
throw new Error("retention minutes must be between 1 and 5256000");
|
|
1159
|
+
}
|
|
1160
|
+
return value;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function addMinutes(date: Date, minutes: number): Date {
|
|
1164
|
+
return new Date(date.getTime() + minutes * 60 * 1000);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
818
1167
|
function isSafeId(value: unknown): value is string {
|
|
819
1168
|
return (
|
|
820
1169
|
typeof value === "string" &&
|
package/src/core/config.ts
CHANGED
|
@@ -154,6 +154,14 @@ interface HarneryConfig {
|
|
|
154
154
|
max_bytes?: number;
|
|
155
155
|
max_unit_bytes?: number;
|
|
156
156
|
};
|
|
157
|
+
/**
|
|
158
|
+
* Page review packs. `auto_clean` lets `qa-run` and `review-pack create`
|
|
159
|
+
* delete expired packs in the artifact store before they start. Read via
|
|
160
|
+
* `reviewPackAutoCleanEnabled()`; ships off.
|
|
161
|
+
*/
|
|
162
|
+
review_pack?: {
|
|
163
|
+
auto_clean?: boolean;
|
|
164
|
+
};
|
|
157
165
|
/**
|
|
158
166
|
* `harn backup` (restic) defaults: `repo` path/URL, `password_file`, and the
|
|
159
167
|
* `keep_daily`/`keep_weekly`/`keep_monthly` prune policy. Read via `backupConfig()`.
|
|
@@ -801,6 +809,21 @@ export function artifactAutoCleanEnabled(coordRoot?: string | null): boolean {
|
|
|
801
809
|
return readConfig(root).artifacts?.auto_clean !== false;
|
|
802
810
|
}
|
|
803
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Whether `qa-run` and `review-pack create` sweep expired page review packs
|
|
814
|
+
* out of the artifact store before starting. Precedence:
|
|
815
|
+
* `HARNERY_REVIEW_PACK_AUTO_CLEAN` (1/true enables, 0/false disables) ->
|
|
816
|
+
* `review_pack.auto_clean` -> disabled. The sweep deletes only packs whose
|
|
817
|
+
* manifest says `managed: true` with `retention.expires_at` in the past.
|
|
818
|
+
*/
|
|
819
|
+
export function reviewPackAutoCleanEnabled(coordRoot?: string | null): boolean {
|
|
820
|
+
const env = coordEnv("REVIEW_PACK_AUTO_CLEAN");
|
|
821
|
+
if (env !== undefined) return env === "1" || env.toLowerCase() === "true";
|
|
822
|
+
const root = coordRoot ?? findCoordRoot();
|
|
823
|
+
if (!root) return false;
|
|
824
|
+
return readConfig(root).review_pack?.auto_clean === true;
|
|
825
|
+
}
|
|
826
|
+
|
|
804
827
|
/**
|
|
805
828
|
* Sweep cadence in hours. Env-only (`HARNERY_ARTIFACT_AUTO_CLEAN_INTERVAL_HOURS`,
|
|
806
829
|
* mainly for tests): the cadence is an implementation detail, not policy, and
|