@egoistmachines/opencode-switchboard 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/config.schema.json +81 -0
- package/package.json +38 -0
- package/src/cli.js +41 -0
- package/src/config.js +82 -0
- package/src/context.js +203 -0
- package/src/host.js +11 -0
- package/src/hostedTransport.js +271 -0
- package/src/index.js +12 -0
- package/src/localTransport.js +213 -0
- package/src/outcomes.js +104 -0
- package/src/plugin.js +223 -0
- package/src/shared/atomicFile.js +32 -0
- package/src/shared/cache.js +34 -0
- package/src/shared/cli.js +103 -0
- package/src/shared/client.js +195 -0
- package/src/shared/config.js +105 -0
- package/src/shared/credentials.js +326 -0
- package/src/shared/format.js +88 -0
- package/src/shared/policy.js +599 -0
- package/src/shared/transport.js +256 -0
- package/src/status.js +186 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { GOVERNED_CATEGORIES } from "./config.js";
|
|
4
|
+
import { OPENCODE_HOST_METADATA } from "./host.js";
|
|
5
|
+
import { unavailableProposal, unavailableRead, withInternalReason } from "./outcomes.js";
|
|
6
|
+
import { createTtlCache } from "./shared/cache.js";
|
|
7
|
+
import { createCredentials } from "./shared/credentials.js";
|
|
8
|
+
import { createPlaneTransport } from "./shared/transport.js";
|
|
9
|
+
|
|
10
|
+
const QUERY_MAX_LENGTH = 256;
|
|
11
|
+
const SESSION_KEY_MAX_LENGTH = 128;
|
|
12
|
+
const LIMIT_MAX = 50;
|
|
13
|
+
const PREFETCH_BUDGET_MAX = 30;
|
|
14
|
+
const DEFAULT_CACHE_TTL_MS = 60_000;
|
|
15
|
+
const SKIP_REASONS = new Set(["no_pass", "once_only", "locked"]);
|
|
16
|
+
|
|
17
|
+
const boundedText = (value, max, collapseWhitespace = false) => {
|
|
18
|
+
if (typeof value !== "string") return null;
|
|
19
|
+
const normalized = collapseWhitespace ? value.replace(/\s+/g, " ").trim() : value.trim();
|
|
20
|
+
return normalized ? normalized.slice(0, max) : null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const boundedLimit = (value, fallback = 20) => {
|
|
24
|
+
const candidate = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : fallback;
|
|
25
|
+
return Math.min(LIMIT_MAX, Math.max(1, candidate));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const nullableString = (value) => value === null || typeof value === "string";
|
|
29
|
+
|
|
30
|
+
function normalizeRow(row) {
|
|
31
|
+
if (
|
|
32
|
+
!row ||
|
|
33
|
+
typeof row !== "object" ||
|
|
34
|
+
Array.isArray(row) ||
|
|
35
|
+
typeof row.memory_id !== "string" ||
|
|
36
|
+
typeof row.content !== "string" ||
|
|
37
|
+
!nullableString(row.source) ||
|
|
38
|
+
!nullableString(row.created_at) ||
|
|
39
|
+
!nullableString(row.occurred_at) ||
|
|
40
|
+
!GOVERNED_CATEGORIES.includes(row.category) ||
|
|
41
|
+
!nullableString(row.client_id) ||
|
|
42
|
+
!nullableString(row.evidence_basis) ||
|
|
43
|
+
!nullableString(row.record_kind) ||
|
|
44
|
+
!nullableString(row.verified_issuer) ||
|
|
45
|
+
!nullableString(row.verified_at)
|
|
46
|
+
) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
memory_id: row.memory_id,
|
|
51
|
+
content: row.content,
|
|
52
|
+
source: row.source,
|
|
53
|
+
created_at: row.created_at,
|
|
54
|
+
occurred_at: row.occurred_at,
|
|
55
|
+
category: row.category,
|
|
56
|
+
client_id: row.client_id,
|
|
57
|
+
evidence_basis: row.evidence_basis,
|
|
58
|
+
record_kind: row.record_kind,
|
|
59
|
+
verified_issuer: row.verified_issuer,
|
|
60
|
+
verified_at: row.verified_at,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeHostedPrefetch(payload, { categories, asOf }) {
|
|
65
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
66
|
+
if (!Array.isArray(payload.rows) || !Array.isArray(payload.skipped_categories)) return null;
|
|
67
|
+
|
|
68
|
+
const requested = new Set(categories);
|
|
69
|
+
const rows = payload.rows.map(normalizeRow);
|
|
70
|
+
if (rows.some((row) => !row) || rows.some((row) => !requested.has(row.category))) return null;
|
|
71
|
+
|
|
72
|
+
const skippedCategories = payload.skipped_categories.map((entry) => {
|
|
73
|
+
if (
|
|
74
|
+
!entry ||
|
|
75
|
+
typeof entry !== "object" ||
|
|
76
|
+
Array.isArray(entry) ||
|
|
77
|
+
!requested.has(entry.category) ||
|
|
78
|
+
!SKIP_REASONS.has(entry.reason)
|
|
79
|
+
) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return { category: entry.category, reason: entry.reason };
|
|
83
|
+
});
|
|
84
|
+
if (skippedCategories.some((entry) => !entry)) return null;
|
|
85
|
+
|
|
86
|
+
let status = "empty";
|
|
87
|
+
if (rows.length) status = "results";
|
|
88
|
+
else if (new Set(skippedCategories.map((entry) => entry.category)).size === requested.size) {
|
|
89
|
+
status = skippedCategories.every((entry) => entry.reason === "locked") ? "locked" : "blocked";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
status,
|
|
94
|
+
transport: "hosted",
|
|
95
|
+
connectivity: "online",
|
|
96
|
+
freshness: "fresh",
|
|
97
|
+
as_of: asOf,
|
|
98
|
+
rows,
|
|
99
|
+
skipped_categories: skippedCategories,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function requireOwnerOnlyMode(credentialsPath) {
|
|
104
|
+
const metadata = await stat(credentialsPath);
|
|
105
|
+
if ((metadata.mode & 0o077) !== 0) {
|
|
106
|
+
const error = new Error("unsafe_hosted_credentials_mode");
|
|
107
|
+
error.code = "unsafe_hosted_credentials_mode";
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function validateHostedCredentialsFile(credentialsPath) {
|
|
113
|
+
await requireOwnerOnlyMode(credentialsPath);
|
|
114
|
+
const record = JSON.parse(await readFile(credentialsPath, "utf8"));
|
|
115
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("invalid_hosted_credentials");
|
|
116
|
+
for (const field of ["token_url", "client_id", "refresh_token"]) {
|
|
117
|
+
if (typeof record[field] !== "string" || !record[field].trim()) throw new Error("invalid_hosted_credentials");
|
|
118
|
+
}
|
|
119
|
+
const tokenUrl = new URL(record.token_url);
|
|
120
|
+
if (tokenUrl.protocol !== "https:" && tokenUrl.protocol !== "http:") throw new Error("invalid_hosted_credentials");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function guardedCredentials(credentials, credentialsPath) {
|
|
124
|
+
if (!credentialsPath) return credentials;
|
|
125
|
+
return {
|
|
126
|
+
async baseUrl() {
|
|
127
|
+
await validateHostedCredentialsFile(credentialsPath);
|
|
128
|
+
return credentials.baseUrl();
|
|
129
|
+
},
|
|
130
|
+
async accessToken(options) {
|
|
131
|
+
await validateHostedCredentialsFile(credentialsPath);
|
|
132
|
+
return credentials.accessToken(options);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function unavailableReason(state) {
|
|
138
|
+
if (state.backoffReason === "forbidden") return "hosted_plane_closed";
|
|
139
|
+
if (state.terminalReason) return `hosted_credentials_${state.terminalReason}`;
|
|
140
|
+
if (state.backoffReason) return `hosted_${state.backoffReason}`;
|
|
141
|
+
return "hosted_unavailable";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createHostedTransport({
|
|
145
|
+
credentialsPath,
|
|
146
|
+
timeoutMs = 1500,
|
|
147
|
+
cacheTtlMs = DEFAULT_CACHE_TTL_MS,
|
|
148
|
+
fetchImpl = globalThis.fetch,
|
|
149
|
+
now = () => Date.now(),
|
|
150
|
+
logger = null,
|
|
151
|
+
credentials: suppliedCredentials = null,
|
|
152
|
+
} = {}) {
|
|
153
|
+
const rawCredentials =
|
|
154
|
+
suppliedCredentials ??
|
|
155
|
+
createCredentials({
|
|
156
|
+
credentialsPath,
|
|
157
|
+
fetchImpl,
|
|
158
|
+
now,
|
|
159
|
+
logger,
|
|
160
|
+
hostMetadata: OPENCODE_HOST_METADATA,
|
|
161
|
+
});
|
|
162
|
+
const credentials = guardedCredentials(rawCredentials, suppliedCredentials ? null : credentialsPath);
|
|
163
|
+
const plane = createPlaneTransport({
|
|
164
|
+
config: { baseUrl: null },
|
|
165
|
+
credentials,
|
|
166
|
+
fetchImpl,
|
|
167
|
+
logger,
|
|
168
|
+
now,
|
|
169
|
+
label: "prefetch",
|
|
170
|
+
budgetMax: PREFETCH_BUDGET_MAX,
|
|
171
|
+
});
|
|
172
|
+
const cache = createTtlCache({ ttlMs: cacheTtlMs, now });
|
|
173
|
+
const pending = new Map();
|
|
174
|
+
let lastOutcome = null;
|
|
175
|
+
let lastReason = null;
|
|
176
|
+
|
|
177
|
+
const read = async (input = {}) => {
|
|
178
|
+
const categories = Array.isArray(input.categories)
|
|
179
|
+
? [...new Set(input.categories.filter((category) => GOVERNED_CATEGORIES.includes(category)))]
|
|
180
|
+
: [];
|
|
181
|
+
if (!categories.length) {
|
|
182
|
+
lastOutcome = "unavailable";
|
|
183
|
+
lastReason = "invalid_contract";
|
|
184
|
+
return unavailableRead("hosted", lastReason);
|
|
185
|
+
}
|
|
186
|
+
const query = boundedText(input.query, QUERY_MAX_LENGTH, true);
|
|
187
|
+
const sessionKey = boundedText(input.session_id ?? input.session_key, SESSION_KEY_MAX_LENGTH);
|
|
188
|
+
const limit = boundedLimit(input.limit);
|
|
189
|
+
const key = JSON.stringify([categories, limit, query ?? ""]);
|
|
190
|
+
const cached = cache.get(key);
|
|
191
|
+
if (cached.hit && cached.fresh) {
|
|
192
|
+
lastOutcome = cached.value.status;
|
|
193
|
+
lastReason = null;
|
|
194
|
+
return structuredClone(cached.value);
|
|
195
|
+
}
|
|
196
|
+
if (pending.has(key)) return pending.get(key);
|
|
197
|
+
|
|
198
|
+
const attempt = (async () => {
|
|
199
|
+
const unavailableOrStale = (reason) => {
|
|
200
|
+
lastReason = reason;
|
|
201
|
+
if (cached.hit) {
|
|
202
|
+
const stale = {
|
|
203
|
+
...structuredClone(cached.value),
|
|
204
|
+
connectivity: "offline",
|
|
205
|
+
freshness: "stale",
|
|
206
|
+
};
|
|
207
|
+
lastOutcome = stale.status;
|
|
208
|
+
return withInternalReason(stale, reason);
|
|
209
|
+
}
|
|
210
|
+
lastOutcome = "unavailable";
|
|
211
|
+
return unavailableRead("hosted", reason);
|
|
212
|
+
};
|
|
213
|
+
const payload = await plane.request({
|
|
214
|
+
path: "/agent/prefetch",
|
|
215
|
+
method: "POST",
|
|
216
|
+
body: {
|
|
217
|
+
categories,
|
|
218
|
+
...(query ? { query } : {}),
|
|
219
|
+
...(sessionKey ? { session_key: sessionKey } : {}),
|
|
220
|
+
limit,
|
|
221
|
+
},
|
|
222
|
+
timeoutMs: typeof input.timeoutMs === "number" ? input.timeoutMs : timeoutMs,
|
|
223
|
+
});
|
|
224
|
+
if (!payload) {
|
|
225
|
+
return unavailableOrStale(unavailableReason(plane.state()));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const asOf = new Date(now()).toISOString();
|
|
229
|
+
const normalized = normalizeHostedPrefetch(payload, { categories, asOf });
|
|
230
|
+
if (!normalized) {
|
|
231
|
+
return unavailableOrStale("invalid_contract");
|
|
232
|
+
}
|
|
233
|
+
cache.set(key, normalized);
|
|
234
|
+
lastOutcome = normalized.status;
|
|
235
|
+
lastReason = null;
|
|
236
|
+
return structuredClone(normalized);
|
|
237
|
+
})().finally(() => pending.delete(key));
|
|
238
|
+
pending.set(key, attempt);
|
|
239
|
+
return attempt;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
async status() {
|
|
244
|
+
let paired = Boolean(suppliedCredentials);
|
|
245
|
+
if (!paired && credentialsPath) {
|
|
246
|
+
try {
|
|
247
|
+
await validateHostedCredentialsFile(credentialsPath);
|
|
248
|
+
paired = true;
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
transport: "hosted",
|
|
253
|
+
paired,
|
|
254
|
+
lastOutcome,
|
|
255
|
+
lastReason,
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
prefetch: read,
|
|
259
|
+
recall(input = {}) {
|
|
260
|
+
return read({ ...input, ambient: false });
|
|
261
|
+
},
|
|
262
|
+
propose(input = {}) {
|
|
263
|
+
lastOutcome = "unavailable";
|
|
264
|
+
lastReason = "hosted_propose_unsupported";
|
|
265
|
+
return Promise.resolve(unavailableProposal("hosted", lastReason, input.save_id));
|
|
266
|
+
},
|
|
267
|
+
__state() {
|
|
268
|
+
return { ...plane.state(), cacheSize: cache.size, lastOutcome, lastReason };
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
import { createPassportHooks } from "./plugin.js";
|
|
4
|
+
|
|
5
|
+
// This is the only module that imports the host. Runtime logic remains
|
|
6
|
+
// testable without installing OpenCode or its plugin package.
|
|
7
|
+
export const AIPassportPlugin = async (input, options = {}) =>
|
|
8
|
+
(await createPassportHooks({
|
|
9
|
+
rawConfig: options,
|
|
10
|
+
tool,
|
|
11
|
+
project: typeof input?.directory === "string" ? input.directory : null,
|
|
12
|
+
})).hooks;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { normalizeProposalOutcome, normalizeReadOutcome, unavailableProposal, unavailableRead } from "./outcomes.js";
|
|
6
|
+
|
|
7
|
+
const MAX_STDOUT_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function normalizeHandoffClaimOutcome(value) {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
11
|
+
if (value.status === "none_pending") {
|
|
12
|
+
return { status: "none_pending", handoff_id: null, snapshot: null, expires_at: null };
|
|
13
|
+
}
|
|
14
|
+
if (value.status === "claimed") {
|
|
15
|
+
if (
|
|
16
|
+
typeof value.handoff_id !== "string" ||
|
|
17
|
+
!value.handoff_id ||
|
|
18
|
+
typeof value.snapshot !== "string" ||
|
|
19
|
+
!value.snapshot ||
|
|
20
|
+
typeof value.expires_at !== "string" ||
|
|
21
|
+
!value.expires_at
|
|
22
|
+
) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
status: "claimed",
|
|
27
|
+
handoff_id: value.handoff_id,
|
|
28
|
+
snapshot: value.snapshot,
|
|
29
|
+
expires_at: value.expires_at,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (value.status === "expired") {
|
|
33
|
+
if (
|
|
34
|
+
typeof value.handoff_id !== "string" ||
|
|
35
|
+
!value.handoff_id ||
|
|
36
|
+
typeof value.expires_at !== "string" ||
|
|
37
|
+
!value.expires_at
|
|
38
|
+
) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return { status: "expired", handoff_id: value.handoff_id, snapshot: null, expires_at: value.expires_at };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const unavailableHandoffClaim = (_transport, reason) => ({
|
|
47
|
+
status: "unavailable",
|
|
48
|
+
handoff_id: null,
|
|
49
|
+
snapshot: null,
|
|
50
|
+
expires_at: null,
|
|
51
|
+
internalReason: reason,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function readJson(filePath) {
|
|
55
|
+
const body = await readFile(filePath, "utf8");
|
|
56
|
+
const value = JSON.parse(body);
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json_object");
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function loadRuntime(discoveryPath) {
|
|
62
|
+
const record = await readJson(discoveryPath);
|
|
63
|
+
if (typeof record.bin !== "string" || !path.isAbsolute(record.bin)) throw new Error("invalid_discovery_record");
|
|
64
|
+
return record.bin;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadCredentials(credentialsPath) {
|
|
68
|
+
const metadata = await stat(credentialsPath);
|
|
69
|
+
if ((metadata.mode & 0o077) !== 0) throw new Error("unsafe_credentials_mode");
|
|
70
|
+
const record = await readJson(credentialsPath);
|
|
71
|
+
const secret = typeof record.client_secret === "string" ? record.client_secret : record.secret;
|
|
72
|
+
if (typeof record.client_id !== "string" || !record.client_id || typeof secret !== "string" || !secret) {
|
|
73
|
+
throw new Error("invalid_credentials");
|
|
74
|
+
}
|
|
75
|
+
return { client_id: record.client_id, client_secret: secret };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runJsonCommand({ bin, command, payload, timeoutMs, spawnImpl }) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
let settled = false;
|
|
81
|
+
let stdout = "";
|
|
82
|
+
const finish = (result) => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
resolve(result);
|
|
87
|
+
};
|
|
88
|
+
let child;
|
|
89
|
+
try {
|
|
90
|
+
child = spawnImpl(bin, [command, "--json"], {
|
|
91
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
resolve({ ok: false, reason: "spawn_failed" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
child.kill("SIGKILL");
|
|
100
|
+
finish({ ok: false, reason: "deadline_exceeded" });
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
timer.unref?.();
|
|
103
|
+
|
|
104
|
+
child.once("error", () => finish({ ok: false, reason: "spawn_failed" }));
|
|
105
|
+
child.stdout.setEncoding("utf8");
|
|
106
|
+
child.stdout.on("data", (chunk) => {
|
|
107
|
+
stdout += chunk;
|
|
108
|
+
if (Buffer.byteLength(stdout) > MAX_STDOUT_BYTES) {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
finish({ ok: false, reason: "stdout_too_large" });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
child.once("close", (code) => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
if (code !== 0) {
|
|
116
|
+
finish({ ok: false, reason: "nonzero_exit" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(stdout);
|
|
121
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid_stdout");
|
|
122
|
+
finish({ ok: true, payload: parsed });
|
|
123
|
+
} catch {
|
|
124
|
+
finish({ ok: false, reason: "malformed_stdout" });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
child.stdin.once("error", () => finish({ ok: false, reason: "stdin_failed" }));
|
|
128
|
+
child.stdin.end(`${JSON.stringify(payload)}\n`);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createLocalTransport({
|
|
133
|
+
discoveryPath,
|
|
134
|
+
credentialsPath,
|
|
135
|
+
timeoutMs = 1500,
|
|
136
|
+
spawnImpl = nodeSpawn,
|
|
137
|
+
} = {}) {
|
|
138
|
+
let lastOutcome = null;
|
|
139
|
+
let lastReason = null;
|
|
140
|
+
|
|
141
|
+
const invoke = async (command, input, normalize, unavailable) => {
|
|
142
|
+
try {
|
|
143
|
+
const [bin, credentials] = await Promise.all([loadRuntime(discoveryPath), loadCredentials(credentialsPath)]);
|
|
144
|
+
const { timeoutMs: callTimeoutMs, ...commandInput } = input ?? {};
|
|
145
|
+
const executed = await runJsonCommand({
|
|
146
|
+
bin,
|
|
147
|
+
command,
|
|
148
|
+
payload: { ...commandInput, ...credentials },
|
|
149
|
+
timeoutMs: callTimeoutMs ?? timeoutMs,
|
|
150
|
+
spawnImpl,
|
|
151
|
+
});
|
|
152
|
+
if (!executed.ok) {
|
|
153
|
+
lastReason = executed.reason;
|
|
154
|
+
lastOutcome = "unavailable";
|
|
155
|
+
return unavailable("local", executed.reason, input?.save_id);
|
|
156
|
+
}
|
|
157
|
+
const outcome = normalize(executed.payload, "local");
|
|
158
|
+
const requestedCategories = Array.isArray(input?.categories) ? new Set(input.categories) : null;
|
|
159
|
+
const crossedCategoryBoundary =
|
|
160
|
+
command === "prefetch" &&
|
|
161
|
+
requestedCategories &&
|
|
162
|
+
[...(outcome?.rows ?? []), ...(outcome?.skipped_categories ?? [])].some(
|
|
163
|
+
(entry) => !requestedCategories.has(entry.category)
|
|
164
|
+
);
|
|
165
|
+
if (!outcome || crossedCategoryBoundary) {
|
|
166
|
+
lastReason = "invalid_contract";
|
|
167
|
+
lastOutcome = "unavailable";
|
|
168
|
+
return unavailable("local", "invalid_contract", input?.save_id);
|
|
169
|
+
}
|
|
170
|
+
lastReason = null;
|
|
171
|
+
lastOutcome = outcome.status;
|
|
172
|
+
return outcome;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const reason = error?.code === "ENOENT" ? "missing_local_state" : error?.message || "local_state_unavailable";
|
|
175
|
+
lastReason = reason;
|
|
176
|
+
lastOutcome = "unavailable";
|
|
177
|
+
return unavailable("local", reason, input?.save_id);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
async status() {
|
|
183
|
+
const result = {
|
|
184
|
+
transport: "local",
|
|
185
|
+
discoveryFound: false,
|
|
186
|
+
paired: false,
|
|
187
|
+
lastOutcome,
|
|
188
|
+
lastReason,
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
await loadRuntime(discoveryPath);
|
|
192
|
+
result.discoveryFound = true;
|
|
193
|
+
} catch {}
|
|
194
|
+
try {
|
|
195
|
+
await loadCredentials(credentialsPath);
|
|
196
|
+
result.paired = true;
|
|
197
|
+
} catch {}
|
|
198
|
+
return result;
|
|
199
|
+
},
|
|
200
|
+
prefetch(input = {}) {
|
|
201
|
+
return invoke("prefetch", input, normalizeReadOutcome, unavailableRead);
|
|
202
|
+
},
|
|
203
|
+
recall(input = {}) {
|
|
204
|
+
return invoke("prefetch", { ...input, ambient: false }, normalizeReadOutcome, unavailableRead);
|
|
205
|
+
},
|
|
206
|
+
propose(input = {}) {
|
|
207
|
+
return invoke("propose", input, normalizeProposalOutcome, unavailableProposal);
|
|
208
|
+
},
|
|
209
|
+
claimHandoff(input = {}) {
|
|
210
|
+
return invoke("handoff-claim", input, normalizeHandoffClaimOutcome, unavailableHandoffClaim);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/outcomes.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { GOVERNED_CATEGORIES } from "./config.js";
|
|
2
|
+
|
|
3
|
+
export const READ_STATUSES = Object.freeze(["results", "empty", "blocked", "locked", "unavailable"]);
|
|
4
|
+
export const PROPOSAL_STATUSES = Object.freeze(["recorded", "duplicate", "rejected", "unavailable"]);
|
|
5
|
+
|
|
6
|
+
export function unavailableRead(transport, internalReason) {
|
|
7
|
+
return withInternalReason(
|
|
8
|
+
{
|
|
9
|
+
status: "unavailable",
|
|
10
|
+
transport,
|
|
11
|
+
connectivity: "offline",
|
|
12
|
+
freshness: "stale",
|
|
13
|
+
as_of: null,
|
|
14
|
+
rows: [],
|
|
15
|
+
skipped_categories: [],
|
|
16
|
+
},
|
|
17
|
+
internalReason
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function unavailableProposal(transport, internalReason, saveId = null) {
|
|
22
|
+
return withInternalReason(
|
|
23
|
+
{
|
|
24
|
+
status: "unavailable",
|
|
25
|
+
proposal_id: null,
|
|
26
|
+
save_id: typeof saveId === "string" && saveId ? saveId : null,
|
|
27
|
+
disposition: "pending",
|
|
28
|
+
},
|
|
29
|
+
internalReason
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function withInternalReason(outcome, internalReason) {
|
|
34
|
+
Object.defineProperty(outcome, "internalReason", {
|
|
35
|
+
configurable: false,
|
|
36
|
+
enumerable: false,
|
|
37
|
+
writable: false,
|
|
38
|
+
value: internalReason,
|
|
39
|
+
});
|
|
40
|
+
return outcome;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeReadOutcome(payload, transport) {
|
|
44
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
45
|
+
if (!READ_STATUSES.includes(payload.status)) return null;
|
|
46
|
+
if (!Array.isArray(payload.rows) || !Array.isArray(payload.skipped_categories)) return null;
|
|
47
|
+
if (payload.transport !== transport) return null;
|
|
48
|
+
if (payload.connectivity !== "online" && payload.connectivity !== "offline") return null;
|
|
49
|
+
if (payload.freshness !== "fresh" && payload.freshness !== "stale") return null;
|
|
50
|
+
if (payload.as_of !== null && typeof payload.as_of !== "string") return null;
|
|
51
|
+
if (payload.status === "results" && payload.rows.length === 0) return null;
|
|
52
|
+
if (payload.status !== "results" && payload.rows.length > 0) return null;
|
|
53
|
+
const nullableString = (value) => value === null || typeof value === "string";
|
|
54
|
+
const rows = payload.rows.filter(
|
|
55
|
+
(row) =>
|
|
56
|
+
row &&
|
|
57
|
+
typeof row === "object" &&
|
|
58
|
+
typeof row.memory_id === "string" &&
|
|
59
|
+
typeof row.content === "string" &&
|
|
60
|
+
nullableString(row.source) &&
|
|
61
|
+
typeof row.created_at === "string" &&
|
|
62
|
+
GOVERNED_CATEGORIES.includes(row.category) &&
|
|
63
|
+
nullableString(row.occurred_at) &&
|
|
64
|
+
nullableString(row.client_id) &&
|
|
65
|
+
nullableString(row.evidence_basis) &&
|
|
66
|
+
nullableString(row.record_kind) &&
|
|
67
|
+
nullableString(row.verified_issuer) &&
|
|
68
|
+
nullableString(row.verified_at)
|
|
69
|
+
);
|
|
70
|
+
if (rows.length !== payload.rows.length) return null;
|
|
71
|
+
const skippedCategories = payload.skipped_categories.filter(
|
|
72
|
+
(entry) =>
|
|
73
|
+
entry &&
|
|
74
|
+
typeof entry === "object" &&
|
|
75
|
+
GOVERNED_CATEGORIES.includes(entry.category) &&
|
|
76
|
+
GOVERNED_SKIP_REASONS.has(entry.reason)
|
|
77
|
+
);
|
|
78
|
+
if (skippedCategories.length !== payload.skipped_categories.length) return null;
|
|
79
|
+
return {
|
|
80
|
+
status: payload.status,
|
|
81
|
+
transport,
|
|
82
|
+
connectivity: payload.connectivity,
|
|
83
|
+
freshness: payload.freshness,
|
|
84
|
+
as_of: payload.as_of,
|
|
85
|
+
rows,
|
|
86
|
+
skipped_categories: skippedCategories,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeProposalOutcome(payload) {
|
|
91
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
92
|
+
if (!PROPOSAL_STATUSES.includes(payload.status)) return null;
|
|
93
|
+
if (payload.disposition !== "auto_approved" && payload.disposition !== "pending") return null;
|
|
94
|
+
if (typeof payload.save_id !== "string" || !payload.save_id) return null;
|
|
95
|
+
if (payload.proposal_id !== null && typeof payload.proposal_id !== "string") return null;
|
|
96
|
+
return {
|
|
97
|
+
status: payload.status,
|
|
98
|
+
proposal_id: payload.proposal_id,
|
|
99
|
+
save_id: payload.save_id,
|
|
100
|
+
disposition: payload.disposition,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const GOVERNED_SKIP_REASONS = new Set(["no_pass", "once_only", "locked"]);
|