@xfey/tutti 0.1.102 → 0.1.104

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.
Files changed (106) hide show
  1. package/README.md +9 -4
  2. package/dist/chat-assistant/index.d.ts +6 -1
  3. package/dist/chat-assistant/index.js +22 -12
  4. package/dist/collaboration-ingestion/external-source.d.ts +15 -0
  5. package/dist/collaboration-ingestion/external-source.js +120 -0
  6. package/dist/collaboration-ingestion/index.d.ts +5 -0
  7. package/dist/collaboration-ingestion/index.js +4 -0
  8. package/dist/collaboration-ingestion/invalidations.d.ts +4 -0
  9. package/dist/collaboration-ingestion/invalidations.js +15 -0
  10. package/dist/collaboration-ingestion/main-chat.d.ts +33 -0
  11. package/dist/collaboration-ingestion/main-chat.js +67 -0
  12. package/dist/collaboration-ingestion/reference-files.d.ts +29 -0
  13. package/dist/collaboration-ingestion/reference-files.js +158 -0
  14. package/dist/collaboration-ingestion/types.d.ts +19 -0
  15. package/dist/collaboration-ingestion/types.js +2 -0
  16. package/dist/platform-outbound/coordinator.d.ts +17 -0
  17. package/dist/platform-outbound/coordinator.js +48 -0
  18. package/dist/platform-outbound/index.d.ts +5 -0
  19. package/dist/platform-outbound/index.js +5 -0
  20. package/dist/platform-outbound/repository.d.ts +30 -0
  21. package/dist/platform-outbound/repository.js +108 -0
  22. package/dist/platform-outbound/types.d.ts +38 -0
  23. package/dist/platform-outbound/types.js +2 -0
  24. package/dist/platform-outbound/worker.d.ts +36 -0
  25. package/dist/platform-outbound/worker.js +164 -0
  26. package/dist/server-shell/cli/cli.js +9 -0
  27. package/dist/server-shell/cli/errors.d.ts +1 -1
  28. package/dist/server-shell/cli/host-lifecycle.d.ts +2 -0
  29. package/dist/server-shell/cli/host-lifecycle.js +34 -1
  30. package/dist/server-shell/cli/host-relay-connection-manager.js +22 -11
  31. package/dist/server-shell/cli/host-relay-status.js +1 -0
  32. package/dist/server-shell/cli/host-runtime-endpoint.js +17 -1
  33. package/dist/server-shell/cli/host-server-runtime.d.ts +2 -0
  34. package/dist/server-shell/cli/host-server-runtime.js +88 -14
  35. package/dist/server-shell/cli/launch-command.d.ts +7 -0
  36. package/dist/server-shell/cli/launch-command.js +39 -1
  37. package/dist/server-shell/cli/launch.d.ts +2 -0
  38. package/dist/server-shell/cli/launch.js +3 -0
  39. package/dist/server-shell/cli/machine-local.d.ts +20 -0
  40. package/dist/server-shell/cli/machine-local.js +61 -1
  41. package/dist/server-shell/cli/managed-host.d.ts +16 -1
  42. package/dist/server-shell/cli/managed-host.js +191 -23
  43. package/dist/server-shell/cli/relay-registration.d.ts +4 -1
  44. package/dist/server-shell/cli/relay-registration.js +36 -7
  45. package/dist/server-shell/cli/runtime-commands.d.ts +5 -1
  46. package/dist/server-shell/cli/runtime-commands.js +9 -3
  47. package/dist/server-shell/http/host-tunnel.d.ts +5 -0
  48. package/dist/server-shell/http/host-tunnel.js +109 -0
  49. package/dist/server-shell/http/routes/local-control.d.ts +2 -0
  50. package/dist/server-shell/http/routes/local-control.js +2 -0
  51. package/dist/server-shell/http/routes/project-api/invalidations.d.ts +0 -1
  52. package/dist/server-shell/http/routes/project-api/invalidations.js +0 -3
  53. package/dist/server-shell/http/routes/project-api/messages-routes.js +32 -63
  54. package/dist/server-shell/http/routes/project-api/openapi-workspace-routes.d.ts +8 -4
  55. package/dist/server-shell/http/routes/project-api/openapi.d.ts +8 -4
  56. package/dist/server-shell/http/routes/project-api/reference-files-routes.js +25 -94
  57. package/dist/server-shell/http/routes/project-api/reference-files.d.ts +1 -11
  58. package/dist/server-shell/http/routes/project-api/reference-files.js +2 -87
  59. package/dist/server-shell/http/routes/project-api/schemas.d.ts +8 -4
  60. package/dist/server-shell/http/routes/project-api/schemas.js +8 -3
  61. package/dist/server-shell/http/routes/project-api/session-requirements.js +34 -2
  62. package/dist/server-shell/http/routes/project-api/types.d.ts +3 -1
  63. package/dist/server-shell/http/routes/project-api/workspace-projections.js +8 -2
  64. package/dist/server-shell/local-console/package-update-hosts.js +4 -1
  65. package/dist/server-shell/local-console/project-service.d.ts +11 -2
  66. package/dist/server-shell/local-console/project-service.js +36 -8
  67. package/dist/server-shell/local-console/server.js +6 -1
  68. package/dist/server-shell/local-console/session.d.ts +4 -0
  69. package/dist/server-shell/local-console/session.js +14 -0
  70. package/dist/server-shell/session/relay-session-context.d.ts +12 -1
  71. package/dist/server-shell/session/relay-session-context.js +19 -1
  72. package/migrations/0016_external_source_mappings.sql +141 -0
  73. package/migrations/0017_platform_outbound_intents.sql +31 -0
  74. package/migrations/README.md +3 -1
  75. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +26 -1
  76. package/node_modules/@tutti/relay-client/dist/host-control.js +62 -0
  77. package/node_modules/@tutti/relay-client/dist/tunnel-frames.js +32 -6
  78. package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +5 -1
  79. package/node_modules/@tutti/shared/dist/domain/index.d.ts +9 -0
  80. package/node_modules/@tutti/shared/dist/domain/index.js +14 -0
  81. package/node_modules/@tutti/shared/dist/ids/index.d.ts +6 -0
  82. package/node_modules/@tutti/shared/dist/ids/index.js +4 -0
  83. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +4 -0
  84. package/node_modules/@tutti/shared/dist/schemas/api/index.js +2 -0
  85. package/node_modules/@tutti/shared/dist/schemas/api/local-console-account-handoffs.d.ts +13 -0
  86. package/node_modules/@tutti/shared/dist/schemas/api/local-console-account-handoffs.js +16 -0
  87. package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +43 -0
  88. package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +44 -0
  89. package/node_modules/@tutti/shared/dist/schemas/domain/index.d.ts +7 -0
  90. package/node_modules/@tutti/shared/dist/schemas/domain/index.js +10 -0
  91. package/node_modules/@tutti/shared/dist/schemas/internal/index.d.ts +29 -0
  92. package/node_modules/@tutti/shared/dist/schemas/internal/index.js +24 -1
  93. package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +1346 -0
  94. package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +516 -0
  95. package/node_modules/@tutti/shared/dist/utils/feishu-diagnostics/index.d.ts +10 -0
  96. package/node_modules/@tutti/shared/dist/utils/feishu-diagnostics/index.js +19 -0
  97. package/node_modules/@tutti/shared/dist/utils/index.d.ts +1 -0
  98. package/node_modules/@tutti/shared/dist/utils/index.js +1 -0
  99. package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
  100. package/node_modules/@tutti/shared/dist/utils/redaction/index.js +18 -0
  101. package/package.json +1 -1
  102. package/web/assets/{homepage-motion-scene-BtT5bd30.js → homepage-motion-scene-DEVrQ9aj.js} +1 -1
  103. package/web/assets/{index-CxCTqGFO.css → index-6i9Uk7fS.css} +1 -1
  104. package/web/assets/index-Lxtst2sl.js +69 -0
  105. package/web/index.html +2 -2
  106. package/web/assets/index-DtiStc-n.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 child = spawn(process.execPath, [
23
- ...process.execArgv,
24
- currentCliEntrypoint(),
25
- "internal",
26
- "host-run",
27
- "--workspace",
28
- resolve(options.workspaceRoot),
29
- "--yes",
30
- ], {
31
- cwd: options.workspaceRoot,
32
- detached: true,
33
- env: options.inheritProcessEnv === false
34
- ? options.env
35
- : {
36
- ...process.env,
37
- ...(options.env ?? {}),
38
- },
39
- stdio: "ignore",
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 (status.relay?.join_url === undefined && options.inviteMode !== "preserve") {
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
- throw new LaunchError("existing_server_unhealthy", `Tutti host did not become ready before timeout: ${lastReason}`, "Run `tutti logs` for details, then retry `tutti launch`.");
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 ((options.joinTokenMode ?? "rotate") === "rotate" &&
42
- (result.join_token.visibility !== "visible_once" || result.join_token.join_url === undefined)) {
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.join_url === undefined ? {} : { join_url: result.join_token.join_url }),
56
- join_url_visibility: result.join_token.visibility,
57
- join_token_expires_at: result.join_token.expires_at,
58
- join_token_reusable: result.join_token.reusable,
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): Promise<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({ endpoint });
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[];
@@ -1,6 +1,3 @@
1
- export function mainChatMessageInvalidates() {
2
- return [{ kind: "messages", scope: "main_chat" }, { kind: "read_state" }, { kind: "bootstrap" }];
3
- }
4
1
  export function worklistSubmitInvalidates() {
5
2
  return [
6
3
  { kind: "worklist" },