tinker-agent 2.13.0 → 3.0.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 +53 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
package/src/cli/serve-runner.ts
CHANGED
|
@@ -1,45 +1,179 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadPackageMetadata } from "./package-metadata";
|
|
2
|
+
import { prepareDefaultServiceConfig } from "./default-service-config";
|
|
3
|
+
import { stopLocalService } from "./service-control";
|
|
4
|
+
import {
|
|
5
|
+
disableSupervisor,
|
|
6
|
+
installSupervisor,
|
|
7
|
+
startSupervisor,
|
|
8
|
+
uninstallSupervisor,
|
|
9
|
+
supervisedEnvironment,
|
|
10
|
+
supervisorPaths,
|
|
11
|
+
} from "./service-supervisor";
|
|
12
|
+
import {
|
|
13
|
+
defaultServiceConfigPath,
|
|
14
|
+
discoverLocalService,
|
|
15
|
+
loadLocalServiceTarget,
|
|
16
|
+
publishLocalService,
|
|
17
|
+
} from "../remote/local-service-discovery";
|
|
18
|
+
import { ensureLocalService } from "./local-service-start";
|
|
2
19
|
import { startRemoteHttpServer } from "../remote/http-server";
|
|
3
20
|
import { RemoteService } from "../remote/service";
|
|
4
21
|
import { RemoteServiceStore } from "../remote/service-store";
|
|
5
|
-
import { defaultHomeRoot } from "../session/workspace-storage";
|
|
6
22
|
import { createHostedRuntimeFactory } from "./serve-runtime";
|
|
7
23
|
import { writeCliOutput, type CliOutputWriter } from "./output";
|
|
8
24
|
|
|
9
25
|
export async function runServe(input: {
|
|
10
|
-
configPath
|
|
26
|
+
configPath?: string;
|
|
27
|
+
background?: boolean;
|
|
28
|
+
install?: boolean;
|
|
29
|
+
uninstall?: boolean;
|
|
30
|
+
stop?: boolean;
|
|
31
|
+
restart?: boolean;
|
|
32
|
+
force?: boolean;
|
|
33
|
+
status?: boolean;
|
|
11
34
|
env: NodeJS.ProcessEnv;
|
|
12
35
|
stdout: CliOutputWriter;
|
|
13
36
|
}): Promise<number> {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
homeRoot,
|
|
37
|
+
const env = await supervisedEnvironment(input.env);
|
|
38
|
+
// MCP and tool subprocesses inherit the service process environment.
|
|
39
|
+
if (input.env.TINKER_SERVICE_ENV_FILE) Object.assign(process.env, env);
|
|
40
|
+
if (input.install && !input.configPath) await prepareDefaultServiceConfig(env);
|
|
41
|
+
const target = await loadLocalServiceTarget(
|
|
42
|
+
input.configPath ?? defaultServiceConfigPath(env),
|
|
43
|
+
env,
|
|
22
44
|
);
|
|
45
|
+
if (input.install && (await Bun.file(supervisorPaths(target).marker).exists())) {
|
|
46
|
+
const instance = await ensureLocalService(target, env);
|
|
47
|
+
await writeCliOutput(
|
|
48
|
+
input.stdout,
|
|
49
|
+
`${JSON.stringify({ status: "online", ...instance })}\n`,
|
|
50
|
+
);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (input.install || input.uninstall || input.stop || input.restart) {
|
|
54
|
+
await disableSupervisor(target);
|
|
55
|
+
try {
|
|
56
|
+
await stopLocalService(target, input.force);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await startSupervisor(target).catch(() => undefined);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (input.uninstall) await uninstallSupervisor(target);
|
|
62
|
+
if (input.install) await installSupervisor(target, env);
|
|
63
|
+
const instance =
|
|
64
|
+
input.install || input.restart
|
|
65
|
+
? await ensureLocalService(target, env)
|
|
66
|
+
: undefined;
|
|
67
|
+
await writeCliOutput(
|
|
68
|
+
input.stdout,
|
|
69
|
+
`${JSON.stringify(instance ? { status: "online", ...instance } : { status: "stopped", stateDirectory: target.config.stateDirectory })}\n`,
|
|
70
|
+
);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (input.background || input.status) {
|
|
74
|
+
const instance = input.status
|
|
75
|
+
? await discoverLocalService(target, true)
|
|
76
|
+
: await ensureLocalService(target, env);
|
|
77
|
+
await writeCliOutput(
|
|
78
|
+
input.stdout,
|
|
79
|
+
`${JSON.stringify(
|
|
80
|
+
instance
|
|
81
|
+
? { status: "online", ...instance }
|
|
82
|
+
: {
|
|
83
|
+
status: "offline",
|
|
84
|
+
configPath: target.configPath,
|
|
85
|
+
stateDirectory: target.config.stateDirectory,
|
|
86
|
+
},
|
|
87
|
+
)}\n`,
|
|
88
|
+
);
|
|
89
|
+
return instance ? 0 : 1;
|
|
90
|
+
}
|
|
91
|
+
const { config, homeRoot } = target;
|
|
92
|
+
const store = await RemoteServiceStore.open(config.stateDirectory);
|
|
93
|
+
let service: RemoteService;
|
|
94
|
+
try {
|
|
95
|
+
service = new RemoteService(
|
|
96
|
+
store,
|
|
97
|
+
config.workspaces,
|
|
98
|
+
createHostedRuntimeFactory(() => service.workspaces, env, homeRoot),
|
|
99
|
+
homeRoot,
|
|
100
|
+
config.resident,
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await store.close();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
23
106
|
let transport: ReturnType<typeof startRemoteHttpServer> | undefined;
|
|
107
|
+
let unpublish: (() => Promise<void>) | undefined;
|
|
108
|
+
let stopRequested = false;
|
|
109
|
+
let signalStop: (() => void) | undefined;
|
|
110
|
+
let hardStop: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
+
const setDeadline = () => {
|
|
112
|
+
hardStop ??= setTimeout(
|
|
113
|
+
() => {
|
|
114
|
+
console.error(
|
|
115
|
+
"Service shutdown timed out; recovery will mark unfinished work interrupted on restart.",
|
|
116
|
+
);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
},
|
|
119
|
+
(config.resident?.shutdownGraceMs ?? 30000) + 10000,
|
|
120
|
+
);
|
|
121
|
+
hardStop.unref();
|
|
122
|
+
};
|
|
123
|
+
const stop = () => {
|
|
124
|
+
setDeadline();
|
|
125
|
+
stopRequested = true;
|
|
126
|
+
signalStop?.();
|
|
127
|
+
};
|
|
128
|
+
const onSignal = () => {
|
|
129
|
+
setDeadline();
|
|
130
|
+
void service
|
|
131
|
+
.drain(true)
|
|
132
|
+
.catch(() => undefined)
|
|
133
|
+
.finally(stop);
|
|
134
|
+
};
|
|
135
|
+
process.once("SIGINT", onSignal);
|
|
136
|
+
process.once("SIGTERM", onSignal);
|
|
24
137
|
try {
|
|
25
138
|
await service.initialize();
|
|
26
139
|
transport = startRemoteHttpServer(service, config);
|
|
140
|
+
if (stopRequested) return 0;
|
|
141
|
+
if (process.platform !== "win32")
|
|
142
|
+
unpublish = await publishLocalService(
|
|
143
|
+
target,
|
|
144
|
+
service.epoch,
|
|
145
|
+
transport.port,
|
|
146
|
+
(directory) => service.registerLocalWorkspace(directory),
|
|
147
|
+
{
|
|
148
|
+
appVersion: (await loadPackageMetadata()).version,
|
|
149
|
+
status: () => service.residentStatus(),
|
|
150
|
+
shutdown: async (force) => {
|
|
151
|
+
await service.drain(force);
|
|
152
|
+
return stop;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
);
|
|
27
156
|
await writeCliOutput(
|
|
28
157
|
input.stdout,
|
|
29
|
-
`Tinker service listening on https://${config.hostname}:${transport.port}; ${
|
|
158
|
+
`Tinker service listening on https://${config.hostname === "::1" ? "[::1]" : config.hostname}:${transport.port}; ${service.workspaces.length} workspace(s).\nClient disconnects detach only. Stop the process to shut down hosted sessions.\n`,
|
|
30
159
|
);
|
|
31
160
|
await new Promise<void>((resolve) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
process.off("SIGTERM", stop);
|
|
35
|
-
resolve();
|
|
36
|
-
};
|
|
37
|
-
process.once("SIGINT", stop);
|
|
38
|
-
process.once("SIGTERM", stop);
|
|
161
|
+
signalStop = resolve;
|
|
162
|
+
if (stopRequested) resolve();
|
|
39
163
|
});
|
|
40
164
|
return 0;
|
|
41
165
|
} finally {
|
|
42
|
-
|
|
43
|
-
|
|
166
|
+
process.off("SIGINT", onSignal);
|
|
167
|
+
process.off("SIGTERM", onSignal);
|
|
168
|
+
try {
|
|
169
|
+
await unpublish?.();
|
|
170
|
+
} finally {
|
|
171
|
+
try {
|
|
172
|
+
await transport?.stopTransport();
|
|
173
|
+
} finally {
|
|
174
|
+
await service.close();
|
|
175
|
+
if (hardStop) clearTimeout(hardStop);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
44
178
|
}
|
|
45
179
|
}
|
package/src/cli/serve-runtime.ts
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
|
-
import { createRuntimeSession } from "../agent/runtime-session";
|
|
2
1
|
import { parseSessionId } from "../ids/runtime-id";
|
|
3
|
-
import {
|
|
4
|
-
buildSystemPrompt,
|
|
5
|
-
loadProjectInstructions,
|
|
6
|
-
projectInstructionManifest,
|
|
7
|
-
} from "../instructions/project-instructions";
|
|
8
|
-
import { createReasoningEffortController } from "../model/reasoning-effort";
|
|
9
2
|
import { resolveSessionDatabasePath } from "../session/session-store";
|
|
10
3
|
import { SessionCatalog } from "../session/session-catalog";
|
|
11
|
-
import { loadSkillCatalog } from "../skills/skill-loader";
|
|
12
4
|
import type { RemoteWorkspaceConfig } from "../remote/config";
|
|
13
5
|
import type { HostedRuntimeFactory } from "../agent/runtime-hosted-session";
|
|
14
6
|
import { deriveRunnerConfig, resolvePublicConfig } from "./config";
|
|
15
7
|
import { resolveSessionProfileName } from "./model-profiles";
|
|
16
|
-
import {
|
|
17
|
-
createRunnerModelClient,
|
|
18
|
-
createWebFetchRefiner,
|
|
19
|
-
RUNTIME_INSTRUCTIONS,
|
|
20
|
-
} from "./runner-dependencies";
|
|
8
|
+
import { createInteractiveRuntimeSession } from "./interactive-runtime";
|
|
21
9
|
|
|
22
10
|
/** Service composition reuses the existing configuration/provider/runtime contracts. */
|
|
23
11
|
export function createHostedRuntimeFactory(
|
|
24
|
-
workspaces:
|
|
12
|
+
workspaces:
|
|
13
|
+
| readonly RemoteWorkspaceConfig[]
|
|
14
|
+
| (() => readonly RemoteWorkspaceConfig[]),
|
|
25
15
|
env: NodeJS.ProcessEnv,
|
|
26
16
|
homeRoot?: string,
|
|
27
17
|
): HostedRuntimeFactory {
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
const entries = () => (typeof workspaces === "function" ? workspaces() : workspaces);
|
|
19
|
+
const factory: HostedRuntimeFactory = async ({ record, sink, lease }) => {
|
|
20
|
+
const workspace = entries().find((entry) => entry.id === record.workspaceId);
|
|
30
21
|
if (!workspace || workspace.path !== record.workspacePath)
|
|
31
22
|
throw new Error("Managed workspace configuration changed.");
|
|
32
23
|
const sessionId = parseSessionId(record.id);
|
|
@@ -34,7 +25,7 @@ export function createHostedRuntimeFactory(
|
|
|
34
25
|
env: { ...env, TINKER_WORKSPACE: workspace.path },
|
|
35
26
|
cwd: workspace.path,
|
|
36
27
|
});
|
|
37
|
-
let profileName = workspace.profile;
|
|
28
|
+
let profileName = record.profileName ?? workspace.profile;
|
|
38
29
|
if (record.initialized && publicConfig.mode === "profile") {
|
|
39
30
|
const summary = await new SessionCatalog({
|
|
40
31
|
workspaceRoot: workspace.path,
|
|
@@ -46,38 +37,16 @@ export function createHostedRuntimeFactory(
|
|
|
46
37
|
sessionId,
|
|
47
38
|
...(profileName ? { profileName } : {}),
|
|
48
39
|
});
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
const runtime = await createInteractiveRuntimeSession({
|
|
41
|
+
config,
|
|
42
|
+
sessionLease: lease,
|
|
52
43
|
workspaceRoot: workspace.path,
|
|
53
44
|
...(homeRoot === undefined ? {} : { homeRoot }),
|
|
54
|
-
|
|
55
|
-
? { selection: { mode: "resume" as const, sessionId } }
|
|
56
|
-
: { selection: { mode: "new" as const, sessionId } }),
|
|
57
|
-
modelName: config.modelName,
|
|
58
|
-
profileName: config.profileName,
|
|
59
|
-
maxIterations: config.maxIterations,
|
|
60
|
-
includeReasoningContent: config.includeReasoningContent,
|
|
61
|
-
contextProfile: config.contextProfile,
|
|
62
|
-
contextBudget: config.contextBudget,
|
|
63
|
-
modelClient: createRunnerModelClient(config, undefined, env, reasoning),
|
|
64
|
-
systemPrompt: buildSystemPrompt({
|
|
65
|
-
workspaceRoot: workspace.path,
|
|
66
|
-
runtimeInstructions: RUNTIME_INSTRUCTIONS(workspace.path),
|
|
67
|
-
projectInstructions,
|
|
68
|
-
}),
|
|
69
|
-
projectInstruction: projectInstructionManifest(projectInstructions),
|
|
70
|
-
skillCatalog: await loadSkillCatalog({ workspaceRoot: workspace.path }),
|
|
71
|
-
presentationSinks: [sink],
|
|
72
|
-
assistantTextDeltaSink: sink,
|
|
45
|
+
selection: { mode: record.initialized ? "resume" : "new", sessionId },
|
|
73
46
|
toolingConfig: publicConfig.tooling,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
mode: config.bashGuardMode,
|
|
78
|
-
source: config.bashGuardSource,
|
|
79
|
-
surface: "tui",
|
|
80
|
-
},
|
|
47
|
+
env,
|
|
48
|
+
sink,
|
|
49
|
+
owner: "service",
|
|
81
50
|
});
|
|
82
51
|
try {
|
|
83
52
|
return {
|
|
@@ -88,6 +57,8 @@ export function createHostedRuntimeFactory(
|
|
|
88
57
|
homeRoot,
|
|
89
58
|
),
|
|
90
59
|
modelName: config.modelName,
|
|
60
|
+
profileName: config.profileName,
|
|
61
|
+
modelCatalog: await factory.profiles!(workspace.id),
|
|
91
62
|
};
|
|
92
63
|
} catch (error) {
|
|
93
64
|
await runtime.dispose({
|
|
@@ -97,4 +68,34 @@ export function createHostedRuntimeFactory(
|
|
|
97
68
|
throw error;
|
|
98
69
|
}
|
|
99
70
|
};
|
|
71
|
+
const readConfig = async (workspaceId: string) => {
|
|
72
|
+
const workspace = entries().find((entry) => entry.id === workspaceId);
|
|
73
|
+
if (!workspace) throw new Error("Workspace is not configured.");
|
|
74
|
+
return resolvePublicConfig({
|
|
75
|
+
env: { ...env, TINKER_WORKSPACE: workspace.path },
|
|
76
|
+
cwd: workspace.path,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
factory.profiles = async (workspaceId) => {
|
|
80
|
+
const config = await readConfig(workspaceId);
|
|
81
|
+
if (config.mode !== "profile") return undefined;
|
|
82
|
+
return {
|
|
83
|
+
defaultProfile: config.profiles.defaultProfile,
|
|
84
|
+
profiles: [...config.profiles.profiles.values()].map(
|
|
85
|
+
({ name, model, contextWindowTokens, maxSupportedOutputTokens }) => ({
|
|
86
|
+
name,
|
|
87
|
+
model,
|
|
88
|
+
contextWindowTokens,
|
|
89
|
+
maxSupportedOutputTokens,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
factory.persistDefaultProfile = async (workspaceId, profileName) => {
|
|
95
|
+
const config = await readConfig(workspaceId);
|
|
96
|
+
if (config.mode !== "profile")
|
|
97
|
+
throw new Error("Model profiles are not configured.");
|
|
98
|
+
await config.persistDefaultProfile(profileName);
|
|
99
|
+
};
|
|
100
|
+
return factory;
|
|
100
101
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { inspectSessionLock } from "../session/session-lock";
|
|
2
|
+
import { SERVICE_LEASE_ID } from "../remote/service-store";
|
|
3
|
+
import {
|
|
4
|
+
shutdownLocalService,
|
|
5
|
+
type LocalServiceTarget,
|
|
6
|
+
} from "../remote/local-service-discovery";
|
|
7
|
+
|
|
8
|
+
/** Uses the private live endpoint; never sends signals to a PID from stale metadata. */
|
|
9
|
+
export async function stopLocalService(
|
|
10
|
+
target: LocalServiceTarget,
|
|
11
|
+
force = false,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
await shutdownLocalService(target, force);
|
|
14
|
+
const deadline =
|
|
15
|
+
Date.now() + (target.config.resident?.shutdownGraceMs ?? 30000) + 15000;
|
|
16
|
+
while (Date.now() < deadline) {
|
|
17
|
+
const ownership = await inspectSessionLock({
|
|
18
|
+
sessionDirectory: target.config.stateDirectory,
|
|
19
|
+
sessionId: SERVICE_LEASE_ID,
|
|
20
|
+
});
|
|
21
|
+
if (ownership === "none" || ownership === "stale") return;
|
|
22
|
+
await Bun.sleep(50);
|
|
23
|
+
}
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Service has not released ownership. Inspect ${target.config.stateDirectory}; no process was killed automatically.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile, chmod } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { LocalServiceTarget } from "../remote/local-service-discovery";
|
|
7
|
+
|
|
8
|
+
export function supervisorPaths(target: LocalServiceTarget) {
|
|
9
|
+
const label = `dev.tinker.service.${createHash("sha256").update(target.config.stateDirectory).digest("hex").slice(0, 16)}`;
|
|
10
|
+
return {
|
|
11
|
+
label,
|
|
12
|
+
job: `gui/${process.getuid?.()}/${label}`,
|
|
13
|
+
plist: path.join(homedir(), "Library/LaunchAgents", `${label}.plist`),
|
|
14
|
+
enabled: path.join(target.config.stateDirectory, "supervisor.enabled"),
|
|
15
|
+
environment: path.join(target.config.stateDirectory, "supervisor-env.json"),
|
|
16
|
+
marker: path.join(target.config.stateDirectory, "supervisor.json"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const xml = (text: string) =>
|
|
20
|
+
text.replace(
|
|
21
|
+
/[&<>"']/g,
|
|
22
|
+
(c) =>
|
|
23
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
|
24
|
+
);
|
|
25
|
+
export function launchAgentPlist(target: LocalServiceTarget): string {
|
|
26
|
+
const files = supervisorPaths(target);
|
|
27
|
+
const args = [
|
|
28
|
+
process.execPath,
|
|
29
|
+
fileURLToPath(new URL("./index.ts", import.meta.url)),
|
|
30
|
+
"serve",
|
|
31
|
+
"--config",
|
|
32
|
+
target.configPath,
|
|
33
|
+
];
|
|
34
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
|
|
35
|
+
<key>Label</key><string>${xml(files.label)}</string>
|
|
36
|
+
<key>ProgramArguments</key><array>${args.map((value) => `<string>${xml(value)}</string>`).join("")}</array>
|
|
37
|
+
<key>WorkingDirectory</key><string>${xml(path.dirname(target.configPath))}</string>
|
|
38
|
+
<key>EnvironmentVariables</key><dict><key>TINKER_HOME</key><string>${xml(target.homeRoot)}</string><key>TINKER_SERVICE_ENV_FILE</key><string>${xml(files.environment)}</string></dict>
|
|
39
|
+
<key>KeepAlive</key><dict><key>PathState</key><dict><key>${xml(files.enabled)}</key><true/></dict></dict>
|
|
40
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
41
|
+
<key>ExitTimeOut</key><integer>${Math.ceil((target.config.resident?.shutdownGraceMs ?? 30000) / 1000) + 10}</integer>
|
|
42
|
+
<key>StandardOutPath</key><string>${xml(path.join(target.config.stateDirectory, "service.log"))}</string>
|
|
43
|
+
<key>StandardErrorPath</key><string>${xml(path.join(target.config.stateDirectory, "service.log"))}</string>
|
|
44
|
+
<key>Umask</key><integer>63</integer>
|
|
45
|
+
</dict></plist>\n`;
|
|
46
|
+
}
|
|
47
|
+
export async function launchctl(args: string[]): Promise<void> {
|
|
48
|
+
const child = Bun.spawn(["/bin/launchctl", ...args], {
|
|
49
|
+
stdout: "ignore",
|
|
50
|
+
stderr: "pipe",
|
|
51
|
+
});
|
|
52
|
+
const [error, code] = await Promise.all([
|
|
53
|
+
new Response(child.stderr).text(),
|
|
54
|
+
child.exited,
|
|
55
|
+
]);
|
|
56
|
+
if (code !== 0)
|
|
57
|
+
throw new Error(`launchctl ${args[0]} failed (${code}): ${error.trim()}`);
|
|
58
|
+
}
|
|
59
|
+
export async function installSupervisor(
|
|
60
|
+
target: LocalServiceTarget,
|
|
61
|
+
env: NodeJS.ProcessEnv,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
if (process.platform !== "darwin")
|
|
64
|
+
throw new Error(
|
|
65
|
+
"System supervision currently supports macOS LaunchAgents; use serve --background elsewhere.",
|
|
66
|
+
);
|
|
67
|
+
const files = supervisorPaths(target);
|
|
68
|
+
if (await Bun.file(files.marker).exists())
|
|
69
|
+
throw new Error(
|
|
70
|
+
"Supervision is already installed. Stop and uninstall before replacing its configuration.",
|
|
71
|
+
);
|
|
72
|
+
await mkdir(path.dirname(files.plist), { recursive: true, mode: 0o700 });
|
|
73
|
+
await mkdir(target.config.stateDirectory, { recursive: true, mode: 0o700 });
|
|
74
|
+
const kept = Object.fromEntries(
|
|
75
|
+
Object.entries(env).filter(
|
|
76
|
+
([key, value]) =>
|
|
77
|
+
value !== undefined &&
|
|
78
|
+
key !== "TINKER_SERVICE_ENV_FILE" &&
|
|
79
|
+
(key.startsWith("TINKER_") ||
|
|
80
|
+
[
|
|
81
|
+
"PATH",
|
|
82
|
+
"HOME",
|
|
83
|
+
"LANG",
|
|
84
|
+
"LC_ALL",
|
|
85
|
+
"TZ",
|
|
86
|
+
"EXA_API_KEY",
|
|
87
|
+
"HTTP_PROXY",
|
|
88
|
+
"HTTPS_PROXY",
|
|
89
|
+
"ALL_PROXY",
|
|
90
|
+
"NO_PROXY",
|
|
91
|
+
"SSL_CERT_FILE",
|
|
92
|
+
"SSL_CERT_DIR",
|
|
93
|
+
].includes(key)),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
await writeFile(
|
|
97
|
+
files.environment,
|
|
98
|
+
JSON.stringify({ ...kept, TINKER_HOME: target.homeRoot }) + "\n",
|
|
99
|
+
{ mode: 0o600, flag: "wx" },
|
|
100
|
+
);
|
|
101
|
+
let wrotePlist = false;
|
|
102
|
+
let bootstrapped = false;
|
|
103
|
+
try {
|
|
104
|
+
await writeFile(files.plist, launchAgentPlist(target), { mode: 0o600, flag: "wx" });
|
|
105
|
+
wrotePlist = true;
|
|
106
|
+
await writeFile(files.enabled, "enabled\n", { mode: 0o600 });
|
|
107
|
+
await launchctl(["bootstrap", `gui/${process.getuid?.()}`, files.plist]);
|
|
108
|
+
bootstrapped = true;
|
|
109
|
+
await writeFile(
|
|
110
|
+
files.marker,
|
|
111
|
+
JSON.stringify({ version: 1, label: files.label }) + "\n",
|
|
112
|
+
{ mode: 0o600 },
|
|
113
|
+
);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await rm(files.enabled, { force: true });
|
|
116
|
+
if (bootstrapped) await launchctl(["bootout", files.job]).catch(() => undefined);
|
|
117
|
+
await Promise.all(
|
|
118
|
+
[...(wrotePlist ? [files.plist] : []), files.environment].map((file) =>
|
|
119
|
+
rm(file, { force: true }),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function disableSupervisor(target: LocalServiceTarget): Promise<void> {
|
|
126
|
+
await rm(supervisorPaths(target).enabled, { force: true });
|
|
127
|
+
}
|
|
128
|
+
export async function startSupervisor(target: LocalServiceTarget): Promise<boolean> {
|
|
129
|
+
const files = supervisorPaths(target);
|
|
130
|
+
if (!(await Bun.file(files.marker).exists())) return false;
|
|
131
|
+
if (process.platform !== "darwin")
|
|
132
|
+
throw new Error("This service is managed by macOS launchd.");
|
|
133
|
+
await writeFile(files.enabled, "enabled\n", { mode: 0o600 });
|
|
134
|
+
// A job may be registered after a login, or may need bootstrap after an explicit bootout.
|
|
135
|
+
try {
|
|
136
|
+
await launchctl(["print", files.job]);
|
|
137
|
+
} catch {
|
|
138
|
+
await launchctl(["bootstrap", `gui/${process.getuid?.()}`, files.plist]);
|
|
139
|
+
}
|
|
140
|
+
await launchctl(["kickstart", files.job]);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
export async function uninstallSupervisor(target: LocalServiceTarget): Promise<void> {
|
|
144
|
+
const files = supervisorPaths(target);
|
|
145
|
+
if (!(await Bun.file(files.marker).exists())) return;
|
|
146
|
+
await disableSupervisor(target);
|
|
147
|
+
await launchctl(["bootout", files.job]);
|
|
148
|
+
await Promise.all(
|
|
149
|
+
[files.plist, files.marker, files.environment].map((file) =>
|
|
150
|
+
rm(file, { force: true }),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
export async function supervisedEnvironment(
|
|
155
|
+
env: NodeJS.ProcessEnv,
|
|
156
|
+
): Promise<NodeJS.ProcessEnv> {
|
|
157
|
+
if (!env.TINKER_SERVICE_ENV_FILE) return env;
|
|
158
|
+
const values = JSON.parse(
|
|
159
|
+
await readFile(env.TINKER_SERVICE_ENV_FILE, "utf8"),
|
|
160
|
+
) as Record<string, unknown>;
|
|
161
|
+
if (
|
|
162
|
+
!values ||
|
|
163
|
+
Array.isArray(values) ||
|
|
164
|
+
typeof values !== "object" ||
|
|
165
|
+
Object.values(values).some((value) => typeof value !== "string")
|
|
166
|
+
)
|
|
167
|
+
throw new Error("Invalid supervised service environment.");
|
|
168
|
+
await chmod(env.TINKER_SERVICE_ENV_FILE, 0o600);
|
|
169
|
+
return { ...env, ...values } as NodeJS.ProcessEnv;
|
|
170
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, readdir, realpath, readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
localServicePaths,
|
|
7
|
+
type LocalServiceInstance,
|
|
8
|
+
} from "../remote/local-service-discovery";
|
|
9
|
+
|
|
10
|
+
/** Check every live service using this installation, including explicit non-default state directories. */
|
|
11
|
+
export async function assertServiceUpgradeSafe(packageRoot: string): Promise<void> {
|
|
12
|
+
if (process.platform === "win32") return;
|
|
13
|
+
const root = path.dirname(localServicePaths("").socket);
|
|
14
|
+
let entries: string[];
|
|
15
|
+
try {
|
|
16
|
+
const info = await lstat(root);
|
|
17
|
+
if (
|
|
18
|
+
!info.isDirectory() ||
|
|
19
|
+
info.isSymbolicLink() ||
|
|
20
|
+
info.uid !== process.getuid?.() ||
|
|
21
|
+
info.mode & 0o077
|
|
22
|
+
)
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Unsafe local service discovery directory; cannot verify upgrade safety.",
|
|
25
|
+
);
|
|
26
|
+
entries = await readdir(root);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const canonical = await realpath(packageRoot);
|
|
32
|
+
for (const entry of entries.filter((name) =>
|
|
33
|
+
/^[a-f0-9]{32}\.sock\.json$/.test(name),
|
|
34
|
+
)) {
|
|
35
|
+
const instance = JSON.parse(
|
|
36
|
+
await readFile(path.join(root, entry), "utf8"),
|
|
37
|
+
) as LocalServiceInstance;
|
|
38
|
+
if (
|
|
39
|
+
instance.packageRoot === canonical &&
|
|
40
|
+
(await Bun.file(
|
|
41
|
+
path.join(instance.stateDirectory, "supervisor.enabled"),
|
|
42
|
+
).exists())
|
|
43
|
+
)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Disable the supervised service before upgrading: tinker serve --config ${instance.configPath} --stop.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries.filter((name) => /^[a-f0-9]{32}\.sock$/.test(name))) {
|
|
49
|
+
const instance = await probe(path.join(root, entry));
|
|
50
|
+
if (instance && (!instance.packageRoot || instance.packageRoot === canonical))
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Stop the running service before upgrading: tinker serve --config ${instance.configPath} --stop. Upgrade does not interrupt work or replace a live runtime.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function probe(socketPath: string): Promise<LocalServiceInstance | undefined> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const socket = createConnection(socketPath);
|
|
59
|
+
const nonce = randomUUID();
|
|
60
|
+
let content = "";
|
|
61
|
+
let finished = false;
|
|
62
|
+
const done = (instance?: LocalServiceInstance, error?: Error) => {
|
|
63
|
+
if (finished) return;
|
|
64
|
+
finished = true;
|
|
65
|
+
socket.destroy();
|
|
66
|
+
if (error) reject(error);
|
|
67
|
+
else resolve(instance);
|
|
68
|
+
};
|
|
69
|
+
socket.setEncoding("utf8");
|
|
70
|
+
socket.setTimeout(1000, () =>
|
|
71
|
+
done(
|
|
72
|
+
undefined,
|
|
73
|
+
new Error(
|
|
74
|
+
"A local service is unresponsive; stop it explicitly before upgrading.",
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
socket.once("connect", () => socket.write(`${nonce}\n`));
|
|
79
|
+
socket.once("error", (error: NodeJS.ErrnoException) =>
|
|
80
|
+
["ENOENT", "ECONNREFUSED"].includes(error.code ?? "")
|
|
81
|
+
? done()
|
|
82
|
+
: done(undefined, error),
|
|
83
|
+
);
|
|
84
|
+
socket.once("end", () => done());
|
|
85
|
+
socket.on("data", (chunk: string) => {
|
|
86
|
+
content += chunk;
|
|
87
|
+
if (content.length > 16384)
|
|
88
|
+
return done(undefined, new Error("Invalid service upgrade probe response."));
|
|
89
|
+
if (!content.includes("\n")) return;
|
|
90
|
+
try {
|
|
91
|
+
const response = JSON.parse(content.trim()) as {
|
|
92
|
+
nonce: string;
|
|
93
|
+
instance: LocalServiceInstance;
|
|
94
|
+
};
|
|
95
|
+
if (response.nonce !== nonce || response.instance?.version !== 1)
|
|
96
|
+
throw new Error("Invalid service upgrade probe response.");
|
|
97
|
+
done(response.instance);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
done(
|
|
100
|
+
undefined,
|
|
101
|
+
error instanceof Error ? error : new Error("Invalid service response."),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|