@siuver/omp-debug-mode 0.1.4 → 0.1.6
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/CHANGELOG.md +73 -35
- package/README.md +98 -24
- package/package.json +41 -41
- package/src/debug-mode.ts +463 -409
- package/src/evidence.ts +325 -196
- package/src/gate.ts +138 -45
- package/src/log-files.ts +154 -115
- package/src/machine.ts +421 -0
- package/src/main.ts +24 -24
- package/src/methodology.ts +74 -17
- package/src/probes.ts +9 -9
- package/src/state.ts +392 -84
- package/src/tools.ts +232 -30
- package/src/ui.ts +120 -56
package/src/evidence.ts
CHANGED
|
@@ -1,196 +1,325 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
if (
|
|
73
|
-
return fail(`request ${record.id}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
requestId: string | null
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type DebugSession,
|
|
5
|
+
type DebugState,
|
|
6
|
+
type EvidenceArtifact,
|
|
7
|
+
type EvidenceMethod,
|
|
8
|
+
type EvidenceRequest,
|
|
9
|
+
type EvidenceView,
|
|
10
|
+
currentRound,
|
|
11
|
+
pendingRequests,
|
|
12
|
+
} from "./state";
|
|
13
|
+
|
|
14
|
+
/** Upper bound on accepted plan entries so one model reply cannot flood the gate. */
|
|
15
|
+
export const MAX_EVIDENCE_REQUESTS = 12;
|
|
16
|
+
|
|
17
|
+
const METHODS: readonly EvidenceMethod[] = ["agent_inspection", "runtime_probe", "user_report", "user_artifact"];
|
|
18
|
+
|
|
19
|
+
export interface EvidencePlanParseResult {
|
|
20
|
+
found: boolean;
|
|
21
|
+
valid: boolean;
|
|
22
|
+
requests: EvidenceRequest[];
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
27
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isNonEmptyStringArray(value: unknown): value is string[] {
|
|
31
|
+
return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasOnlyUniqueValues(values: readonly string[]): boolean {
|
|
35
|
+
return new Set(values).size === values.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type EvidenceRequestsValidation =
|
|
39
|
+
| { ok: true; requests: EvidenceRequest[] }
|
|
40
|
+
| { ok: false; error: string };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate evidence plan entries. All-or-nothing: any malformed or invalid
|
|
44
|
+
* entry rejects the whole plan so a partial plan can never silently drop a
|
|
45
|
+
* hypothesis. Shared by the `hand_off_to_user` tool and the legacy
|
|
46
|
+
* `<evidence_plan>` tag parser, so both accept exactly the same shape and a
|
|
47
|
+
* rejection always says the same thing.
|
|
48
|
+
*/
|
|
49
|
+
export function validateEvidenceRequests(entries: unknown): EvidenceRequestsValidation {
|
|
50
|
+
const fail = (error: string): EvidenceRequestsValidation => ({ ok: false, error });
|
|
51
|
+
if (!Array.isArray(entries)) return fail("the evidence plan must be a JSON array");
|
|
52
|
+
if (entries.length === 0) return fail("the evidence plan array must not be empty");
|
|
53
|
+
if (entries.length > MAX_EVIDENCE_REQUESTS) {
|
|
54
|
+
return fail(`the evidence plan has ${entries.length} entries; at most ${MAX_EVIDENCE_REQUESTS} are allowed`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const requests: EvidenceRequest[] = [];
|
|
58
|
+
const seenIds = new Set<string>();
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (typeof entry !== "object" || entry === null) return fail("every evidence plan entry must be an object");
|
|
61
|
+
const record = entry as Record<string, unknown>;
|
|
62
|
+
if (!isNonEmptyString(record.id)) return fail("every evidence plan entry needs a non-empty id");
|
|
63
|
+
if (seenIds.has(record.id)) return fail(`duplicate evidence request id ${record.id}`);
|
|
64
|
+
seenIds.add(record.id);
|
|
65
|
+
if (!isNonEmptyStringArray(record.hypothesisIds) || !hasOnlyUniqueValues(record.hypothesisIds)) {
|
|
66
|
+
return fail(`request ${record.id} needs one or more unique non-empty hypothesisIds`);
|
|
67
|
+
}
|
|
68
|
+
if (!METHODS.includes(record.method as EvidenceMethod)) {
|
|
69
|
+
return fail(`request ${record.id} has unknown method ${JSON.stringify(record.method)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!isNonEmptyString(record.title)) return fail(`request ${record.id} needs a non-empty title`);
|
|
72
|
+
if (!isNonEmptyString(record.rationale)) {
|
|
73
|
+
return fail(`request ${record.id} needs a non-empty rationale explaining why this method is decisive`);
|
|
74
|
+
}
|
|
75
|
+
if (!isNonEmptyStringArray(record.instructions)) {
|
|
76
|
+
return fail(`request ${record.id} needs one or more non-empty instructions`);
|
|
77
|
+
}
|
|
78
|
+
if (record.artifactHint !== undefined && !isNonEmptyString(record.artifactHint)) {
|
|
79
|
+
return fail(`request ${record.id} has an empty artifactHint`);
|
|
80
|
+
}
|
|
81
|
+
requests.push({
|
|
82
|
+
id: record.id,
|
|
83
|
+
hypothesisIds: record.hypothesisIds,
|
|
84
|
+
method: record.method as EvidenceMethod,
|
|
85
|
+
title: record.title,
|
|
86
|
+
rationale: record.rationale,
|
|
87
|
+
instructions: record.instructions,
|
|
88
|
+
...(record.artifactHint === undefined ? {} : { artifactHint: record.artifactHint }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, requests };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse the legacy `<evidence_plan>` block from a model reply. Kept so a plan
|
|
96
|
+
* described in prose is still recorded rather than lost — but a parsed block no
|
|
97
|
+
* longer closes a round: the turn is sent back for the `hand_off_to_user` call,
|
|
98
|
+
* which is the only closure.
|
|
99
|
+
*/
|
|
100
|
+
export function parseEvidencePlan(text: string): EvidencePlanParseResult {
|
|
101
|
+
const match = /<evidence_plan>([\s\S]*?)<\/evidence_plan>/.exec(text);
|
|
102
|
+
if (!match) return { found: false, valid: false, requests: [] };
|
|
103
|
+
|
|
104
|
+
let parsed: unknown;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(match[1].trim());
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
found: true,
|
|
110
|
+
valid: false,
|
|
111
|
+
requests: [],
|
|
112
|
+
error: `malformed JSON in <evidence_plan>: ${(error as Error).message}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const validation = validateEvidenceRequests(parsed);
|
|
116
|
+
if (!validation.ok) return { found: true, valid: false, requests: [], error: validation.error };
|
|
117
|
+
return { found: true, valid: true, requests: validation.requests };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** First argument of `/debug-evidence` when the file answers no request. */
|
|
121
|
+
export const UNLINKED_SELECTOR = "-";
|
|
122
|
+
|
|
123
|
+
export type EvidenceArgument =
|
|
124
|
+
| { ok: true; requestId: string | null; rawPath: string | undefined }
|
|
125
|
+
| { ok: false; error: string };
|
|
126
|
+
|
|
127
|
+
/** A request as a link target: which round declared it, and whether it is settled. */
|
|
128
|
+
interface RequestTarget {
|
|
129
|
+
request: EvidenceRequest;
|
|
130
|
+
round: number;
|
|
131
|
+
current: boolean;
|
|
132
|
+
pending: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every request that can be linked to, best candidate first: this round before
|
|
137
|
+
* older rounds, and within this round the ones still waiting on the user.
|
|
138
|
+
*/
|
|
139
|
+
function requestTargets(session: DebugSession): RequestTarget[] {
|
|
140
|
+
const round = currentRound(session);
|
|
141
|
+
const pending = new Set(pendingRequests(session, round).map(request => request.id));
|
|
142
|
+
const targets: RequestTarget[] = [];
|
|
143
|
+
for (const candidate of session.rounds) {
|
|
144
|
+
for (const request of candidate.plan ?? []) {
|
|
145
|
+
targets.push({
|
|
146
|
+
request,
|
|
147
|
+
round: candidate.index,
|
|
148
|
+
current: candidate.index === round.index,
|
|
149
|
+
pending: pending.has(request.id),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return targets.sort((a, b) => {
|
|
154
|
+
if (a.current !== b.current) return a.current ? -1 : 1;
|
|
155
|
+
if (a.pending !== b.pending) return a.pending ? -1 : 1;
|
|
156
|
+
return b.round - a.round;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function knownIds(session: DebugSession): string[] {
|
|
161
|
+
return requestTargets(session).map(target => target.request.id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function describeKnownIds(ids: readonly string[]): string {
|
|
165
|
+
return ids.length > 0 ? ids.join(", ") : "(none — no evidence request exists yet)";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse `/debug-evidence <request-id|-> [path]`.
|
|
170
|
+
*
|
|
171
|
+
* The first token is always a link selector, never part of the path. Deciding
|
|
172
|
+
* that by looking the token up in session state is what used to make
|
|
173
|
+
* `/debug-evidence E1 frame.rdc` resolve `<cwd>/E1 frame.rdc` as one path
|
|
174
|
+
* whenever E1 was not a currently pending artifact request, and the failure was
|
|
175
|
+
* reported as a missing file rather than as an unrecognised id.
|
|
176
|
+
*/
|
|
177
|
+
export function parseEvidenceArgument(args: string, session: DebugSession): EvidenceArgument {
|
|
178
|
+
const trimmed = args.trim();
|
|
179
|
+
const ids = knownIds(session);
|
|
180
|
+
if (!trimmed) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
error:
|
|
184
|
+
`name what the file answers first: /debug-evidence <request-id|${UNLINKED_SELECTOR}> [path]. ` +
|
|
185
|
+
`Known request ids: ${describeKnownIds(ids)}.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const token = trimmed.split(/\s+/, 1)[0];
|
|
189
|
+
const rest = trimmed.slice(token.length).trim();
|
|
190
|
+
const rawPath = rest.length > 0 ? rest : undefined;
|
|
191
|
+
if (token === UNLINKED_SELECTOR) return { ok: true, requestId: null, rawPath };
|
|
192
|
+
if (ids.includes(token)) return { ok: true, requestId: token, rawPath };
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error:
|
|
196
|
+
`${JSON.stringify(token)} is not an evidence request id. Known request ids: ${describeKnownIds(ids)}. ` +
|
|
197
|
+
`Pass ${UNLINKED_SELECTOR} as the first argument to attach a file that answers no request.`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Structurally an `AutocompleteItem`, declared here so no host type is vendored. */
|
|
202
|
+
export interface EvidenceCompletion {
|
|
203
|
+
value: string;
|
|
204
|
+
label: string;
|
|
205
|
+
description?: string;
|
|
206
|
+
hint?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function describeTarget(target: RequestTarget): string {
|
|
210
|
+
const status = target.current ? (target.pending ? "pending" : "satisfied") : `round ${target.round}`;
|
|
211
|
+
return `${target.request.method} · ${target.request.title} — ${status}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Complete the link selector under the editor. Must stay synchronous and free
|
|
216
|
+
* of side effects: the host calls it on every keystroke.
|
|
217
|
+
*
|
|
218
|
+
* Whitespace in the prefix means the selector is settled, so returning null
|
|
219
|
+
* hands the rest of the argument to the host's own file-path completion.
|
|
220
|
+
*/
|
|
221
|
+
export function evidenceCompletions(argumentPrefix: string, state: DebugState): EvidenceCompletion[] | null {
|
|
222
|
+
if (/\s/.test(argumentPrefix) || !state.active) return null;
|
|
223
|
+
const prefix = argumentPrefix.trim().toLowerCase();
|
|
224
|
+
const items: EvidenceCompletion[] = [
|
|
225
|
+
{
|
|
226
|
+
value: `${UNLINKED_SELECTOR} `,
|
|
227
|
+
label: UNLINKED_SELECTOR,
|
|
228
|
+
description: "Attach a file that answers no request",
|
|
229
|
+
hint: "<path>",
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
for (const target of requestTargets(state)) {
|
|
233
|
+
items.push({
|
|
234
|
+
value: `${target.request.id} `,
|
|
235
|
+
label: target.request.id,
|
|
236
|
+
description: describeTarget(target),
|
|
237
|
+
hint: "<path>",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const matched = items.filter(item => item.label.toLowerCase().startsWith(prefix));
|
|
241
|
+
return matched.length > 0 ? matched : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type ArtifactValidation =
|
|
245
|
+
| { ok: true; artifact: Omit<EvidenceArtifact, "id" | "addedAt" | "requestId"> }
|
|
246
|
+
| { ok: false; reason: string };
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Resolve a user-supplied path and capture filesystem metadata only. The file
|
|
250
|
+
* is referenced in place: its contents are never read, copied or deleted here.
|
|
251
|
+
*/
|
|
252
|
+
export function validateEvidenceArtifact(rawPath: string, cwd: string): ArtifactValidation {
|
|
253
|
+
const trimmed = rawPath.trim();
|
|
254
|
+
if (trimmed.length === 0) return { ok: false, reason: "no evidence file path provided" };
|
|
255
|
+
const absolute = path.resolve(cwd, trimmed);
|
|
256
|
+
let stats: fs.Stats;
|
|
257
|
+
try {
|
|
258
|
+
stats = fs.statSync(absolute);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return { ok: false, reason: `cannot stat ${absolute}: ${(error as Error).message}` };
|
|
261
|
+
}
|
|
262
|
+
if (!stats.isFile()) return { ok: false, reason: `${absolute} is not a regular file` };
|
|
263
|
+
try {
|
|
264
|
+
fs.accessSync(absolute, fs.constants.R_OK);
|
|
265
|
+
} catch {
|
|
266
|
+
return { ok: false, reason: `${absolute} is not readable` };
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
artifact: { path: absolute, name: path.basename(absolute), size: stats.size, mtimeMs: stats.mtimeMs },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Human-readable evidence description for the blackboard and the model-facing
|
|
276
|
+
* tool. Availability is re-checked on every call: a missing or unreadable file
|
|
277
|
+
* is never treated as captured evidence.
|
|
278
|
+
*/
|
|
279
|
+
export function describeEvidence(view: EvidenceView): string {
|
|
280
|
+
const { requests, artifacts, observations } = view;
|
|
281
|
+
if (requests.length === 0 && artifacts.length === 0 && observations.length === 0) {
|
|
282
|
+
return "(none)";
|
|
283
|
+
}
|
|
284
|
+
const lines: string[] = [];
|
|
285
|
+
for (const request of requests) {
|
|
286
|
+
const linked = artifacts.filter(artifact => artifact.requestId === request.id);
|
|
287
|
+
const reports = observations.filter(observation => observation.requestIds.includes(request.id));
|
|
288
|
+
const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
|
|
289
|
+
const coverage =
|
|
290
|
+
request.method === "user_artifact"
|
|
291
|
+
? linked.length > 0
|
|
292
|
+
? "artifact attached"
|
|
293
|
+
: "PENDING artifact"
|
|
294
|
+
: request.method === "user_report"
|
|
295
|
+
? reports.length > 0
|
|
296
|
+
? "report submitted"
|
|
297
|
+
: "PENDING report"
|
|
298
|
+
: "agent-collected";
|
|
299
|
+
lines.push(
|
|
300
|
+
`- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
for (const observation of observations) {
|
|
304
|
+
lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
|
|
305
|
+
}
|
|
306
|
+
for (const artifact of artifacts) {
|
|
307
|
+
let stats: fs.Stats;
|
|
308
|
+
try {
|
|
309
|
+
stats = fs.statSync(artifact.path);
|
|
310
|
+
fs.accessSync(artifact.path, fs.constants.R_OK);
|
|
311
|
+
} catch {
|
|
312
|
+
lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (file missing or unreadable)`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (!stats.isFile()) {
|
|
316
|
+
lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (no longer a regular file)`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const linked = artifact.requestId ? `, request ${artifact.requestId}` : ", unlinked";
|
|
320
|
+
lines.push(
|
|
321
|
+
`- ${artifact.id} [artifact] ${artifact.path} — available${linked}, ${stats.size} bytes, mtime ${new Date(stats.mtimeMs).toISOString()}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return lines.join("\n");
|
|
325
|
+
}
|