@xfey/tutti 0.1.26 → 0.1.27

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.
@@ -3,6 +3,9 @@ import type { MachineRuntimeEndpointRecord } from "./machine-local.js";
3
3
  export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
4
4
  export type HostRuntimeEndpointProbeResult = {
5
5
  kind: "alive";
6
+ readiness?: {
7
+ relay?: "not_connected" | "connected";
8
+ };
6
9
  } | {
7
10
  kind: "stale";
8
11
  reason: string;
@@ -5,12 +5,23 @@ function delay(ms) {
5
5
  setTimeout(resolve, ms);
6
6
  });
7
7
  }
8
- function isHostHealthPayload(value) {
8
+ function parseHostHealthPayload(value) {
9
9
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
10
- return false;
10
+ return null;
11
11
  }
12
12
  const record = value;
13
- return record.service === "tutti-host" && record.status === "ok";
13
+ if (record.service !== "tutti-host" || record.status !== "ok") {
14
+ return null;
15
+ }
16
+ if (typeof record.readiness !== "object" ||
17
+ record.readiness === null ||
18
+ Array.isArray(record.readiness)) {
19
+ return {};
20
+ }
21
+ const readiness = record.readiness;
22
+ return readiness.relay === "connected" || readiness.relay === "not_connected"
23
+ ? { relay: readiness.relay }
24
+ : {};
14
25
  }
15
26
  async function fetchWithTimeout(fetchImpl, url, init = {}, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
16
27
  const controller = new AbortController();
@@ -38,11 +49,14 @@ export function createRuntimeEndpointProbe(fetchImpl = fetch) {
38
49
  if (!response.ok) {
39
50
  return { kind: "stale", reason: `health returned HTTP ${response.status}` };
40
51
  }
41
- const payload = await response.json();
42
- if (!isHostHealthPayload(payload)) {
52
+ const readiness = parseHostHealthPayload(await response.json());
53
+ if (readiness === null) {
43
54
  return { kind: "stale", reason: "health response is not a Tutti host" };
44
55
  }
45
- return { kind: "alive" };
56
+ return {
57
+ kind: "alive",
58
+ ...(readiness.relay === undefined ? {} : { readiness }),
59
+ };
46
60
  }
47
61
  catch (error) {
48
62
  const reason = error instanceof Error ? error.message : "health check failed";
@@ -4,7 +4,7 @@ import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
4
4
  import type { LocalControlFetch } from "./local-control-client.js";
5
5
  export type RuntimeProjectRow = {
6
6
  project_id: ProjectId;
7
- status: "online" | "stale";
7
+ status: "online" | "offline" | "stale";
8
8
  display_name: string;
9
9
  workspace_root: string;
10
10
  endpoint?: string;
@@ -11,6 +11,7 @@ import { resolveManagedProjectContext } from "./project-resolver.js";
11
11
  import { formatJoinLinkValidity, renderTerminalQr } from "./terminal-qr.js";
12
12
  import { resolveTuttiHome } from "../../providers/openai/index.js";
13
13
  import { readCliVersion } from "./version.js";
14
+ import { readProjectIdentity } from "./project-identity.js";
14
15
  const PROJECT_ID_DISPLAY_MIN_LENGTH = 12;
15
16
  function resolveProject(target, options = {}) {
16
17
  return resolveManagedProjectContext({
@@ -45,6 +46,30 @@ function readBindingFile(tuttiHome, projectId) {
45
46
  return null;
46
47
  }
47
48
  }
49
+ function workspaceBelongsToProject(workspaceRoot, projectId) {
50
+ if (!existsSync(workspaceRoot)) {
51
+ return false;
52
+ }
53
+ try {
54
+ const identity = readProjectIdentity(workspaceRoot);
55
+ return identity.kind === "present" && identity.project_id === projectId;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function cleanupOrphanedRuntimeProject(options) {
62
+ if (options.hostAlive) {
63
+ await requestHostLocalShutdown({
64
+ endpoint: options.endpoint,
65
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
66
+ }).catch(() => undefined);
67
+ }
68
+ deleteMachineProjectLocalState({
69
+ tuttiHome: options.tuttiHome,
70
+ projectId: options.projectId,
71
+ });
72
+ }
48
73
  export function formatProjectIdForDisplay(projectId, projectIds) {
49
74
  for (let length = Math.min(PROJECT_ID_DISPLAY_MIN_LENGTH, projectId.length); length <= projectId.length; length += 1) {
50
75
  const prefix = projectId.slice(0, length);
@@ -65,13 +90,27 @@ export async function listRuntimeProjects(options = {}) {
65
90
  continue;
66
91
  }
67
92
  const binding = readBindingFile(tuttiHome, projectId);
93
+ const workspaceRoot = binding?.workspace_root ?? endpoint.workspace_root;
94
+ const displayName = binding?.workspace_root === undefined
95
+ ? projectId
96
+ : binding.workspace_root.split(/[\\/]/u).pop() ?? projectId;
68
97
  const health = await probe(endpoint);
98
+ if (!workspaceBelongsToProject(workspaceRoot, projectId)) {
99
+ await cleanupOrphanedRuntimeProject({
100
+ tuttiHome,
101
+ projectId,
102
+ endpoint,
103
+ hostAlive: health.kind === "alive",
104
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
105
+ });
106
+ continue;
107
+ }
69
108
  if (health.kind === "stale") {
70
109
  rows.push({
71
110
  project_id: projectId,
72
111
  status: "stale",
73
- display_name: binding?.workspace_root === undefined ? projectId : binding.workspace_root.split(/[\\/]/u).pop() ?? projectId,
74
- workspace_root: binding?.workspace_root ?? endpoint.workspace_root,
112
+ display_name: displayName,
113
+ workspace_root: workspaceRoot,
75
114
  endpoint: endpoint.base_url,
76
115
  });
77
116
  continue;
@@ -84,9 +123,9 @@ export async function listRuntimeProjects(options = {}) {
84
123
  ]);
85
124
  const row = {
86
125
  project_id: projectId,
87
- status: "online",
88
- display_name: project.status === "fulfilled" ? project.value.display_name : binding?.workspace_root.split(/[\\/]/u).pop() ?? projectId,
89
- workspace_root: binding?.workspace_root ?? endpoint.workspace_root,
126
+ status: health.readiness?.relay === "connected" ? "online" : "offline",
127
+ display_name: project.status === "fulfilled" ? project.value.display_name : displayName,
128
+ workspace_root: workspaceRoot,
90
129
  endpoint: endpoint.base_url,
91
130
  };
92
131
  if (provider.status === "fulfilled") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",