codexteer 0.18.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +13 -5
- package/bin/codexteer.mjs +1 -1
- package/docs/distribution.md +23 -6
- package/docs/orchestration.md +4 -4
- package/package.json +1 -1
- package/src/app-server.mjs +4 -4
- package/src/checkpoint.mjs +10 -10
- package/src/cli.mjs +90 -79
- package/src/cloud-cli.mjs +29 -33
- package/src/connection-error.mjs +7 -0
- package/src/cursor.mjs +370 -0
- package/src/desktop-cloud-id.mjs +9 -1
- package/src/desktop-cloud-project.mjs +37 -4
- package/src/desktop-cloud.mjs +10 -2
- package/src/desktop.mjs +9 -9
- package/src/directive.mjs +7 -6
- package/src/distribution.mjs +58 -33
- package/src/evidence.mjs +3 -3
- package/src/findings.mjs +13 -8
- package/src/help-cloud-remote.mjs +399 -0
- package/src/help-errors.mjs +10 -0
- package/src/help-intervention.mjs +492 -0
- package/src/help-local.mjs +510 -0
- package/src/help-observation.mjs +200 -0
- package/src/help-supervision.mjs +257 -0
- package/src/help.mjs +126 -304
- package/src/journal.mjs +33 -12
- package/src/launcher.mjs +6 -6
- package/src/monitor.mjs +5 -4
- package/src/notify.mjs +3 -3
- package/src/observe.mjs +11 -40
- package/src/orchestrate-prompt.mjs +14 -12
- package/src/orchestrate.mjs +4 -7
- package/src/paged-read.mjs +10 -17
- package/src/prompt-command.mjs +45 -7
- package/src/prompt.mjs +15 -12
- package/src/resource.mjs +6 -6
- package/src/runner.mjs +5 -5
- package/src/store.mjs +2 -2
- package/src/supervise.mjs +5 -7
- package/src/task-id.mjs +29 -4
- package/src/thread-id.mjs +5 -5
- package/src/unknown-options.mjs +17 -0
- package/src/wrapper.mjs +2 -2
package/src/cloud-cli.mjs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import { desktopCloudProjectDoctor, assignDesktopCloudProject, desktopCloudProjectReceipt } from "./desktop-cloud-project.mjs";
|
|
1
|
+
import { desktopCloudProjectDoctor, assignDesktopCloudProject, desktopCloudProjectReceipt, resolveDesktopCloudProjectAttempt } from "./desktop-cloud-project.mjs";
|
|
2
2
|
import { listProjects as listDesktopProjects } from "./projects.mjs";
|
|
3
3
|
import { isDesktopCloudTask, desktopCloudId, desktopCloudLink } from "./desktop-cloud-id.mjs";
|
|
4
4
|
import { desktopCloudConnection, desktopCloudEnvironments } from "./desktop-cloud-api.mjs";
|
|
5
5
|
import { createDesktopCloudTask, listDesktopCloudTasks, desktopCloudReceipt } from "./desktop-cloud.mjs";
|
|
6
|
-
import { isCloudTask } from "./task-id.mjs";
|
|
6
|
+
import { isCloudTask, selectedCloudInvocation } from "./task-id.mjs";
|
|
7
7
|
import { cloudTaskId } from "./cloud-id.mjs";
|
|
8
|
+
import { normalizeThreadId } from "./thread-id.mjs";
|
|
9
|
+
import { connectionMismatch } from "./connection-error.mjs";
|
|
8
10
|
import { cloudConnection } from "./cloud-connection.mjs";
|
|
9
11
|
import { observeThread, printObservation } from "./observe.mjs";
|
|
10
12
|
import { monitorCommand } from "./monitor.mjs";
|
|
11
13
|
import { notificationOptions } from "./notify.mjs";
|
|
12
14
|
import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
|
|
15
|
+
import { rejectRepeatedBinding } from "./prompt-command.mjs";
|
|
16
|
+
import { rejectUnknownOptions } from "./unknown-options.mjs";
|
|
13
17
|
import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, assertSupervisorRegistration, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
|
|
14
18
|
import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
|
|
15
|
-
import { sendTrackedMessage, listMessages, getMessage, messageSummary, reconcileMessages, markMessage, listInstructions } from "./journal.mjs";
|
|
19
|
+
import { sendTrackedMessage, listMessages, getMessage, messageSummary, reconcileMessages, markMessage, resolveUnknownDelivery, listInstructions } from "./journal.mjs";
|
|
16
20
|
import { createCloudTask, cloudTaskDiff, listCloudTasks, cloudCreationReceipt } from "./cloud.mjs";
|
|
17
21
|
import { cloudSendReceipt, reconcileCloudSend } from "./cloud-observe.mjs";
|
|
18
22
|
import { cloudEnvironments } from "./cloud-api.mjs";
|
|
@@ -24,7 +28,6 @@ import { createCheckpoint, listCheckpoints, getCheckpoint, checkpointSummary, ve
|
|
|
24
28
|
import { acquireResource, resourceStatus, renewResource, releaseResource, listResources, runWithResource } from "./resource.mjs";
|
|
25
29
|
|
|
26
30
|
const failure = (message, code = "CLOUD_OPTIONS_INVALID") => Object.assign(new Error(message), { code, execution_environment: "cloud" });
|
|
27
|
-
const values = new Set("--environment --connection --supervisor --require-node-version --host --remote-transport --context-thread --thread --backend --source --kind --evidence --based-on --finding --expires-at --supersedes --checkpoint --limit --max-chars --since --poll-ms --timeout-ms --until --notify --settle-ms --max-hold-ms --owner --key --condition --note --rating --reason --root --path --exclude --command --cwd --artifact --status --by --token --ttl-ms --agent --project --env --branch --title --model --thinking --cursor --attempt".split(" "));
|
|
28
31
|
function option(args, name, fallback) {
|
|
29
32
|
const index = args.indexOf(name), stop = args.indexOf("--");
|
|
30
33
|
if (index < 0 || (stop >= 0 && index > stop)) return fallback;
|
|
@@ -40,35 +43,26 @@ const repeated = (args, name) => { const result = []; let value; while ((value =
|
|
|
40
43
|
const message = args => { if (args[0] === "--") args.shift(); return args.length === 1 && args[0] === "-" ? readFileSync(0, "utf8") : args.join(" "); };
|
|
41
44
|
function exact(args, count) { if (args.length !== count || args.some(arg => arg.startsWith("--"))) throw failure("Unexpected arguments. See the command's help."); }
|
|
42
45
|
|
|
43
|
-
function selectedCloudInvocation(argv) {
|
|
44
|
-
const end = argv.indexOf("--"), own = end < 0 ? argv : argv.slice(0, end);
|
|
45
|
-
const connection = own.indexOf("--connection");
|
|
46
|
-
const positional = [];
|
|
47
|
-
for (let i = 0; i < own.length; i++) {
|
|
48
|
-
if (values.has(own[i])) { i++; continue; }
|
|
49
|
-
if (!own[i].startsWith("--")) positional.push(own[i]);
|
|
50
|
-
}
|
|
51
|
-
const [command, action] = positional;
|
|
52
|
-
if (["help", "--help", "--version"].includes(command) || own.includes("--help") || own.includes("--version")) return false;
|
|
53
|
-
if (connection >= 0 && ["cloud", "desktop-cloud"].includes(own[connection + 1])) return own[connection + 1];
|
|
54
|
-
const thread = own.indexOf("--thread");
|
|
55
|
-
if (["doctor", "resource"].includes(command) && thread >= 0) return isDesktopCloudTask(own[thread + 1]) ? "desktop-cloud" : isCloudTask(own[thread + 1]) ? "cloud" : false;
|
|
56
|
-
if (command === "thread" && action === "create") return false;
|
|
57
|
-
const nested = ["history", "instructions", "findings", "checkpoint", "thread"].includes(command)
|
|
58
|
-
|| (command === "supervise" && ["prompt", "register", "status", "pause", "resume", "stop", "list"].includes(action));
|
|
59
|
-
const target = isCloudTask(command) || isDesktopCloudTask(command) ? command : positional[nested ? 2 : 1];
|
|
60
|
-
return isDesktopCloudTask(target) ? "desktop-cloud" : isCloudTask(target) ? "cloud" : false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
46
|
export const cloudInvocation = argv => !!selectedCloudInvocation(argv);
|
|
64
47
|
|
|
65
48
|
// Only parsing/routing is backend-specific. Supervision, monitoring, directives,
|
|
66
49
|
// findings, journaling and saved-agent launch use the same local implementation.
|
|
67
50
|
export async function mainCloud(argv) {
|
|
51
|
+
rejectRepeatedBinding(argv);
|
|
68
52
|
const args = [...argv], json = flag(args, "--json"), connection = option(args, "--connection", selectedCloudInvocation(argv) || "cloud");
|
|
69
53
|
const desktopCloud = connection === "desktop-cloud";
|
|
70
|
-
const targetId = desktopCloud ? desktopCloudId :
|
|
71
|
-
|
|
54
|
+
const targetId = desktopCloud ? desktopCloudId : value => {
|
|
55
|
+
try { return cloudTaskId(value); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
// Preserve the Web Cloud ID validator's existing error code.
|
|
58
|
+
if (isDesktopCloudTask(value)) throw connectionMismatch("desktop-cloud", connection, error.code);
|
|
59
|
+
let local = false;
|
|
60
|
+
try { normalizeThreadId(value); local = true; } catch {}
|
|
61
|
+
if (local) throw connectionMismatch("shared", connection, error.code);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
if (!["cloud", "desktop-cloud"].includes(connection)) throw Object.assign(connectionMismatch(selectedCloudInvocation(argv) || "cloud", connection), { execution_environment: "cloud" });
|
|
72
66
|
const own = args.slice(0, args.includes("--") ? args.indexOf("--") : args.length);
|
|
73
67
|
if (own.some(value => ["--host", "--remote-transport", "--context-thread"].includes(value))) throw failure("Desktop host routes cannot select a cloud task.", "CLOUD_TARGET_CONFLICT");
|
|
74
68
|
const requiredNode = option(args, "--require-node-version");
|
|
@@ -159,11 +153,12 @@ export async function mainCloud(argv) {
|
|
|
159
153
|
const settings = { taskId: id };
|
|
160
154
|
output(`history.${action}`, action === "show" ? await cloudSendReceipt(attempt, settings) : await reconcileCloudSend(attempt, settings)); return;
|
|
161
155
|
}
|
|
162
|
-
const includeText = flag(args, "--include-text"), pending = flag(args, "--pending");
|
|
163
|
-
const report = action === "mark" ? { status: option(args, "--status"), note: option(args, "--note"), evidence: repeated(args, "--evidence"), by: option(args, "--by", "local") } : {};
|
|
156
|
+
const includeText = flag(args, "--include-text"), pending = flag(args, "--pending"), resolveUnknown = desktopCloud && action === "mark" && flag(args, "--resolve-unknown");
|
|
157
|
+
const report = resolveUnknown ? { note: option(args, "--note") } : action === "mark" ? { status: option(args, "--status"), note: option(args, "--note"), evidence: repeated(args, "--evidence"), by: option(args, "--by", "local") } : {};
|
|
164
158
|
if (!args[0] || args.length > 2 || !["list", "show", "check", "mark"].includes(action) || (["show", "mark"].includes(action) && !args[1]) || (action === "list" && args[1])) throw failure("Invalid history arguments.");
|
|
159
|
+
if (resolveUnknown && supervisor) throw failure("A supervisor cannot release its own unknown delivery. The user must run this without --supervisor.", "SUPERVISOR_CONTROL_FORBIDDEN");
|
|
165
160
|
const id = await bound(args[0]); const data = action === "list" ? await listMessages(id, { includeText, pending }) : action === "show" ? messageSummary(await getMessage(id, args[1]), includeText)
|
|
166
|
-
: action === "check" ? await reconcileMessages(id, args[1], { includeText }, dependencies) : await markMessage(id, args[1], report);
|
|
161
|
+
: action === "check" ? await reconcileMessages(id, args[1], { includeText }, dependencies) : resolveUnknown ? await resolveUnknownDelivery(id, args[1], report) : await markMessage(id, args[1], report);
|
|
167
162
|
output(`history.${action}`, data); return;
|
|
168
163
|
}
|
|
169
164
|
if (command === "instructions") {
|
|
@@ -225,7 +220,7 @@ export async function mainCloud(argv) {
|
|
|
225
220
|
args.shift(); if (supervisor) throw failure("A bound supervisor cannot create another task.", "SUPERVISOR_CONTROL_FORBIDDEN");
|
|
226
221
|
const project = option(args, "--project"), requestedBranch = option(args, "--branch"), dryRun = flag(args, "--dry-run");
|
|
227
222
|
if (project === undefined) throw failure("Cloud creation requires --project with an environment ID or exact name.");
|
|
228
|
-
const end = args.includes("--") ? args.indexOf("--") : args.length;
|
|
223
|
+
const end = args.includes("--") ? args.indexOf("--") : args.length; rejectUnknownOptions(args.slice(0, end), "cloud creation", "Explicit model/title/permission overrides are not implemented yet.", failure);
|
|
229
224
|
const body = message(args); if (!body.trim()) throw failure("A non-empty initial instruction is required.");
|
|
230
225
|
const rows = (await cloudEnvironments()).environments, selector = project, matches = rows.filter(e => e.id === selector || e.name === selector);
|
|
231
226
|
if (matches.length !== 1) throw failure("The cloud environment selector must match one accessible ID or exact name.");
|
|
@@ -271,7 +266,7 @@ export async function mainCloud(argv) {
|
|
|
271
266
|
const options = { dryRun, newTurn, supervisor, connection, evidence: repeated(args, "--evidence") };
|
|
272
267
|
for (const [key, name] of Object.entries({ source: "--source", kind: "--kind", basedOn: "--based-on", supersedes: "--supersedes", expiresAt: "--expires-at", checkpoint: "--checkpoint", finding: "--finding" })) { const value = option(args, name); if (value !== undefined) options[key] = value; }
|
|
273
268
|
const id = targetId(args.shift()), end = args.includes("--") ? args.indexOf("--") : args.length;
|
|
274
|
-
|
|
269
|
+
rejectUnknownOptions(args.slice(0, end), "send", "Put instruction text that starts with -- after --.", failure);
|
|
275
270
|
const body = message(args); if (!body.trim()) throw failure("send requires a non-empty message.");
|
|
276
271
|
const receipt = await sendTrackedMessage(id, body, options);
|
|
277
272
|
receipt.sound = noSound ? { played: false, reason: "disabled" } : playSendSound(receipt);
|
|
@@ -296,11 +291,12 @@ async function desktopCloudCommands(command, args, { supervisor, output, bound,
|
|
|
296
291
|
if (action === "create") {
|
|
297
292
|
const environment = option(args, "--environment"), project = option(args, "--project"), title = option(args, "--title"), dryRun = flag(args, "--dry-run");
|
|
298
293
|
const end = args.includes("--") ? args.indexOf("--") : args.length;
|
|
299
|
-
|
|
294
|
+
rejectUnknownOptions(args.slice(0, end), "Desktop Cloud creation", "Put an instruction that starts with -- after --.", failure);
|
|
300
295
|
output("thread.create", await createDesktopCloudTask(environment, message(args), { project, title, dryRun })); return true;
|
|
301
296
|
}
|
|
302
297
|
if (action === "assign") { const project = option(args, "--project"); exact(args, 1); const data = await assignDesktopCloudProject(await bound(args[0]), project); output("thread.assign", data); if (data.verification_status !== "verified") process.exitCode = 1; return true; }
|
|
303
|
-
exact(args, 1);
|
|
298
|
+
const resolveUnknown = action === "receipt" && flag(args, "--resolve-unknown"), note = resolveUnknown ? option(args, "--note") : undefined; exact(args, 1);
|
|
299
|
+
if (resolveUnknown) { output("thread.receipt", await resolveDesktopCloudProjectAttempt(args[0], { note })); return true; }
|
|
304
300
|
if (action === "receipt") { let data; try { data = await desktopCloudReceipt(args[0]); } catch (error) { if (error.code !== "DESKTOP_CLOUD_CREATION_RECEIPT_NOT_FOUND") throw error; data = await desktopCloudProjectReceipt(args[0]); } output("thread.receipt", data); return true; }
|
|
305
301
|
if (action === "resolve") { const id = await bound(args[0]); output("thread.resolve", { thread_id: id, deep_link: desktopCloudLink(id) }); return true; }
|
|
306
302
|
throw failure("Expected thread create|assign|receipt|resolve.");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Diagnostics only: callers retain their existing routing and rejection rules.
|
|
2
|
+
export function connectionMismatch(expected, selected, code = "CLOUD_TARGET_CONFLICT") {
|
|
3
|
+
const message = expected === "shared"
|
|
4
|
+
? `For a local task, use --connection shared (the default local connection; got ${selected}).`
|
|
5
|
+
: `This task requires --connection ${expected} (got ${selected}).`;
|
|
6
|
+
return Object.assign(new Error(message), { code, expected_connection: expected, connection: selected });
|
|
7
|
+
}
|
package/src/cursor.mjs
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The read cursor format. No other module builds or reads cursor bytes.
|
|
2
|
+
//
|
|
3
|
+
// token = "c1" base64url(check[8] body). The check is SHA-256 over the target
|
|
4
|
+
// binding and the body. It is not a MAC: it binds the token to the thread, host
|
|
5
|
+
// and transport without storing them, and rejects copy errors. Sends stay safe
|
|
6
|
+
// because every --based-on and --since is re-validated against the live server.
|
|
7
|
+
//
|
|
8
|
+
// A v2 body commits to the turn list instead of listing it, so the token does
|
|
9
|
+
// not grow with the thread. parseCursor needs no server and returns a claim.
|
|
10
|
+
// resolveTurns checks the claim against the live turn list and rebuilds the
|
|
11
|
+
// in-memory cursor that readPaged and verifyPagedFreshness use.
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { hostFields, targetHost, remoteTransport } from "./target.mjs";
|
|
14
|
+
|
|
15
|
+
const PREFIX = "c1", MAX_TOKEN = 1024 * 1024, MAX_POSITION = 16384;
|
|
16
|
+
const RUNNING = ["inProgress", "running", "pending"];
|
|
17
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
19
|
+
|
|
20
|
+
/** @typedef {{ kind: "none" } | { kind: "turn", index: number } | { kind: "inline", id: string }} TurnRef */
|
|
21
|
+
/**
|
|
22
|
+
* What a v2 token proves about the turn list without listing it. `running` holds
|
|
23
|
+
* the status each running turn had when the token was made.
|
|
24
|
+
* @typedef {{ count: number, commitment: string | null, running: Map<number, string> }} TurnCommitment
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Offline view of a v2 cursor. It has no `turns`, so code that skips resolveTurns
|
|
28
|
+
* fails on first use instead of running a weaker check.
|
|
29
|
+
* @typedef {{ v: 2, turn_commitment: TurnCommitment, active_turn_id: string | null, state: string, pending: boolean,
|
|
30
|
+
* head: { cursor: string | null, id: string | null, turn: TurnRef, hash: string | null }, last_public: string | null,
|
|
31
|
+
* groups: { cursor: string | null, turn: TurnRef, count: number,
|
|
32
|
+
* items: { index: number, id: string, turn: TurnRef, hash: string, mode: "running" | "tail" }[] }[] }} PagedClaim
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {{ v: 1, count: number, open: { index: number, hash: string }[], prefix: string, state: string,
|
|
36
|
+
* active_turn_id: string | null, latest_turn_id?: string | null, user_revision?: string }} SnapshotCursor
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {{ v: 2, thread_id: string, turns: { id: string, status: string }[],
|
|
40
|
+
* head: { cursor: string | null, id: string | null, turn_id: string | null, hash: string | null },
|
|
41
|
+
* groups: { cursor: string | null, turn_id: string | null, count: number,
|
|
42
|
+
* items: { index: number, id: string, turn_id: string, hash: string, mode: "running" | "tail" }[] }[],
|
|
43
|
+
* last_public: string | null, state: string, active_turn_id: string | null, pending: boolean }} PagedCursor
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export function invalidCursor() { return Object.assign(new Error("Cursor is invalid or history changed. Read again without --since."), { code: "STALE_CURSOR" }); }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {PagedCursor | SnapshotCursor} cursor
|
|
50
|
+
* @param {string} threadId
|
|
51
|
+
*/
|
|
52
|
+
export function encodeCursor(cursor, threadId) {
|
|
53
|
+
const w = new Writer();
|
|
54
|
+
if (cursor.v === 1) writeSnapshotBody(w, cursor); else writePagedBody(w, claimOf(cursor), threadId);
|
|
55
|
+
const body = w.done(), token = PREFIX + Buffer.concat([check(threadId, body), body]).toString("base64url");
|
|
56
|
+
// v1 is the --full-history fallback and has always been uncapped, so only the fast cursor is refused here.
|
|
57
|
+
if (cursor.v === 2 && token.length > MAX_TOKEN) throw Object.assign(new Error("Too many in-progress items for a fast cursor. Use --full-history."), { code: "CURSOR_TOO_LARGE" });
|
|
58
|
+
return token;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse without the server. Accepts "c1" tokens and the earlier base64url JSON tokens.
|
|
63
|
+
* @param {unknown} token
|
|
64
|
+
* @param {string} threadId
|
|
65
|
+
* @returns {PagedClaim | SnapshotCursor}
|
|
66
|
+
*/
|
|
67
|
+
export function parseCursor(token, threadId) {
|
|
68
|
+
try {
|
|
69
|
+
if (typeof token !== "string" || token.length > MAX_TOKEN) throw invalidCursor();
|
|
70
|
+
if (!token.startsWith(PREFIX)) return parseLegacy(token, threadId);
|
|
71
|
+
const text = token.slice(PREFIX.length), bytes = Buffer.from(text, "base64url");
|
|
72
|
+
// Node decodes base64url leniently, so only the canonical spelling of the bytes is accepted.
|
|
73
|
+
if (bytes.length < 9 || bytes.toString("base64url") !== text) throw invalidCursor();
|
|
74
|
+
const body = bytes.subarray(8);
|
|
75
|
+
if (!check(threadId, body).equals(bytes.subarray(0, 8))) throw invalidCursor();
|
|
76
|
+
const r = new Reader(body), version = r.byte();
|
|
77
|
+
if (version !== 1 && version !== 2) throw invalidCursor();
|
|
78
|
+
const claim = version === 1 ? readSnapshotBody(r) : readPagedBody(r, threadId);
|
|
79
|
+
r.end();
|
|
80
|
+
return claim;
|
|
81
|
+
} catch { throw invalidCursor(); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check a v2 claim against the live turn list and rebuild the cursor. Accepts a live
|
|
86
|
+
* list exactly when it extends the committed one with the same ids in order and every
|
|
87
|
+
* turn that was final keeps its status.
|
|
88
|
+
* @param {PagedClaim} claim
|
|
89
|
+
* @param {{ id: string, status: string }[]} liveTurns from turnMetadata, free of duplicates
|
|
90
|
+
* @param {string} threadId
|
|
91
|
+
* @returns {PagedCursor}
|
|
92
|
+
*/
|
|
93
|
+
export function resolveTurns(claim, liveTurns, threadId) {
|
|
94
|
+
const { count, commitment: expected, running } = claim.turn_commitment;
|
|
95
|
+
if (liveTurns.length < count) throw invalidCursor();
|
|
96
|
+
const prefix = liveTurns.slice(0, count);
|
|
97
|
+
if (count && commitment(prefix, running) !== expected) throw invalidCursor();
|
|
98
|
+
// A running turn keeps its observed status, so the next read reports its change.
|
|
99
|
+
const turns = prefix.map((t, i) => ({ id: t.id, status: running.get(i) ?? t.status }));
|
|
100
|
+
const turnId = ref => ref.kind === "none" ? null : ref.kind === "inline" ? ref.id : turns[ref.index].id;
|
|
101
|
+
return { v: 2, thread_id: threadId, turns,
|
|
102
|
+
head: { cursor: claim.head.cursor, id: claim.head.id, turn_id: turnId(claim.head.turn), hash: claim.head.hash },
|
|
103
|
+
groups: claim.groups.map(g => ({ cursor: g.cursor, turn_id: turnId(g.turn), count: g.count,
|
|
104
|
+
items: g.items.map(i => ({ index: i.index, id: i.id, turn_id: turnId(i.turn), hash: i.hash, mode: i.mode })) })),
|
|
105
|
+
last_public: claim.last_public, state: claim.state, active_turn_id: claim.active_turn_id, pending: claim.pending };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The output view of a stored record. Its based_on token becomes based_on_sha256,
|
|
110
|
+
* the hex SHA-256 of the token. Stored records keep the full token.
|
|
111
|
+
* @template {object} T
|
|
112
|
+
* @param {T} record
|
|
113
|
+
*/
|
|
114
|
+
export function withCursorReference(record) {
|
|
115
|
+
if (!record || !Object.hasOwn(record, "based_on")) return record;
|
|
116
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => key !== "based_on" ? [key, value]
|
|
117
|
+
: ["based_on_sha256", typeof value === "string" ? createHash("sha256").update(value).digest("hex") : null]));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const check = (threadId, body) => createHash("sha256").update(JSON.stringify(["codexteer-cursor", threadId, hostFields()])).update(body).digest().subarray(0, 8);
|
|
121
|
+
const commitment = (turns, running) => createHash("sha256").update(JSON.stringify(turns.map((t, i) => [t.id, running.has(i) ? null : t.status]))).digest("hex");
|
|
122
|
+
|
|
123
|
+
/** @param {PagedCursor} cursor @returns {PagedClaim} */
|
|
124
|
+
function claimOf(cursor) {
|
|
125
|
+
const index = new Map(cursor.turns.map((t, i) => [t.id, i]));
|
|
126
|
+
const running = new Map(cursor.turns.flatMap((t, i) => RUNNING.includes(t.status) ? [[i, t.status]] : []));
|
|
127
|
+
const turn = id => id === null ? { kind: "none" } : index.has(id) ? { kind: "turn", index: index.get(id) } : { kind: "inline", id };
|
|
128
|
+
const count = cursor.turns.length;
|
|
129
|
+
return { v: 2, turn_commitment: { count, commitment: count ? commitment(cursor.turns, running) : null, running },
|
|
130
|
+
active_turn_id: cursor.active_turn_id, state: cursor.state, pending: cursor.pending,
|
|
131
|
+
head: { cursor: cursor.head.cursor, id: cursor.head.id, turn: turn(cursor.head.turn_id), hash: cursor.head.hash },
|
|
132
|
+
last_public: cursor.last_public,
|
|
133
|
+
groups: cursor.groups.map(g => ({ cursor: g.cursor, turn: turn(g.turn_id), count: g.count,
|
|
134
|
+
items: g.items.map(i => ({ index: i.index, id: i.id, turn: turn(i.turn_id), hash: i.hash, mode: i.mode })) })) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// v2 body: 0x02 flags count commitment? running active? state ids head last_public? groups.
|
|
138
|
+
// Flags: 1 pending, 2 active turn, 4 head id, 8 head hash, 16 last public.
|
|
139
|
+
function writePagedBody(w, claim, threadId) {
|
|
140
|
+
const { count, commitment, running } = claim.turn_commitment, { head } = claim;
|
|
141
|
+
const ids = [...new Set([head.id, claim.last_public, ...claim.groups.flatMap(g => g.items.map(i => i.id))].filter(id => id !== null))];
|
|
142
|
+
const idIndex = new Map(ids.map((id, i) => [id, i]));
|
|
143
|
+
w.byte(2);
|
|
144
|
+
w.byte((claim.pending ? 1 : 0) | (claim.active_turn_id !== null ? 2 : 0) | (head.id !== null ? 4 : 0) | (head.hash !== null ? 8 : 0) | (claim.last_public !== null ? 16 : 0));
|
|
145
|
+
w.uint(count);
|
|
146
|
+
if (count) w.hash(commitment);
|
|
147
|
+
w.uint(running.size);
|
|
148
|
+
let previous = -1;
|
|
149
|
+
for (const [index, status] of running) { w.uint(index - previous - 1); w.byte(RUNNING.indexOf(status)); previous = index; }
|
|
150
|
+
if (claim.active_turn_id !== null) writeId(w, claim.active_turn_id);
|
|
151
|
+
w.hash(claim.state);
|
|
152
|
+
w.uint(ids.length);
|
|
153
|
+
for (const id of ids) writeId(w, id);
|
|
154
|
+
writePosition(w, head.cursor, threadId);
|
|
155
|
+
if (head.id !== null) w.uint(idIndex.get(head.id));
|
|
156
|
+
writeTurn(w, head.turn);
|
|
157
|
+
if (head.hash !== null) w.hash(head.hash);
|
|
158
|
+
if (claim.last_public !== null) w.uint(idIndex.get(claim.last_public));
|
|
159
|
+
w.uint(claim.groups.length);
|
|
160
|
+
for (const group of claim.groups) {
|
|
161
|
+
writePosition(w, group.cursor, threadId); writeTurn(w, group.turn); w.uint(group.count); w.uint(group.items.length);
|
|
162
|
+
for (const item of group.items) {
|
|
163
|
+
// Most tracked items are the head itself, so their hash is one bit.
|
|
164
|
+
const same = item.hash === head.hash;
|
|
165
|
+
w.uint(item.index * 4 + (item.mode === "tail" ? 2 : 0) + (same ? 1 : 0)); w.uint(idIndex.get(item.id)); writeTurn(w, item.turn);
|
|
166
|
+
if (!same) w.hash(item.hash);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function readPagedBody(r, threadId) {
|
|
172
|
+
const flags = r.byte();
|
|
173
|
+
if (flags > 31) throw invalidCursor();
|
|
174
|
+
const count = r.uint(), commitment = count ? r.hash() : null, running = new Map();
|
|
175
|
+
for (let n = r.count(), index = -1; n > 0; n--) {
|
|
176
|
+
index += r.uint() + 1;
|
|
177
|
+
const status = RUNNING[r.byte()];
|
|
178
|
+
if (index >= count || !status) throw invalidCursor();
|
|
179
|
+
running.set(index, status);
|
|
180
|
+
}
|
|
181
|
+
const active_turn_id = flags & 2 ? readId(r) : null, state = r.hash(), ids = [];
|
|
182
|
+
for (let n = r.count(); n > 0; n--) ids.push(readId(r));
|
|
183
|
+
const id = () => { const k = r.uint(); if (k >= ids.length) throw invalidCursor(); return ids[k]; };
|
|
184
|
+
const head = { cursor: readPosition(r, threadId), id: flags & 4 ? id() : null, turn: readTurn(r, count), hash: flags & 8 ? r.hash() : null };
|
|
185
|
+
if (head.id !== null && head.cursor === null) throw invalidCursor();
|
|
186
|
+
const last_public = flags & 16 ? id() : null, groups = [];
|
|
187
|
+
for (let n = r.count(); n > 0; n--) {
|
|
188
|
+
const group = { cursor: readPosition(r, threadId), turn: readTurn(r, count), count: r.uint(), items: [] };
|
|
189
|
+
if (group.count < 1 || group.count > 100) throw invalidCursor();
|
|
190
|
+
for (let m = r.count(); m > 0; m--) {
|
|
191
|
+
const packed = r.uint(), index = Math.floor(packed / 4), same = packed % 2 === 1;
|
|
192
|
+
const item = { index, id: id(), turn: readTurn(r, count), hash: same ? head.hash : r.hash(), mode: packed % 4 >= 2 ? "tail" : "running" };
|
|
193
|
+
if (index >= group.count || item.hash === null || item.turn.kind === "none") throw invalidCursor();
|
|
194
|
+
group.items.push(item);
|
|
195
|
+
}
|
|
196
|
+
groups.push(group);
|
|
197
|
+
}
|
|
198
|
+
return { v: 2, turn_commitment: { count, commitment, running }, active_turn_id, state, pending: (flags & 1) === 1, head, last_public, groups };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// v1 body: 0x01 flags count open prefix state user_revision active? latest?
|
|
202
|
+
// Flags: 1 active turn, 2 latest turn. readSnapshot always sets every field.
|
|
203
|
+
function writeSnapshotBody(w, cursor) {
|
|
204
|
+
w.byte(1);
|
|
205
|
+
w.byte((cursor.active_turn_id !== null ? 1 : 0) | (cursor.latest_turn_id !== null ? 2 : 0));
|
|
206
|
+
w.uint(cursor.count); w.uint(cursor.open.length);
|
|
207
|
+
let previous = -1;
|
|
208
|
+
for (const { index, hash } of cursor.open) { w.uint(index - previous - 1); w.hash(hash); previous = index; }
|
|
209
|
+
w.hash(cursor.prefix); w.hash(cursor.state); w.hash(cursor.user_revision);
|
|
210
|
+
if (cursor.active_turn_id !== null) writeId(w, cursor.active_turn_id);
|
|
211
|
+
if (cursor.latest_turn_id !== null) writeId(w, cursor.latest_turn_id);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readSnapshotBody(r) {
|
|
215
|
+
const flags = r.byte();
|
|
216
|
+
if (flags > 3) throw invalidCursor();
|
|
217
|
+
const count = r.uint(), open = [];
|
|
218
|
+
for (let n = r.count(), index = -1; n > 0; n--) {
|
|
219
|
+
index += r.uint() + 1;
|
|
220
|
+
if (index >= count) throw invalidCursor();
|
|
221
|
+
open.push({ index, hash: r.hash() });
|
|
222
|
+
}
|
|
223
|
+
const prefix = r.hash(), state = r.hash(), user_revision = r.hash();
|
|
224
|
+
const active_turn_id = flags & 1 ? readId(r) : null, latest_turn_id = flags & 2 ? readId(r) : null;
|
|
225
|
+
return { v: 1, count, open, prefix, state, active_turn_id, latest_turn_id, user_revision };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// An id is a tag and a payload: 0 a UUID, 1 a short prefix and a UUID, 2 a prefix and lowercase hex, 3 text.
|
|
229
|
+
const ID_PARTS = /^([a-z]{1,16}[_-])?(.*)$/s;
|
|
230
|
+
const uuid = bytes => bytes.toString("hex").replace(/^(.{8})(.{4})(.{4})(.{4})/, "$1-$2-$3-$4-");
|
|
231
|
+
|
|
232
|
+
function writeId(w, id) {
|
|
233
|
+
if (UUID.test(id)) { w.byte(0); w.bytes(Buffer.from(id.replaceAll("-", ""), "hex")); return; }
|
|
234
|
+
const [, prefix = "", body] = ID_PARTS.exec(id);
|
|
235
|
+
if (prefix && UUID.test(body)) {
|
|
236
|
+
w.byte(1); w.text(prefix); w.bytes(Buffer.from(body.replaceAll("-", ""), "hex"));
|
|
237
|
+
} else if (/^(?:[0-9a-f]{2})+$/.test(body)) {
|
|
238
|
+
w.byte(2); w.text(prefix); w.uint(body.length / 2); w.bytes(Buffer.from(body, "hex"));
|
|
239
|
+
} else { w.byte(3); w.text(id); }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readId(r) {
|
|
243
|
+
const tag = r.byte();
|
|
244
|
+
if (tag === 0) return uuid(r.bytes(16));
|
|
245
|
+
if (tag === 1) return r.text() + uuid(r.bytes(16));
|
|
246
|
+
if (tag === 2) return r.text() + r.bytes(r.uint()).toString("hex");
|
|
247
|
+
if (tag === 3) return r.text();
|
|
248
|
+
throw invalidCursor();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// App Server positions are kept byte for byte. The known form is stored as an
|
|
252
|
+
// ordinal only when rendering it back reproduces the server's exact string, so
|
|
253
|
+
// codexteer never synthesizes a position and a format change only costs size.
|
|
254
|
+
// Tags: 0 null, 1 known form with includeAnchor true, 2 with false, 3 the exact text.
|
|
255
|
+
const position = (threadId, rolloutOrdinal, includeAnchor) => JSON.stringify({ requestedThreadId: threadId, rolloutOrdinal, includeAnchor, scope: { kind: "itemsByCreatedAtOrdinal" } });
|
|
256
|
+
|
|
257
|
+
function writePosition(w, value, threadId) {
|
|
258
|
+
if (value === null) return w.byte(0);
|
|
259
|
+
let known = null;
|
|
260
|
+
try { known = JSON.parse(value); } catch { /* Not JSON, so it is stored as text. */ }
|
|
261
|
+
if (Number.isSafeInteger(known?.rolloutOrdinal) && known.rolloutOrdinal >= 0 && typeof known.includeAnchor === "boolean"
|
|
262
|
+
&& position(threadId, known.rolloutOrdinal, known.includeAnchor) === value) {
|
|
263
|
+
w.byte(known.includeAnchor ? 1 : 2); w.uint(known.rolloutOrdinal);
|
|
264
|
+
} else { w.byte(3); w.text(value); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readPosition(r, threadId) {
|
|
268
|
+
const tag = r.byte();
|
|
269
|
+
if (tag === 0) return null;
|
|
270
|
+
if (tag === 1 || tag === 2) return position(threadId, r.uint(), tag === 1);
|
|
271
|
+
if (tag !== 3) throw invalidCursor();
|
|
272
|
+
const value = r.text();
|
|
273
|
+
if (value.length >= MAX_POSITION) throw invalidCursor();
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A turn reference: 0 none, 1 an inline id, k + 2 the committed turn k.
|
|
278
|
+
function writeTurn(w, ref) {
|
|
279
|
+
if (ref.kind === "none") w.uint(0);
|
|
280
|
+
else if (ref.kind === "inline") { w.uint(1); writeId(w, ref.id); }
|
|
281
|
+
else w.uint(ref.index + 2);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readTurn(r, count) {
|
|
285
|
+
const n = r.uint();
|
|
286
|
+
if (n === 0) return { kind: "none" };
|
|
287
|
+
if (n === 1) return { kind: "inline", id: readId(r) };
|
|
288
|
+
if (n - 2 >= count) throw invalidCursor();
|
|
289
|
+
return { kind: "turn", index: n - 2 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The earlier format, base64url(JSON). These are the 0.19 decoder's checks, plus
|
|
293
|
+
// lowercase-hex v1 hashes, which every earlier encoder wrote and the v1 encoder needs.
|
|
294
|
+
function parseLegacy(token, threadId) {
|
|
295
|
+
const c = JSON.parse(Buffer.from(token, "base64url").toString());
|
|
296
|
+
if ((c.host_id ?? "local") !== targetHost()) throw invalidCursor();
|
|
297
|
+
if (targetHost() !== "local" && (c.remote_transport ?? "native") !== remoteTransport()) throw invalidCursor();
|
|
298
|
+
if (c?.v === 2) {
|
|
299
|
+
const hash = x => x === null || (typeof x === "string" && HASH.test(x));
|
|
300
|
+
const pos = x => x === null || (typeof x === "string" && x.length < MAX_POSITION);
|
|
301
|
+
if (c.thread_id !== threadId || typeof c.state !== "string" || !Array.isArray(c.turns) || !Array.isArray(c.groups)
|
|
302
|
+
|| !c.turns.every(t => typeof t.id === "string" && typeof t.status === "string")
|
|
303
|
+
|| new Set(c.turns.map(t => t.id)).size !== c.turns.length
|
|
304
|
+
|| !c.head || !pos(c.head.cursor) || !hash(c.head.hash) || !(c.head.id === null || typeof c.head.id === "string")
|
|
305
|
+
|| !(c.head.turn_id === null || typeof c.head.turn_id === "string") || (c.head.id !== null && c.head.cursor === null)
|
|
306
|
+
|| !(c.last_public === null || typeof c.last_public === "string")
|
|
307
|
+
|| !(c.active_turn_id === null || typeof c.active_turn_id === "string") || typeof c.pending !== "boolean"
|
|
308
|
+
|| !c.groups.every(g => pos(g.cursor) && (g.turn_id === null || typeof g.turn_id === "string") && Number.isInteger(g.count) && g.count > 0 && g.count <= 100
|
|
309
|
+
&& Array.isArray(g.items) && g.items.every(i => Number.isInteger(i.index) && i.index >= 0 && i.index < g.count && typeof i.id === "string" && typeof i.turn_id === "string" && hash(i.hash) && ["running", "tail"].includes(i.mode)))) throw invalidCursor();
|
|
310
|
+
return claimOf(c);
|
|
311
|
+
}
|
|
312
|
+
if (c.v !== 1 || c.thread_id !== threadId || !Number.isSafeInteger(c.count) || c.count < 0 || !Array.isArray(c.open)
|
|
313
|
+
|| !c.open.every(x => Number.isSafeInteger(x.index) && x.index >= 0 && x.index < c.count && typeof x.hash === "string" && HASH.test(x.hash))
|
|
314
|
+
|| new Set(c.open.map(x => x.index)).size !== c.open.length || typeof c.prefix !== "string"
|
|
315
|
+
|| !(c.latest_turn_id === undefined || c.latest_turn_id === null || (typeof c.latest_turn_id === "string" && c.latest_turn_id))) throw invalidCursor();
|
|
316
|
+
return c;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
class Writer {
|
|
320
|
+
#parts = [];
|
|
321
|
+
byte(value) { this.#parts.push(Buffer.of(value)); }
|
|
322
|
+
// Every integer in a cursor is a count, an index or an ordinal. Anything else is a bug that must not be written.
|
|
323
|
+
uint(value) {
|
|
324
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error("A cursor integer must be a non-negative safe integer.");
|
|
325
|
+
const out = [];
|
|
326
|
+
for (; value >= 128; value = Math.floor(value / 128)) out.push(value % 128 | 128);
|
|
327
|
+
out.push(value);
|
|
328
|
+
this.#parts.push(Buffer.from(out));
|
|
329
|
+
}
|
|
330
|
+
bytes(value) { this.#parts.push(value); }
|
|
331
|
+
hash(hex) {
|
|
332
|
+
if (!HASH.test(hex)) throw new Error("A cursor hash must be 64 lowercase hex characters.");
|
|
333
|
+
this.#parts.push(Buffer.from(hex, "hex"));
|
|
334
|
+
}
|
|
335
|
+
// UTF-8 cannot carry a lone surrogate, so such text is stored as its JSON spelling, marked by the low bit.
|
|
336
|
+
text(value) {
|
|
337
|
+
const json = !value.isWellFormed(), bytes = Buffer.from(json ? JSON.stringify(value) : value);
|
|
338
|
+
this.uint(bytes.length * 2 + (json ? 1 : 0)); this.bytes(bytes);
|
|
339
|
+
}
|
|
340
|
+
done() { return Buffer.concat(this.#parts); }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Every read is bounded by the buffer: a length or count larger than the bytes left fails before any allocation.
|
|
344
|
+
class Reader {
|
|
345
|
+
#buffer; #at = 0;
|
|
346
|
+
constructor(buffer) { this.#buffer = buffer; }
|
|
347
|
+
get #left() { return this.#buffer.length - this.#at; }
|
|
348
|
+
byte() { if (!this.#left) throw invalidCursor(); return this.#buffer[this.#at++]; }
|
|
349
|
+
uint() {
|
|
350
|
+
let value = 0;
|
|
351
|
+
for (let k = 0, scale = 1; k < 8; k++, scale *= 128) {
|
|
352
|
+
const x = this.byte();
|
|
353
|
+
value += (x & 127) * scale;
|
|
354
|
+
if (x < 128) { if (!Number.isSafeInteger(value)) throw invalidCursor(); return value; }
|
|
355
|
+
}
|
|
356
|
+
throw invalidCursor();
|
|
357
|
+
}
|
|
358
|
+
// Each counted element takes at least one byte.
|
|
359
|
+
count() { const n = this.uint(); if (n > this.#left) throw invalidCursor(); return n; }
|
|
360
|
+
bytes(n) { if (n > this.#left) throw invalidCursor(); return this.#buffer.subarray(this.#at, this.#at += n); }
|
|
361
|
+
hash() { return this.bytes(32).toString("hex"); }
|
|
362
|
+
text() {
|
|
363
|
+
const n = this.uint(), raw = this.bytes(Math.floor(n / 2)).toString();
|
|
364
|
+
if (n % 2 === 0) return raw;
|
|
365
|
+
const value = JSON.parse(raw);
|
|
366
|
+
if (typeof value !== "string") throw invalidCursor();
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
end() { if (this.#left) throw invalidCursor(); }
|
|
370
|
+
}
|
package/src/desktop-cloud-id.mjs
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { normalizeThreadId } from "./thread-id.mjs";
|
|
2
|
+
import { isCloudTask } from "./cloud-id.mjs";
|
|
3
|
+
import { connectionMismatch } from "./connection-error.mjs";
|
|
2
4
|
export function isDesktopCloudTask(value) {
|
|
3
5
|
if (typeof value !== "string") return false;
|
|
4
6
|
if (value.startsWith("durable_")) return true;
|
|
5
7
|
try { const u = new URL(value); return u.protocol === "codex:" && u.hostname === "threads" && u.searchParams.getAll("hostId").includes("durable"); } catch { return false; }
|
|
6
8
|
}
|
|
7
9
|
export function desktopCloudId(value) {
|
|
8
|
-
if (!isDesktopCloudTask(value))
|
|
10
|
+
if (!isDesktopCloudTask(value)) {
|
|
11
|
+
if (isCloudTask(value)) throw connectionMismatch("cloud", "desktop-cloud");
|
|
12
|
+
let local = false;
|
|
13
|
+
try { normalizeThreadId(value); local = true; } catch {}
|
|
14
|
+
if (local) throw connectionMismatch("shared", "desktop-cloud");
|
|
15
|
+
throw Object.assign(new Error("Desktop Cloud requires a durable_ ID or a link with hostId=durable."), { code: "CLOUD_TARGET_CONFLICT" });
|
|
16
|
+
}
|
|
9
17
|
let uuid = value.slice(8);
|
|
10
18
|
if (!value.startsWith("durable_")) {
|
|
11
19
|
const url = new URL(value);
|
|
@@ -13,6 +13,7 @@ import { listProjects } from "./projects.mjs";
|
|
|
13
13
|
import { listRecords, readRecord, writeRecord, withStoreLock, storePath } from "./store.mjs";
|
|
14
14
|
const failure = (message, code = "DESKTOP_CLOUD_PROJECT_UNAVAILABLE", uncertain = false) => Object.assign(new Error(message), { code, uncertain });
|
|
15
15
|
const sha = value => createHash("sha256").update(value).digest("hex");
|
|
16
|
+
const shows = (state, projectId) => state?.assignment?.projectKind === "local" && state.assignment.projectId === projectId && state.host_id === "durable";
|
|
16
17
|
|
|
17
18
|
export async function discoverProjectApi({ discover = discoverRuntime, endpoint = discoverNativeEndpoint, run = spawnSync } = {}) {
|
|
18
19
|
const selected = await discover(), { state } = selected;
|
|
@@ -100,7 +101,7 @@ export async function desktopCloudProjectDoctor(target, selector, { connect = co
|
|
|
100
101
|
const state = await readState(api.paths.home, id);
|
|
101
102
|
report.membership = { status: state.assignment?.projectKind === "local" ? (state.host_id === "durable" ? "registered" : "recorded_host_unverified") : state.projectless ? "projectless" : "source_unknown",
|
|
102
103
|
project_id: state.assignment?.projectId ?? null, host_id: state.host_id, source: "read-only-desktop-profile-state" };
|
|
103
|
-
if (selected) report.membership.matches_selected_project = state
|
|
104
|
+
if (selected) report.membership.matches_selected_project = shows(state, selected.project_id);
|
|
104
105
|
return report;
|
|
105
106
|
} finally { try { client?.close(); } catch { /* Preserve diagnostics. */ } }
|
|
106
107
|
}
|
|
@@ -109,7 +110,9 @@ export async function assignDesktopCloudProject(target, selector, { connect = co
|
|
|
109
110
|
const id = desktopCloudId(target);
|
|
110
111
|
if (typeof selector !== "string" || !selector.trim()) throw failure("--project is required.", "PROJECT_REQUIRED");
|
|
111
112
|
return withStoreLock(`desktop-cloud-project-${id}`, async () => {
|
|
112
|
-
|
|
113
|
+
const pending = (await confirmFromState(id, (await listRecords("desktop-cloud-projects", storage)).filter(row => row.thread_id === id && row.delivery_status === "unknown"), readState, storage)).find(row => !row.resolution);
|
|
114
|
+
if (pending) throw Object.assign(failure(`An earlier assignment is unknown and is never replayed. Run thread receipt ${pending.attempt_id} --connection desktop-cloud to recheck Desktop's profile state; if it stays unknown, the user (not a supervisor) can release it with --resolve-unknown --note TEXT.`, "DESKTOP_CLOUD_PROJECT_PENDING"),
|
|
115
|
+
{ existing_attempt_id: pending.attempt_id, existing_receipt_path: pending.receipt_path });
|
|
113
116
|
let client, receipt, api, dispatching = false;
|
|
114
117
|
try {
|
|
115
118
|
client = await connect(); const environmentId = await targetEnvironment(client, id); api = await discoverApi();
|
|
@@ -129,7 +132,7 @@ export async function assignDesktopCloudProject(target, selector, { connect = co
|
|
|
129
132
|
receipt.delivery_status = "accepted"; receipt.proof_sha256 = sha(response.payload);
|
|
130
133
|
await writeRecord("desktop-cloud-projects", attempt, receipt, storage); await check(api);
|
|
131
134
|
const stored = await readState(api.paths.home, id);
|
|
132
|
-
receipt.verification_status = stored
|
|
135
|
+
receipt.verification_status = shows(stored, selected.project_id) ? "verified" : "not_observed";
|
|
133
136
|
await writeRecord("desktop-cloud-projects", attempt, receipt, storage); return receipt;
|
|
134
137
|
} catch (error) {
|
|
135
138
|
if (receipt) {
|
|
@@ -141,8 +144,38 @@ export async function assignDesktopCloudProject(target, selector, { connect = co
|
|
|
141
144
|
} finally { try { client?.close(); } catch { /* Preserve diagnostics. */ } }
|
|
142
145
|
}, storage);
|
|
143
146
|
}
|
|
144
|
-
|
|
147
|
+
// Absence from Desktop state never proves the assignment was not applied, so
|
|
148
|
+
// only a positive observation rewrites an unknown attempt.
|
|
149
|
+
async function confirmFromState(id, rows, readState, storage) {
|
|
150
|
+
if (!rows.length) return rows;
|
|
151
|
+
let state; try { state = await readState(storage.home ?? codexHome(), id); } catch { return rows; }
|
|
152
|
+
const remaining = [];
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
if (!shows(state, row.project_id)) { remaining.push(row); continue; }
|
|
155
|
+
await writeRecord("desktop-cloud-projects", row.attempt_id, { ...row, delivery_status: "accepted", verification_status: "verified", delivery_verification: "desktop-profile-state", verified_at: new Date().toISOString() }, storage);
|
|
156
|
+
}
|
|
157
|
+
return remaining;
|
|
158
|
+
}
|
|
159
|
+
async function readAttempt(attempt, storage) {
|
|
145
160
|
if (typeof attempt !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(attempt)) throw failure("Invalid assignment attempt ID.");
|
|
146
161
|
const row = await readRecord("desktop-cloud-projects", attempt, storage);
|
|
147
162
|
if (!row || row.type !== "desktop-cloud-project" || row.attempt_id !== attempt) throw failure("Assignment receipt not found."); return row;
|
|
148
163
|
}
|
|
164
|
+
export async function desktopCloudProjectReceipt(attempt, { readState = readDesktopProjectState, ...storage } = {}) {
|
|
165
|
+
const row = await readAttempt(attempt, storage);
|
|
166
|
+
if (row.delivery_status !== "unknown") return row;
|
|
167
|
+
return withStoreLock(`desktop-cloud-project-${row.thread_id}`, async () => {
|
|
168
|
+
await confirmFromState(row.thread_id, [await readAttempt(attempt, storage)].filter(current => current.delivery_status === "unknown"), readState, storage);
|
|
169
|
+
return readAttempt(attempt, storage);
|
|
170
|
+
}, storage);
|
|
171
|
+
}
|
|
172
|
+
export async function resolveDesktopCloudProjectAttempt(attempt, { note, ...storage } = {}) {
|
|
173
|
+
if (typeof note !== "string" || !note.trim()) throw failure("--resolve-unknown requires a non-empty --note.", "DESKTOP_CLOUD_OPTIONS_INVALID");
|
|
174
|
+
const { thread_id: id } = await readAttempt(attempt, storage);
|
|
175
|
+
return withStoreLock(`desktop-cloud-project-${id}`, async () => {
|
|
176
|
+
const row = await readAttempt(attempt, storage);
|
|
177
|
+
if (row.delivery_status !== "unknown" || row.resolution) throw failure("This assignment attempt has no unresolved unknown delivery.", "DESKTOP_CLOUD_OPTIONS_INVALID");
|
|
178
|
+
const updated = { ...row, resolution: { by: "user", at: new Date().toISOString(), note } };
|
|
179
|
+
await writeRecord("desktop-cloud-projects", attempt, updated, storage); return updated;
|
|
180
|
+
}, storage);
|
|
181
|
+
}
|