@xfey/tutti 0.1.103 → 0.1.105
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/README.md +9 -4
- package/dist/chat-assistant/index.d.ts +6 -1
- package/dist/chat-assistant/index.js +22 -12
- package/dist/collaboration-ingestion/external-source.d.ts +15 -0
- package/dist/collaboration-ingestion/external-source.js +120 -0
- package/dist/collaboration-ingestion/index.d.ts +5 -0
- package/dist/collaboration-ingestion/index.js +4 -0
- package/dist/collaboration-ingestion/invalidations.d.ts +4 -0
- package/dist/collaboration-ingestion/invalidations.js +15 -0
- package/dist/collaboration-ingestion/main-chat.d.ts +33 -0
- package/dist/collaboration-ingestion/main-chat.js +67 -0
- package/dist/collaboration-ingestion/reference-files.d.ts +29 -0
- package/dist/collaboration-ingestion/reference-files.js +158 -0
- package/dist/collaboration-ingestion/types.d.ts +19 -0
- package/dist/collaboration-ingestion/types.js +2 -0
- package/dist/platform-outbound/coordinator.d.ts +17 -0
- package/dist/platform-outbound/coordinator.js +48 -0
- package/dist/platform-outbound/index.d.ts +5 -0
- package/dist/platform-outbound/index.js +5 -0
- package/dist/platform-outbound/repository.d.ts +30 -0
- package/dist/platform-outbound/repository.js +108 -0
- package/dist/platform-outbound/types.d.ts +38 -0
- package/dist/platform-outbound/types.js +2 -0
- package/dist/platform-outbound/worker.d.ts +36 -0
- package/dist/platform-outbound/worker.js +164 -0
- package/dist/server-shell/cli/cli.js +9 -0
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/host-lifecycle.d.ts +2 -0
- package/dist/server-shell/cli/host-lifecycle.js +34 -1
- package/dist/server-shell/cli/host-relay-connection-manager.js +22 -11
- package/dist/server-shell/cli/host-relay-status.js +1 -0
- package/dist/server-shell/cli/host-runtime-endpoint.js +17 -1
- package/dist/server-shell/cli/host-server-runtime.d.ts +2 -0
- package/dist/server-shell/cli/host-server-runtime.js +88 -14
- package/dist/server-shell/cli/launch-command.d.ts +7 -0
- package/dist/server-shell/cli/launch-command.js +39 -1
- package/dist/server-shell/cli/launch.d.ts +2 -0
- package/dist/server-shell/cli/launch.js +3 -0
- package/dist/server-shell/cli/machine-local.d.ts +20 -0
- package/dist/server-shell/cli/machine-local.js +61 -1
- package/dist/server-shell/cli/managed-host.d.ts +16 -1
- package/dist/server-shell/cli/managed-host.js +191 -23
- package/dist/server-shell/cli/relay-registration.d.ts +4 -1
- package/dist/server-shell/cli/relay-registration.js +36 -7
- package/dist/server-shell/cli/runtime-commands.d.ts +5 -1
- package/dist/server-shell/cli/runtime-commands.js +9 -3
- package/dist/server-shell/http/host-tunnel.d.ts +5 -0
- package/dist/server-shell/http/host-tunnel.js +109 -0
- package/dist/server-shell/http/routes/local-control.d.ts +2 -0
- package/dist/server-shell/http/routes/local-control.js +2 -0
- package/dist/server-shell/http/routes/project-api/invalidations.d.ts +0 -1
- package/dist/server-shell/http/routes/project-api/invalidations.js +0 -3
- package/dist/server-shell/http/routes/project-api/messages-routes.js +32 -63
- package/dist/server-shell/http/routes/project-api/openapi-workspace-routes.d.ts +8 -4
- package/dist/server-shell/http/routes/project-api/openapi.d.ts +8 -4
- package/dist/server-shell/http/routes/project-api/reference-files-routes.js +25 -94
- package/dist/server-shell/http/routes/project-api/reference-files.d.ts +1 -11
- package/dist/server-shell/http/routes/project-api/reference-files.js +2 -87
- package/dist/server-shell/http/routes/project-api/schemas.d.ts +8 -4
- package/dist/server-shell/http/routes/project-api/schemas.js +8 -3
- package/dist/server-shell/http/routes/project-api/session-requirements.js +34 -2
- package/dist/server-shell/http/routes/project-api/types.d.ts +3 -1
- package/dist/server-shell/http/routes/project-api/workspace-projections.js +8 -2
- package/dist/server-shell/local-console/package-update-hosts.js +4 -1
- package/dist/server-shell/local-console/project-service.d.ts +13 -2
- package/dist/server-shell/local-console/project-service.js +73 -8
- package/dist/server-shell/local-console/server.js +10 -1
- package/dist/server-shell/local-console/session.d.ts +4 -0
- package/dist/server-shell/local-console/session.js +14 -0
- package/dist/server-shell/session/relay-session-context.d.ts +12 -1
- package/dist/server-shell/session/relay-session-context.js +19 -1
- package/migrations/0016_external_source_mappings.sql +141 -0
- package/migrations/0017_platform_outbound_intents.sql +31 -0
- package/migrations/README.md +3 -1
- package/node_modules/@tutti/relay-client/dist/host-control.d.ts +26 -1
- package/node_modules/@tutti/relay-client/dist/host-control.js +62 -0
- package/node_modules/@tutti/relay-client/dist/tunnel-frames.js +32 -6
- package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +5 -1
- package/node_modules/@tutti/shared/dist/domain/index.d.ts +9 -0
- package/node_modules/@tutti/shared/dist/domain/index.js +14 -0
- package/node_modules/@tutti/shared/dist/ids/index.d.ts +6 -0
- package/node_modules/@tutti/shared/dist/ids/index.js +4 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +4 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +2 -0
- package/node_modules/@tutti/shared/dist/schemas/api/local-console-account-handoffs.d.ts +13 -0
- package/node_modules/@tutti/shared/dist/schemas/api/local-console-account-handoffs.js +16 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +43 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +44 -0
- package/node_modules/@tutti/shared/dist/schemas/domain/index.d.ts +7 -0
- package/node_modules/@tutti/shared/dist/schemas/domain/index.js +10 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.d.ts +29 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.js +24 -1
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +1346 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +516 -0
- package/node_modules/@tutti/shared/dist/utils/feishu-diagnostics/index.d.ts +10 -0
- package/node_modules/@tutti/shared/dist/utils/feishu-diagnostics/index.js +19 -0
- package/node_modules/@tutti/shared/dist/utils/index.d.ts +1 -0
- package/node_modules/@tutti/shared/dist/utils/index.js +1 -0
- package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/utils/redaction/index.js +18 -0
- package/package.json +1 -1
- package/web/assets/{homepage-motion-scene-CPs4EYQq.js → homepage-motion-scene-BnRTgItL.js} +1 -1
- package/web/assets/{index-CxCTqGFO.css → index-CTMOjCgU.css} +1 -1
- package/web/assets/index-CmzEr4-V.js +69 -0
- package/web/index.html +2 -2
- package/web/assets/index-D3lwP_UR.js +0 -69
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
|
|
3
5
|
import { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
|
|
4
|
-
import { LaunchError } from "./errors.js";
|
|
5
|
-
import { readMachineRuntimeEndpoint } from "./machine-local.js";
|
|
6
|
+
import { formatCliErrorReason, LaunchError } from "./errors.js";
|
|
7
|
+
import { appendHostLogLine, getHostLogFilePath, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
|
|
6
8
|
import { readHostLocalLaunchStatus, rotateHostLocalInvite } from "./local-control-client.js";
|
|
7
9
|
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
|
8
10
|
const READY_POLL_INTERVAL_MS = 250;
|
|
11
|
+
const HOST_STARTUP_ATTEMPT_ENV = "TUTTI_HOST_STARTUP_ATTEMPT_REF";
|
|
12
|
+
const HOST_STARTUP_PROJECT_ENV = "TUTTI_HOST_STARTUP_PROJECT_ID";
|
|
13
|
+
const HOST_STARTUP_ATTEMPT_PATTERN = /^host_startup_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
9
14
|
function delay(ms) {
|
|
10
15
|
return new Promise((resolveDelay) => {
|
|
11
16
|
setTimeout(resolveDelay, ms);
|
|
@@ -16,48 +21,181 @@ function currentCliEntrypoint() {
|
|
|
16
21
|
if (entrypoint === undefined || entrypoint.trim() === "") {
|
|
17
22
|
throw new Error("Cannot locate the current Tutti CLI entrypoint.");
|
|
18
23
|
}
|
|
19
|
-
return entrypoint;
|
|
24
|
+
return resolve(entrypoint);
|
|
25
|
+
}
|
|
26
|
+
export function normalizeDetachedHostExecArgv(execArgv, resolveImport = (specifier) => import.meta.resolve(specifier)) {
|
|
27
|
+
const normalized = [];
|
|
28
|
+
for (let index = 0; index < execArgv.length; index += 1) {
|
|
29
|
+
const argument = execArgv[index];
|
|
30
|
+
if (argument === "--import" && execArgv[index + 1] === "tsx") {
|
|
31
|
+
normalized.push(argument, resolveImport("tsx"));
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (argument === "--import=tsx") {
|
|
36
|
+
normalized.push(`--import=${resolveImport("tsx")}`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (argument !== undefined) {
|
|
40
|
+
normalized.push(argument);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
function appendDetachedHostStartupEvent(tuttiHome, event) {
|
|
46
|
+
try {
|
|
47
|
+
appendHostLogLine(getHostLogFilePath(tuttiHome), JSON.stringify({
|
|
48
|
+
level: event.disposition === "failed" || event.disposition === "timed_out" ? 50 : 30,
|
|
49
|
+
time: Date.now(),
|
|
50
|
+
service: "tutti-host-manager",
|
|
51
|
+
component: "managed-host-startup",
|
|
52
|
+
...event,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Logging must never turn a process lifecycle observation into a launch failure.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function readStartupAttemptRef(env) {
|
|
60
|
+
const value = env[HOST_STARTUP_ATTEMPT_ENV];
|
|
61
|
+
return value !== undefined && HOST_STARTUP_ATTEMPT_PATTERN.test(value) ? value : undefined;
|
|
20
62
|
}
|
|
21
63
|
export function spawnDetachedHost(options) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
const startupAttemptRef = `host_startup_${randomUUID()}`;
|
|
65
|
+
const childEnv = options.inheritProcessEnv === false
|
|
66
|
+
? { ...(options.env ?? {}) }
|
|
67
|
+
: {
|
|
68
|
+
...process.env,
|
|
69
|
+
...(options.env ?? {}),
|
|
70
|
+
};
|
|
71
|
+
childEnv.TUTTI_HOME = options.tuttiHome;
|
|
72
|
+
childEnv[HOST_STARTUP_ATTEMPT_ENV] = startupAttemptRef;
|
|
73
|
+
childEnv[HOST_STARTUP_PROJECT_ENV] = options.projectId;
|
|
74
|
+
let child;
|
|
75
|
+
try {
|
|
76
|
+
child = spawn(process.execPath, [
|
|
77
|
+
...normalizeDetachedHostExecArgv(process.execArgv),
|
|
78
|
+
currentCliEntrypoint(),
|
|
79
|
+
"internal",
|
|
80
|
+
"host-run",
|
|
81
|
+
"--workspace",
|
|
82
|
+
resolve(options.workspaceRoot),
|
|
83
|
+
"--yes",
|
|
84
|
+
], {
|
|
85
|
+
cwd: options.workspaceRoot,
|
|
86
|
+
detached: true,
|
|
87
|
+
env: childEnv,
|
|
88
|
+
stdio: "ignore",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
93
|
+
startup_attempt_ref: startupAttemptRef,
|
|
94
|
+
project_id: options.projectId,
|
|
95
|
+
stage: "spawn",
|
|
96
|
+
disposition: "failed",
|
|
97
|
+
reason_code: "spawn_failed",
|
|
98
|
+
detail: formatCliErrorReason(error),
|
|
99
|
+
});
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
child.once("error", (error) => {
|
|
103
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
104
|
+
startup_attempt_ref: startupAttemptRef,
|
|
105
|
+
project_id: options.projectId,
|
|
106
|
+
stage: "spawn",
|
|
107
|
+
disposition: "failed",
|
|
108
|
+
reason_code: "spawn_error",
|
|
109
|
+
detail: formatCliErrorReason(error),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
113
|
+
startup_attempt_ref: startupAttemptRef,
|
|
114
|
+
project_id: options.projectId,
|
|
115
|
+
stage: "spawn",
|
|
116
|
+
disposition: "started",
|
|
117
|
+
...(child.pid === undefined ? {} : { pid: child.pid }),
|
|
40
118
|
});
|
|
41
119
|
child.unref();
|
|
120
|
+
return {
|
|
121
|
+
startup_attempt_ref: startupAttemptRef,
|
|
122
|
+
...(child.pid === undefined ? {} : { pid: child.pid }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function recordInternalHostStartupFailure(options) {
|
|
126
|
+
const env = options.env ?? process.env;
|
|
127
|
+
const startupAttemptRef = readStartupAttemptRef(env);
|
|
128
|
+
if (startupAttemptRef === undefined) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const projectIdValue = env[HOST_STARTUP_PROJECT_ENV];
|
|
132
|
+
const projectId = projectIdValue !== undefined && isPrefixedId(projectIdValue, ID_PREFIXES.project)
|
|
133
|
+
? projectIdValue
|
|
134
|
+
: undefined;
|
|
135
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
136
|
+
startup_attempt_ref: startupAttemptRef,
|
|
137
|
+
...(projectId === undefined ? {} : { project_id: projectId }),
|
|
138
|
+
stage: "internal_host_run",
|
|
139
|
+
disposition: "failed",
|
|
140
|
+
reason_code: "host_startup_failed",
|
|
141
|
+
detail: formatCliErrorReason(options.error),
|
|
142
|
+
});
|
|
42
143
|
}
|
|
43
144
|
export async function waitForManagedHostReady(options) {
|
|
145
|
+
const projectSurface = readMachineProjectBinding(options.tuttiHome, options.projectId)?.surface_profile.kind ??
|
|
146
|
+
"standalone";
|
|
44
147
|
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
45
148
|
const probe = createRuntimeEndpointProbe(options.fetchImpl ?? fetch);
|
|
46
149
|
let lastReason = "runtime endpoint was not written";
|
|
150
|
+
let lastReasonCode = "runtime_endpoint_missing";
|
|
151
|
+
let lastLoggedWaitState;
|
|
152
|
+
const startedAt = Date.now();
|
|
153
|
+
const logWaitState = (reasonCode, detail) => {
|
|
154
|
+
if (options.startupAttemptRef === undefined || lastLoggedWaitState === reasonCode) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
lastLoggedWaitState = reasonCode;
|
|
158
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
159
|
+
startup_attempt_ref: options.startupAttemptRef,
|
|
160
|
+
project_id: options.projectId,
|
|
161
|
+
stage: "wait",
|
|
162
|
+
disposition: "waiting",
|
|
163
|
+
reason_code: reasonCode,
|
|
164
|
+
...(detail === undefined ? {} : { detail: formatCliErrorReason(detail) }),
|
|
165
|
+
latency_ms: Date.now() - startedAt,
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const logReady = () => {
|
|
169
|
+
if (options.startupAttemptRef === undefined) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
173
|
+
startup_attempt_ref: options.startupAttemptRef,
|
|
174
|
+
project_id: options.projectId,
|
|
175
|
+
stage: "wait",
|
|
176
|
+
disposition: "ready",
|
|
177
|
+
latency_ms: Date.now() - startedAt,
|
|
178
|
+
});
|
|
179
|
+
};
|
|
47
180
|
while (Date.now() < deadline) {
|
|
48
181
|
const endpoint = readMachineRuntimeEndpoint(options.tuttiHome, options.projectId);
|
|
49
182
|
if (endpoint === null) {
|
|
183
|
+
logWaitState(lastReasonCode);
|
|
50
184
|
await delay(READY_POLL_INTERVAL_MS);
|
|
51
185
|
continue;
|
|
52
186
|
}
|
|
53
187
|
if (resolve(endpoint.workspace_root) !== resolve(options.workspaceRoot)) {
|
|
54
188
|
lastReason = "runtime endpoint points at a different workspace";
|
|
189
|
+
lastReasonCode = "runtime_endpoint_workspace_mismatch";
|
|
190
|
+
logWaitState(lastReasonCode);
|
|
55
191
|
await delay(READY_POLL_INTERVAL_MS);
|
|
56
192
|
continue;
|
|
57
193
|
}
|
|
58
194
|
const health = await probe(endpoint);
|
|
59
195
|
if (health.kind === "stale") {
|
|
60
196
|
lastReason = health.reason;
|
|
197
|
+
lastReasonCode = "runtime_endpoint_stale";
|
|
198
|
+
logWaitState(lastReasonCode, health.reason);
|
|
61
199
|
await delay(READY_POLL_INTERVAL_MS);
|
|
62
200
|
continue;
|
|
63
201
|
}
|
|
@@ -66,13 +204,16 @@ export async function waitForManagedHostReady(options) {
|
|
|
66
204
|
endpoint,
|
|
67
205
|
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
68
206
|
});
|
|
69
|
-
if (
|
|
207
|
+
if (projectSurface === "standalone" &&
|
|
208
|
+
status.relay?.join_url === undefined &&
|
|
209
|
+
options.inviteMode !== "preserve") {
|
|
70
210
|
status = await rotateHostLocalInvite({
|
|
71
211
|
endpoint,
|
|
72
212
|
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
73
213
|
});
|
|
74
214
|
}
|
|
75
215
|
if (status.relay?.join_url !== undefined) {
|
|
216
|
+
logReady();
|
|
76
217
|
return {
|
|
77
218
|
endpoint,
|
|
78
219
|
join_url: status.relay.join_url,
|
|
@@ -85,15 +226,42 @@ export async function waitForManagedHostReady(options) {
|
|
|
85
226
|
};
|
|
86
227
|
}
|
|
87
228
|
if (options.inviteMode === "preserve" && status.relay !== undefined) {
|
|
229
|
+
logReady();
|
|
88
230
|
return { endpoint };
|
|
89
231
|
}
|
|
232
|
+
if (projectSurface === "feishu" && status.relay !== undefined) {
|
|
233
|
+
logReady();
|
|
234
|
+
return {
|
|
235
|
+
endpoint,
|
|
236
|
+
...(status.relay.feishu_setup === undefined
|
|
237
|
+
? {}
|
|
238
|
+
: { feishu_setup: status.relay.feishu_setup }),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
90
241
|
lastReason = "host is running but Relay invite is not visible yet";
|
|
242
|
+
lastReasonCode = "relay_launch_status_not_ready";
|
|
243
|
+
logWaitState(lastReasonCode);
|
|
91
244
|
}
|
|
92
245
|
catch (error) {
|
|
93
246
|
lastReason = error instanceof Error ? error.message : "host-local launch status failed";
|
|
247
|
+
lastReasonCode = "host_local_launch_status_failed";
|
|
248
|
+
logWaitState(lastReasonCode, lastReason);
|
|
94
249
|
}
|
|
95
250
|
await delay(READY_POLL_INTERVAL_MS);
|
|
96
251
|
}
|
|
97
|
-
|
|
252
|
+
if (options.startupAttemptRef !== undefined) {
|
|
253
|
+
appendDetachedHostStartupEvent(options.tuttiHome, {
|
|
254
|
+
startup_attempt_ref: options.startupAttemptRef,
|
|
255
|
+
project_id: options.projectId,
|
|
256
|
+
stage: "wait",
|
|
257
|
+
disposition: "timed_out",
|
|
258
|
+
reason_code: lastReasonCode,
|
|
259
|
+
detail: formatCliErrorReason(lastReason),
|
|
260
|
+
latency_ms: Date.now() - startedAt,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
throw new LaunchError("existing_server_unhealthy", `Tutti host did not become ready before timeout: ${lastReason}`, "Run `tutti logs` for details, then retry `tutti launch`.", options.startupAttemptRef === undefined
|
|
264
|
+
? {}
|
|
265
|
+
: { startup_attempt_ref: options.startupAttemptRef });
|
|
98
266
|
}
|
|
99
267
|
//# sourceMappingURL=managed-host.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ConnectRelayHostTunnelOptions, type RelayHostTunnelHandle, type RegisterRelayHostConnectionOptions, type RegisterRelayHostConnectionResponse } from "@tutti/relay-client";
|
|
2
2
|
import { type HostConnectionRef, type RelayProjectRef } from "@tutti/shared/ids";
|
|
3
|
+
import type { FeishuSetupProjection } from "@tutti/shared/schemas/internal";
|
|
3
4
|
import { LaunchError } from "./errors.js";
|
|
4
5
|
import type { LaunchPreparationResult } from "./launch.js";
|
|
5
6
|
type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
@@ -12,6 +13,8 @@ export type HostLaunchRelaySummary = {
|
|
|
12
13
|
join_token_reusable: boolean;
|
|
13
14
|
tunnel_url: string;
|
|
14
15
|
connected_at: string;
|
|
16
|
+
surface_profile?: LaunchPreparationResult["machine_local"]["binding"]["surface_profile"];
|
|
17
|
+
feishu_setup?: FeishuSetupProjection;
|
|
15
18
|
};
|
|
16
19
|
export type RelayHostConnectionRegistrar = (options: RegisterRelayHostConnectionOptions) => Promise<RegisterRelayHostConnectionResponse>;
|
|
17
20
|
export type RelayHostTunnelConnector = (options: ConnectRelayHostTunnelOptions) => RelayHostTunnelHandle;
|
|
@@ -20,7 +23,7 @@ export type RegisteredRelayHostConnection = {
|
|
|
20
23
|
hostConnectionToken: string;
|
|
21
24
|
heartbeatIntervalMs: number;
|
|
22
25
|
};
|
|
23
|
-
export type HostRelayRegistrationPreparation = Pick<LaunchPreparationResult, "tutti_home" | "project_id" | "workspace_root" | "relay_url"
|
|
26
|
+
export type HostRelayRegistrationPreparation = Pick<LaunchPreparationResult, "tutti_home" | "project_id" | "workspace_root" | "relay_url"> & Partial<Pick<LaunchPreparationResult, "machine_local">>;
|
|
24
27
|
export declare function relayErrorToLaunchError(error: unknown, relayUrl: string): LaunchError;
|
|
25
28
|
export declare function registerHostWithRelay(options: {
|
|
26
29
|
preparation: HostRelayRegistrationPreparation;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { RelayHostControlClientError, } from "@tutti/relay-client";
|
|
3
3
|
import { createIdempotencyKey, } from "@tutti/shared/ids";
|
|
4
|
-
import { readHostRegistrationSecret, updateMachineProjectRelayBinding } from "./machine-local.js";
|
|
4
|
+
import { deleteFeishuOwnerAccountHandoff, readFeishuOwnerAccountHandoff, readHostRegistrationSecret, updateMachineProjectRelayBinding, } from "./machine-local.js";
|
|
5
5
|
import { LaunchError } from "./errors.js";
|
|
6
6
|
import { readProjectDisplayName } from "./project-identity.js";
|
|
7
7
|
export function relayErrorToLaunchError(error, relayUrl) {
|
|
@@ -12,6 +12,9 @@ export function relayErrorToLaunchError(error, relayUrl) {
|
|
|
12
12
|
if (error.code === "relay_unavailable") {
|
|
13
13
|
return new LaunchError("relay_unavailable", `Cannot register host connection because Relay is unavailable at ${relayUrl}`, "Keep the project host stopped, start or fix Relay, then run `tutti launch` again.");
|
|
14
14
|
}
|
|
15
|
+
if (error.relayErrorCode === "project_surface_conflict") {
|
|
16
|
+
return new LaunchError("project_surface_conflict", "Relay rejected a different collaboration surface for this project", "Keep the Host stopped and inspect the machine project binding and Relay project metadata.");
|
|
17
|
+
}
|
|
15
18
|
return new LaunchError("relay_registration_failed", `Relay rejected host registration${error.relayErrorCode === undefined ? "" : ` (${error.relayErrorCode})`}`, error.retryable
|
|
16
19
|
? "Retry after Relay recovers, or run `tutti launch` again with a new registration command."
|
|
17
20
|
: "Check Relay host-control registration state and this machine's host registration credential.");
|
|
@@ -20,6 +23,8 @@ export function relayErrorToLaunchError(error, relayUrl) {
|
|
|
20
23
|
}
|
|
21
24
|
export async function registerHostWithRelay(options) {
|
|
22
25
|
const hostSecret = readHostRegistrationSecret(options.preparation.tutti_home, options.preparation.project_id);
|
|
26
|
+
const surfaceProfile = options.preparation.machine_local?.binding.surface_profile ??
|
|
27
|
+
{ kind: "standalone", version: 1 };
|
|
23
28
|
const registrationOptions = {
|
|
24
29
|
relayUrl: options.preparation.relay_url,
|
|
25
30
|
projectId: options.preparation.project_id,
|
|
@@ -27,19 +32,34 @@ export async function registerHostWithRelay(options) {
|
|
|
27
32
|
displayName: readProjectDisplayName(options.preparation.workspace_root) ??
|
|
28
33
|
(basename(options.preparation.workspace_root) || options.preparation.project_id),
|
|
29
34
|
serverVersion: options.serverVersion,
|
|
35
|
+
surfaceProfile,
|
|
30
36
|
commandId: createIdempotencyKey(),
|
|
31
37
|
joinToken: { mode: options.joinTokenMode ?? "rotate" },
|
|
38
|
+
capabilities: {
|
|
39
|
+
integration_source_ingestion_v1: true,
|
|
40
|
+
integration_outbound_v1: true,
|
|
41
|
+
},
|
|
32
42
|
fetch: options.fetchImpl,
|
|
33
43
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
34
44
|
...(options.now === undefined ? {} : { issuedAt: options.now() }),
|
|
35
45
|
};
|
|
46
|
+
const accountHandoff = surfaceProfile.kind === "feishu"
|
|
47
|
+
? readFeishuOwnerAccountHandoff(options.preparation.tutti_home, options.preparation.project_id, options.now?.() ?? new Date())
|
|
48
|
+
: null;
|
|
49
|
+
if (accountHandoff !== null) {
|
|
50
|
+
registrationOptions.accountHandoffToken = accountHandoff.token;
|
|
51
|
+
}
|
|
36
52
|
const response = await options.registerRelayHostConnection(registrationOptions);
|
|
37
53
|
if (response.disposition.kind !== "registered" || response.result === undefined) {
|
|
38
54
|
throw new LaunchError("relay_registration_failed", "Relay host registration did not complete", "Retry `tutti launch` with a new registration command.");
|
|
39
55
|
}
|
|
40
56
|
const result = response.result;
|
|
41
|
-
if ((
|
|
42
|
-
(
|
|
57
|
+
if ((result.relay_project.surface_profile?.kind ?? "standalone") !== surfaceProfile.kind) {
|
|
58
|
+
throw new LaunchError("project_surface_conflict", "Relay returned a different project collaboration surface", "Keep the Host stopped and inspect the machine project binding and Relay project metadata.");
|
|
59
|
+
}
|
|
60
|
+
if (surfaceProfile.kind === "standalone" &&
|
|
61
|
+
(options.joinTokenMode ?? "rotate") === "rotate" &&
|
|
62
|
+
(result.join_token?.visibility !== "visible_once" || result.join_token.join_url === undefined)) {
|
|
43
63
|
throw new LaunchError("relay_registration_failed", "Relay did not return a visible join URL for this launch", "Retry `tutti launch` so the host can rotate a fresh visible-once join URL.");
|
|
44
64
|
}
|
|
45
65
|
updateMachineProjectRelayBinding({
|
|
@@ -48,16 +68,25 @@ export async function registerHostWithRelay(options) {
|
|
|
48
68
|
relayProjectRef: result.relay_project.relay_project_ref,
|
|
49
69
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
50
70
|
});
|
|
71
|
+
if (accountHandoff !== null && result.feishu_setup?.state !== "account_handoff_required") {
|
|
72
|
+
deleteFeishuOwnerAccountHandoff(options.preparation.tutti_home, options.preparation.project_id);
|
|
73
|
+
}
|
|
51
74
|
return {
|
|
52
75
|
summary: {
|
|
53
76
|
relay_project_ref: result.relay_project.relay_project_ref,
|
|
54
77
|
host_connection_ref: result.host_connection.host_connection_ref,
|
|
55
|
-
...(result.join_token
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
...(result.join_token?.join_url === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: { join_url: result.join_token.join_url }),
|
|
81
|
+
join_url_visibility: result.join_token?.visibility ?? "not_recoverable",
|
|
82
|
+
...(result.join_token === undefined
|
|
83
|
+
? {}
|
|
84
|
+
: { join_token_expires_at: result.join_token.expires_at }),
|
|
85
|
+
join_token_reusable: result.join_token?.reusable ?? false,
|
|
59
86
|
tunnel_url: result.host_connection.tunnel_url,
|
|
60
87
|
connected_at: result.host_connection.connected_at,
|
|
88
|
+
surface_profile: surfaceProfile,
|
|
89
|
+
...(result.feishu_setup === undefined ? {} : { feishu_setup: result.feishu_setup }),
|
|
61
90
|
},
|
|
62
91
|
hostConnectionToken: result.host_connection_token,
|
|
63
92
|
heartbeatIntervalMs: result.host_connection.heartbeat_interval_ms,
|
|
@@ -56,7 +56,11 @@ export declare function runForgetCommand(target: string, options?: {
|
|
|
56
56
|
fetchImpl?: LocalControlFetch;
|
|
57
57
|
shutdownWaitMs?: number;
|
|
58
58
|
}): Promise<string>;
|
|
59
|
-
export declare function runInviteCommand(target?: string
|
|
59
|
+
export declare function runInviteCommand(target?: string, options?: {
|
|
60
|
+
cwd?: string;
|
|
61
|
+
env?: NodeJS.ProcessEnv;
|
|
62
|
+
fetchImpl?: LocalControlFetch;
|
|
63
|
+
}): Promise<string>;
|
|
60
64
|
export declare function runProviderStatusCommand(target?: string): string;
|
|
61
65
|
export declare function runLogsCommand(target?: string, tailLines?: number): string;
|
|
62
66
|
export declare function runDoctorCommand(target?: string, options?: {
|
|
@@ -442,15 +442,21 @@ export async function runForgetCommand(target, options = {}) {
|
|
|
442
442
|
});
|
|
443
443
|
return `Forgot ${project.display_name} (${project.project_id}) on this machine. The Relay project and memberships were not archived.`;
|
|
444
444
|
}
|
|
445
|
-
export async function runInviteCommand(target) {
|
|
446
|
-
const project = resolveProject(target);
|
|
445
|
+
export async function runInviteCommand(target, options = {}) {
|
|
446
|
+
const project = resolveProject(target, options);
|
|
447
|
+
if (project.binding?.surface_profile.kind !== "standalone") {
|
|
448
|
+
throw new LaunchError("project_surface_mismatch", "Feishu collaboration projects do not use Standalone invite links.", "Manage the Feishu project binding from the Tutti Local Console.");
|
|
449
|
+
}
|
|
447
450
|
const endpoint = project.runtime_endpoint;
|
|
448
451
|
if (endpoint === null) {
|
|
449
452
|
throw new LaunchError("host_not_running", "Tutti host is not running for this project.", "Run `tutti launch` first, then retry `tutti invite`.");
|
|
450
453
|
}
|
|
451
454
|
let status;
|
|
452
455
|
try {
|
|
453
|
-
status = await rotateHostLocalInvite({
|
|
456
|
+
status = await rotateHostLocalInvite({
|
|
457
|
+
endpoint,
|
|
458
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
459
|
+
});
|
|
454
460
|
}
|
|
455
461
|
catch (error) {
|
|
456
462
|
throw new LaunchError("existing_server_unhealthy", `Could not refresh the invite: ${formatCliErrorReason(error)}`, "Run `tutti launch` to restart or reconnect the host, then retry `tutti invite`.");
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { FastifyInstance } from "fastify";
|
|
2
2
|
import type { RelayHostTunnelRequestHandler, RelayHostTunnelStreamRequestHandler } from "@tutti/relay-client";
|
|
3
|
+
import type { IntegrationDeliveryDisposition, TrustedConversationSourceIngestionCommand } from "@tutti/shared/schemas/internal";
|
|
4
|
+
import type { ProjectSurface } from "@tutti/shared/domain";
|
|
3
5
|
import { type TrustedRelaySessionMetadataStore } from "../session/relay-session-context.js";
|
|
4
6
|
export type HostProjectApiTunnelDispatchOptions = {
|
|
5
7
|
app: FastifyInstance;
|
|
8
|
+
projectSurface?: ProjectSurface;
|
|
6
9
|
trustedMetadataStore: TrustedRelaySessionMetadataStore;
|
|
10
|
+
integrationDelivery?: (command: TrustedConversationSourceIngestionCommand) => IntegrationDeliveryDisposition;
|
|
7
11
|
};
|
|
8
12
|
export type HostProjectApiTunnelStreamDispatchOptions = {
|
|
9
13
|
baseUrl: string;
|
|
14
|
+
projectSurface?: ProjectSurface;
|
|
10
15
|
trustedMetadataStore: TrustedRelaySessionMetadataStore;
|
|
11
16
|
fetch?: typeof fetch;
|
|
12
17
|
};
|
|
@@ -1,10 +1,97 @@
|
|
|
1
|
+
import { isTrustedConversationSourceIngestionCommand } from "@tutti/shared/schemas/internal";
|
|
2
|
+
import { TRUSTED_INTEGRATION_INGESTION_PATH } from "../../collaboration-ingestion/index.js";
|
|
1
3
|
import { TRUSTED_RELAY_METADATA_REQUEST_HEADER, } from "../session/relay-session-context.js";
|
|
4
|
+
function browserMetadataMatchesSurface(projectSurface, request) {
|
|
5
|
+
return projectSurface === "standalone"
|
|
6
|
+
? "relay_session_context" in request.metadata
|
|
7
|
+
: "feishu_platform_session_context" in request.metadata;
|
|
8
|
+
}
|
|
9
|
+
function forbiddenBrowserResponse(requestId) {
|
|
10
|
+
return {
|
|
11
|
+
request_id: requestId,
|
|
12
|
+
status_code: 403,
|
|
13
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
14
|
+
body_base64: Buffer.from(JSON.stringify({
|
|
15
|
+
error: {
|
|
16
|
+
code: "forbidden",
|
|
17
|
+
message: "Project surface does not allow this session",
|
|
18
|
+
retryable: false,
|
|
19
|
+
},
|
|
20
|
+
}), "utf8").toString("base64"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
2
23
|
function decodeRequestBody(bodyBase64) {
|
|
3
24
|
if (bodyBase64 === undefined) {
|
|
4
25
|
return undefined;
|
|
5
26
|
}
|
|
6
27
|
return Buffer.from(bodyBase64, "base64");
|
|
7
28
|
}
|
|
29
|
+
function integrationResponse(requestId, disposition) {
|
|
30
|
+
const body = {
|
|
31
|
+
protocol_version: 1,
|
|
32
|
+
disposition,
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
request_id: requestId,
|
|
36
|
+
status_code: 200,
|
|
37
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
38
|
+
body_base64: Buffer.from(JSON.stringify(body), "utf8").toString("base64"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function sameInstallation(left, right) {
|
|
42
|
+
return left.platform === right.platform && left.installation_ref === right.installation_ref;
|
|
43
|
+
}
|
|
44
|
+
function dispatchIntegrationDelivery(options, request) {
|
|
45
|
+
if ((options.projectSurface ?? "standalone") !== "feishu") {
|
|
46
|
+
return Promise.resolve(integrationResponse(request.request_id, {
|
|
47
|
+
kind: "unsupported",
|
|
48
|
+
reason_code: "project_surface_mismatch",
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
if (!("integration_delivery" in request.metadata)) {
|
|
52
|
+
throw new TypeError("Integration delivery metadata is required");
|
|
53
|
+
}
|
|
54
|
+
if (request.method.toUpperCase() !== "POST" ||
|
|
55
|
+
request.path !== TRUSTED_INTEGRATION_INGESTION_PATH) {
|
|
56
|
+
return Promise.resolve(integrationResponse(request.request_id, {
|
|
57
|
+
kind: "invalid",
|
|
58
|
+
reason_code: "internal_route_invalid",
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
const payload = decodeRequestBody(request.body_base64);
|
|
62
|
+
let command;
|
|
63
|
+
try {
|
|
64
|
+
command = payload === undefined ? undefined : JSON.parse(payload.toString("utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
command = undefined;
|
|
68
|
+
}
|
|
69
|
+
if (!isTrustedConversationSourceIngestionCommand(command)) {
|
|
70
|
+
return Promise.resolve(integrationResponse(request.request_id, {
|
|
71
|
+
kind: "invalid",
|
|
72
|
+
reason_code: "trusted_command_invalid",
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
const metadata = request.metadata.integration_delivery;
|
|
76
|
+
if (command.relay_project_ref !== metadata.relay_project_ref ||
|
|
77
|
+
command.binding_ref !== metadata.binding_ref ||
|
|
78
|
+
command.receipt.receipt_key !== metadata.receipt_key ||
|
|
79
|
+
command.actor.verified_at !== metadata.verified_at ||
|
|
80
|
+
!sameInstallation(command.receipt.installation, metadata.installation)) {
|
|
81
|
+
return Promise.resolve(integrationResponse(request.request_id, {
|
|
82
|
+
kind: "invalid",
|
|
83
|
+
reason_code: "trusted_metadata_mismatch",
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
if (options.integrationDelivery === undefined) {
|
|
87
|
+
return Promise.resolve(integrationResponse(request.request_id, {
|
|
88
|
+
kind: "host_incompatible",
|
|
89
|
+
retryable: true,
|
|
90
|
+
reason_code: "integration_source_ingestion_unsupported",
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
return Promise.resolve(integrationResponse(request.request_id, options.integrationDelivery(command)));
|
|
94
|
+
}
|
|
8
95
|
function injectHeaders(request) {
|
|
9
96
|
const headers = {};
|
|
10
97
|
for (const [name, value] of Object.entries(request.headers)) {
|
|
@@ -69,6 +156,12 @@ function toInjectMethod(method) {
|
|
|
69
156
|
}
|
|
70
157
|
export function createHostProjectApiTunnelRequestHandler(options) {
|
|
71
158
|
return async (request) => {
|
|
159
|
+
if ("integration_delivery" in request.metadata) {
|
|
160
|
+
return dispatchIntegrationDelivery(options, request);
|
|
161
|
+
}
|
|
162
|
+
if (!browserMetadataMatchesSurface(options.projectSurface ?? "standalone", request)) {
|
|
163
|
+
return forbiddenBrowserResponse(request.request_id);
|
|
164
|
+
}
|
|
72
165
|
const headers = injectHeaders(request);
|
|
73
166
|
headers[TRUSTED_RELAY_METADATA_REQUEST_HEADER] = options.trustedMetadataStore.stage(request.metadata);
|
|
74
167
|
const payload = decodeRequestBody(request.body_base64);
|
|
@@ -132,6 +225,22 @@ async function writeFetchBodyToStreamSink(response, sink) {
|
|
|
132
225
|
}
|
|
133
226
|
export function createHostProjectApiTunnelStreamRequestHandler(options) {
|
|
134
227
|
return async (request, sink) => {
|
|
228
|
+
if ("integration_delivery" in request.metadata) {
|
|
229
|
+
sink.error();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!browserMetadataMatchesSurface(options.projectSurface ?? "standalone", request)) {
|
|
233
|
+
const response = forbiddenBrowserResponse(request.request_id);
|
|
234
|
+
sink.start({
|
|
235
|
+
status_code: response.status_code,
|
|
236
|
+
...(response.headers === undefined ? {} : { headers: response.headers }),
|
|
237
|
+
});
|
|
238
|
+
if (response.body_base64 !== undefined) {
|
|
239
|
+
sink.write(Buffer.from(response.body_base64, "base64"));
|
|
240
|
+
}
|
|
241
|
+
sink.end();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
135
244
|
const headers = injectHeaders(request);
|
|
136
245
|
headers[TRUSTED_RELAY_METADATA_REQUEST_HEADER] = options.trustedMetadataStore.stage(request.metadata);
|
|
137
246
|
const payload = decodeRequestBody(request.body_base64);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance } from "fastify";
|
|
2
2
|
import type { ProviderConfigProjection } from "@tutti/shared/schemas/api";
|
|
3
3
|
import type { ProjectId } from "@tutti/shared/ids";
|
|
4
|
+
import { type FeishuSetupProjection } from "@tutti/shared/schemas/internal";
|
|
4
5
|
export type HostLocalControlOptions = {
|
|
5
6
|
token: string;
|
|
6
7
|
getLaunchStatus?: () => HostLocalLaunchStatus;
|
|
@@ -38,6 +39,7 @@ export type HostLocalLaunchStatus = {
|
|
|
38
39
|
join_token_reusable?: boolean;
|
|
39
40
|
tunnel_url: string;
|
|
40
41
|
connected_at: string;
|
|
42
|
+
feishu_setup?: FeishuSetupProjection;
|
|
41
43
|
};
|
|
42
44
|
};
|
|
43
45
|
export type HostLocalProviderConfigBody = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { FeishuSetupProjectionSchema, } from "@tutti/shared/schemas/internal";
|
|
2
3
|
class HostLocalControlUnauthorizedError extends Error {
|
|
3
4
|
statusCode = 401;
|
|
4
5
|
code = "unauthorized";
|
|
@@ -174,6 +175,7 @@ const HostLocalLaunchStatusResponseSchema = {
|
|
|
174
175
|
join_token_reusable: { type: "boolean" },
|
|
175
176
|
tunnel_url: { type: "string" },
|
|
176
177
|
connected_at: { type: "string" },
|
|
178
|
+
feishu_setup: FeishuSetupProjectionSchema,
|
|
177
179
|
},
|
|
178
180
|
},
|
|
179
181
|
},
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ClarificationRoundRef, TaskId } from "@tutti/shared/ids";
|
|
2
2
|
import type { QueryInvalidationHint, RunSchedulerNowDisposition } from "@tutti/shared/schemas/api";
|
|
3
|
-
export declare function mainChatMessageInvalidates(): QueryInvalidationHint[];
|
|
4
3
|
export declare function worklistSubmitInvalidates(): QueryInvalidationHint[];
|
|
5
4
|
export declare function schedulerRunNowInvalidates(disposition: RunSchedulerNowDisposition): QueryInvalidationHint[];
|
|
6
5
|
export declare function taskRetryInvalidates(taskId: TaskId): QueryInvalidationHint[];
|