@rightkit/qa 0.3.0 → 0.3.1
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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/storage.d.ts +50 -0
- package/dist/storage.js +315 -0
- package/dist/workspace.d.ts +10 -1
- package/dist/workspace.js +31 -31
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const RUN_MARKER = ".rightkit-run.json";
|
|
2
|
+
export declare const DEFAULT_RETENTION_BYTES = 20000000000;
|
|
3
|
+
export declare const DEFAULT_YOUNG_MS: number;
|
|
4
|
+
export declare const DEFAULT_KEEP_COUNT = 5;
|
|
5
|
+
export interface RunMarker {
|
|
6
|
+
schemaVersion: 1;
|
|
7
|
+
app: string;
|
|
8
|
+
runId: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
ownerPid: number;
|
|
11
|
+
leaseUntil: number;
|
|
12
|
+
completedAt?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ManagedRun {
|
|
15
|
+
root: string;
|
|
16
|
+
app: string;
|
|
17
|
+
runId: string;
|
|
18
|
+
marker: RunMarker;
|
|
19
|
+
}
|
|
20
|
+
export interface RetentionPolicy {
|
|
21
|
+
maxBytes?: number;
|
|
22
|
+
keepCount?: number;
|
|
23
|
+
youngMs?: number;
|
|
24
|
+
now?: number;
|
|
25
|
+
keepRunIds?: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface RetentionReport {
|
|
28
|
+
removed: string[];
|
|
29
|
+
retained: Array<{
|
|
30
|
+
path: string;
|
|
31
|
+
reason: string;
|
|
32
|
+
}>;
|
|
33
|
+
totalBytes: number;
|
|
34
|
+
capBytes: number;
|
|
35
|
+
overCapBytes: number;
|
|
36
|
+
blockedBytes: number;
|
|
37
|
+
unknownBytes: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function resolveManagedRoot(explicit?: string): string;
|
|
40
|
+
export declare function safeKey(value: string, name: string): string;
|
|
41
|
+
export declare function runRoot(managedRoot: string, app: string, runId: string): string;
|
|
42
|
+
export declare function createManagedRun(options: {
|
|
43
|
+
managedRoot?: string;
|
|
44
|
+
app: string;
|
|
45
|
+
runId?: string;
|
|
46
|
+
leaseMs?: number;
|
|
47
|
+
retention?: RetentionPolicy;
|
|
48
|
+
}): ManagedRun;
|
|
49
|
+
export declare function finishManagedRun(run: ManagedRun | string): RunMarker | undefined;
|
|
50
|
+
export declare function pruneManagedRuns(managedRoot: string, app: string, policy?: RetentionPolicy): RetentionReport;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const RUN_MARKER = '.rightkit-run.json';
|
|
4
|
+
export const DEFAULT_RETENTION_BYTES = 20_000_000_000;
|
|
5
|
+
export const DEFAULT_YOUNG_MS = 24 * 60 * 60_000;
|
|
6
|
+
export const DEFAULT_KEEP_COUNT = 5;
|
|
7
|
+
const LOCK_WAIT_MS = 5_000;
|
|
8
|
+
export function resolveManagedRoot(explicit) {
|
|
9
|
+
if (explicit?.trim())
|
|
10
|
+
return path.resolve(explicit);
|
|
11
|
+
if (process.env.RIGHTKIT_MANAGED_ROOT?.trim())
|
|
12
|
+
return path.resolve(process.env.RIGHTKIT_MANAGED_ROOT);
|
|
13
|
+
if (process.platform === 'win32')
|
|
14
|
+
return path.resolve('D:\\.rightkit-managed');
|
|
15
|
+
return '/Volumes/D/.rightkit-managed';
|
|
16
|
+
}
|
|
17
|
+
export function safeKey(value, name) {
|
|
18
|
+
const v = String(value ?? '').trim();
|
|
19
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v))
|
|
20
|
+
throw new Error(`unsafe QA ${name}: ${value}`);
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
export function runRoot(managedRoot, app, runId) {
|
|
24
|
+
const root = path.resolve(managedRoot);
|
|
25
|
+
const appKey = safeKey(app, 'app');
|
|
26
|
+
const id = safeKey(runId, 'run id');
|
|
27
|
+
const result = path.join(root, 'runs', appKey, id);
|
|
28
|
+
if (!isInside(path.join(root, 'runs', appKey), result))
|
|
29
|
+
throw new Error('QA run path escapes managed root');
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
export function createManagedRun(options) {
|
|
33
|
+
const managedRoot = resolveManagedRoot(options.managedRoot);
|
|
34
|
+
const app = safeKey(options.app, 'app');
|
|
35
|
+
const runId = safeKey(options.runId ?? cryptoRandomId(), 'run id');
|
|
36
|
+
const appRoot = path.join(managedRoot, 'runs', app);
|
|
37
|
+
withRetentionLock(appRoot, () => {
|
|
38
|
+
ensureSafeDirectory(managedRoot);
|
|
39
|
+
ensureSafeDirectory(path.join(managedRoot, 'runs'));
|
|
40
|
+
ensureSafeDirectory(appRoot);
|
|
41
|
+
const retention = { now: Date.now(), ...(options.retention ?? {}) };
|
|
42
|
+
const report = { removed: [], retained: [], totalBytes: 0, capBytes: DEFAULT_RETENTION_BYTES, overCapBytes: 0, blockedBytes: 0, unknownBytes: 0 };
|
|
43
|
+
pruneUnlocked(appRoot, app, retention, report);
|
|
44
|
+
if (report.overCapBytes > 0)
|
|
45
|
+
throw new Error(`QA retention cap is blocked by ${report.overCapBytes} bytes of protected or unknown data`);
|
|
46
|
+
const root = runRoot(managedRoot, app, runId);
|
|
47
|
+
if (fs.existsSync(root))
|
|
48
|
+
throw new Error(`QA run already exists: ${root}`);
|
|
49
|
+
fs.mkdirSync(root, { recursive: true });
|
|
50
|
+
const marker = {
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
app,
|
|
53
|
+
runId,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
ownerPid: process.pid,
|
|
56
|
+
leaseUntil: Date.now() + Math.max(1, options.leaseMs ?? DEFAULT_YOUNG_MS),
|
|
57
|
+
};
|
|
58
|
+
writeMarker(root, marker);
|
|
59
|
+
});
|
|
60
|
+
const root = runRoot(managedRoot, app, runId);
|
|
61
|
+
const marker = readMarker(path.join(root, RUN_MARKER));
|
|
62
|
+
if (!marker)
|
|
63
|
+
throw new Error(`QA run marker was not created: ${root}`);
|
|
64
|
+
return { root, app, runId, marker };
|
|
65
|
+
}
|
|
66
|
+
export function finishManagedRun(run) {
|
|
67
|
+
const root = typeof run === 'string' ? run : run.root;
|
|
68
|
+
const markerPath = path.join(path.resolve(root), RUN_MARKER);
|
|
69
|
+
const marker = readMarker(markerPath);
|
|
70
|
+
if (!marker)
|
|
71
|
+
return undefined;
|
|
72
|
+
const finished = { ...marker, ownerPid: 0, leaseUntil: 0, completedAt: Date.now() };
|
|
73
|
+
writeMarker(root, finished);
|
|
74
|
+
return finished;
|
|
75
|
+
}
|
|
76
|
+
export function pruneManagedRuns(managedRoot, app, policy = {}) {
|
|
77
|
+
const root = resolveManagedRoot(managedRoot);
|
|
78
|
+
const appKey = safeKey(app, 'app');
|
|
79
|
+
const appRoot = path.join(root, 'runs', appKey);
|
|
80
|
+
const cap = Number.isFinite(policy.maxBytes) && policy.maxBytes >= 0 ? policy.maxBytes : DEFAULT_RETENTION_BYTES;
|
|
81
|
+
const report = { removed: [], retained: [], totalBytes: 0, capBytes: cap, overCapBytes: 0, blockedBytes: 0, unknownBytes: 0 };
|
|
82
|
+
if (!safeDirectory(appRoot))
|
|
83
|
+
return report;
|
|
84
|
+
return withRetentionLock(appRoot, () => pruneUnlocked(appRoot, appKey, policy, report));
|
|
85
|
+
}
|
|
86
|
+
function pruneUnlocked(appRoot, app, policy, report) {
|
|
87
|
+
const now = policy.now ?? Date.now();
|
|
88
|
+
const cap = Number.isFinite(policy.maxBytes) && policy.maxBytes >= 0 ? policy.maxBytes : DEFAULT_RETENTION_BYTES;
|
|
89
|
+
report.capBytes = cap;
|
|
90
|
+
const keepCount = Math.max(0, Math.floor(policy.keepCount ?? DEFAULT_KEEP_COUNT));
|
|
91
|
+
const youngMs = Math.max(0, policy.youngMs ?? DEFAULT_YOUNG_MS);
|
|
92
|
+
const candidates = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(appRoot, { withFileTypes: true });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return report;
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.name.startsWith('.'))
|
|
103
|
+
continue;
|
|
104
|
+
if (entry.isSymbolicLink()) {
|
|
105
|
+
report.retained.push({ path: path.join(appRoot, entry.name), reason: 'symlink skipped' });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.isDirectory())
|
|
109
|
+
continue;
|
|
110
|
+
const runPath = path.join(appRoot, entry.name);
|
|
111
|
+
if (!isInside(appRoot, runPath))
|
|
112
|
+
continue;
|
|
113
|
+
const marker = readMarker(path.join(runPath, RUN_MARKER));
|
|
114
|
+
if (!marker || marker.app !== app || marker.runId !== entry.name) {
|
|
115
|
+
const bytes = directoryBytes(runPath);
|
|
116
|
+
if (bytes !== undefined)
|
|
117
|
+
report.unknownBytes += bytes;
|
|
118
|
+
report.retained.push({ path: runPath, reason: 'unknown or malformed run metadata' });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const bytes = directoryBytes(runPath);
|
|
122
|
+
if (bytes === undefined) {
|
|
123
|
+
report.retained.push({ path: runPath, reason: 'symlink or unreadable content skipped' });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const active = marker.ownerPid > 0 && isProcessLive(marker.ownerPid) || marker.leaseUntil > now;
|
|
127
|
+
total += bytes;
|
|
128
|
+
candidates.push({ root: runPath, marker, bytes, active });
|
|
129
|
+
}
|
|
130
|
+
candidates.sort((a, b) => b.marker.createdAt - a.marker.createdAt || a.marker.runId.localeCompare(b.marker.runId));
|
|
131
|
+
const countProtected = new Set(candidates.slice(0, keepCount).map((x) => x.marker.runId));
|
|
132
|
+
const protectedIds = new Set();
|
|
133
|
+
const callerProtected = new Set(policy.keepRunIds ?? []);
|
|
134
|
+
for (const item of candidates) {
|
|
135
|
+
if (now - item.marker.createdAt < youngMs)
|
|
136
|
+
protectedIds.add(item.marker.runId);
|
|
137
|
+
if (item.active)
|
|
138
|
+
protectedIds.add(item.marker.runId);
|
|
139
|
+
if (callerProtected.has(item.marker.runId))
|
|
140
|
+
protectedIds.add(item.marker.runId);
|
|
141
|
+
}
|
|
142
|
+
const old = candidates
|
|
143
|
+
.filter((x) => !protectedIds.has(x.marker.runId) && (total > cap || !countProtected.has(x.marker.runId)))
|
|
144
|
+
.sort((a, b) => a.marker.createdAt - b.marker.createdAt || a.marker.runId.localeCompare(b.marker.runId));
|
|
145
|
+
const removed = new Set();
|
|
146
|
+
for (const item of old) {
|
|
147
|
+
if (total <= cap && countProtected.has(item.marker.runId))
|
|
148
|
+
continue;
|
|
149
|
+
if (!safeDirectory(item.root))
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(item.root, { recursive: true, force: false });
|
|
153
|
+
report.removed.push(item.root);
|
|
154
|
+
removed.add(item.marker.runId);
|
|
155
|
+
total -= item.bytes;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
report.retained.push({ path: item.root, reason: describeError(error) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const item of candidates) {
|
|
162
|
+
if (!removed.has(item.marker.runId) && (protectedIds.has(item.marker.runId) || (total <= cap && countProtected.has(item.marker.runId))))
|
|
163
|
+
report.retained.push({ path: item.root, reason: callerProtected.has(item.marker.runId) ? 'caller protected run' : item.active ? 'active run; may belong to a live run' : (now - item.marker.createdAt < youngMs ? 'younger than retention window' : 'newest protected run') });
|
|
164
|
+
}
|
|
165
|
+
report.blockedBytes = candidates.filter((item) => !removed.has(item.marker.runId) && (protectedIds.has(item.marker.runId) || (total <= cap && countProtected.has(item.marker.runId)))).reduce((sum, item) => sum + item.bytes, 0);
|
|
166
|
+
report.totalBytes = total + report.unknownBytes;
|
|
167
|
+
report.overCapBytes = Math.max(0, report.totalBytes - report.capBytes);
|
|
168
|
+
return report;
|
|
169
|
+
}
|
|
170
|
+
function withRetentionLock(appRoot, body) {
|
|
171
|
+
ensureSafeDirectory(path.dirname(appRoot));
|
|
172
|
+
ensureSafeDirectory(appRoot);
|
|
173
|
+
const lock = path.join(appRoot, '.retention-lock');
|
|
174
|
+
const owner = path.join(lock, 'owner.json');
|
|
175
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
176
|
+
while (true) {
|
|
177
|
+
try {
|
|
178
|
+
fs.mkdirSync(lock);
|
|
179
|
+
fs.writeFileSync(owner, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), 'utf8');
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== 'EEXIST')
|
|
184
|
+
throw error;
|
|
185
|
+
let pid;
|
|
186
|
+
try {
|
|
187
|
+
pid = JSON.parse(fs.readFileSync(owner, 'utf8')).pid;
|
|
188
|
+
}
|
|
189
|
+
catch { /* uncertain owner: wait */ }
|
|
190
|
+
if (typeof pid === 'number' && pid > 0 && !isProcessLive(pid)) {
|
|
191
|
+
try {
|
|
192
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
catch { /* retry */ }
|
|
195
|
+
}
|
|
196
|
+
else if (Date.now() >= deadline) {
|
|
197
|
+
throw new Error(`QA retention lock is held: ${lock}`);
|
|
198
|
+
}
|
|
199
|
+
if (Date.now() < deadline)
|
|
200
|
+
sleepSync(20);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return body();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
try {
|
|
208
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
catch { /* best effort */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function writeMarker(root, marker) {
|
|
214
|
+
const target = path.join(root, RUN_MARKER);
|
|
215
|
+
const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
216
|
+
fs.writeFileSync(temp, `${JSON.stringify(marker)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
217
|
+
fs.renameSync(temp, target);
|
|
218
|
+
}
|
|
219
|
+
function readMarker(file) {
|
|
220
|
+
try {
|
|
221
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
222
|
+
if (!raw || typeof raw !== 'object')
|
|
223
|
+
return undefined;
|
|
224
|
+
const x = raw;
|
|
225
|
+
if (x.schemaVersion !== 1 || typeof x.app !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(x.app) || typeof x.runId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(x.runId) || typeof x.createdAt !== 'number' || !Number.isFinite(x.createdAt) || x.createdAt <= 0 || typeof x.ownerPid !== 'number' || !Number.isInteger(x.ownerPid) || x.ownerPid < 0 || typeof x.leaseUntil !== 'number' || !Number.isFinite(x.leaseUntil) || x.leaseUntil < 0)
|
|
226
|
+
return undefined;
|
|
227
|
+
if (x.completedAt !== undefined && (typeof x.completedAt !== 'number' || !Number.isFinite(x.completedAt) || x.completedAt < 0))
|
|
228
|
+
return undefined;
|
|
229
|
+
return x;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function directoryBytes(root) {
|
|
236
|
+
let total = 0;
|
|
237
|
+
const walk = (dir) => {
|
|
238
|
+
let entries;
|
|
239
|
+
try {
|
|
240
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
const p = path.join(dir, entry.name);
|
|
247
|
+
if (entry.isSymbolicLink())
|
|
248
|
+
return false;
|
|
249
|
+
if (entry.isDirectory()) {
|
|
250
|
+
if (!walk(p))
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
else if (entry.isFile()) {
|
|
254
|
+
try {
|
|
255
|
+
total += fs.statSync(p).size;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
};
|
|
266
|
+
return walk(root) ? total : undefined;
|
|
267
|
+
}
|
|
268
|
+
function safeDirectory(dir) {
|
|
269
|
+
try {
|
|
270
|
+
return fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink();
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function ensureSafeDirectory(dir) {
|
|
277
|
+
const absolute = path.resolve(dir);
|
|
278
|
+
const parts = absolute.split(path.sep);
|
|
279
|
+
let current = parts[0] || path.sep;
|
|
280
|
+
for (const part of parts.slice(1)) {
|
|
281
|
+
if (!part)
|
|
282
|
+
continue;
|
|
283
|
+
current = path.join(current, part);
|
|
284
|
+
try {
|
|
285
|
+
if (fs.lstatSync(current).isSymbolicLink())
|
|
286
|
+
throw new Error(`refusing symlink in managed path: ${current}`);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error.code !== 'ENOENT')
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
fs.mkdirSync(absolute, { recursive: true });
|
|
294
|
+
}
|
|
295
|
+
function isInside(root, candidate) {
|
|
296
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
297
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
298
|
+
}
|
|
299
|
+
function isProcessLive(pid) {
|
|
300
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
301
|
+
return false;
|
|
302
|
+
try {
|
|
303
|
+
process.kill(pid, 0);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return error.code === 'EPERM';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function describeError(error) {
|
|
311
|
+
const x = error;
|
|
312
|
+
return x?.code ? `${x.code}: ${x.message}` : String(error?.message ?? error);
|
|
313
|
+
}
|
|
314
|
+
function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
315
|
+
function cryptoRandomId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; }
|
package/dist/workspace.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { type ManagedRun } from './storage.js';
|
|
1
2
|
export interface QaWorkspace {
|
|
2
3
|
runId: string;
|
|
4
|
+
appKey: string;
|
|
5
|
+
managedRoot: string;
|
|
3
6
|
root: string;
|
|
4
7
|
/** Isolated user-data directory; pass to the app so it takes its own lock. */
|
|
5
8
|
dataDir: string;
|
|
6
9
|
/** Somewhere for screenshots, logs, and other recorded evidence. */
|
|
7
10
|
evidenceDir: string;
|
|
11
|
+
/** Managed capture output directory. */
|
|
12
|
+
captureDir: string;
|
|
8
13
|
/** Environment overrides that point the app at this workspace. */
|
|
9
14
|
env: Record<string, string>;
|
|
15
|
+
marker: ManagedRun['marker'];
|
|
10
16
|
}
|
|
11
17
|
export interface DisposeReport {
|
|
12
18
|
removed: string[];
|
|
@@ -16,9 +22,12 @@ export interface DisposeReport {
|
|
|
16
22
|
}>;
|
|
17
23
|
}
|
|
18
24
|
export interface CreateQaWorkspaceOptions {
|
|
19
|
-
root
|
|
25
|
+
/** Managed root override; tests may provide a temporary directory. */
|
|
26
|
+
root?: string;
|
|
20
27
|
runId?: string;
|
|
21
28
|
appKey: string;
|
|
29
|
+
leaseMs?: number;
|
|
30
|
+
retention?: import('./storage.js').RetentionPolicy;
|
|
22
31
|
}
|
|
23
32
|
export declare function createQaWorkspace(options: CreateQaWorkspaceOptions): QaWorkspace;
|
|
24
33
|
export interface DisposeOptions {
|
package/dist/workspace.js
CHANGED
|
@@ -17,26 +17,38 @@
|
|
|
17
17
|
import crypto from 'node:crypto';
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
+
import { createManagedRun, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
|
|
20
21
|
export function createQaWorkspace(options) {
|
|
21
22
|
const appKey = requireText(options.appKey, 'appKey');
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const root = path.resolve(requireText(options.root, 'root'), 'runs', runId);
|
|
23
|
+
const managed = createManagedRun({ managedRoot: options.root ? requireText(options.root, 'root') : resolveManagedRoot(), app: appKey, runId: options.runId ?? crypto.randomUUID(), leaseMs: options.leaseMs, retention: options.retention });
|
|
24
|
+
const runId = managed.runId;
|
|
25
|
+
const root = managed.root;
|
|
26
26
|
const dataDir = path.join(root, 'data');
|
|
27
27
|
const evidenceDir = path.join(root, 'evidence');
|
|
28
|
+
const captureDir = path.join(root, 'captures');
|
|
29
|
+
const tmpDir = path.join(root, 'tmp');
|
|
28
30
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
29
31
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
32
|
+
fs.mkdirSync(captureDir, { recursive: true });
|
|
33
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
30
34
|
return {
|
|
31
35
|
runId,
|
|
36
|
+
appKey,
|
|
37
|
+
managedRoot: path.resolve(options.root ? requireText(options.root, 'root') : resolveManagedRoot()),
|
|
32
38
|
root,
|
|
33
39
|
dataDir,
|
|
34
40
|
evidenceDir,
|
|
41
|
+
captureDir,
|
|
35
42
|
env: {
|
|
36
43
|
RIGHTKIT_QA_NATIVE: '1',
|
|
37
44
|
RIGHTKIT_QA_RUN_ID: runId,
|
|
45
|
+
RIGHTKIT_RUN_ROOT: root,
|
|
38
46
|
RIGHTKIT_QA_DATA_DIR: dataDir,
|
|
39
47
|
RIGHTKIT_QA_EVIDENCE_DIR: evidenceDir,
|
|
48
|
+
RIGHTKIT_QA_CAPTURE_DIR: captureDir,
|
|
49
|
+
TMPDIR: tmpDir,
|
|
50
|
+
TMP: tmpDir,
|
|
51
|
+
TEMP: tmpDir,
|
|
40
52
|
// Tauri/webview honour these for profile isolation; setting all three keeps
|
|
41
53
|
// the run off the user's real profile regardless of which the app reads.
|
|
42
54
|
XDG_DATA_HOME: dataDir,
|
|
@@ -44,6 +56,7 @@ export function createQaWorkspace(options) {
|
|
|
44
56
|
WEBVIEW2_USER_DATA_FOLDER: path.join(dataDir, 'webview2'),
|
|
45
57
|
[`${appKey.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_DATA_DIR`]: dataDir,
|
|
46
58
|
},
|
|
59
|
+
marker: managed.marker,
|
|
47
60
|
};
|
|
48
61
|
}
|
|
49
62
|
/**
|
|
@@ -57,6 +70,7 @@ export function disposeQaWorkspace(workspace, options = {}) {
|
|
|
57
70
|
const sleep = options.sleep ?? sleepSync;
|
|
58
71
|
const targets = options.keepEvidence ? [workspace.dataDir] : [workspace.root];
|
|
59
72
|
const report = { removed: [], retained: [] };
|
|
73
|
+
finishManagedRun(workspace.root);
|
|
60
74
|
for (const target of targets) {
|
|
61
75
|
// Only ever remove inside the workspace root we created.
|
|
62
76
|
const resolved = path.resolve(target);
|
|
@@ -93,39 +107,25 @@ export function disposeQaWorkspace(workspace, options = {}) {
|
|
|
93
107
|
* workspace's own `runs` directory and to entries older than `olderThanMs`, so a
|
|
94
108
|
* concurrent run is never removed out from under itself.
|
|
95
109
|
*/
|
|
96
|
-
export function pruneAbandonedWorkspaces(root, { olderThanMs
|
|
97
|
-
const
|
|
110
|
+
export function pruneAbandonedWorkspaces(root, { olderThanMs, now = Date.now(), keep = [] } = {}) {
|
|
111
|
+
const managedRoot = path.resolve(root);
|
|
98
112
|
const report = { removed: [], retained: [] };
|
|
99
|
-
|
|
113
|
+
const runsRoot = path.join(managedRoot, 'runs');
|
|
114
|
+
let apps;
|
|
100
115
|
try {
|
|
101
|
-
|
|
116
|
+
apps = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
102
117
|
}
|
|
103
118
|
catch {
|
|
104
119
|
return report;
|
|
105
120
|
}
|
|
106
|
-
for (const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
ageMs = now - fs.statSync(target).mtimeMs;
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if (ageMs < olderThanMs) {
|
|
120
|
-
report.retained.push({ path: target, reason: `only ${Math.round(ageMs / 1000)}s old; may belong to a live run` });
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
125
|
-
report.removed.push(target);
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
report.retained.push({ path: target, reason: describeError(error) });
|
|
121
|
+
for (const app of apps) {
|
|
122
|
+
if (app.isDirectory() && !app.isSymbolicLink()) {
|
|
123
|
+
const policy = olderThanMs === undefined
|
|
124
|
+
? { now, keepRunIds: keep }
|
|
125
|
+
: { now, youngMs: olderThanMs, keepCount: 0, maxBytes: 0, keepRunIds: keep };
|
|
126
|
+
const sub = pruneManagedRuns(managedRoot, app.name, policy);
|
|
127
|
+
report.removed.push(...sub.removed);
|
|
128
|
+
report.retained.push(...sub.retained);
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
return report;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/qa",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Shared QA evidence, ownership, browser helpers, and release-backdoor guards for Right Suite apps.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"sideEffects": false,
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@rightkit/qa-chrome": "0.1.
|
|
27
|
+
"@rightkit/qa-chrome": "0.1.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^24.0.0",
|
|
@@ -43,6 +43,6 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc -p tsconfig.json",
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
-
"test": "vitest run"
|
|
46
|
+
"test": "vitest run && node --test public-storage.test.mjs"
|
|
47
47
|
}
|
|
48
48
|
}
|