@bli-cockpit/cli 0.1.2 → 0.1.5
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/README.md +38 -9
- package/dist/adapters/codex-attribution.js +344 -0
- package/dist/adapters/local-sources.js +3 -0
- package/dist/adapters/raw-evidence.js +97 -17
- package/dist/commands/local.js +389 -16
- package/dist/cursors/raw-evidence-cursor.js +129 -0
- package/dist/evidence-upload-client.js +362 -0
- package/dist/local-state.js +93 -9
- package/dist/repo-identity.js +203 -0
- package/dist/server.js +7 -3
- package/dist/upload.js +271 -95
- package/package.json +2 -2
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
5
|
+
const RETRY_DELAY_MS = 250;
|
|
6
|
+
export async function uploadRawEvidenceFilesChunked(options) {
|
|
7
|
+
const outcomes = [];
|
|
8
|
+
if (options.files.length === 0) {
|
|
9
|
+
return summarizeOutcomes(outcomes, false);
|
|
10
|
+
}
|
|
11
|
+
const chunkSizeBytes = options.chunkSizeBytes ?? RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES;
|
|
12
|
+
const loaded = [];
|
|
13
|
+
const loadedByObjectKey = new Map();
|
|
14
|
+
for (const file of options.files) {
|
|
15
|
+
let bytes;
|
|
16
|
+
try {
|
|
17
|
+
bytes = await fs.readFile(file.local_path);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (bytes.byteLength === 0) {
|
|
24
|
+
outcomes.push(failedOutcome(file, "empty_file"));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (bytes.byteLength > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
28
|
+
outcomes.push(failedOutcome(file, "file_too_large"));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Content-addressed keys collide for byte-identical files (e.g. a resumed
|
|
32
|
+
// session copied twice); upload once and share the outcome instead of
|
|
33
|
+
// racing a second upload against a committed ledger row.
|
|
34
|
+
const existing = file.pointer.object_key
|
|
35
|
+
? loadedByObjectKey.get(file.pointer.object_key)
|
|
36
|
+
: undefined;
|
|
37
|
+
if (existing) {
|
|
38
|
+
existing.duplicates.push(file);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const entry = {
|
|
42
|
+
file,
|
|
43
|
+
bytes,
|
|
44
|
+
chunkCount: Math.ceil(bytes.byteLength / chunkSizeBytes),
|
|
45
|
+
duplicates: [],
|
|
46
|
+
};
|
|
47
|
+
loaded.push(entry);
|
|
48
|
+
if (file.pointer.object_key) {
|
|
49
|
+
loadedByObjectKey.set(file.pointer.object_key, entry);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const resolvedEntries = new Set();
|
|
53
|
+
let beginUnavailable = false;
|
|
54
|
+
for (const batch of batches(loaded, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN)) {
|
|
55
|
+
if (beginUnavailable)
|
|
56
|
+
break;
|
|
57
|
+
const begin = await requestJson(options, "/api/ambient/evidence/upload/begin", {
|
|
58
|
+
schema_version: "ambient-raw-evidence-upload-begin.v1",
|
|
59
|
+
generated_at: options.generatedAt,
|
|
60
|
+
provenance: options.provenance,
|
|
61
|
+
objects: batch.map((entry) => ({
|
|
62
|
+
pointer: entry.file.pointer,
|
|
63
|
+
chunk_size_bytes: chunkSizeBytes,
|
|
64
|
+
chunk_count: entry.chunkCount,
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
// 404 means an old dashboard without the chunk routes; a persistent 5xx
|
|
68
|
+
// (after retries) covers a new dashboard whose ledger migration has not
|
|
69
|
+
// been applied yet. Both still serve the legacy v1 route.
|
|
70
|
+
if (begin.status === 404 || begin.status >= 500) {
|
|
71
|
+
beginUnavailable = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (!begin.ok) {
|
|
75
|
+
for (const entry of batch) {
|
|
76
|
+
outcomes.push(failedOutcome(entry.file, `begin_failed_http_${begin.status}`));
|
|
77
|
+
for (const duplicate of entry.duplicates) {
|
|
78
|
+
outcomes.push(failedOutcome(duplicate, `begin_failed_http_${begin.status}`));
|
|
79
|
+
}
|
|
80
|
+
resolvedEntries.add(entry);
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const dispositions = readBeginDispositions(begin.body);
|
|
85
|
+
for (const entry of batch) {
|
|
86
|
+
const objectKey = entry.file.pointer.object_key ?? "";
|
|
87
|
+
const disposition = dispositions.get(objectKey);
|
|
88
|
+
if (!disposition) {
|
|
89
|
+
outcomes.push(failedOutcome(entry.file, "begin_missing_disposition"));
|
|
90
|
+
for (const duplicate of entry.duplicates) {
|
|
91
|
+
outcomes.push(failedOutcome(duplicate, "begin_missing_disposition"));
|
|
92
|
+
}
|
|
93
|
+
resolvedEntries.add(entry);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const outcome = await uploadOneObject(options, entry, disposition, chunkSizeBytes);
|
|
97
|
+
outcomes.push(outcome);
|
|
98
|
+
for (const duplicate of entry.duplicates) {
|
|
99
|
+
outcomes.push(duplicateOutcome(outcome, duplicate));
|
|
100
|
+
}
|
|
101
|
+
resolvedEntries.add(entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (beginUnavailable) {
|
|
105
|
+
return uploadWithLegacyFallback(options, loaded.filter((entry) => !resolvedEntries.has(entry)), outcomes);
|
|
106
|
+
}
|
|
107
|
+
return summarizeOutcomes(outcomes, false);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A duplicate file shares its primary's fate, but a primary that physically
|
|
111
|
+
* uploaded leaves the duplicate as reuse of now-durable content — counting it
|
|
112
|
+
* as a second upload would inflate object counts.
|
|
113
|
+
*/
|
|
114
|
+
function duplicateOutcome(primary, duplicate) {
|
|
115
|
+
return {
|
|
116
|
+
...primary,
|
|
117
|
+
pointer: duplicate.pointer,
|
|
118
|
+
codex_session_id: duplicate.codex_session_id ?? null,
|
|
119
|
+
kind: duplicate.kind ?? "unknown",
|
|
120
|
+
upload_state: primary.upload_state === "uploaded"
|
|
121
|
+
? "reused_existing"
|
|
122
|
+
: primary.upload_state,
|
|
123
|
+
reason: primary.upload_state === "uploaded" ? "duplicate_in_batch" : primary.reason,
|
|
124
|
+
uploaded_chunk_count: 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
|
|
128
|
+
const objectKey = entry.file.pointer.object_key ?? "";
|
|
129
|
+
if (disposition.disposition === "already_committed") {
|
|
130
|
+
return {
|
|
131
|
+
pointer: entry.file.pointer,
|
|
132
|
+
object_key: objectKey,
|
|
133
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
134
|
+
kind: entry.file.kind ?? "unknown",
|
|
135
|
+
upload_state: "reused_existing",
|
|
136
|
+
reason: "already_committed",
|
|
137
|
+
uploaded_chunk_count: 0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (disposition.disposition === "conflict" || !disposition.upload_id) {
|
|
141
|
+
return failedOutcome(entry.file, disposition.reason ?? "upload_conflict");
|
|
142
|
+
}
|
|
143
|
+
const received = new Set(disposition.received_chunk_indexes);
|
|
144
|
+
let uploadedChunks = 0;
|
|
145
|
+
for (let index = 0; index < entry.chunkCount; index += 1) {
|
|
146
|
+
if (received.has(index))
|
|
147
|
+
continue;
|
|
148
|
+
const chunk = entry.bytes.subarray(index * chunkSizeBytes, Math.min((index + 1) * chunkSizeBytes, entry.bytes.byteLength));
|
|
149
|
+
const chunkResponse = await requestJson(options, "/api/ambient/evidence/upload/chunk", {
|
|
150
|
+
schema_version: "ambient-raw-evidence-upload-chunk.v1",
|
|
151
|
+
generated_at: options.generatedAt,
|
|
152
|
+
provenance: options.provenance,
|
|
153
|
+
upload_id: disposition.upload_id,
|
|
154
|
+
object_key: objectKey,
|
|
155
|
+
chunk_index: index,
|
|
156
|
+
chunk_count: entry.chunkCount,
|
|
157
|
+
chunk_hash_sha256: sha256(chunk),
|
|
158
|
+
content_base64: chunk.toString("base64"),
|
|
159
|
+
});
|
|
160
|
+
if (!chunkResponse.ok) {
|
|
161
|
+
return failedOutcome(entry.file, `chunk_${index}_failed_http_${chunkResponse.status}`, uploadedChunks);
|
|
162
|
+
}
|
|
163
|
+
uploadedChunks += 1;
|
|
164
|
+
}
|
|
165
|
+
const commit = await requestJson(options, "/api/ambient/evidence/upload/commit", {
|
|
166
|
+
schema_version: "ambient-raw-evidence-upload-commit.v1",
|
|
167
|
+
generated_at: options.generatedAt,
|
|
168
|
+
provenance: options.provenance,
|
|
169
|
+
upload_id: disposition.upload_id,
|
|
170
|
+
object_key: objectKey,
|
|
171
|
+
});
|
|
172
|
+
if (!commit.ok) {
|
|
173
|
+
return failedOutcome(entry.file, `commit_failed_http_${commit.status}`, uploadedChunks);
|
|
174
|
+
}
|
|
175
|
+
// "already_committed" means a concurrent or earlier sync made these bytes
|
|
176
|
+
// durable; this sync does not own them, so they must be reported as reuse —
|
|
177
|
+
// a later ingest failure here must not clean up an object another sync's
|
|
178
|
+
// indexed refs already point at.
|
|
179
|
+
const commitStatus = commit.body && typeof commit.body === "object"
|
|
180
|
+
? commit.body.status
|
|
181
|
+
: undefined;
|
|
182
|
+
if (commitStatus === "already_committed") {
|
|
183
|
+
return {
|
|
184
|
+
pointer: entry.file.pointer,
|
|
185
|
+
object_key: objectKey,
|
|
186
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
187
|
+
kind: entry.file.kind ?? "unknown",
|
|
188
|
+
upload_state: "reused_existing",
|
|
189
|
+
reason: "already_committed",
|
|
190
|
+
uploaded_chunk_count: uploadedChunks,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
pointer: entry.file.pointer,
|
|
195
|
+
object_key: objectKey,
|
|
196
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
197
|
+
kind: entry.file.kind ?? "unknown",
|
|
198
|
+
upload_state: "uploaded",
|
|
199
|
+
reason: null,
|
|
200
|
+
uploaded_chunk_count: uploadedChunks,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Older dashboards predate the chunk endpoints. Fall back to one legacy
|
|
205
|
+
* request per file so an oversized or already-existing object fails (or
|
|
206
|
+
* reuses) individually instead of dragging the whole batch down. Large files
|
|
207
|
+
* are attempted rather than pre-failed: a non-serverless dashboard (the
|
|
208
|
+
* default localhost deployment) accepts them like the old CLI could, and a
|
|
209
|
+
* platform body-limit rejection comes back as a labeled per-file failure.
|
|
210
|
+
*/
|
|
211
|
+
async function uploadWithLegacyFallback(options, loaded, outcomes) {
|
|
212
|
+
for (const entry of loaded) {
|
|
213
|
+
let outcome;
|
|
214
|
+
const response = await requestJson(options, "/api/ambient/evidence/upload", {
|
|
215
|
+
schema_version: "ambient-raw-evidence-upload.v1",
|
|
216
|
+
generated_at: options.generatedAt,
|
|
217
|
+
provenance: options.provenance,
|
|
218
|
+
files: [
|
|
219
|
+
{
|
|
220
|
+
pointer: entry.file.pointer,
|
|
221
|
+
content_base64: entry.bytes.toString("base64"),
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
if (response.ok) {
|
|
226
|
+
outcome = {
|
|
227
|
+
pointer: entry.file.pointer,
|
|
228
|
+
object_key: entry.file.pointer.object_key ?? "",
|
|
229
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
230
|
+
kind: entry.file.kind ?? "unknown",
|
|
231
|
+
upload_state: "uploaded",
|
|
232
|
+
reason: "legacy_single_shot_upload",
|
|
233
|
+
uploaded_chunk_count: 1,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
else if (response.status === 409) {
|
|
237
|
+
// Content-addressed keys repeat across syncs; the legacy route reports
|
|
238
|
+
// an existing object as a duplicate, which means the bytes are durable.
|
|
239
|
+
outcome = {
|
|
240
|
+
pointer: entry.file.pointer,
|
|
241
|
+
object_key: entry.file.pointer.object_key ?? "",
|
|
242
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
243
|
+
kind: entry.file.kind ?? "unknown",
|
|
244
|
+
upload_state: "reused_existing",
|
|
245
|
+
reason: "legacy_already_exists",
|
|
246
|
+
uploaded_chunk_count: 0,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
outcome = failedOutcome(entry.file, `legacy_upload_failed_http_${response.status}`);
|
|
251
|
+
}
|
|
252
|
+
outcomes.push(outcome);
|
|
253
|
+
for (const duplicate of entry.duplicates) {
|
|
254
|
+
outcomes.push(duplicateOutcome(outcome, duplicate));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return summarizeOutcomes(outcomes, true);
|
|
258
|
+
}
|
|
259
|
+
async function requestJson(options, routePath, body) {
|
|
260
|
+
const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_MAX_ATTEMPTS;
|
|
261
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
262
|
+
let lastStatus = 0;
|
|
263
|
+
let lastBody = null;
|
|
264
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
265
|
+
try {
|
|
266
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}${routePath}`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: {
|
|
269
|
+
"Authorization": `Bearer ${options.deviceToken}`,
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
},
|
|
272
|
+
body: JSON.stringify(body),
|
|
273
|
+
});
|
|
274
|
+
lastStatus = response.status;
|
|
275
|
+
lastBody = await readResponseJson(response);
|
|
276
|
+
if (response.ok || (response.status < 500 && response.status !== 429)) {
|
|
277
|
+
return { ok: response.ok, status: response.status, body: lastBody };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
lastStatus = 0;
|
|
282
|
+
lastBody = null;
|
|
283
|
+
}
|
|
284
|
+
if (attempt < maxAttempts)
|
|
285
|
+
await sleep(RETRY_DELAY_MS * attempt);
|
|
286
|
+
}
|
|
287
|
+
return { ok: false, status: lastStatus, body: lastBody };
|
|
288
|
+
}
|
|
289
|
+
function readBeginDispositions(body) {
|
|
290
|
+
const map = new Map();
|
|
291
|
+
if (!body || typeof body !== "object")
|
|
292
|
+
return map;
|
|
293
|
+
const objects = body.objects;
|
|
294
|
+
if (!Array.isArray(objects))
|
|
295
|
+
return map;
|
|
296
|
+
for (const entry of objects) {
|
|
297
|
+
if (!entry || typeof entry !== "object")
|
|
298
|
+
continue;
|
|
299
|
+
const record = entry;
|
|
300
|
+
const objectKey = record["object_key"];
|
|
301
|
+
const disposition = record["disposition"];
|
|
302
|
+
if (typeof objectKey !== "string" || typeof disposition !== "string") {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
map.set(objectKey, {
|
|
306
|
+
disposition,
|
|
307
|
+
upload_id: typeof record["upload_id"] === "string" ? record["upload_id"] : null,
|
|
308
|
+
received_chunk_indexes: Array.isArray(record["received_chunk_indexes"])
|
|
309
|
+
? record["received_chunk_indexes"].filter((value) => typeof value === "number")
|
|
310
|
+
: [],
|
|
311
|
+
reason: typeof record["reason"] === "string" ? record["reason"] : null,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return map;
|
|
315
|
+
}
|
|
316
|
+
function failedOutcome(file, reason, uploadedChunks = 0) {
|
|
317
|
+
return {
|
|
318
|
+
pointer: file.pointer,
|
|
319
|
+
object_key: file.pointer.object_key ?? "",
|
|
320
|
+
codex_session_id: file.codex_session_id ?? null,
|
|
321
|
+
kind: file.kind ?? "unknown",
|
|
322
|
+
upload_state: "upload_failed",
|
|
323
|
+
reason,
|
|
324
|
+
uploaded_chunk_count: uploadedChunks,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function summarizeOutcomes(outcomes, usedLegacyFallback) {
|
|
328
|
+
const uploaded = outcomes.filter((outcome) => outcome.upload_state === "uploaded");
|
|
329
|
+
return {
|
|
330
|
+
outcomes,
|
|
331
|
+
uploaded_object_keys: uploaded.map((outcome) => outcome.object_key),
|
|
332
|
+
uploaded_object_count: uploaded.length,
|
|
333
|
+
uploaded_chunk_count: outcomes.reduce((sum, outcome) => sum + outcome.uploaded_chunk_count, 0),
|
|
334
|
+
reused_count: outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length,
|
|
335
|
+
failed_count: outcomes.filter((outcome) => outcome.upload_state === "upload_failed").length,
|
|
336
|
+
used_legacy_fallback: usedLegacyFallback,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async function readResponseJson(response) {
|
|
340
|
+
const text = await response.text();
|
|
341
|
+
if (!text)
|
|
342
|
+
return {};
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(text);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return { message: text };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function batches(items, size) {
|
|
351
|
+
const out = [];
|
|
352
|
+
for (let offset = 0; offset < items.length; offset += size) {
|
|
353
|
+
out.push(items.slice(offset, offset + size));
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
function defaultSleep(milliseconds) {
|
|
358
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
359
|
+
}
|
|
360
|
+
function sha256(value) {
|
|
361
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
362
|
+
}
|
package/dist/local-state.js
CHANGED
|
@@ -3,14 +3,16 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
|
|
6
7
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
7
|
-
export const LOCAL_COLLECTOR_VERSION = "0.1.
|
|
8
|
+
export const LOCAL_COLLECTOR_VERSION = "0.1.5";
|
|
8
9
|
export const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:3100";
|
|
9
10
|
export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
10
11
|
const paths = getUserLocalCockpitPaths(homeDir);
|
|
11
12
|
return {
|
|
12
13
|
...paths,
|
|
13
14
|
active_work_context_file: path.join(paths.state_dir, "active-work-context.json"),
|
|
15
|
+
work_contexts_dir: path.join(paths.state_dir, "work-contexts"),
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
export async function installLocalCollector(options = {}) {
|
|
@@ -119,16 +121,24 @@ export async function startLocalWorkContext(options = {}) {
|
|
|
119
121
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
120
122
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
121
123
|
await ensureRuntimeDirectories(paths);
|
|
124
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
122
125
|
const session = await readLocalSessionReference(paths, {
|
|
123
126
|
operatorId: options.operatorId,
|
|
124
127
|
sessionId: options.sessionId,
|
|
125
128
|
});
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const workContextId = existingContext?.work_context_id ?? `work-${crypto.randomUUID()}`;
|
|
129
|
+
const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
|
|
130
|
+
const branch = options.branch ?? identity.branch;
|
|
129
131
|
const sessionId = options.sessionId ??
|
|
130
132
|
(session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
|
|
131
133
|
const operatorId = options.operatorId ?? session.operator_id;
|
|
134
|
+
const deviceId = session.device_id ?? config?.device_id ?? "device-unknown";
|
|
135
|
+
const workContextId = stableWorkContextId({
|
|
136
|
+
operatorId,
|
|
137
|
+
deviceId,
|
|
138
|
+
repoFingerprint: identity.repo_fingerprint,
|
|
139
|
+
worktreeFingerprint: identity.worktree_fingerprint,
|
|
140
|
+
});
|
|
141
|
+
const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
|
|
132
142
|
const ticketBindingCandidates = options.activeTicketId
|
|
133
143
|
? [
|
|
134
144
|
{
|
|
@@ -141,8 +151,15 @@ export async function startLocalWorkContext(options = {}) {
|
|
|
141
151
|
: [];
|
|
142
152
|
const context = LocalWorkContextSchema.parse({
|
|
143
153
|
work_context_id: workContextId,
|
|
144
|
-
repo:
|
|
154
|
+
repo: identity.repo_root,
|
|
145
155
|
branch,
|
|
156
|
+
repo_label: identity.repo_label,
|
|
157
|
+
repo_fingerprint: identity.repo_fingerprint,
|
|
158
|
+
repo_origin_url: identity.repo_origin_url ?? undefined,
|
|
159
|
+
head_sha: identity.head_sha ?? undefined,
|
|
160
|
+
worktree_label: identity.worktree_label,
|
|
161
|
+
worktree_fingerprint: identity.worktree_fingerprint,
|
|
162
|
+
worktree_is_primary: identity.worktree_is_primary,
|
|
146
163
|
operator_id: operatorId,
|
|
147
164
|
session_id: sessionId,
|
|
148
165
|
started_at: existingContext?.started_at ?? now.toISOString(),
|
|
@@ -154,13 +171,20 @@ export async function startLocalWorkContext(options = {}) {
|
|
|
154
171
|
capture_source: "collector_runtime",
|
|
155
172
|
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
156
173
|
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
157
|
-
repo:
|
|
174
|
+
repo: identity.repo_root,
|
|
158
175
|
branch,
|
|
176
|
+
repo_label: identity.repo_label,
|
|
177
|
+
repo_fingerprint: identity.repo_fingerprint,
|
|
178
|
+
repo_origin_url: identity.repo_origin_url ?? undefined,
|
|
179
|
+
worktree_label: identity.worktree_label,
|
|
180
|
+
worktree_fingerprint: identity.worktree_fingerprint,
|
|
181
|
+
worktree_is_primary: identity.worktree_is_primary,
|
|
159
182
|
operator_id: operatorId,
|
|
160
183
|
session_id: sessionId,
|
|
161
184
|
work_context_id: workContextId,
|
|
162
185
|
},
|
|
163
186
|
});
|
|
187
|
+
await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
|
|
164
188
|
await writeJsonFile(paths.active_work_context_file, context);
|
|
165
189
|
return context;
|
|
166
190
|
}
|
|
@@ -174,8 +198,9 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
174
198
|
operatorId: options.operatorId,
|
|
175
199
|
sessionId: options.sessionId,
|
|
176
200
|
});
|
|
177
|
-
const
|
|
178
|
-
const
|
|
201
|
+
const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
|
|
202
|
+
const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
|
|
203
|
+
const branch = options.branch ?? identity.branch;
|
|
179
204
|
const freshness = classifyCollectorFreshness(context, now);
|
|
180
205
|
const uploadSpool = await summarizeLocalUploadSpool(paths);
|
|
181
206
|
const uploadState = !config
|
|
@@ -212,10 +237,15 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
212
237
|
config_file: paths.config_file,
|
|
213
238
|
session_file: paths.session_file,
|
|
214
239
|
session_state: session.session_state,
|
|
215
|
-
repo:
|
|
240
|
+
repo: context?.repo ?? identity.repo_root,
|
|
216
241
|
branch,
|
|
217
242
|
active_ticket_id: context?.active_ticket_id ?? null,
|
|
218
243
|
work_context_id: context?.work_context_id ?? null,
|
|
244
|
+
repo_label: context?.repo_label ?? identity.repo_label,
|
|
245
|
+
repo_fingerprint: context?.repo_fingerprint ?? identity.repo_fingerprint,
|
|
246
|
+
worktree_label: context?.worktree_label ?? identity.worktree_label,
|
|
247
|
+
worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
|
|
248
|
+
worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
|
|
219
249
|
collector_freshness: freshness,
|
|
220
250
|
upload_state: uploadState,
|
|
221
251
|
last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
|
|
@@ -232,6 +262,21 @@ export async function readLocalCollectorConfig(paths) {
|
|
|
232
262
|
export async function readLocalWorkContext(paths) {
|
|
233
263
|
return LocalWorkContextSchema.parse(await readJsonFile(paths.active_work_context_file));
|
|
234
264
|
}
|
|
265
|
+
export async function readLocalWorkContextForRepo(paths, repoRoot) {
|
|
266
|
+
const identity = await resolveIdentityOrFallback(repoRoot);
|
|
267
|
+
const context = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
|
|
268
|
+
if (context)
|
|
269
|
+
return context;
|
|
270
|
+
const active = await readLocalWorkContext(paths);
|
|
271
|
+
if (active.worktree_fingerprint === identity.worktree_fingerprint ||
|
|
272
|
+
path.resolve(active.repo) === identity.repo_root) {
|
|
273
|
+
return active;
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --repo "${identity.repo_root}"\`.`);
|
|
276
|
+
}
|
|
277
|
+
async function readLocalWorkContextByFingerprint(paths, worktreeFingerprint) {
|
|
278
|
+
return LocalWorkContextSchema.parse(await readJsonFile(workContextFile(paths, worktreeFingerprint)));
|
|
279
|
+
}
|
|
235
280
|
export async function readLocalSessionReference(paths, fallback = {}) {
|
|
236
281
|
try {
|
|
237
282
|
const rawSession = await readJsonFile(paths.session_file);
|
|
@@ -284,15 +329,54 @@ async function ensureRuntimeDirectories(paths) {
|
|
|
284
329
|
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
285
330
|
await fs.mkdir(paths.spool_dir, { recursive: true, mode: 0o700 });
|
|
286
331
|
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
332
|
+
await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
|
|
287
333
|
if (process.platform !== "win32") {
|
|
288
334
|
await Promise.all([
|
|
289
335
|
fs.chmod(paths.config_dir, 0o700).catch(() => undefined),
|
|
290
336
|
fs.chmod(paths.state_dir, 0o700).catch(() => undefined),
|
|
291
337
|
fs.chmod(paths.spool_dir, 0o700).catch(() => undefined),
|
|
292
338
|
fs.chmod(paths.cursors_dir, 0o700).catch(() => undefined),
|
|
339
|
+
fs.chmod(paths.work_contexts_dir, 0o700).catch(() => undefined),
|
|
293
340
|
]);
|
|
294
341
|
}
|
|
295
342
|
}
|
|
343
|
+
function workContextFile(paths, worktreeFingerprint) {
|
|
344
|
+
return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
|
|
345
|
+
}
|
|
346
|
+
async function resolveIdentityOrFallback(repoRoot, branchOverride) {
|
|
347
|
+
const resolvedRoot = path.resolve(repoRoot);
|
|
348
|
+
const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
|
|
349
|
+
if (identity) {
|
|
350
|
+
return branchOverride ? { ...identity, branch: branchOverride } : identity;
|
|
351
|
+
}
|
|
352
|
+
const repoLabel = path.basename(resolvedRoot) || "workspace";
|
|
353
|
+
const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
|
|
354
|
+
const worktreeFingerprint = `wt-${sha256(`${repoFingerprint}:${resolvedRoot}`).slice(0, 24)}`;
|
|
355
|
+
const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
|
|
356
|
+
return {
|
|
357
|
+
requested_path: resolvedRoot,
|
|
358
|
+
repo_root: resolvedRoot,
|
|
359
|
+
repo_label: repoLabel,
|
|
360
|
+
repo_fingerprint: repoFingerprint,
|
|
361
|
+
repo_origin_url: null,
|
|
362
|
+
branch,
|
|
363
|
+
head_sha: null,
|
|
364
|
+
worktree_label: repoLabel,
|
|
365
|
+
worktree_fingerprint: worktreeFingerprint,
|
|
366
|
+
worktree_is_primary: true,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function stableWorkContextId(input) {
|
|
370
|
+
return `work-${sha256([
|
|
371
|
+
input.operatorId,
|
|
372
|
+
input.deviceId,
|
|
373
|
+
input.repoFingerprint,
|
|
374
|
+
input.worktreeFingerprint,
|
|
375
|
+
].join(":")).slice(0, 32)}`;
|
|
376
|
+
}
|
|
377
|
+
function sha256(value) {
|
|
378
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
379
|
+
}
|
|
296
380
|
async function readJsonFile(filePath) {
|
|
297
381
|
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
298
382
|
}
|