@fungi.computer/truffle 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +294 -2
- package/computerd-loopback.cjs +16 -0
- package/dist/auth-cli.d.ts +36 -0
- package/dist/auth-cli.js +351 -0
- package/dist/fungi-account-command.d.ts +18 -0
- package/dist/fungi-account-command.js +92 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/internal/private-workerd-sandbox-worker.mjs +27488 -0
- package/dist/internal/sandbox-metafile.json +6726 -0
- package/dist/linear-auth.d.ts +4 -0
- package/dist/linear-auth.js +72 -0
- package/dist/local-extensions.d.ts +97 -0
- package/dist/local-extensions.js +484 -0
- package/dist/local-login.d.ts +55 -0
- package/dist/local-login.js +167 -0
- package/dist/local-models.d.ts +14 -0
- package/dist/local-models.js +77 -0
- package/dist/local-skill-source.d.ts +6 -0
- package/dist/local-skill-source.js +357 -0
- package/dist/log-event.d.ts +2 -0
- package/dist/log-event.js +9 -0
- package/dist/private-bun-shiitake-host.d.ts +11 -0
- package/dist/private-bun-shiitake-host.js +66 -0
- package/dist/private-file-credential-store.d.ts +33 -0
- package/dist/private-file-credential-store.js +283 -0
- package/dist/private-files-patch.d.ts +3 -0
- package/dist/private-files-patch.js +86 -0
- package/dist/private-fungi-account.d.ts +235 -0
- package/dist/private-fungi-account.js +1149 -0
- package/dist/private-fungi-auth-error.d.ts +5 -0
- package/dist/private-fungi-auth-error.js +7 -0
- package/dist/private-fungi-mcp-connection.d.ts +22 -0
- package/dist/private-fungi-mcp-connection.js +143 -0
- package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
- package/dist/private-fungi-mcp-endpoint.js +15 -0
- package/dist/private-fungi-mcp-selection.d.ts +14 -0
- package/dist/private-fungi-mcp-selection.js +36 -0
- package/dist/private-fungi-model-api.d.ts +6 -0
- package/dist/private-fungi-model-api.js +125 -0
- package/dist/private-fungi-model-catalog.d.ts +6 -0
- package/dist/private-fungi-model-catalog.js +78 -0
- package/dist/private-fungi-model-provider.d.ts +6 -0
- package/dist/private-fungi-model-provider.js +46 -0
- package/dist/private-fungi-pending-logouts.d.ts +34 -0
- package/dist/private-fungi-pending-logouts.js +136 -0
- package/dist/private-invocation-computer.d.ts +37 -0
- package/dist/private-invocation-computer.js +459 -0
- package/dist/private-invocation-files.d.ts +15 -0
- package/dist/private-invocation-files.js +449 -0
- package/dist/private-local-acp-stdio.d.ts +17 -0
- package/dist/private-local-acp-stdio.js +142 -0
- package/dist/private-local-auth-rpc.d.ts +69 -0
- package/dist/private-local-auth-rpc.js +273 -0
- package/dist/private-local-computer-attach.d.ts +49 -0
- package/dist/private-local-computer-attach.js +216 -0
- package/dist/private-local-computer-backend.d.ts +282 -0
- package/dist/private-local-computer-backend.js +402 -0
- package/dist/private-local-computer-processes.d.ts +199 -0
- package/dist/private-local-computer-processes.js +589 -0
- package/dist/private-local-default-model.d.ts +11 -0
- package/dist/private-local-default-model.js +45 -0
- package/dist/private-local-delegation.d.ts +29 -0
- package/dist/private-local-delegation.js +78 -0
- package/dist/private-local-effective-model.d.ts +19 -0
- package/dist/private-local-effective-model.js +14 -0
- package/dist/private-local-exec-launch.d.ts +41 -0
- package/dist/private-local-exec-launch.js +51 -0
- package/dist/private-local-exec-path.d.ts +6 -0
- package/dist/private-local-exec-path.js +40 -0
- package/dist/private-local-git-exclude.d.ts +2 -0
- package/dist/private-local-git-exclude.js +33 -0
- package/dist/private-local-http-dispatch.d.ts +5 -0
- package/dist/private-local-http-dispatch.js +14 -0
- package/dist/private-local-mail.d.ts +11 -0
- package/dist/private-local-mail.js +69 -0
- package/dist/private-local-model-settings.d.ts +5 -0
- package/dist/private-local-model-settings.js +31 -0
- package/dist/private-local-runtime-host.d.ts +141 -0
- package/dist/private-local-runtime-host.js +988 -0
- package/dist/private-local-service-contract.d.ts +90 -0
- package/dist/private-local-service-contract.js +1 -0
- package/dist/private-local-service-discovery.d.ts +101 -0
- package/dist/private-local-service-discovery.js +221 -0
- package/dist/private-local-service-ids.d.ts +3 -0
- package/dist/private-local-service-ids.js +8 -0
- package/dist/private-local-service-listener.d.ts +15 -0
- package/dist/private-local-service-listener.js +211 -0
- package/dist/private-local-service-owner.d.ts +1567 -0
- package/dist/private-local-service-owner.js +371 -0
- package/dist/private-local-service-process.d.ts +1 -0
- package/dist/private-local-service-process.js +6 -0
- package/dist/private-local-service.d.ts +17 -0
- package/dist/private-local-service.js +330 -0
- package/dist/private-local-session-discovery.d.ts +19 -0
- package/dist/private-local-session-discovery.js +157 -0
- package/dist/private-local-shiitake-host.d.ts +44 -0
- package/dist/private-local-shiitake-host.js +143 -0
- package/dist/private-local-subagents.d.ts +27 -0
- package/dist/private-local-subagents.js +77 -0
- package/dist/private-local-worker-server.d.ts +15 -0
- package/dist/private-local-worker-server.js +136 -0
- package/dist/private-sqlite-owner.d.ts +7 -0
- package/dist/private-sqlite-owner.js +103 -0
- package/dist/private-team-computers.d.ts +38 -0
- package/dist/private-team-computers.js +101 -0
- package/dist/private-workerd-sandbox.d.ts +3 -0
- package/dist/private-workerd-sandbox.js +347 -0
- package/dist/team-apps.d.ts +24 -0
- package/dist/team-apps.js +123 -0
- package/dist/tracker-git-credential.d.ts +15 -0
- package/dist/tracker-git-credential.js +87 -0
- package/native/bin/build.json +4037 -0
- package/native/bin/fungi-apply-patch +0 -0
- package/package.json +148 -3
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { FungiMcpSelectionConflict } from "./private-fungi-mcp-selection.js";
|
|
2
|
+
import { LocalSessionPathConflict } from "./private-local-service-owner.js";
|
|
3
|
+
export { LocalSessionPathConflict } from "./private-local-service-owner.js";
|
|
4
|
+
import { EventSource } from "eventsource";
|
|
5
|
+
import { newWebSocketRpcSession } from "@fungi.computer/capnweb";
|
|
6
|
+
import { runPrivateLocalAuthRemote, } from "./private-local-auth-rpc.js";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { stat } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
12
|
+
import { createShiitakeClient, parseSessionSnapshot, } from "@fungi.computer/shiitake/client";
|
|
13
|
+
import { acquireServiceLock, assertNoUntrackedServiceOwner, handshake, modelChoicesSchema, readLiveServiceStatus, readServiceRecord, removeOwnedServiceRecord, selectedSchema, sessionDiscoverySchema, sessionInspectionSchema, serviceDirectory, serviceOwnerIsLive, } from "./private-local-service-discovery.js";
|
|
14
|
+
import { privateLocalEffectiveModelSchema } from "./private-local-effective-model.js";
|
|
15
|
+
import { projectPrivateLocalSessionDiscovery } from "./private-local-session-discovery.js";
|
|
16
|
+
import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
export { runPrivateLocalWorkerServer, } from "./private-local-worker-server.js";
|
|
19
|
+
/** Construction does not start a service or open a store. Call start/connect explicitly. */
|
|
20
|
+
export function createPrivateLocalServiceManager(options = {}) {
|
|
21
|
+
const clientPath = options.executionPath === undefined
|
|
22
|
+
? capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd())
|
|
23
|
+
: parsePrivateLocalExecPath(options.executionPath);
|
|
24
|
+
const expectedBuildId = z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(/^[a-f0-9]{64}$/u)
|
|
27
|
+
.optional()
|
|
28
|
+
.parse(process.env.FUNGI_BUNDLED_BUILD_ID);
|
|
29
|
+
function acceptBuild(ready) {
|
|
30
|
+
if (ready.buildId !== expectedBuildId)
|
|
31
|
+
throw new Error("The running service belongs to a different Fungi build. Finish active turns, run fungi stop, then reopen Fungi. Saved Sessions are preserved.");
|
|
32
|
+
return ready;
|
|
33
|
+
}
|
|
34
|
+
const maximumInspectionResponseBytes = 2_097_152;
|
|
35
|
+
async function readBoundedResponse(response) {
|
|
36
|
+
const declared = response.headers.get("content-length");
|
|
37
|
+
if (declared !== null) {
|
|
38
|
+
const declaredBytes = Number(declared);
|
|
39
|
+
if (!Number.isSafeInteger(declaredBytes) ||
|
|
40
|
+
declaredBytes < 0 ||
|
|
41
|
+
declaredBytes > maximumInspectionResponseBytes)
|
|
42
|
+
throw new Error("Local Session inspection response is too large");
|
|
43
|
+
}
|
|
44
|
+
if (response.body === null)
|
|
45
|
+
throw new Error("Local Session inspection returned no body");
|
|
46
|
+
const stream = response.body.pipeThrough(new TransformStream());
|
|
47
|
+
const reader = stream.getReader();
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const next = await reader.read();
|
|
52
|
+
if (next.done)
|
|
53
|
+
break;
|
|
54
|
+
const bytes = next.value;
|
|
55
|
+
size += bytes.byteLength;
|
|
56
|
+
if (size > maximumInspectionResponseBytes) {
|
|
57
|
+
await reader.cancel();
|
|
58
|
+
throw new Error("Local Session inspection response is too large");
|
|
59
|
+
}
|
|
60
|
+
chunks.push(bytes);
|
|
61
|
+
}
|
|
62
|
+
const joined = new Uint8Array(size);
|
|
63
|
+
let offset = 0;
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
joined.set(chunk, offset);
|
|
66
|
+
offset += chunk.byteLength;
|
|
67
|
+
}
|
|
68
|
+
return new TextDecoder().decode(joined);
|
|
69
|
+
}
|
|
70
|
+
async function status(timeoutMs) {
|
|
71
|
+
const record = await readServiceRecord(await serviceDirectory(options.directory));
|
|
72
|
+
if (record === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const probe = await readLiveServiceStatus(record, timeoutMs);
|
|
75
|
+
return probe.kind === "running" ? probe.service : undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A loaded machine can delay a live service past the short handshake. Its
|
|
79
|
+
* lease proves it is alive, so wait for it outside the startup lock instead
|
|
80
|
+
* of spawning a second service that can only fail on that lease.
|
|
81
|
+
*/
|
|
82
|
+
async function awaitBusyService(request) {
|
|
83
|
+
const waitMs = options.busyServiceWaitMs ?? 60_000;
|
|
84
|
+
const deadline = Date.now() + waitMs;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
request.signal?.throwIfAborted();
|
|
87
|
+
const ready = await status(Math.min(10_000, waitMs));
|
|
88
|
+
if (ready)
|
|
89
|
+
return acceptBuild(ready);
|
|
90
|
+
await delay(250, undefined, { signal: request.signal });
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`The Fungi service is running but has not answered for ${String(Math.round(waitMs / 1000))} seconds; the machine may be overloaded. Retry, or run fungi stop after its turns finish.`);
|
|
93
|
+
}
|
|
94
|
+
async function ownerLockReleased(directory) {
|
|
95
|
+
try {
|
|
96
|
+
await stat(join(directory, "owner.lock"));
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
101
|
+
return true;
|
|
102
|
+
throw cause;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function start(request = {}) {
|
|
106
|
+
const started = await startUnderLock(request);
|
|
107
|
+
return started === "busy" ? awaitBusyService(request) : started;
|
|
108
|
+
}
|
|
109
|
+
async function startUnderLock(request) {
|
|
110
|
+
request.signal?.throwIfAborted();
|
|
111
|
+
const directory = await serviceDirectory(options.directory);
|
|
112
|
+
const release = await acquireServiceLock(directory, "startup");
|
|
113
|
+
try {
|
|
114
|
+
const existing = await status();
|
|
115
|
+
request.signal?.throwIfAborted();
|
|
116
|
+
if (existing)
|
|
117
|
+
return acceptBuild(existing);
|
|
118
|
+
await assertNoUntrackedServiceOwner(directory);
|
|
119
|
+
if (serviceOwnerIsLive(directory))
|
|
120
|
+
return "busy";
|
|
121
|
+
const bundledCli = process.env.FUNGI_BUNDLED_CLI;
|
|
122
|
+
const child = spawn(options.executable ?? process.execPath, bundledCli === undefined
|
|
123
|
+
? [
|
|
124
|
+
fileURLToPath(new URL("./private-local-service-process.js", import.meta.url)),
|
|
125
|
+
directory,
|
|
126
|
+
]
|
|
127
|
+
: [bundledCli, "--internal-service", directory], { detached: true, stdio: "ignore" });
|
|
128
|
+
let failure;
|
|
129
|
+
let exited = false;
|
|
130
|
+
const readFailure = () => failure;
|
|
131
|
+
const hasExited = () => exited;
|
|
132
|
+
child.once("error", (cause) => {
|
|
133
|
+
failure = cause;
|
|
134
|
+
});
|
|
135
|
+
child.once("exit", () => {
|
|
136
|
+
exited = true;
|
|
137
|
+
});
|
|
138
|
+
child.unref();
|
|
139
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
140
|
+
const childFailure = readFailure();
|
|
141
|
+
if (childFailure !== undefined)
|
|
142
|
+
throw childFailure;
|
|
143
|
+
const ready = await status();
|
|
144
|
+
if (ready)
|
|
145
|
+
return acceptBuild(ready);
|
|
146
|
+
if (hasExited())
|
|
147
|
+
throw new Error("Local service exited before its handshake");
|
|
148
|
+
await delay(100, undefined, { signal: request.signal });
|
|
149
|
+
}
|
|
150
|
+
child.kill("SIGTERM");
|
|
151
|
+
for (let attempt = 0; attempt < 20 && !hasExited(); attempt += 1)
|
|
152
|
+
await delay(100);
|
|
153
|
+
throw new Error(hasExited()
|
|
154
|
+
? "Local service did not become ready and its owned child was stopped"
|
|
155
|
+
: `Local service did not become ready; child ${String(child.pid)} is still running`);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await release();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const openConnection = async (endpoint, input, request = {}) => {
|
|
162
|
+
request.signal?.throwIfAborted();
|
|
163
|
+
const service = await start(request);
|
|
164
|
+
const init = {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json" },
|
|
167
|
+
body: JSON.stringify({ ...input, clientPath }),
|
|
168
|
+
redirect: "error",
|
|
169
|
+
};
|
|
170
|
+
if (request.signal !== undefined)
|
|
171
|
+
init.signal = request.signal;
|
|
172
|
+
const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/${endpoint}`, init);
|
|
173
|
+
if (response.status === 409) {
|
|
174
|
+
const conflict = z.object({ code: z.string() }).safeParse(await response.json().catch((cause) => {
|
|
175
|
+
console.error("Local service response unavailable", cause);
|
|
176
|
+
return undefined;
|
|
177
|
+
}));
|
|
178
|
+
if (conflict.success && conflict.data.code === "session_path_conflict")
|
|
179
|
+
throw new LocalSessionPathConflict();
|
|
180
|
+
if (conflict.success && conflict.data.code === "store_in_use")
|
|
181
|
+
throw new Error("This Agent store is held by another Fungi service. Stop the older service process, then retry; keep the Agent store files.");
|
|
182
|
+
throw new FungiMcpSelectionConflict();
|
|
183
|
+
}
|
|
184
|
+
if (!response.ok)
|
|
185
|
+
throw new Error(`Local service selection failed (${String(response.status)})`);
|
|
186
|
+
const selected = selectedSchema.parse(await response.json());
|
|
187
|
+
const baseUrl = `${service.baseUrl}/service/${service.serviceId}${selected.routePath}`;
|
|
188
|
+
const client = createShiitakeClient({
|
|
189
|
+
baseUrl,
|
|
190
|
+
eventSource: options.eventSource ?? EventSource,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
service,
|
|
194
|
+
agentId: selected.agentId,
|
|
195
|
+
databasePath: selected.databasePath,
|
|
196
|
+
sessionId: selected.sessionId,
|
|
197
|
+
client,
|
|
198
|
+
session: client.session(selected.sessionId),
|
|
199
|
+
effectiveModel: async (request = {}) => {
|
|
200
|
+
request.signal?.throwIfAborted();
|
|
201
|
+
const init = { redirect: "error" };
|
|
202
|
+
if (request.signal !== undefined)
|
|
203
|
+
init.signal = request.signal;
|
|
204
|
+
const response = await fetch(`${baseUrl}/sessions/${encodeURIComponent(selected.sessionId)}/effective-model`, init);
|
|
205
|
+
if (!response.ok)
|
|
206
|
+
throw new Error(`Local effective model read failed (${String(response.status)})`);
|
|
207
|
+
return privateLocalEffectiveModelSchema.parse(await response.json());
|
|
208
|
+
},
|
|
209
|
+
close: () => client.close(),
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
const connect = (selection, request) => openConnection("select", selection, request);
|
|
213
|
+
const inspectResponseSchema = z.strictObject({
|
|
214
|
+
selected: selectedSchema,
|
|
215
|
+
snapshot: z.unknown(),
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
status,
|
|
219
|
+
start,
|
|
220
|
+
createSession: (creation, request) => openConnection("sessions", creation, request),
|
|
221
|
+
connect,
|
|
222
|
+
async discover(invocationRoot, request = {}) {
|
|
223
|
+
request.signal?.throwIfAborted();
|
|
224
|
+
const service = await start(request);
|
|
225
|
+
const init = {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "Content-Type": "application/json" },
|
|
228
|
+
body: JSON.stringify({ invocationRoot }),
|
|
229
|
+
redirect: "error",
|
|
230
|
+
};
|
|
231
|
+
if (request.signal !== undefined)
|
|
232
|
+
init.signal = request.signal;
|
|
233
|
+
const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/discover`, init);
|
|
234
|
+
if (!response.ok)
|
|
235
|
+
throw new Error(`Local session discovery failed (${String(response.status)})`);
|
|
236
|
+
return projectPrivateLocalSessionDiscovery(sessionDiscoverySchema.parse(await response.json()), connect);
|
|
237
|
+
},
|
|
238
|
+
async inspect(selection, request = {}) {
|
|
239
|
+
request.signal?.throwIfAborted();
|
|
240
|
+
const service = await start(request);
|
|
241
|
+
const input = sessionInspectionSchema.parse({
|
|
242
|
+
...selection,
|
|
243
|
+
limit: request.limit ?? 100,
|
|
244
|
+
});
|
|
245
|
+
const init = {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: { "Content-Type": "application/json" },
|
|
248
|
+
body: JSON.stringify(input),
|
|
249
|
+
redirect: "error",
|
|
250
|
+
};
|
|
251
|
+
if (request.signal !== undefined)
|
|
252
|
+
init.signal = request.signal;
|
|
253
|
+
const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/inspect`, init);
|
|
254
|
+
if (!response.ok)
|
|
255
|
+
throw new Error(`Local Session inspection failed (${String(response.status)})`);
|
|
256
|
+
const decoded = inspectResponseSchema.parse(JSON.parse(await readBoundedResponse(response)));
|
|
257
|
+
const baseUrl = `${service.baseUrl}/service/${service.serviceId}${decoded.selected.routePath}`;
|
|
258
|
+
const client = createShiitakeClient({
|
|
259
|
+
baseUrl,
|
|
260
|
+
eventSource: options.eventSource ?? EventSource,
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
session: client.session(decoded.selected.sessionId),
|
|
264
|
+
snapshot: parseSessionSnapshot(JSON.stringify(decoded.snapshot)),
|
|
265
|
+
close: () => client.close(),
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
async auth(request, interaction) {
|
|
269
|
+
interaction.signal?.throwIfAborted();
|
|
270
|
+
const service = await start(interaction.signal === undefined ? {} : { signal: interaction.signal });
|
|
271
|
+
interaction.signal?.throwIfAborted();
|
|
272
|
+
const url = new URL(`${service.baseUrl}/service/${service.serviceId}/auth`);
|
|
273
|
+
url.protocol = "ws:";
|
|
274
|
+
const socket = new WebSocket(url);
|
|
275
|
+
const remote = newWebSocketRpcSession(socket);
|
|
276
|
+
try {
|
|
277
|
+
return await runPrivateLocalAuthRemote(remote, request, interaction);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
socket.close();
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async models(request = {}) {
|
|
284
|
+
request.signal?.throwIfAborted();
|
|
285
|
+
const service = await start(request);
|
|
286
|
+
const init = { redirect: "error" };
|
|
287
|
+
if (request.signal !== undefined)
|
|
288
|
+
init.signal = request.signal;
|
|
289
|
+
const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/models`, init);
|
|
290
|
+
if (!response.ok)
|
|
291
|
+
throw new Error(`Local service model catalogue failed (${String(response.status)})`);
|
|
292
|
+
return modelChoicesSchema.parse(await response.json());
|
|
293
|
+
},
|
|
294
|
+
async stop(expectedServiceId) {
|
|
295
|
+
const directory = await serviceDirectory(options.directory);
|
|
296
|
+
const release = await acquireServiceLock(directory, "startup");
|
|
297
|
+
try {
|
|
298
|
+
const service = await readServiceRecord(directory);
|
|
299
|
+
if (service === undefined)
|
|
300
|
+
return { kind: "not-running", recordCleaned: false };
|
|
301
|
+
if (expectedServiceId !== undefined &&
|
|
302
|
+
service.serviceId !== expectedServiceId)
|
|
303
|
+
return { kind: "replaced" };
|
|
304
|
+
const probe = await readLiveServiceStatus(service);
|
|
305
|
+
if (probe.kind === "not-running" && !serviceOwnerIsLive(directory)) {
|
|
306
|
+
await removeOwnedServiceRecord(directory, service.serviceId);
|
|
307
|
+
return { kind: "not-running", recordCleaned: true };
|
|
308
|
+
}
|
|
309
|
+
if (probe.kind !== "running")
|
|
310
|
+
throw new Error("Local service is not answering; retry after its active turns finish.");
|
|
311
|
+
const response = await fetch(`${service.baseUrl}/service/${service.serviceId}/stop`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
redirect: "error",
|
|
314
|
+
});
|
|
315
|
+
if (!response.ok)
|
|
316
|
+
throw new Error("Local service refused shutdown");
|
|
317
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
318
|
+
if (!(await handshake(service)) &&
|
|
319
|
+
(await ownerLockReleased(directory)))
|
|
320
|
+
return { kind: "stopped" };
|
|
321
|
+
await delay(100);
|
|
322
|
+
}
|
|
323
|
+
throw new Error("Local service shutdown has not released its owner lock");
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
await release();
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type StoredSessionSummary } from "@fungi.computer/shiitake/effect";
|
|
2
|
+
import type { PrivateLocalServiceManager, PrivateLocalServiceRequestOptions, PrivateLocalSessionDiscovery } from "./private-local-service-contract.js";
|
|
3
|
+
export type LocalSummary = StoredSessionSummary & Readonly<{
|
|
4
|
+
databasePath: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function listPrivateLocalAgentStores(root: string): Promise<readonly string[]>;
|
|
8
|
+
export declare function readPrivateLocalSessionSummaries(path: string): Promise<readonly LocalSummary[]>;
|
|
9
|
+
/** Discover existing folder sessions without opening or reconciling a host. */
|
|
10
|
+
export declare function readPrivateLocalSessionDiscovery(invocationRoot: string, request?: PrivateLocalServiceRequestOptions): Promise<{
|
|
11
|
+
root: string;
|
|
12
|
+
agentCount: number;
|
|
13
|
+
sessions: LocalSummary[];
|
|
14
|
+
problems: Readonly<{
|
|
15
|
+
kind: "corrupt" | "in_use" | "unreadable";
|
|
16
|
+
}>[];
|
|
17
|
+
}>;
|
|
18
|
+
/** Project the service-owned discovery into choices that resume through it. */
|
|
19
|
+
export declare function projectPrivateLocalSessionDiscovery(discovered: Awaited<ReturnType<typeof readPrivateLocalSessionDiscovery>>, connect: PrivateLocalServiceManager["connect"]): PrivateLocalSessionDiscovery;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import { readSessionSummaries, } from "@fungi.computer/shiitake/effect";
|
|
6
|
+
import { createPrivateSqliteOwner, privateSqliteFailureCode, } from "./private-sqlite-owner.js";
|
|
7
|
+
function compareSummaries(left, right) {
|
|
8
|
+
return (right.updatedAt - left.updatedAt ||
|
|
9
|
+
right.createdAt - left.createdAt ||
|
|
10
|
+
left.title.localeCompare(right.title) ||
|
|
11
|
+
left.databasePath.localeCompare(right.databasePath) ||
|
|
12
|
+
left.sessionId.localeCompare(right.sessionId));
|
|
13
|
+
}
|
|
14
|
+
class LocalSessionDiscoveryError extends Error {
|
|
15
|
+
kind;
|
|
16
|
+
constructor(kind, options) {
|
|
17
|
+
super("Local session store could not be read", options);
|
|
18
|
+
this.kind = kind;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sqliteProblemKind(code) {
|
|
22
|
+
if (code === "busy" || code === "locked")
|
|
23
|
+
return "in_use";
|
|
24
|
+
if (code === "corrupt" || code === "not_a_database")
|
|
25
|
+
return "corrupt";
|
|
26
|
+
return "unreadable";
|
|
27
|
+
}
|
|
28
|
+
function problemKind(cause) {
|
|
29
|
+
return cause instanceof LocalSessionDiscoveryError
|
|
30
|
+
? cause.kind
|
|
31
|
+
: sqliteProblemKind(privateSqliteFailureCode(cause));
|
|
32
|
+
}
|
|
33
|
+
async function immediateStoreCandidates(root, relative) {
|
|
34
|
+
const directory = resolve(root, relative);
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
41
|
+
return [];
|
|
42
|
+
throw cause;
|
|
43
|
+
}
|
|
44
|
+
return Promise.all(entries
|
|
45
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) &&
|
|
46
|
+
entry.name.length > 0)
|
|
47
|
+
.map(async (entry) => {
|
|
48
|
+
const path = join(directory, entry.name, "agent.sqlite");
|
|
49
|
+
try {
|
|
50
|
+
const metadata = await stat(path);
|
|
51
|
+
if (!metadata.isFile())
|
|
52
|
+
return undefined;
|
|
53
|
+
return { path };
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
if (cause instanceof Error &&
|
|
57
|
+
"code" in cause &&
|
|
58
|
+
cause.code === "ENOENT")
|
|
59
|
+
return undefined;
|
|
60
|
+
throw cause;
|
|
61
|
+
}
|
|
62
|
+
})).then((values) => values.filter((value) => value !== undefined));
|
|
63
|
+
}
|
|
64
|
+
export async function listPrivateLocalAgentStores(root) {
|
|
65
|
+
const candidates = await immediateStoreCandidates(root, ".fungi/agents");
|
|
66
|
+
const paths = new Set();
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
paths.add(await realpath(candidate.path));
|
|
70
|
+
}
|
|
71
|
+
catch (cause) {
|
|
72
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
73
|
+
continue;
|
|
74
|
+
throw cause;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...paths];
|
|
78
|
+
}
|
|
79
|
+
export async function readPrivateLocalSessionSummaries(path) {
|
|
80
|
+
const database = new DatabaseSync(path, { readOnly: true, timeout: 0 });
|
|
81
|
+
try {
|
|
82
|
+
database.exec("PRAGMA foreign_keys = ON");
|
|
83
|
+
let sqliteFailure;
|
|
84
|
+
const owner = createPrivateSqliteOwner(database, (code) => {
|
|
85
|
+
sqliteFailure = code;
|
|
86
|
+
});
|
|
87
|
+
const summaries = [];
|
|
88
|
+
let after;
|
|
89
|
+
try {
|
|
90
|
+
do {
|
|
91
|
+
const page = await Effect.runPromise(readSessionSummaries(owner, after));
|
|
92
|
+
for (const row of page.sessions) {
|
|
93
|
+
// One unreadable Session marks this store corrupt, as before.
|
|
94
|
+
if (row.kind === "unavailable")
|
|
95
|
+
throw new LocalSessionDiscoveryError("corrupt");
|
|
96
|
+
summaries.push({
|
|
97
|
+
sessionId: row.sessionId,
|
|
98
|
+
title: row.title,
|
|
99
|
+
createdAt: row.createdAt,
|
|
100
|
+
updatedAt: row.updatedAt,
|
|
101
|
+
databasePath: path,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
after = page.next;
|
|
105
|
+
} while (after !== undefined);
|
|
106
|
+
}
|
|
107
|
+
catch (cause) {
|
|
108
|
+
if (cause instanceof LocalSessionDiscoveryError)
|
|
109
|
+
throw cause;
|
|
110
|
+
throw new LocalSessionDiscoveryError(sqliteProblemKind(sqliteFailure), {
|
|
111
|
+
cause,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return summaries.sort(compareSummaries);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
database.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Discover existing folder sessions without opening or reconciling a host. */
|
|
121
|
+
export async function readPrivateLocalSessionDiscovery(invocationRoot, request = {}) {
|
|
122
|
+
request.signal?.throwIfAborted();
|
|
123
|
+
const root = await realpath(resolve(invocationRoot));
|
|
124
|
+
const paths = await listPrivateLocalAgentStores(root);
|
|
125
|
+
const discovered = [];
|
|
126
|
+
const problems = [];
|
|
127
|
+
for (const path of paths) {
|
|
128
|
+
request.signal?.throwIfAborted();
|
|
129
|
+
try {
|
|
130
|
+
discovered.push(...(await readPrivateLocalSessionSummaries(path)));
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
problems.push({ kind: problemKind(cause) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
discovered.sort(compareSummaries);
|
|
137
|
+
return { root, agentCount: paths.length, sessions: discovered, problems };
|
|
138
|
+
}
|
|
139
|
+
/** Project the service-owned discovery into choices that resume through it. */
|
|
140
|
+
export function projectPrivateLocalSessionDiscovery(discovered, connect) {
|
|
141
|
+
const sessions = discovered.sessions.map(({ databasePath, sessionId, ...display }) => Object.freeze({
|
|
142
|
+
...display,
|
|
143
|
+
databasePath,
|
|
144
|
+
sessionId,
|
|
145
|
+
resume: (resumeRequest) => connect({
|
|
146
|
+
invocationRoot: discovered.root,
|
|
147
|
+
databasePath,
|
|
148
|
+
sessionId,
|
|
149
|
+
fungiMcp: resumeRequest?.fungiMcp,
|
|
150
|
+
}, resumeRequest),
|
|
151
|
+
}));
|
|
152
|
+
return Object.freeze({
|
|
153
|
+
agentCount: discovered.agentCount,
|
|
154
|
+
sessions: Object.freeze(sessions),
|
|
155
|
+
problems: Object.freeze(discovered.problems),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ShiitakeAgent, ShiitakeSession } from "@fungi.computer/shiitake";
|
|
2
|
+
import type { ShiitakeEventSourceConstructor } from "@fungi.computer/shiitake/client";
|
|
3
|
+
import { openPrivateLocalRuntimeHost, type PrivateLocalRuntimeHostOptions } from "./private-local-runtime-host.js";
|
|
4
|
+
export type PrivateLocalShiitakeServer = Readonly<{
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
close: () => Promise<void>;
|
|
7
|
+
}>;
|
|
8
|
+
export type PrivateLocalShiitakeServerOpener = (fetch: (request: Request) => Response | Promise<Response>) => PrivateLocalShiitakeServer;
|
|
9
|
+
export type PrivateLocalShiitakeHostOpener = (input: PrivateLocalRuntimeHostOptions) => Promise<PrivateLocalShiitakeHost>;
|
|
10
|
+
/** One private loopback Hono owner plus a neutral client mounted on it. */
|
|
11
|
+
export type PrivateLocalShiitakeHost = Readonly<{
|
|
12
|
+
/** Stable Local directory identity of this authorized Agent. */
|
|
13
|
+
agentId: string;
|
|
14
|
+
/** Canonical file path owned by the server-backed local host. */
|
|
15
|
+
databasePath: string;
|
|
16
|
+
/** Private loopback origin for another neutral Shiitake client. */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** Client-owned neutral Session; close only releases client watches. */
|
|
19
|
+
client: ShiitakeAgent;
|
|
20
|
+
session: ShiitakeSession;
|
|
21
|
+
/** Stop the transport before releasing the RuntimeHost and database. */
|
|
22
|
+
close: () => Promise<void>;
|
|
23
|
+
}>;
|
|
24
|
+
/** Open one bounded private local Hono host on a loopback-only ephemeral port. */
|
|
25
|
+
export declare function openPrivateLocalShiitakeHost(input: PrivateLocalRuntimeHostOptions, openServer: PrivateLocalShiitakeServerOpener, openHost?: typeof openPrivateLocalRuntimeHost, eventSource?: ShiitakeEventSourceConstructor): Promise<PrivateLocalShiitakeHost>;
|
|
26
|
+
/** Explicit static input for one independently owned local worker route. */
|
|
27
|
+
export type PrivateLocalShiitakeWorkerInput = Readonly<{
|
|
28
|
+
name: string;
|
|
29
|
+
owner: PrivateLocalRuntimeHostOptions;
|
|
30
|
+
}>;
|
|
31
|
+
/** One mounted route and its resolved durable Agent store. */
|
|
32
|
+
export type PrivateLocalShiitakeWorkerRoute = Readonly<{
|
|
33
|
+
name: string;
|
|
34
|
+
routePath: string;
|
|
35
|
+
databasePath: string;
|
|
36
|
+
}>;
|
|
37
|
+
/** One physical server hosting a static batch of independent local owners. */
|
|
38
|
+
export type PrivateLocalShiitakeWorkerBatch = Readonly<{
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
workers: readonly PrivateLocalShiitakeWorkerRoute[];
|
|
41
|
+
close: () => Promise<void>;
|
|
42
|
+
}>;
|
|
43
|
+
/** Mount a static batch of Local Agents behind exactly one maintained Hono server. */
|
|
44
|
+
export declare function openPrivateLocalShiitakeWorkerBatch(inputs: readonly PrivateLocalShiitakeWorkerInput[], openServer: PrivateLocalShiitakeServerOpener, openHost?: typeof openPrivateLocalRuntimeHost): Promise<PrivateLocalShiitakeWorkerBatch>;
|