@fungi.computer/truffle 0.3.1 → 0.4.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/dist/local-extensions.js +108 -33
- package/dist/local-login.js +9 -3
- package/dist/local-upgrade.d.ts +63 -0
- package/dist/local-upgrade.js +358 -0
- package/dist/private-local-agent-control.d.ts +3 -0
- package/dist/private-local-agent-control.js +5 -1
- package/dist/private-local-delegation.js +12 -5
- package/dist/private-local-exec-review.js +45 -8
- package/dist/private-local-extension-archive-notice.d.ts +8 -0
- package/dist/private-local-extension-archive-notice.js +108 -0
- package/dist/private-local-extension-records.d.ts +25 -0
- package/dist/private-local-extension-records.js +51 -0
- package/dist/private-local-process-identity.d.ts +14 -0
- package/dist/private-local-process-identity.js +51 -0
- package/dist/private-local-runtime-host.d.ts +7 -1
- package/dist/private-local-runtime-host.js +48 -18
- package/dist/private-local-service-discovery.js +2 -3
- package/dist/private-local-service-owner.js +19 -7
- package/dist/private-local-service-termination.js +12 -11
- package/dist/private-local-service.js +12 -0
- package/dist/private-local-session-discovery.d.ts +6 -0
- package/dist/private-local-session-discovery.js +31 -0
- package/dist/private-mcp-form-contract.d.ts +13 -0
- package/dist/private-mcp-form-contract.js +6 -1
- package/dist/private-mcp-form-session.d.ts +26 -0
- package/dist/private-mcp-form-session.js +8 -5
- package/package.json +20 -13
package/dist/local-extensions.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { access, constants, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
2
|
+
import { access, constants, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { join, resolve, sep } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { Mycelium } from "@fungi.computer/mycelium";
|
|
6
6
|
import { defineExtension, ExtensionId, } from "@fungi.computer/shiitake/extensions";
|
|
7
7
|
import * as Predicate from "effect/Predicate";
|
|
8
8
|
import * as Schema from "effect/Schema";
|
|
9
|
-
import lockfile from "proper-lockfile";
|
|
10
9
|
import writeFileAtomic from "write-file-atomic";
|
|
11
10
|
import { z } from "zod";
|
|
12
11
|
import { describeExtensionSkills, } from "./local-skill-source.js";
|
|
12
|
+
import { activationsSchema, capabilities, } from "./private-local-extension-records.js";
|
|
13
|
+
import { acquireExtensionOwnerLock, extensionArchiveNoticesAreDeferred, extensionArchiveNameSchema, takePendingExtensionArchiveNoticesUnderOwnerLock, writeExtensionArchiveNotice, } from "./private-local-extension-archive-notice.js";
|
|
13
14
|
/**
|
|
14
15
|
* Installed Local extensions. One owner holds the package bytes under
|
|
15
16
|
* `~/.fungi/extensions`, and the activations its typed operations write. A
|
|
@@ -23,19 +24,6 @@ import { describeExtensionSkills, } from "./local-skill-source.js";
|
|
|
23
24
|
* them. This covers Local installed extensions only; Hub-hosted extensions
|
|
24
25
|
* are not covered.
|
|
25
26
|
*/
|
|
26
|
-
const capabilities = [
|
|
27
|
-
"state",
|
|
28
|
-
"attention",
|
|
29
|
-
"settled",
|
|
30
|
-
"send",
|
|
31
|
-
"timers",
|
|
32
|
-
"commands",
|
|
33
|
-
"projection",
|
|
34
|
-
"run_tools",
|
|
35
|
-
"files",
|
|
36
|
-
"prompt",
|
|
37
|
-
"compaction_handoff",
|
|
38
|
-
];
|
|
39
27
|
const decodeExtensionId = Schema.decodeUnknownSync(ExtensionId);
|
|
40
28
|
const identifier = z.string().regex(/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/u);
|
|
41
29
|
const revisionPattern = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/u;
|
|
@@ -93,14 +81,13 @@ const installedSchema = z.strictObject({
|
|
|
93
81
|
revisions: z.record(z.string(), revisionRecord),
|
|
94
82
|
})),
|
|
95
83
|
});
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}))),
|
|
84
|
+
const extensionNameNoticeSchema = z.object({
|
|
85
|
+
extensions: z.record(z.string(), z.object({
|
|
86
|
+
current: z.string(),
|
|
87
|
+
revisions: z.record(z.string(), z.object({
|
|
88
|
+
name: z.unknown().optional(),
|
|
89
|
+
})),
|
|
90
|
+
})),
|
|
104
91
|
});
|
|
105
92
|
const lockSchema = z.looseObject({
|
|
106
93
|
packages: z.record(z.string(), z.array(z.unknown()).min(1)),
|
|
@@ -169,20 +156,108 @@ export function localExtensions(options) {
|
|
|
169
156
|
throw new LocalExtensionError("install", `${path} is from an earlier extension format; ${remedy}`, { cause: parsed.error });
|
|
170
157
|
return parsed.data;
|
|
171
158
|
}
|
|
172
|
-
|
|
159
|
+
function extensionNamesForNotice(text) {
|
|
160
|
+
let value;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(text);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const parsed = extensionNameNoticeSchema.safeParse(value);
|
|
168
|
+
if (!parsed.success)
|
|
169
|
+
return [];
|
|
170
|
+
return [
|
|
171
|
+
...new Set(Object.entries(parsed.data.extensions).flatMap(([id, extension]) => {
|
|
172
|
+
if (!Object.hasOwn(extension.revisions, extension.current))
|
|
173
|
+
return [];
|
|
174
|
+
const revision = extension.revisions[extension.current];
|
|
175
|
+
if (revision === undefined)
|
|
176
|
+
return [];
|
|
177
|
+
const revisionName = extensionArchiveNameSchema.safeParse(revision.name);
|
|
178
|
+
const idName = extensionArchiveNameSchema.safeParse(id);
|
|
179
|
+
const name = revisionName.success ? revisionName : idName;
|
|
180
|
+
return name.success ? [name.data] : [];
|
|
181
|
+
})),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
async function archiveInstalled(text) {
|
|
185
|
+
const names = extensionNamesForNotice(text);
|
|
186
|
+
const archiveRoot = join(resolve(options.home), ".fungi", "archive");
|
|
187
|
+
await mkdir(archiveRoot, { recursive: true, mode: 0o700 });
|
|
188
|
+
for (let suffix = 1; suffix <= 9; suffix += 1) {
|
|
189
|
+
const slug = "unversioned";
|
|
190
|
+
const directory = join(archiveRoot, slug, "extensions");
|
|
191
|
+
const filename = suffix === 1 ? "installed.json" : `installed-${suffix.toString()}.json`;
|
|
192
|
+
const destination = join(directory, filename);
|
|
193
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
194
|
+
const reservation = await open(destination, "wx", 0o600).catch((cause) => {
|
|
195
|
+
if (cause instanceof Error &&
|
|
196
|
+
"code" in cause &&
|
|
197
|
+
cause.code === "EEXIST")
|
|
198
|
+
return undefined;
|
|
199
|
+
throw cause;
|
|
200
|
+
});
|
|
201
|
+
if (reservation === undefined)
|
|
202
|
+
continue;
|
|
203
|
+
try {
|
|
204
|
+
await rename(installedPath, destination);
|
|
205
|
+
}
|
|
206
|
+
catch (cause) {
|
|
207
|
+
if (absent(cause))
|
|
208
|
+
return { extensions: {} };
|
|
209
|
+
throw new LocalExtensionError("install", `Could not archive ${installedPath} at ${destination}`, { cause });
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await reservation.close();
|
|
213
|
+
}
|
|
214
|
+
await writeExtensionArchiveNotice(options.home, slug, filename, names);
|
|
215
|
+
if (!extensionArchiveNoticesAreDeferred(options.home)) {
|
|
216
|
+
const pending = await takePendingExtensionArchiveNoticesUnderOwnerLock(options.home);
|
|
217
|
+
for (const message of pending)
|
|
218
|
+
process.stderr.write(`${message}\n`);
|
|
219
|
+
}
|
|
220
|
+
return { extensions: {} };
|
|
221
|
+
}
|
|
222
|
+
throw new LocalExtensionError("install", `Could not archive ${installedPath}: every unversioned archive destination already exists`);
|
|
223
|
+
}
|
|
224
|
+
async function readInstalled(underOwnerLock = false) {
|
|
225
|
+
let text;
|
|
226
|
+
try {
|
|
227
|
+
text = await readFile(installedPath, "utf8");
|
|
228
|
+
}
|
|
229
|
+
catch (cause) {
|
|
230
|
+
if (absent(cause))
|
|
231
|
+
return { extensions: {} };
|
|
232
|
+
throw cause;
|
|
233
|
+
}
|
|
234
|
+
let value;
|
|
235
|
+
try {
|
|
236
|
+
value = JSON.parse(text);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
value = undefined;
|
|
240
|
+
}
|
|
241
|
+
const parsed = installedSchema.safeParse(value);
|
|
242
|
+
if (parsed.success)
|
|
243
|
+
return parsed.data;
|
|
244
|
+
if (!underOwnerLock) {
|
|
245
|
+
const release = await acquireExtensionOwnerLock(root);
|
|
246
|
+
try {
|
|
247
|
+
return await readInstalled(true);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await release();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return archiveInstalled(text);
|
|
254
|
+
}
|
|
173
255
|
const readActivations = () => readJson(activationsPath, activationsSchema, { projects: {}, agents: {} }, "delete it, then re-enable your extensions");
|
|
174
256
|
/** Serialize owner writes across CLI and service processes. */
|
|
175
257
|
async function update(change) {
|
|
176
|
-
await
|
|
177
|
-
const release = await lockfile.lock(root, {
|
|
178
|
-
realpath: false,
|
|
179
|
-
lockfilePath: join(root, ".owner.lock"),
|
|
180
|
-
stale: 30_000,
|
|
181
|
-
update: 10_000,
|
|
182
|
-
retries: { retries: 20, factor: 1.2, minTimeout: 25, maxTimeout: 100 },
|
|
183
|
-
});
|
|
258
|
+
const release = await acquireExtensionOwnerLock(root);
|
|
184
259
|
try {
|
|
185
|
-
const installed = await readInstalled();
|
|
260
|
+
const installed = await readInstalled(true);
|
|
186
261
|
const activations = await readActivations();
|
|
187
262
|
const result = change(installed, activations);
|
|
188
263
|
const options = { encoding: "utf8", fsync: true, mode: 0o600 };
|
package/dist/local-login.js
CHANGED
|
@@ -74,10 +74,16 @@ async function ask(interaction, prompt) {
|
|
|
74
74
|
// redirect URL (OpenAI, Anthropic): the provider's own
|
|
75
75
|
// message explains what to paste; this adds the SSH hint once, here,
|
|
76
76
|
// instead of baking it into one provider's registry label.
|
|
77
|
-
const
|
|
77
|
+
const summary = "Connecting over SSH? Paste the URL here instead of opening a browser.";
|
|
78
|
+
const { message } = prompt;
|
|
78
79
|
return interaction.prompt(prompt.placeholder === undefined
|
|
79
|
-
? { type: "text", message }
|
|
80
|
-
: {
|
|
80
|
+
? { type: "text", message, summary }
|
|
81
|
+
: {
|
|
82
|
+
type: "text",
|
|
83
|
+
message,
|
|
84
|
+
summary,
|
|
85
|
+
placeholder: prompt.placeholder,
|
|
86
|
+
}, options);
|
|
81
87
|
}
|
|
82
88
|
case "secret":
|
|
83
89
|
case "text":
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PrivateLocalServiceStopResult } from "./private-local-service-contract.js";
|
|
3
|
+
declare const versionMarkerSchema: z.ZodObject<{
|
|
4
|
+
version: z.ZodLiteral<1>;
|
|
5
|
+
release: z.ZodString;
|
|
6
|
+
spore: z.ZodString;
|
|
7
|
+
build: z.ZodUnion<readonly [z.ZodLiteral<"source">, z.ZodString]>;
|
|
8
|
+
}, z.core.$strict>;
|
|
9
|
+
export type PriorInstall = Readonly<{
|
|
10
|
+
kind: "recorded";
|
|
11
|
+
release: string;
|
|
12
|
+
spore: string;
|
|
13
|
+
}> | Readonly<{
|
|
14
|
+
kind: "protocol";
|
|
15
|
+
protocol: number;
|
|
16
|
+
build: string;
|
|
17
|
+
}> | Readonly<{
|
|
18
|
+
kind: "unrecorded";
|
|
19
|
+
}>;
|
|
20
|
+
/** Version marker is a label, not permission to accept an older store schema. */
|
|
21
|
+
export declare function readPriorInstall(home: string): Promise<PriorInstall>;
|
|
22
|
+
/** Only the completed upgrade pass calls this writer; never write it early. */
|
|
23
|
+
export declare function writeLocalVersionMarker(home: string, marker: Omit<z.input<typeof versionMarkerSchema>, "version">): Promise<void>;
|
|
24
|
+
export type LocalUpgradeOptions = Readonly<{
|
|
25
|
+
home?: string;
|
|
26
|
+
directory?: string;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
/** The package's generated, changelog-checked asset beside its CLI. */
|
|
29
|
+
newsPath: string;
|
|
30
|
+
spore: string;
|
|
31
|
+
/** Tests may supply the same bounded stop contract as the real manager. */
|
|
32
|
+
stopService?: (expectedServiceId?: string) => Promise<PrivateLocalServiceStopResult>;
|
|
33
|
+
/** Override only for an isolated proof of the installed-client identity. */
|
|
34
|
+
installedClientBuildId?: () => Promise<string | undefined>;
|
|
35
|
+
}>;
|
|
36
|
+
/** Invalid news lines are optional; a missing release label is not safe to mark current. */
|
|
37
|
+
export declare function readLocalUpgradeNews(path: string): Promise<{
|
|
38
|
+
release: string;
|
|
39
|
+
lines: string[];
|
|
40
|
+
}>;
|
|
41
|
+
export type ArchivedArtifact = Readonly<{
|
|
42
|
+
kind: "agent-store" | "extension-activations";
|
|
43
|
+
from: string;
|
|
44
|
+
to: string;
|
|
45
|
+
}>;
|
|
46
|
+
export type LocalUpgradeReport = Readonly<{
|
|
47
|
+
from: PriorInstall;
|
|
48
|
+
to: string;
|
|
49
|
+
service: "stopped" | "killed" | "absent";
|
|
50
|
+
archive: string | undefined;
|
|
51
|
+
archived: readonly ArchivedArtifact[];
|
|
52
|
+
whatsNew: readonly string[];
|
|
53
|
+
}>;
|
|
54
|
+
export type LocalUpgradeOutcome = Readonly<{
|
|
55
|
+
kind: "current";
|
|
56
|
+
}> | Readonly<{
|
|
57
|
+
kind: "upgraded";
|
|
58
|
+
report: LocalUpgradeReport;
|
|
59
|
+
}>;
|
|
60
|
+
/** Upgrade → service startup → extensions owner: this lock is always first. */
|
|
61
|
+
export declare function upgradeLocalHome(options: LocalUpgradeOptions): Promise<LocalUpgradeOutcome>;
|
|
62
|
+
export declare function formatLocalUpgradeNotice(report: LocalUpgradeReport): readonly string[];
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import lockfile from "proper-lockfile";
|
|
5
|
+
import semver from "semver";
|
|
6
|
+
import writeFileAtomic from "write-file-atomic";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { logEvent } from "./log-event.js";
|
|
9
|
+
import { incompatibleLocalExtensionActivations } from "./private-local-extension-records.js";
|
|
10
|
+
import { createPrivateLocalServiceManager, LocalServiceUpdated, } from "./private-local-service.js";
|
|
11
|
+
import { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
|
|
12
|
+
import { LocalStoreSchemaError, probePrivateLocalAgentStoreEntries, readPrivateLocalAgentProfile, } from "./private-local-session-discovery.js";
|
|
13
|
+
import { assertNoUntrackedServiceOwner, privateLocalServiceVersion, readServiceRegistration, serviceOwnerIsLive, } from "./private-local-service-discovery.js";
|
|
14
|
+
const version = z
|
|
15
|
+
.string()
|
|
16
|
+
.refine((value) => semver.valid(value) !== null, "Invalid semver version");
|
|
17
|
+
const versionMarkerSchema = z.strictObject({
|
|
18
|
+
version: z.literal(1),
|
|
19
|
+
release: version,
|
|
20
|
+
spore: version,
|
|
21
|
+
build: z.union([z.literal("source"), z.string().regex(/^[a-f0-9]{64}$/u)]),
|
|
22
|
+
});
|
|
23
|
+
const newsSchema = z.strictObject({
|
|
24
|
+
release: versionMarkerSchema.shape.release,
|
|
25
|
+
lines: z.array(z.string().max(120)).max(3),
|
|
26
|
+
});
|
|
27
|
+
const markerPath = (home) => join(home, ".fungi", "version.json");
|
|
28
|
+
const pendingPath = (home) => join(home, ".fungi", ".upgrade-in-progress.json");
|
|
29
|
+
const pendingSchema = z.strictObject({
|
|
30
|
+
version: z.literal(1),
|
|
31
|
+
prior: z.discriminatedUnion("kind", [
|
|
32
|
+
z.strictObject({
|
|
33
|
+
kind: z.literal("recorded"),
|
|
34
|
+
release: version,
|
|
35
|
+
spore: version,
|
|
36
|
+
}),
|
|
37
|
+
z.strictObject({
|
|
38
|
+
kind: z.literal("protocol"),
|
|
39
|
+
protocol: z.number().int().nonnegative(),
|
|
40
|
+
build: z.string().min(1),
|
|
41
|
+
}),
|
|
42
|
+
z.strictObject({ kind: z.literal("unrecorded") }),
|
|
43
|
+
]),
|
|
44
|
+
});
|
|
45
|
+
/** Persist prior identity before stop removes the only protocol-3 record. */
|
|
46
|
+
async function readPendingUpgrade(home) {
|
|
47
|
+
const path = pendingPath(home);
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = await readFile(path, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (cause) {
|
|
53
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
54
|
+
return undefined;
|
|
55
|
+
throw new Error(`Cannot read the in-progress Fungi upgrade at ${path}`, {
|
|
56
|
+
cause,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return pendingSchema.parse(JSON.parse(text)).prior;
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
throw new Error(`Cannot resume the Fungi upgrade at ${path}: inspect or move this marker aside before retrying`, { cause });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function writePendingUpgrade(home, prior) {
|
|
67
|
+
await writeFileAtomic(pendingPath(home), `${JSON.stringify(pendingSchema.parse({ version: 1, prior }))}\n`, { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
/** A completed version marker supersedes a pending pass left after its write. */
|
|
70
|
+
function pendingIsComplete(recorded, pending) {
|
|
71
|
+
return (recorded.kind === "recorded" &&
|
|
72
|
+
(pending.kind !== "recorded" ||
|
|
73
|
+
recorded.release !== pending.release ||
|
|
74
|
+
recorded.spore !== pending.spore));
|
|
75
|
+
}
|
|
76
|
+
/** Version marker is a label, not permission to accept an older store schema. */
|
|
77
|
+
export async function readPriorInstall(home) {
|
|
78
|
+
const path = markerPath(home);
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = await readFile(path, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
85
|
+
throw cause;
|
|
86
|
+
}
|
|
87
|
+
if (text !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
const marker = versionMarkerSchema.parse(JSON.parse(text));
|
|
90
|
+
return { kind: "recorded", release: marker.release, spore: marker.spore };
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
throw new Error(`Cannot read ${path}: unsupported Fungi version marker`, {
|
|
94
|
+
cause,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const registration = await readServiceRegistration(join(home, ".fungi", "service"));
|
|
99
|
+
if (registration?.kind === "other-protocol")
|
|
100
|
+
return {
|
|
101
|
+
kind: "protocol",
|
|
102
|
+
protocol: registration.lifecycle.protocol,
|
|
103
|
+
build: registration.lifecycle.version,
|
|
104
|
+
};
|
|
105
|
+
return { kind: "unrecorded" };
|
|
106
|
+
}
|
|
107
|
+
/** Only the completed upgrade pass calls this writer; never write it early. */
|
|
108
|
+
export async function writeLocalVersionMarker(home, marker) {
|
|
109
|
+
const path = markerPath(home);
|
|
110
|
+
const record = versionMarkerSchema.parse({ version: 1, ...marker });
|
|
111
|
+
await mkdir(join(home, ".fungi"), { recursive: true, mode: 0o700 });
|
|
112
|
+
await writeFileAtomic(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
113
|
+
}
|
|
114
|
+
/** Invalid news lines are optional; a missing release label is not safe to mark current. */
|
|
115
|
+
export async function readLocalUpgradeNews(path) {
|
|
116
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
117
|
+
const release = z
|
|
118
|
+
.looseObject({ release: versionMarkerSchema.shape.release })
|
|
119
|
+
.parse(value).release;
|
|
120
|
+
return {
|
|
121
|
+
release,
|
|
122
|
+
lines: newsSchema.safeParse(value).data?.lines ?? [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async function exists(path) {
|
|
126
|
+
try {
|
|
127
|
+
await lstat(path);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
catch (cause) {
|
|
131
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
132
|
+
return false;
|
|
133
|
+
throw cause;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function archiveSlug(prior) {
|
|
137
|
+
if (prior.kind === "recorded")
|
|
138
|
+
return prior.release;
|
|
139
|
+
if (prior.kind === "unrecorded")
|
|
140
|
+
return "unversioned";
|
|
141
|
+
const build = prior.build.slice(0, 12);
|
|
142
|
+
return `protocol-${String(prior.protocol)}-${/^[a-f0-9]{12}$/u.test(build) ? build : "source"}`;
|
|
143
|
+
}
|
|
144
|
+
async function surveyIncompatibleLocalHome(home) {
|
|
145
|
+
const incompatible = [];
|
|
146
|
+
for (const store of await probePrivateLocalAgentStoreEntries(home)) {
|
|
147
|
+
try {
|
|
148
|
+
readPrivateLocalAgentProfile(store.databasePath);
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
if (cause instanceof LocalStoreSchemaError)
|
|
152
|
+
incompatible.push({
|
|
153
|
+
kind: "agent-store",
|
|
154
|
+
from: store.entry,
|
|
155
|
+
name: store.agentId,
|
|
156
|
+
folder: "agents",
|
|
157
|
+
});
|
|
158
|
+
else
|
|
159
|
+
logEvent("upgrade-agent-store-survey", cause);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const path of await incompatibleLocalExtensionActivations(home))
|
|
163
|
+
incompatible.push({
|
|
164
|
+
kind: "extension-activations",
|
|
165
|
+
from: path,
|
|
166
|
+
name: "activations.json",
|
|
167
|
+
folder: "extensions",
|
|
168
|
+
});
|
|
169
|
+
return incompatible;
|
|
170
|
+
}
|
|
171
|
+
/** Upgrade → service startup → extensions owner: this lock is always first. */
|
|
172
|
+
export async function upgradeLocalHome(options) {
|
|
173
|
+
const home = options.home ?? homedir();
|
|
174
|
+
const spore = versionMarkerSchema.shape.spore.parse(options.spore);
|
|
175
|
+
// A new empty home has no need to read a release artifact to use ordinary
|
|
176
|
+
// commands. A recorded or running older installation needs its label.
|
|
177
|
+
const recorded = await readPriorInstall(home);
|
|
178
|
+
let news = recorded.kind === "unrecorded"
|
|
179
|
+
? undefined
|
|
180
|
+
: await readLocalUpgradeNews(options.newsPath);
|
|
181
|
+
const current = (prior, release) => prior.kind === "recorded" &&
|
|
182
|
+
release !== undefined &&
|
|
183
|
+
(semver.gt(prior.release, release) ||
|
|
184
|
+
(semver.eq(prior.release, release) && semver.gte(prior.spore, spore)));
|
|
185
|
+
if (current(recorded, news?.release))
|
|
186
|
+
return { kind: "current" };
|
|
187
|
+
const root = join(home, ".fungi");
|
|
188
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
189
|
+
const release = await lockfile.lock(root, {
|
|
190
|
+
lockfilePath: join(root, ".upgrade.lock"),
|
|
191
|
+
realpath: false,
|
|
192
|
+
stale: 30_000,
|
|
193
|
+
update: 10_000,
|
|
194
|
+
retries: { retries: 60, factor: 1.2, minTimeout: 25, maxTimeout: 250 },
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
options.signal?.throwIfAborted();
|
|
198
|
+
const observed = await readPriorInstall(home);
|
|
199
|
+
const pending = await readPendingUpgrade(home);
|
|
200
|
+
const prior = pending !== undefined && !pendingIsComplete(observed, pending)
|
|
201
|
+
? pending
|
|
202
|
+
: observed;
|
|
203
|
+
if (prior.kind === "recorded") {
|
|
204
|
+
news ??= await readLocalUpgradeNews(options.newsPath);
|
|
205
|
+
}
|
|
206
|
+
if (current(prior, news?.release))
|
|
207
|
+
return { kind: "current" };
|
|
208
|
+
const directory = options.directory ?? join(root, "service");
|
|
209
|
+
const registration = await readServiceRegistration(directory);
|
|
210
|
+
if (prior.kind === "unrecorded" &&
|
|
211
|
+
registration?.kind === "current" &&
|
|
212
|
+
registration.record.version === privateLocalServiceVersion() &&
|
|
213
|
+
(await surveyIncompatibleLocalHome(home)).length === 0)
|
|
214
|
+
return { kind: "current" };
|
|
215
|
+
if (prior.kind === "unrecorded" && registration === undefined) {
|
|
216
|
+
const archive = join(root, "archive", archiveSlug(prior));
|
|
217
|
+
if ((await surveyIncompatibleLocalHome(home)).length === 0 &&
|
|
218
|
+
!(await exists(join(archive, "agents"))) &&
|
|
219
|
+
!(await exists(join(archive, "extensions", "activations.json"))))
|
|
220
|
+
return { kind: "current" };
|
|
221
|
+
}
|
|
222
|
+
// The service stop removes service.json. Record its prior identity while
|
|
223
|
+
// the upgrade lock still holds it, before any stop or archive rename.
|
|
224
|
+
await writePendingUpgrade(home, prior);
|
|
225
|
+
let service = "absent";
|
|
226
|
+
if (registration !== undefined) {
|
|
227
|
+
// The upgrade is no bypass around the service's installed-client check.
|
|
228
|
+
// An uninstalled or stale window cannot signal a different live build.
|
|
229
|
+
if ((await exists(join(directory, "owner.sqlite"))) &&
|
|
230
|
+
(await serviceOwnerIsLive(directory)) &&
|
|
231
|
+
privateLocalServiceVersion() !==
|
|
232
|
+
(await (options.installedClientBuildId ?? readInstalledLocalClientBuildId)()))
|
|
233
|
+
throw new LocalServiceUpdated();
|
|
234
|
+
const expectedId = registration.kind === "current"
|
|
235
|
+
? registration.record.serviceId
|
|
236
|
+
: registration.kind === "other-protocol"
|
|
237
|
+
? registration.lifecycle.serviceId
|
|
238
|
+
: undefined;
|
|
239
|
+
const stop = options.stopService ??
|
|
240
|
+
createPrivateLocalServiceManager({ home, directory }).stop;
|
|
241
|
+
const stopped = await stop(expectedId);
|
|
242
|
+
if (stopped.kind === "replaced")
|
|
243
|
+
throw new Error(`The Fungi service in ${directory} changed during upgrade; retry before archiving`);
|
|
244
|
+
if (stopped.kind === "stopped" || stopped.kind === "killed")
|
|
245
|
+
service = stopped.kind;
|
|
246
|
+
}
|
|
247
|
+
if ((await readServiceRegistration(directory)) !== undefined ||
|
|
248
|
+
((await exists(join(directory, "owner.sqlite"))) &&
|
|
249
|
+
(await serviceOwnerIsLive(directory))))
|
|
250
|
+
throw new Error(`A Fungi service still owns ${directory}; no files were archived`);
|
|
251
|
+
await assertNoUntrackedServiceOwner(directory);
|
|
252
|
+
const archive = join(root, "archive", archiveSlug(prior));
|
|
253
|
+
const incompatible = await surveyIncompatibleLocalHome(home);
|
|
254
|
+
// The installed-registry owner also uses archive/unversioned. Only our
|
|
255
|
+
// own entries indicate an interrupted home upgrade to resume.
|
|
256
|
+
const existingArchive = (await exists(join(archive, "agents"))) ||
|
|
257
|
+
(await exists(join(archive, "extensions", "activations.json")));
|
|
258
|
+
if (incompatible.length === 0 &&
|
|
259
|
+
!existingArchive &&
|
|
260
|
+
service === "absent" &&
|
|
261
|
+
prior.kind === "unrecorded") {
|
|
262
|
+
await unlink(pendingPath(home));
|
|
263
|
+
return { kind: "current" };
|
|
264
|
+
}
|
|
265
|
+
news ??= await readLocalUpgradeNews(options.newsPath);
|
|
266
|
+
if (incompatible.length > 0) {
|
|
267
|
+
await mkdir(join(archive, "agents"), { recursive: true, mode: 0o700 });
|
|
268
|
+
await mkdir(join(archive, "extensions"), {
|
|
269
|
+
recursive: true,
|
|
270
|
+
mode: 0o700,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const archived = [];
|
|
274
|
+
for (const item of incompatible) {
|
|
275
|
+
options.signal?.throwIfAborted();
|
|
276
|
+
let destination;
|
|
277
|
+
for (const suffix of [
|
|
278
|
+
"",
|
|
279
|
+
"-2",
|
|
280
|
+
"-3",
|
|
281
|
+
"-4",
|
|
282
|
+
"-5",
|
|
283
|
+
"-6",
|
|
284
|
+
"-7",
|
|
285
|
+
"-8",
|
|
286
|
+
"-9",
|
|
287
|
+
]) {
|
|
288
|
+
const candidate = join(archive, item.folder, `${item.name}${suffix}`);
|
|
289
|
+
if (!(await exists(candidate))) {
|
|
290
|
+
destination = candidate;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (destination === undefined)
|
|
295
|
+
throw new Error(`Cannot archive ${item.from} under ${join(archive, item.folder)}: all destinations exist`);
|
|
296
|
+
try {
|
|
297
|
+
await rename(item.from, destination);
|
|
298
|
+
}
|
|
299
|
+
catch (cause) {
|
|
300
|
+
throw new Error(`Cannot archive ${item.from} to ${destination}`, {
|
|
301
|
+
cause,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
archived.push({ kind: item.kind, from: item.from, to: destination });
|
|
305
|
+
}
|
|
306
|
+
await writeLocalVersionMarker(home, {
|
|
307
|
+
release: news.release,
|
|
308
|
+
spore,
|
|
309
|
+
build: privateLocalServiceVersion(),
|
|
310
|
+
});
|
|
311
|
+
await unlink(pendingPath(home));
|
|
312
|
+
return {
|
|
313
|
+
kind: "upgraded",
|
|
314
|
+
report: {
|
|
315
|
+
from: prior,
|
|
316
|
+
to: news.release,
|
|
317
|
+
service,
|
|
318
|
+
archive: incompatible.length > 0 || existingArchive ? archive : undefined,
|
|
319
|
+
archived,
|
|
320
|
+
whatsNew: news.lines,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
await release();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export function formatLocalUpgradeNotice(report) {
|
|
329
|
+
const from = report.from.kind === "recorded"
|
|
330
|
+
? report.from.release
|
|
331
|
+
: report.from.kind === "protocol"
|
|
332
|
+
? `an earlier Fungi (protocol ${String(report.from.protocol)})`
|
|
333
|
+
: "an earlier Fungi";
|
|
334
|
+
const lines = [`Upgraded ${from} → ${report.to}`];
|
|
335
|
+
if (report.service === "stopped")
|
|
336
|
+
lines.push("Stopped the old Fungi service.");
|
|
337
|
+
if (report.service === "killed")
|
|
338
|
+
lines.push("Stopped the old Fungi service after its exit deadline.");
|
|
339
|
+
if (report.archive !== undefined) {
|
|
340
|
+
const agents = report.archived.filter((item) => item.kind === "agent-store").length;
|
|
341
|
+
const activations = report.archived.find((item) => item.kind === "extension-activations");
|
|
342
|
+
const contents = [
|
|
343
|
+
...(agents > 0
|
|
344
|
+
? [`${String(agents)} Agent ${agents === 1 ? "store" : "stores"}`]
|
|
345
|
+
: []),
|
|
346
|
+
...(activations !== undefined ? ["extension activations"] : []),
|
|
347
|
+
].join(" and ");
|
|
348
|
+
lines.push(report.archived.length === 0
|
|
349
|
+
? "The earlier archive is still at"
|
|
350
|
+
: `Archived ${contents} to`, ` ${report.archive} (nothing was deleted)`);
|
|
351
|
+
if (activations !== undefined)
|
|
352
|
+
lines.push(` Extension activations: ${activations.to}`);
|
|
353
|
+
}
|
|
354
|
+
const news = report.whatsNew.slice(0, 3);
|
|
355
|
+
if (news.length > 0)
|
|
356
|
+
lines.push("What's new:", ...news.map((line) => ` • ${line}`));
|
|
357
|
+
return lines;
|
|
358
|
+
}
|
|
@@ -4,6 +4,8 @@ import { createConversationAgents } from "@fungi.computer/shiitake-extensions/ro
|
|
|
4
4
|
export type LocalCreateRequest = Parameters<NonNullable<AgentControlDirectory["create"]>>[0] & Readonly<{
|
|
5
5
|
/** The Agent creating, resolved by the creator's own host, never by the caller. */
|
|
6
6
|
createdBy: string;
|
|
7
|
+
/** The creating Session, captured by the Local Agent-control host. */
|
|
8
|
+
createdBySessionId: string;
|
|
7
9
|
}>;
|
|
8
10
|
/**
|
|
9
11
|
* One ordinary Agent as the project's directory lists it. `unreadable` is
|
|
@@ -57,6 +59,7 @@ export declare function createPrivateLocalAgentControl(input: Readonly<{
|
|
|
57
59
|
acquire: (context: import("@fungi.computer/mycelium").CapabilitySourceContext) => Promise<import("@fungi.computer/mycelium").CapabilitySourceAcquisition>;
|
|
58
60
|
}>;
|
|
59
61
|
source: Readonly<{
|
|
62
|
+
hasLiveChildCommand: (sessionId: string) => boolean;
|
|
60
63
|
read: (sessionId: string, limit: number) => Promise<Readonly<{
|
|
61
64
|
agents: readonly import("@fungi.computer/shiitake-extensions/subagents/host").SubagentRecord[];
|
|
62
65
|
more: number;
|
|
@@ -17,7 +17,11 @@ export function createPrivateLocalAgentControl(input) {
|
|
|
17
17
|
create: async (request, signal) => {
|
|
18
18
|
if (parentSessionId === undefined)
|
|
19
19
|
throw new AgentCreateRefused("no_creator", "creation requires an invoking Session");
|
|
20
|
-
return create({
|
|
20
|
+
return create({
|
|
21
|
+
...request,
|
|
22
|
+
createdBy: input.agentId,
|
|
23
|
+
createdBySessionId: parentSessionId,
|
|
24
|
+
}, signal);
|
|
21
25
|
},
|
|
22
26
|
};
|
|
23
27
|
}
|