tinker-agent 2.9.0 → 2.11.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 +69 -1
- package/README.md +30 -1
- package/package.json +5 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +11 -0
- package/src/agent/runtime-session.ts +29 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/cli/tui-runner.tsx +1 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/session/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store.ts +7 -3
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +148 -155
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/app.tsx +45 -3
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +25 -2
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/remote-app.tsx +210 -0
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- package/src/tui/workspace-file-search.ts +21 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { authenticateDevice, type RemoteServiceConfig } from "./config";
|
|
3
|
+
import {
|
|
4
|
+
RemoteError,
|
|
5
|
+
parseOperation,
|
|
6
|
+
requireId,
|
|
7
|
+
type RemoteCursor,
|
|
8
|
+
type RemoteFrame,
|
|
9
|
+
} from "./protocol";
|
|
10
|
+
import { RemoteService } from "./service";
|
|
11
|
+
import type { HostedSession } from "../agent/runtime-hosted-session";
|
|
12
|
+
|
|
13
|
+
type SocketData = {
|
|
14
|
+
session: HostedSession;
|
|
15
|
+
cursor?: RemoteCursor;
|
|
16
|
+
unsubscribe?: () => void;
|
|
17
|
+
device: string;
|
|
18
|
+
};
|
|
19
|
+
const SOCKET_BUFFER_LIMIT = 2 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export function startRemoteHttpServer(
|
|
22
|
+
service: RemoteService,
|
|
23
|
+
config: RemoteServiceConfig,
|
|
24
|
+
) {
|
|
25
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
26
|
+
const server = Bun.serve<SocketData>({
|
|
27
|
+
hostname: config.hostname,
|
|
28
|
+
port: config.port,
|
|
29
|
+
tls: {
|
|
30
|
+
cert: Bun.file(config.tls.certFile),
|
|
31
|
+
key: Bun.file(config.tls.keyFile),
|
|
32
|
+
},
|
|
33
|
+
maxRequestBodySize: 72 * 1024,
|
|
34
|
+
idleTimeout: 30,
|
|
35
|
+
async fetch(request, server) {
|
|
36
|
+
try {
|
|
37
|
+
// Native clients authenticate in headers. Browser-origin requests are not supported.
|
|
38
|
+
if (request.headers.has("origin"))
|
|
39
|
+
throw new RemoteError(
|
|
40
|
+
403,
|
|
41
|
+
"ORIGIN_REJECTED",
|
|
42
|
+
"Browser origins are not enabled.",
|
|
43
|
+
);
|
|
44
|
+
const device = authenticateDevice(
|
|
45
|
+
request.headers.get("authorization"),
|
|
46
|
+
config.devices,
|
|
47
|
+
);
|
|
48
|
+
if (!device)
|
|
49
|
+
throw new RemoteError(
|
|
50
|
+
401,
|
|
51
|
+
"AUTH_REQUIRED",
|
|
52
|
+
"Pair this device with the Mac before connecting.",
|
|
53
|
+
);
|
|
54
|
+
const url = new URL(request.url);
|
|
55
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
56
|
+
if (parts[0] !== "v1")
|
|
57
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown API version or route.");
|
|
58
|
+
if (request.method === "GET" && url.pathname === "/v1/workspaces") {
|
|
59
|
+
return json({
|
|
60
|
+
version: 1,
|
|
61
|
+
workspaces: service.workspaces.map(({ id, name }) => ({
|
|
62
|
+
id,
|
|
63
|
+
name,
|
|
64
|
+
})),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
request.method === "GET" &&
|
|
69
|
+
parts.length === 4 &&
|
|
70
|
+
parts[1] === "workspaces" &&
|
|
71
|
+
parts[3] === "sessions"
|
|
72
|
+
) {
|
|
73
|
+
return json({
|
|
74
|
+
sessions: await service.listSessions(requireId(parts[2], "workspaceId")),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (parts[1] === "operations") {
|
|
78
|
+
if (request.method === "POST" && parts.length === 2) {
|
|
79
|
+
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
80
|
+
throw new RemoteError(415, "JSON_REQUIRED", "Send application/json.");
|
|
81
|
+
const input = parseOperation(await request.json());
|
|
82
|
+
// Never use request.signal as a runtime cancellation signal.
|
|
83
|
+
return json(await service.submit(input, device), 202);
|
|
84
|
+
}
|
|
85
|
+
if (request.method === "GET" && parts.length === 3)
|
|
86
|
+
return json(service.store.get(requireId(parts[2], "requestId", true)));
|
|
87
|
+
}
|
|
88
|
+
if (request.method === "GET" && parts.length === 4 && parts[1] === "sessions") {
|
|
89
|
+
const session = service.session(requireId(parts[2], "sessionId", true));
|
|
90
|
+
await session.open();
|
|
91
|
+
if (parts[3] === "snapshot") return json(session.hub.snapshot());
|
|
92
|
+
if (parts[3] === "history") {
|
|
93
|
+
return json(
|
|
94
|
+
session.history(
|
|
95
|
+
optionalInteger(
|
|
96
|
+
url.searchParams.get("before"),
|
|
97
|
+
1,
|
|
98
|
+
Number.MAX_SAFE_INTEGER,
|
|
99
|
+
),
|
|
100
|
+
optionalInteger(url.searchParams.get("limit"), 1, 100),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (parts[3] === "events") {
|
|
105
|
+
if (sockets.size >= 64)
|
|
106
|
+
throw new RemoteError(
|
|
107
|
+
429,
|
|
108
|
+
"CONNECTION_LIMIT",
|
|
109
|
+
"Too many connected clients.",
|
|
110
|
+
);
|
|
111
|
+
const epoch = url.searchParams.get("epoch");
|
|
112
|
+
const sequence = optionalInteger(
|
|
113
|
+
url.searchParams.get("after"),
|
|
114
|
+
0,
|
|
115
|
+
Number.MAX_SAFE_INTEGER,
|
|
116
|
+
);
|
|
117
|
+
const cursor =
|
|
118
|
+
epoch && sequence !== undefined ? { epoch, sequence } : undefined;
|
|
119
|
+
if (server.upgrade(request, { data: { session, cursor, device } })) return;
|
|
120
|
+
throw new RemoteError(
|
|
121
|
+
400,
|
|
122
|
+
"WEBSOCKET_REQUIRED",
|
|
123
|
+
"Upgrade this request to WebSocket.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown API route.");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof RemoteError)
|
|
130
|
+
return json(
|
|
131
|
+
{ error: { code: error.code, message: error.message } },
|
|
132
|
+
error.status,
|
|
133
|
+
);
|
|
134
|
+
if (error instanceof SyntaxError)
|
|
135
|
+
return json(
|
|
136
|
+
{
|
|
137
|
+
error: {
|
|
138
|
+
code: "INVALID_JSON",
|
|
139
|
+
message: "Request body is not valid JSON.",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
400,
|
|
143
|
+
);
|
|
144
|
+
return json(
|
|
145
|
+
{
|
|
146
|
+
error: {
|
|
147
|
+
code: "LOCAL_SERVICE_ERROR",
|
|
148
|
+
message:
|
|
149
|
+
error instanceof Error ? error.message : "The local operation failed.",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
500,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
websocket: {
|
|
157
|
+
maxPayloadLength: 1024,
|
|
158
|
+
backpressureLimit: SOCKET_BUFFER_LIMIT,
|
|
159
|
+
closeOnBackpressureLimit: true,
|
|
160
|
+
idleTimeout: 60,
|
|
161
|
+
sendPings: true,
|
|
162
|
+
open(socket) {
|
|
163
|
+
sockets.add(socket);
|
|
164
|
+
const send = (frame: RemoteFrame) => {
|
|
165
|
+
try {
|
|
166
|
+
if (socket.getBufferedAmount() > SOCKET_BUFFER_LIMIT) {
|
|
167
|
+
socket.close(1013, "Reconnect to resynchronize");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const text = JSON.stringify(frame);
|
|
171
|
+
if (socket.send(text) === -1)
|
|
172
|
+
socket.close(1013, "Reconnect to resynchronize");
|
|
173
|
+
} catch {
|
|
174
|
+
socket.close(1011, "Reconnect to resynchronize");
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
socket.data.unsubscribe = socket.data.session.hub.subscribe(
|
|
178
|
+
socket.data.cursor,
|
|
179
|
+
send,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
message(socket) {
|
|
183
|
+
socket.close(1008, "Submit operations over HTTPS");
|
|
184
|
+
},
|
|
185
|
+
close(socket) {
|
|
186
|
+
sockets.delete(socket);
|
|
187
|
+
socket.data.unsubscribe?.();
|
|
188
|
+
// Detach only. The hosted runtime and its AbortController stay alive.
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
port: server.port!,
|
|
194
|
+
async stopTransport() {
|
|
195
|
+
for (const socket of sockets) {
|
|
196
|
+
socket.data.unsubscribe?.();
|
|
197
|
+
socket.terminate();
|
|
198
|
+
}
|
|
199
|
+
// Bun 1.3.14 can leave stop's promise pending after a closing TLS socket
|
|
200
|
+
// is terminated. stop(true) synchronously closes the listener/connections;
|
|
201
|
+
// bound its completion wait so runtime disposal can always proceed.
|
|
202
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
try {
|
|
204
|
+
await Promise.race([
|
|
205
|
+
server.stop(true),
|
|
206
|
+
new Promise<void>((resolve) => {
|
|
207
|
+
timer = setTimeout(resolve, 250);
|
|
208
|
+
}),
|
|
209
|
+
]);
|
|
210
|
+
} finally {
|
|
211
|
+
if (timer) clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function json(value: unknown, status = 200): Response {
|
|
218
|
+
return Response.json(value, {
|
|
219
|
+
status,
|
|
220
|
+
headers: {
|
|
221
|
+
"Cache-Control": "no-store",
|
|
222
|
+
"X-Content-Type-Options": "nosniff",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function optionalInteger(
|
|
227
|
+
value: string | null,
|
|
228
|
+
minimum: number,
|
|
229
|
+
maximum: number,
|
|
230
|
+
): number | undefined {
|
|
231
|
+
if (value === null) return undefined;
|
|
232
|
+
if (
|
|
233
|
+
!/^\d+$/.test(value) ||
|
|
234
|
+
!Number.isSafeInteger(Number(value)) ||
|
|
235
|
+
Number(value) < minimum ||
|
|
236
|
+
Number(value) > maximum
|
|
237
|
+
)
|
|
238
|
+
throw new RemoteError(400, "INVALID_CURSOR", "Invalid history or event cursor.");
|
|
239
|
+
return Number(value);
|
|
240
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemoteHistoryPage,
|
|
3
|
+
RemoteMessage,
|
|
4
|
+
} from "../session/remote-history-reader";
|
|
5
|
+
|
|
6
|
+
export const REMOTE_PROTOCOL_VERSION = 1;
|
|
7
|
+
export type OperationStatus =
|
|
8
|
+
| "accepted"
|
|
9
|
+
| "running"
|
|
10
|
+
| "waiting_input"
|
|
11
|
+
| "completed"
|
|
12
|
+
| "failed"
|
|
13
|
+
| "cancelled"
|
|
14
|
+
| "interrupted";
|
|
15
|
+
|
|
16
|
+
export type RemoteOperationInput = { requestId: string } & (
|
|
17
|
+
| { kind: "create"; workspaceId: string; title?: string }
|
|
18
|
+
| { kind: "adopt"; workspaceId: string; sessionId: string }
|
|
19
|
+
| { kind: "prompt"; sessionId: string; prompt: string }
|
|
20
|
+
| { kind: "stop"; sessionId: string; targetRequestId: string }
|
|
21
|
+
| {
|
|
22
|
+
kind: "answer";
|
|
23
|
+
sessionId: string;
|
|
24
|
+
interactionId: string;
|
|
25
|
+
selectedIndex: number | null;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
kind: "confirm";
|
|
29
|
+
sessionId: string;
|
|
30
|
+
interactionId: string;
|
|
31
|
+
decision: "allow" | "deny";
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export type OperationReceipt = {
|
|
36
|
+
requestId: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
kind: RemoteOperationInput["kind"];
|
|
39
|
+
status: OperationStatus;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
updatedAt: string;
|
|
42
|
+
turnId?: string;
|
|
43
|
+
prompt?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RemoteSessionInfo = {
|
|
48
|
+
id: string;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
title: string;
|
|
51
|
+
modelName: string;
|
|
52
|
+
owner: "service" | "local";
|
|
53
|
+
status: string;
|
|
54
|
+
updatedAt: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type RemoteInteraction =
|
|
58
|
+
| {
|
|
59
|
+
id: string;
|
|
60
|
+
kind: "question";
|
|
61
|
+
question: string;
|
|
62
|
+
options: readonly { description: string }[];
|
|
63
|
+
}
|
|
64
|
+
| { id: string; kind: "confirmation"; command: string; reason: string };
|
|
65
|
+
|
|
66
|
+
export type RemoteTool = {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
arguments: string;
|
|
70
|
+
status: "running" | "completed" | "failed" | "cancelled";
|
|
71
|
+
detail?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type RemoteActivity = {
|
|
75
|
+
session: RemoteSessionInfo;
|
|
76
|
+
status: "idle" | OperationStatus;
|
|
77
|
+
activeRequestId?: string;
|
|
78
|
+
activeTurnId?: string;
|
|
79
|
+
streaming?: { iterationId: string; attempt: number; text: string };
|
|
80
|
+
tools: RemoteTool[];
|
|
81
|
+
interaction?: RemoteInteraction;
|
|
82
|
+
operations: OperationReceipt[];
|
|
83
|
+
error?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type RemoteView = RemoteActivity & { history: RemoteHistoryPage };
|
|
87
|
+
export type RemoteChange = { activity: RemoteActivity; messages: RemoteMessage[] };
|
|
88
|
+
export type RemoteCursor = { epoch: string; sequence: number };
|
|
89
|
+
export type RemoteFrame = { version: 1; epoch: string; sequence: number } & (
|
|
90
|
+
| { type: "snapshot"; view: RemoteView }
|
|
91
|
+
| { type: "event"; change: RemoteChange }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
export class RemoteError extends Error {
|
|
95
|
+
constructor(
|
|
96
|
+
readonly status: number,
|
|
97
|
+
readonly code: string,
|
|
98
|
+
message: string,
|
|
99
|
+
) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "RemoteError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isTerminal(status: OperationStatus): boolean {
|
|
106
|
+
return ["completed", "failed", "cancelled", "interrupted"].includes(status);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/;
|
|
110
|
+
const UUID =
|
|
111
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
112
|
+
export function requireId(value: unknown, name: string, uuid = false): string {
|
|
113
|
+
if (typeof value !== "string" || !(uuid ? UUID : ID).test(value)) {
|
|
114
|
+
throw new RemoteError(400, "INVALID_REQUEST", `${name} is invalid.`);
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseOperation(value: unknown): RemoteOperationInput {
|
|
120
|
+
const object = requireObject(value);
|
|
121
|
+
const requestId = requireId(object.requestId, "requestId", true);
|
|
122
|
+
const kind = object.kind;
|
|
123
|
+
const sessionId = () => requireId(object.sessionId, "sessionId", true);
|
|
124
|
+
let result: RemoteOperationInput;
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case "create":
|
|
127
|
+
result = {
|
|
128
|
+
requestId,
|
|
129
|
+
kind,
|
|
130
|
+
workspaceId: requireId(object.workspaceId, "workspaceId"),
|
|
131
|
+
...(object.title === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { title: requireText(object.title, "title", 240) }),
|
|
134
|
+
};
|
|
135
|
+
break;
|
|
136
|
+
case "adopt":
|
|
137
|
+
result = {
|
|
138
|
+
requestId,
|
|
139
|
+
kind,
|
|
140
|
+
workspaceId: requireId(object.workspaceId, "workspaceId"),
|
|
141
|
+
sessionId: sessionId(),
|
|
142
|
+
};
|
|
143
|
+
break;
|
|
144
|
+
case "prompt":
|
|
145
|
+
result = {
|
|
146
|
+
requestId,
|
|
147
|
+
kind,
|
|
148
|
+
sessionId: sessionId(),
|
|
149
|
+
prompt: requireText(object.prompt, "prompt", 64 * 1024),
|
|
150
|
+
};
|
|
151
|
+
break;
|
|
152
|
+
case "stop":
|
|
153
|
+
result = {
|
|
154
|
+
requestId,
|
|
155
|
+
kind,
|
|
156
|
+
sessionId: sessionId(),
|
|
157
|
+
targetRequestId: requireId(object.targetRequestId, "targetRequestId", true),
|
|
158
|
+
};
|
|
159
|
+
break;
|
|
160
|
+
case "confirm":
|
|
161
|
+
if (object.decision !== "allow" && object.decision !== "deny") {
|
|
162
|
+
throw new RemoteError(
|
|
163
|
+
400,
|
|
164
|
+
"INVALID_REQUEST",
|
|
165
|
+
"decision must be allow or deny.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
result = {
|
|
169
|
+
requestId,
|
|
170
|
+
kind,
|
|
171
|
+
sessionId: sessionId(),
|
|
172
|
+
interactionId: requireId(object.interactionId, "interactionId", true),
|
|
173
|
+
decision: object.decision,
|
|
174
|
+
};
|
|
175
|
+
break;
|
|
176
|
+
case "answer": {
|
|
177
|
+
const selectedIndex = object.selectedIndex;
|
|
178
|
+
if (
|
|
179
|
+
selectedIndex !== null &&
|
|
180
|
+
(typeof selectedIndex !== "number" ||
|
|
181
|
+
!Number.isSafeInteger(selectedIndex) ||
|
|
182
|
+
selectedIndex < 0)
|
|
183
|
+
) {
|
|
184
|
+
throw new RemoteError(
|
|
185
|
+
400,
|
|
186
|
+
"INVALID_REQUEST",
|
|
187
|
+
"selectedIndex must be a nonnegative integer or null.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
result = {
|
|
191
|
+
requestId,
|
|
192
|
+
kind,
|
|
193
|
+
sessionId: sessionId(),
|
|
194
|
+
interactionId: requireId(object.interactionId, "interactionId", true),
|
|
195
|
+
selectedIndex,
|
|
196
|
+
};
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
default:
|
|
200
|
+
throw new RemoteError(400, "INVALID_REQUEST", "Unknown operation kind.");
|
|
201
|
+
}
|
|
202
|
+
if (Object.keys(object).some((key) => !(key in result))) {
|
|
203
|
+
throw new RemoteError(400, "INVALID_REQUEST", "Unexpected operation field.");
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function requireObject(value: unknown): Record<string, unknown> {
|
|
209
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
210
|
+
throw new RemoteError(400, "INVALID_REQUEST", "Expected an object.");
|
|
211
|
+
}
|
|
212
|
+
return value as Record<string, unknown>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function requireText(value: unknown, name: string, maxBytes: number): string {
|
|
216
|
+
if (
|
|
217
|
+
typeof value !== "string" ||
|
|
218
|
+
!value.trim() ||
|
|
219
|
+
Buffer.byteLength(value) > maxBytes
|
|
220
|
+
) {
|
|
221
|
+
throw new RemoteError(
|
|
222
|
+
400,
|
|
223
|
+
"INVALID_REQUEST",
|
|
224
|
+
`${name} must contain 1–${maxBytes} bytes.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { stableJsonStringify, sha256 } from "../model/model-request-preflight";
|
|
5
|
+
import { parseSessionId } from "../ids/runtime-id";
|
|
6
|
+
import { SessionLease } from "../session/session-lock";
|
|
7
|
+
import {
|
|
8
|
+
RemoteError,
|
|
9
|
+
type RemoteOperationInput,
|
|
10
|
+
type OperationReceipt,
|
|
11
|
+
type RemoteSessionInfo,
|
|
12
|
+
} from "./protocol";
|
|
13
|
+
|
|
14
|
+
export type ManagedSessionRecord = RemoteSessionInfo & {
|
|
15
|
+
workspacePath: string;
|
|
16
|
+
initialized: boolean;
|
|
17
|
+
};
|
|
18
|
+
type ReceiptRow = {
|
|
19
|
+
device: string;
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
receipt: string;
|
|
22
|
+
input: string;
|
|
23
|
+
};
|
|
24
|
+
const SERVICE_LEASE_ID = parseSessionId("00000000-0000-7000-8000-000000000001");
|
|
25
|
+
|
|
26
|
+
/** Durable acceptance receipts, separate from the canonical conversation databases. */
|
|
27
|
+
export class RemoteServiceStore {
|
|
28
|
+
private constructor(
|
|
29
|
+
private readonly db: Database,
|
|
30
|
+
private readonly lease: SessionLease,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
static async open(directory: string): Promise<RemoteServiceStore> {
|
|
34
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
35
|
+
await chmod(directory, 0o700);
|
|
36
|
+
const lease = await SessionLease.acquire({
|
|
37
|
+
sessionDirectory: directory,
|
|
38
|
+
sessionId: SERVICE_LEASE_ID,
|
|
39
|
+
});
|
|
40
|
+
let db: Database | undefined;
|
|
41
|
+
try {
|
|
42
|
+
const filename = path.join(directory, "remote.sqlite");
|
|
43
|
+
db = new Database(filename, { create: true, strict: true });
|
|
44
|
+
await chmod(filename, 0o600);
|
|
45
|
+
db.exec(
|
|
46
|
+
"PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;",
|
|
47
|
+
);
|
|
48
|
+
const version = (
|
|
49
|
+
db.query("PRAGMA user_version").get() as { user_version: number }
|
|
50
|
+
).user_version;
|
|
51
|
+
if (version !== 0 && version !== 1)
|
|
52
|
+
throw new Error("Unsupported remote state schema.");
|
|
53
|
+
db.exec(`CREATE TABLE IF NOT EXISTS managed_sessions (id TEXT PRIMARY KEY, record TEXT NOT NULL) STRICT;
|
|
54
|
+
CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, device TEXT NOT NULL, fingerprint TEXT NOT NULL, input TEXT NOT NULL, receipt TEXT NOT NULL) STRICT;
|
|
55
|
+
CREATE INDEX IF NOT EXISTS operations_session ON operations(session_id);
|
|
56
|
+
PRAGMA user_version=1;`);
|
|
57
|
+
const store = new RemoteServiceStore(db, lease);
|
|
58
|
+
for (const row of db.query("SELECT receipt FROM operations").all() as {
|
|
59
|
+
receipt: string;
|
|
60
|
+
}[]) {
|
|
61
|
+
const receipt = JSON.parse(row.receipt) as OperationReceipt;
|
|
62
|
+
if (["accepted", "running", "waiting_input"].includes(receipt.status)) {
|
|
63
|
+
store.update({
|
|
64
|
+
...receipt,
|
|
65
|
+
status: "interrupted",
|
|
66
|
+
error:
|
|
67
|
+
"The local service process stopped. This request will not be replayed automatically.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return store;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
db?.close();
|
|
74
|
+
await lease.release();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sessions(): ManagedSessionRecord[] {
|
|
80
|
+
return (
|
|
81
|
+
this.db.query("SELECT record FROM managed_sessions").all() as { record: string }[]
|
|
82
|
+
).map((row) => JSON.parse(row.record) as ManagedSessionRecord);
|
|
83
|
+
}
|
|
84
|
+
session(id: string): ManagedSessionRecord | undefined {
|
|
85
|
+
const row = this.db
|
|
86
|
+
.query("SELECT record FROM managed_sessions WHERE id = ?")
|
|
87
|
+
.get(id) as { record: string } | null;
|
|
88
|
+
return row ? (JSON.parse(row.record) as ManagedSessionRecord) : undefined;
|
|
89
|
+
}
|
|
90
|
+
saveSession(record: ManagedSessionRecord): void {
|
|
91
|
+
this.db
|
|
92
|
+
.query(
|
|
93
|
+
"INSERT INTO managed_sessions VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record=excluded.record",
|
|
94
|
+
)
|
|
95
|
+
.run(record.id, JSON.stringify(record));
|
|
96
|
+
}
|
|
97
|
+
existing(input: RemoteOperationInput, device: string): OperationReceipt | undefined {
|
|
98
|
+
const row = this.db
|
|
99
|
+
.query("SELECT * FROM operations WHERE id = ?")
|
|
100
|
+
.get(input.requestId) as ReceiptRow | null;
|
|
101
|
+
if (!row) return undefined;
|
|
102
|
+
if (row.device !== device || row.fingerprint !== sha256(stableJsonStringify(input)))
|
|
103
|
+
throw new RemoteError(
|
|
104
|
+
409,
|
|
105
|
+
"REQUEST_ID_REUSED",
|
|
106
|
+
"This request ID was already used with different data or by another device.",
|
|
107
|
+
);
|
|
108
|
+
return JSON.parse(row.receipt) as OperationReceipt;
|
|
109
|
+
}
|
|
110
|
+
accept(
|
|
111
|
+
input: RemoteOperationInput,
|
|
112
|
+
device: string,
|
|
113
|
+
sessionId: string,
|
|
114
|
+
session?: ManagedSessionRecord,
|
|
115
|
+
): OperationReceipt {
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
const receipt: OperationReceipt = {
|
|
118
|
+
requestId: input.requestId,
|
|
119
|
+
kind: input.kind,
|
|
120
|
+
sessionId,
|
|
121
|
+
status: "accepted",
|
|
122
|
+
createdAt: now,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
...(input.kind === "prompt" ? { prompt: input.prompt } : {}),
|
|
125
|
+
};
|
|
126
|
+
this.db.transaction(() => {
|
|
127
|
+
this.db
|
|
128
|
+
.query("INSERT INTO operations VALUES (?, ?, ?, ?, ?, ?)")
|
|
129
|
+
.run(
|
|
130
|
+
input.requestId,
|
|
131
|
+
sessionId,
|
|
132
|
+
device,
|
|
133
|
+
sha256(stableJsonStringify(input)),
|
|
134
|
+
JSON.stringify(input),
|
|
135
|
+
JSON.stringify(receipt),
|
|
136
|
+
);
|
|
137
|
+
if (session) this.saveSession(session);
|
|
138
|
+
})();
|
|
139
|
+
return receipt;
|
|
140
|
+
}
|
|
141
|
+
get(id: string): OperationReceipt {
|
|
142
|
+
const row = this.db
|
|
143
|
+
.query("SELECT receipt FROM operations WHERE id = ?")
|
|
144
|
+
.get(id) as { receipt: string } | null;
|
|
145
|
+
if (!row)
|
|
146
|
+
throw new RemoteError(
|
|
147
|
+
404,
|
|
148
|
+
"REQUEST_NOT_FOUND",
|
|
149
|
+
"Request was not accepted by this service.",
|
|
150
|
+
);
|
|
151
|
+
return JSON.parse(row.receipt) as OperationReceipt;
|
|
152
|
+
}
|
|
153
|
+
operations(sessionId: string): OperationReceipt[] {
|
|
154
|
+
return (
|
|
155
|
+
this.db
|
|
156
|
+
.query(
|
|
157
|
+
"SELECT receipt FROM operations WHERE session_id = ? ORDER BY rowid DESC LIMIT 100",
|
|
158
|
+
)
|
|
159
|
+
.all(sessionId) as { receipt: string }[]
|
|
160
|
+
)
|
|
161
|
+
.reverse()
|
|
162
|
+
.map((row) => JSON.parse(row.receipt) as OperationReceipt);
|
|
163
|
+
}
|
|
164
|
+
update(receipt: OperationReceipt): OperationReceipt {
|
|
165
|
+
const next = { ...receipt, updatedAt: new Date().toISOString() };
|
|
166
|
+
this.db
|
|
167
|
+
.query("UPDATE operations SET receipt = ? WHERE id = ?")
|
|
168
|
+
.run(JSON.stringify(next), receipt.requestId);
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
async close(): Promise<void> {
|
|
172
|
+
this.db.close();
|
|
173
|
+
await this.lease.release();
|
|
174
|
+
}
|
|
175
|
+
}
|