@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.
@@ -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
+ }
@@ -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.2";
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 branch = options.branch ?? (await resolveGitBranch(repoRoot));
127
- const existingContext = await readLocalWorkContext(paths).catch(() => null);
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: repoRoot,
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: repoRoot,
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 context = await readLocalWorkContext(paths).catch(() => null);
178
- const branch = options.branch ?? (await resolveGitBranch(repoRoot));
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: repoRoot,
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
  }