tinker-agent 2.8.0 → 2.10.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 +79 -1
- package/README.md +81 -11
- package/package.json +5 -3
- package/src/agent/runtime-context-capabilities.ts +19 -0
- package/src/agent/runtime-context-events.ts +127 -0
- package/src/agent/runtime-context-maintenance.ts +780 -0
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-interactions.ts +291 -0
- package/src/agent/runtime-prompt-scheduler.ts +182 -0
- package/src/agent/runtime-session-contracts.ts +317 -0
- package/src/agent/runtime-session.ts +250 -2130
- package/src/agent/runtime-skills.ts +544 -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/runner-dependencies.ts +6 -5
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/context/context-automation-policy.ts +12 -118
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +12 -0
- package/src/memory/memory-get-tool.ts +1 -1
- package/src/observation/observation-builder.ts +128 -48
- 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/resume-projection.ts +47 -21
- package/src/session/session-history-access.ts +238 -0
- package/src/session/session-store-context-readers.ts +183 -0
- package/src/session/session-store-ledger-writer.ts +315 -0
- package/src/session/session-store-record-writer.ts +318 -0
- package/src/session/session-store-recovery.ts +225 -0
- package/src/session/session-store-revisions.ts +1004 -0
- package/src/session/session-store-sql.ts +40 -0
- package/src/session/session-store-validation.ts +657 -0
- package/src/session/session-store.ts +756 -3186
- package/src/tools/bash-task.ts +46 -18
- 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 +139 -154
- package/src/tools/read.ts +0 -9
- package/src/tools/recall.ts +106 -50
- package/src/tools/registry.ts +4 -6
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-output-range.ts +146 -0
- package/src/tools/task-output-tool.ts +35 -5
- package/src/tools/task-output.ts +35 -0
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/task-tool-args.ts +34 -0
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +39 -2
- package/src/tui/event-store.ts +23 -5
- package/src/tui/remote-app.tsx +210 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createUuidV7 } from "../ids/uuid-v7";
|
|
3
|
+
import { parseSessionId } from "../ids/runtime-id";
|
|
4
|
+
import { SessionCatalog } from "../session/session-catalog";
|
|
5
|
+
import {
|
|
6
|
+
HostedSession,
|
|
7
|
+
type HostedRuntimeFactory,
|
|
8
|
+
} from "../agent/runtime-hosted-session";
|
|
9
|
+
import type { RemoteWorkspaceConfig } from "./config";
|
|
10
|
+
import { RemoteServiceStore, type ManagedSessionRecord } from "./service-store";
|
|
11
|
+
import {
|
|
12
|
+
RemoteError,
|
|
13
|
+
type RemoteOperationInput,
|
|
14
|
+
type OperationReceipt,
|
|
15
|
+
type RemoteSessionInfo,
|
|
16
|
+
} from "./protocol";
|
|
17
|
+
|
|
18
|
+
export class RemoteService {
|
|
19
|
+
readonly epoch = randomUUID();
|
|
20
|
+
private readonly hosted = new Map<string, HostedSession>();
|
|
21
|
+
private submitting: Promise<void> = Promise.resolve();
|
|
22
|
+
private stopping = false;
|
|
23
|
+
constructor(
|
|
24
|
+
readonly store: RemoteServiceStore,
|
|
25
|
+
readonly workspaces: readonly RemoteWorkspaceConfig[],
|
|
26
|
+
private readonly factory: HostedRuntimeFactory,
|
|
27
|
+
private readonly homeRoot?: string,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
async initialize(): Promise<void> {
|
|
31
|
+
// Reacquire every managed canonical lease; no prompt is resubmitted on boot.
|
|
32
|
+
for (const record of this.store.sessions()) {
|
|
33
|
+
if (!record.initialized) continue;
|
|
34
|
+
try {
|
|
35
|
+
await this.session(record.id).open();
|
|
36
|
+
} catch {
|
|
37
|
+
/* A failed workspace/session remains visible with its error. */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
workspace(id: string): RemoteWorkspaceConfig {
|
|
42
|
+
const workspace = this.workspaces.find((workspace) => workspace.id === id);
|
|
43
|
+
if (!workspace)
|
|
44
|
+
throw new RemoteError(
|
|
45
|
+
404,
|
|
46
|
+
"WORKSPACE_NOT_FOUND",
|
|
47
|
+
"This workspace is not configured on the Mac.",
|
|
48
|
+
);
|
|
49
|
+
return workspace;
|
|
50
|
+
}
|
|
51
|
+
session(id: string): HostedSession {
|
|
52
|
+
const existing = this.hosted.get(id);
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
const record = this.store.session(id);
|
|
55
|
+
if (!record)
|
|
56
|
+
throw new RemoteError(
|
|
57
|
+
404,
|
|
58
|
+
"SESSION_NOT_MANAGED",
|
|
59
|
+
"Attach this local session before connecting to it.",
|
|
60
|
+
);
|
|
61
|
+
const workspace = this.workspace(record.workspaceId);
|
|
62
|
+
if (record.workspacePath !== workspace.path)
|
|
63
|
+
throw new RemoteError(
|
|
64
|
+
409,
|
|
65
|
+
"WORKSPACE_CHANGED",
|
|
66
|
+
"The managed session belongs to a different workspace path.",
|
|
67
|
+
);
|
|
68
|
+
const hosted = new HostedSession(record, this.store, this.epoch, this.factory);
|
|
69
|
+
this.hosted.set(id, hosted);
|
|
70
|
+
return hosted;
|
|
71
|
+
}
|
|
72
|
+
async listSessions(workspaceId: string): Promise<RemoteSessionInfo[]> {
|
|
73
|
+
const workspace = this.workspace(workspaceId);
|
|
74
|
+
const local = await new SessionCatalog({
|
|
75
|
+
workspaceRoot: workspace.path,
|
|
76
|
+
homeRoot: this.homeRoot,
|
|
77
|
+
}).listAll();
|
|
78
|
+
const managed = this.store
|
|
79
|
+
.sessions()
|
|
80
|
+
.filter((record) => record.workspaceId === workspaceId);
|
|
81
|
+
return [
|
|
82
|
+
...managed.map((record) => {
|
|
83
|
+
const view = this.hosted.get(record.id)?.view();
|
|
84
|
+
return (
|
|
85
|
+
view?.session ?? {
|
|
86
|
+
id: record.id,
|
|
87
|
+
workspaceId,
|
|
88
|
+
title: record.title,
|
|
89
|
+
modelName: record.modelName,
|
|
90
|
+
owner: "service" as const,
|
|
91
|
+
status: record.initialized ? "idle" : "interrupted",
|
|
92
|
+
updatedAt: record.updatedAt,
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
}),
|
|
96
|
+
...local
|
|
97
|
+
.filter((summary) => !managed.some((record) => record.id === summary.sessionId))
|
|
98
|
+
.map((summary) => ({
|
|
99
|
+
id: summary.sessionId,
|
|
100
|
+
workspaceId,
|
|
101
|
+
title: summary.firstUserPromptPreview ?? "Empty session",
|
|
102
|
+
modelName: summary.modelName,
|
|
103
|
+
owner: "local" as const,
|
|
104
|
+
status: summary.status,
|
|
105
|
+
updatedAt: summary.updatedAt,
|
|
106
|
+
})),
|
|
107
|
+
].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
submit(input: RemoteOperationInput, device: string): Promise<OperationReceipt> {
|
|
111
|
+
// Serializes acceptance and any async catalog lookup, never task execution.
|
|
112
|
+
const result = this.submitting.then(() => this.accept(input, device));
|
|
113
|
+
this.submitting = result.then(
|
|
114
|
+
() => undefined,
|
|
115
|
+
() => undefined,
|
|
116
|
+
);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
private async accept(
|
|
120
|
+
input: RemoteOperationInput,
|
|
121
|
+
device: string,
|
|
122
|
+
): Promise<OperationReceipt> {
|
|
123
|
+
const existing = this.store.existing(input, device);
|
|
124
|
+
if (existing) return existing;
|
|
125
|
+
if (this.stopping)
|
|
126
|
+
throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
|
|
127
|
+
if (input.kind === "create" || input.kind === "adopt") {
|
|
128
|
+
const workspace = this.workspace(input.workspaceId);
|
|
129
|
+
const id = input.kind === "create" ? createUuidV7() : input.sessionId;
|
|
130
|
+
if (this.store.sessions().length >= 128)
|
|
131
|
+
throw new RemoteError(
|
|
132
|
+
409,
|
|
133
|
+
"SESSION_LIMIT",
|
|
134
|
+
"The service has reached its 128 managed session limit.",
|
|
135
|
+
);
|
|
136
|
+
let record: ManagedSessionRecord;
|
|
137
|
+
if (input.kind === "adopt") {
|
|
138
|
+
if (this.store.session(id))
|
|
139
|
+
throw new RemoteError(
|
|
140
|
+
409,
|
|
141
|
+
"ALREADY_MANAGED",
|
|
142
|
+
"This session is already owned by the service.",
|
|
143
|
+
);
|
|
144
|
+
const summary = await new SessionCatalog({
|
|
145
|
+
workspaceRoot: workspace.path,
|
|
146
|
+
homeRoot: this.homeRoot,
|
|
147
|
+
}).get(parseSessionId(id));
|
|
148
|
+
if (summary.status !== "resumable" && summary.status !== "interrupted")
|
|
149
|
+
throw new RemoteError(
|
|
150
|
+
409,
|
|
151
|
+
"SESSION_UNAVAILABLE",
|
|
152
|
+
"Exit its local TUI before attaching this session; it must be resumable.",
|
|
153
|
+
);
|
|
154
|
+
record = {
|
|
155
|
+
id,
|
|
156
|
+
workspaceId: workspace.id,
|
|
157
|
+
workspacePath: workspace.path,
|
|
158
|
+
title: summary.firstUserPromptPreview ?? "Empty session",
|
|
159
|
+
modelName: summary.modelName,
|
|
160
|
+
owner: "service",
|
|
161
|
+
status: "accepted",
|
|
162
|
+
updatedAt: new Date().toISOString(),
|
|
163
|
+
initialized: true,
|
|
164
|
+
};
|
|
165
|
+
} else {
|
|
166
|
+
record = {
|
|
167
|
+
id,
|
|
168
|
+
workspaceId: workspace.id,
|
|
169
|
+
workspacePath: workspace.path,
|
|
170
|
+
title: input.title ?? "New session",
|
|
171
|
+
modelName: "",
|
|
172
|
+
owner: "service",
|
|
173
|
+
status: "accepted",
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
|
+
initialized: false,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const receipt = this.store.accept(input, device, id, record);
|
|
179
|
+
const hosted = this.session(id);
|
|
180
|
+
void hosted.open().then(
|
|
181
|
+
() => {
|
|
182
|
+
this.store.update({ ...receipt, status: "completed" });
|
|
183
|
+
hosted.receiptChanged();
|
|
184
|
+
},
|
|
185
|
+
(error: unknown) => {
|
|
186
|
+
this.store.update({
|
|
187
|
+
...receipt,
|
|
188
|
+
status: "failed",
|
|
189
|
+
error: error instanceof Error ? error.message : String(error),
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
return receipt;
|
|
194
|
+
}
|
|
195
|
+
const session = this.session(input.sessionId);
|
|
196
|
+
// Attach/initialization must be complete before a new mutation can be accepted.
|
|
197
|
+
await session.open();
|
|
198
|
+
session.validate(input);
|
|
199
|
+
const receipt = this.store.accept(input, device, input.sessionId);
|
|
200
|
+
if (input.kind === "prompt") session.enqueue(receipt);
|
|
201
|
+
else session.control(input, receipt);
|
|
202
|
+
return receipt;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async close(): Promise<void> {
|
|
206
|
+
this.stopping = true;
|
|
207
|
+
await this.submitting;
|
|
208
|
+
const results = await Promise.allSettled(
|
|
209
|
+
[...this.hosted.values()].map((session) => session.close()),
|
|
210
|
+
);
|
|
211
|
+
await this.store.close();
|
|
212
|
+
const errors = results.filter((result) => result.status === "rejected");
|
|
213
|
+
if (errors.length)
|
|
214
|
+
throw new AggregateError(
|
|
215
|
+
errors.map((result) => result.reason as unknown),
|
|
216
|
+
"Remote service shutdown failed.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { RemoteChange, RemoteCursor, RemoteFrame, RemoteView } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/** Synchronous cursor allocation; transport delivery is always outside the runtime. */
|
|
4
|
+
export class RemoteSyncHub {
|
|
5
|
+
private sequence = 0;
|
|
6
|
+
private readonly ring: { frame: RemoteFrame; bytes: number }[] = [];
|
|
7
|
+
private ringBytes = 0;
|
|
8
|
+
private readonly listeners = new Set<(frame: RemoteFrame) => void>();
|
|
9
|
+
private pending: RemoteFrame[] = [];
|
|
10
|
+
private scheduled = false;
|
|
11
|
+
constructor(
|
|
12
|
+
readonly epoch: string,
|
|
13
|
+
private readonly readView: () => RemoteView,
|
|
14
|
+
private readonly capacity = 256,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
snapshot(): RemoteFrame {
|
|
18
|
+
return {
|
|
19
|
+
version: 1,
|
|
20
|
+
type: "snapshot",
|
|
21
|
+
epoch: this.epoch,
|
|
22
|
+
sequence: this.sequence,
|
|
23
|
+
view: this.readView(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
publish(change: RemoteChange): void {
|
|
27
|
+
const frame: RemoteFrame = {
|
|
28
|
+
version: 1,
|
|
29
|
+
type: "event",
|
|
30
|
+
epoch: this.epoch,
|
|
31
|
+
sequence: ++this.sequence,
|
|
32
|
+
change,
|
|
33
|
+
};
|
|
34
|
+
const bytes = Buffer.byteLength(JSON.stringify(frame));
|
|
35
|
+
this.ring.push({ frame, bytes });
|
|
36
|
+
this.ringBytes += bytes;
|
|
37
|
+
while (this.ring.length > this.capacity || this.ringBytes > 8 * 1024 * 1024)
|
|
38
|
+
this.ringBytes -= this.ring.shift()!.bytes;
|
|
39
|
+
// A single scheduled delivery per tick; no subscriber can hold up append().
|
|
40
|
+
this.pending.push(frame);
|
|
41
|
+
if (this.pending.length > this.capacity) this.pending = [this.snapshot()];
|
|
42
|
+
if (!this.scheduled) {
|
|
43
|
+
this.scheduled = true;
|
|
44
|
+
setTimeout(() => this.deliver(), 0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
subscribe(
|
|
48
|
+
cursor: RemoteCursor | undefined,
|
|
49
|
+
listener: (frame: RemoteFrame) => void,
|
|
50
|
+
): () => void {
|
|
51
|
+
// No await between reading the cursor/view and installing the subscription.
|
|
52
|
+
let last = this.sequence;
|
|
53
|
+
const oldest = this.ring[0]?.frame.sequence ?? this.sequence + 1;
|
|
54
|
+
const replay =
|
|
55
|
+
cursor?.epoch === this.epoch &&
|
|
56
|
+
cursor.sequence >= oldest - 1 &&
|
|
57
|
+
cursor.sequence <= this.sequence
|
|
58
|
+
? this.ring
|
|
59
|
+
.filter(({ frame }) => frame.sequence > cursor.sequence)
|
|
60
|
+
.map(({ frame }) => frame)
|
|
61
|
+
: [this.snapshot()];
|
|
62
|
+
// Same-cursor reconnect still needs a handshake to mark the link synchronized.
|
|
63
|
+
if (replay.length === 0) replay.push(this.snapshot());
|
|
64
|
+
const guarded = (frame: RemoteFrame) => {
|
|
65
|
+
if (frame.sequence <= last) return;
|
|
66
|
+
last = frame.sequence;
|
|
67
|
+
listener(frame);
|
|
68
|
+
};
|
|
69
|
+
this.listeners.add(guarded);
|
|
70
|
+
try {
|
|
71
|
+
for (const frame of replay) listener(frame);
|
|
72
|
+
} catch {
|
|
73
|
+
this.listeners.delete(guarded);
|
|
74
|
+
}
|
|
75
|
+
return () => this.listeners.delete(guarded);
|
|
76
|
+
}
|
|
77
|
+
private deliver(): void {
|
|
78
|
+
this.scheduled = false;
|
|
79
|
+
const pending = this.pending;
|
|
80
|
+
this.pending = [];
|
|
81
|
+
for (const frame of pending) {
|
|
82
|
+
for (const listener of this.listeners) {
|
|
83
|
+
try {
|
|
84
|
+
listener(frame);
|
|
85
|
+
} catch {
|
|
86
|
+
this.listeners.delete(listener);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
close(): void {
|
|
92
|
+
this.listeners.clear();
|
|
93
|
+
this.pending = [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { SessionId } from "../ids/runtime-id";
|
|
3
|
+
import { verifyReadableSessionSchema } from "./session-schema";
|
|
4
|
+
import { decodeStoredToolCalls } from "./session-store-record-codecs";
|
|
5
|
+
|
|
6
|
+
export type RemoteMessage = {
|
|
7
|
+
id: string;
|
|
8
|
+
ordinal: number;
|
|
9
|
+
role: "user" | "assistant" | "tool";
|
|
10
|
+
text: string;
|
|
11
|
+
turnId: string;
|
|
12
|
+
turnStatus: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
toolCallId?: string;
|
|
16
|
+
toolCalls?: { id: string; name: string; arguments: string }[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RemoteHistoryPage = {
|
|
20
|
+
messages: RemoteMessage[];
|
|
21
|
+
hasMore: boolean;
|
|
22
|
+
beforeOrdinal?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A read-only canonical projection; open tails are legal and never synthesized. */
|
|
26
|
+
export class RemoteHistoryReader {
|
|
27
|
+
private readonly database: Database;
|
|
28
|
+
constructor(databasePath: string, sessionId: SessionId, workspaceRoot: string) {
|
|
29
|
+
this.database = new Database(databasePath, { readonly: true, strict: true });
|
|
30
|
+
try {
|
|
31
|
+
verifyReadableSessionSchema(this.database, sessionId);
|
|
32
|
+
const identity = this.database
|
|
33
|
+
.query("SELECT session_id, workspace_root FROM session_meta")
|
|
34
|
+
.get() as { session_id: string; workspace_root: string } | null;
|
|
35
|
+
if (
|
|
36
|
+
identity?.session_id !== sessionId ||
|
|
37
|
+
identity.workspace_root !== workspaceRoot
|
|
38
|
+
) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Remote history identity does not match its workspace/session.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.database.close();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
page(before = Number.MAX_SAFE_INTEGER, limit = 80): RemoteHistoryPage {
|
|
50
|
+
const rows = this.database
|
|
51
|
+
.query(
|
|
52
|
+
`${MESSAGE_SELECT} WHERE m.role <> 'system' AND m.ordinal < ? ORDER BY m.ordinal DESC LIMIT ?`,
|
|
53
|
+
)
|
|
54
|
+
.all(before, limit + 1) as MessageRow[];
|
|
55
|
+
const hasMore = rows.length > limit;
|
|
56
|
+
const messages = rows.slice(0, limit).reverse().map(projectMessage);
|
|
57
|
+
return {
|
|
58
|
+
messages,
|
|
59
|
+
hasMore,
|
|
60
|
+
...(messages[0] ? { beforeOrdinal: messages[0].ordinal } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
after(ordinal: number): RemoteMessage[] {
|
|
65
|
+
return (
|
|
66
|
+
this.database
|
|
67
|
+
.query(
|
|
68
|
+
`${MESSAGE_SELECT} WHERE m.role <> 'system' AND m.ordinal > ? ORDER BY m.ordinal`,
|
|
69
|
+
)
|
|
70
|
+
.all(ordinal) as MessageRow[]
|
|
71
|
+
).map(projectMessage);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
latestTurn(): { id: string; status: string; error?: string } | undefined {
|
|
75
|
+
const row = this.database
|
|
76
|
+
.query(
|
|
77
|
+
"SELECT turn_id, status, terminal_detail_json FROM turns ORDER BY turn_number DESC LIMIT 1",
|
|
78
|
+
)
|
|
79
|
+
.get() as {
|
|
80
|
+
turn_id: string;
|
|
81
|
+
status: string;
|
|
82
|
+
terminal_detail_json: string | null;
|
|
83
|
+
} | null;
|
|
84
|
+
if (!row) return undefined;
|
|
85
|
+
const detail = row.terminal_detail_json
|
|
86
|
+
? (JSON.parse(row.terminal_detail_json) as { error?: string })
|
|
87
|
+
: undefined;
|
|
88
|
+
return {
|
|
89
|
+
id: row.turn_id,
|
|
90
|
+
status: row.status,
|
|
91
|
+
...(detail?.error ? { error: detail.error } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
turnStatus(turnId: string): string | undefined {
|
|
96
|
+
return (
|
|
97
|
+
this.database.query("SELECT status FROM turns WHERE turn_id = ?").get(turnId) as {
|
|
98
|
+
status: string;
|
|
99
|
+
} | null
|
|
100
|
+
)?.status;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
close(): void {
|
|
104
|
+
this.database.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const MESSAGE_SELECT =
|
|
109
|
+
"SELECT m.*, t.status AS turn_status FROM messages m JOIN turns t ON t.turn_id = m.turn_id";
|
|
110
|
+
type MessageRow = {
|
|
111
|
+
message_id: string;
|
|
112
|
+
ordinal: number;
|
|
113
|
+
role: RemoteMessage["role"];
|
|
114
|
+
content: string | null;
|
|
115
|
+
turn_id: string;
|
|
116
|
+
turn_status: string;
|
|
117
|
+
created_at: string;
|
|
118
|
+
name: string | null;
|
|
119
|
+
tool_call_id: string | null;
|
|
120
|
+
tool_calls_json: string | null;
|
|
121
|
+
};
|
|
122
|
+
function projectMessage(row: MessageRow): RemoteMessage {
|
|
123
|
+
return {
|
|
124
|
+
id: row.message_id,
|
|
125
|
+
ordinal: row.ordinal,
|
|
126
|
+
role: row.role,
|
|
127
|
+
text: row.content ?? "",
|
|
128
|
+
turnId: row.turn_id,
|
|
129
|
+
turnStatus: row.turn_status,
|
|
130
|
+
createdAt: row.created_at,
|
|
131
|
+
...(row.name ? { name: row.name } : {}),
|
|
132
|
+
...(row.tool_call_id ? { toolCallId: row.tool_call_id } : {}),
|
|
133
|
+
...(row.tool_calls_json
|
|
134
|
+
? {
|
|
135
|
+
toolCalls: decodeStoredToolCalls(row.tool_calls_json).map((call) => ({
|
|
136
|
+
id: call.toolCallId,
|
|
137
|
+
name: call.name,
|
|
138
|
+
arguments: JSON.stringify(call.args),
|
|
139
|
+
})),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
}
|