@relayfile/sdk 0.7.1 → 0.7.2

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/dist/client.d.ts CHANGED
@@ -63,6 +63,14 @@ export declare class RelayFileClient {
63
63
  * sync-vs-async tokenProvider shapes.
64
64
  */
65
65
  getToken(): Promise<string>;
66
+ /**
67
+ * Return the normalized API base URL this client was constructed with.
68
+ *
69
+ * onWrite/sync consumers use this so an explicit RelayFileClient remains the
70
+ * source of truth for both HTTP and WebSocket traffic instead of letting a
71
+ * process-level env override silently redirect only the WS side.
72
+ */
73
+ getBaseUrl(): string;
66
74
  listTree(workspaceId: string, options?: ListTreeOptions): Promise<TreeResponse>;
67
75
  readFile(workspaceId: string, path: string, correlationId?: string, signal?: AbortSignal): Promise<FileReadResponse>;
68
76
  readFile(input: ReadFileInput): Promise<FileReadResponse>;
package/dist/client.js CHANGED
@@ -185,6 +185,16 @@ export class RelayFileClient {
185
185
  async getToken() {
186
186
  return resolveToken(this.tokenProvider);
187
187
  }
188
+ /**
189
+ * Return the normalized API base URL this client was constructed with.
190
+ *
191
+ * onWrite/sync consumers use this so an explicit RelayFileClient remains the
192
+ * source of truth for both HTTP and WebSocket traffic instead of letting a
193
+ * process-level env override silently redirect only the WS side.
194
+ */
195
+ getBaseUrl() {
196
+ return this.baseUrl;
197
+ }
188
198
  async listTree(workspaceId, options = {}) {
189
199
  const query = buildQuery({
190
200
  path: options.path ?? "/",
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL, type AccessTokenProvider, type ConnectWebSocketOptions, type RelayFileClientOptions, type RelayFileRetryOptions, type WebSocketConnection } from "./client.js";
2
2
  export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
3
  export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
4
- export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
5
- export { WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
4
+ export { CloudAbortError, CloudApiError, CloudTimeoutError, InvalidLocalDirError, InvalidMountModeError, InvalidRemotePathError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, MountModeUnavailableError, MountReadyTimeoutError, MountSessionInputError, ProviderNotConnectedError, ProviderNotReadyError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
5
+ export { type EnsureMountedWorkspaceInput, WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type MountLauncher, type MountLauncherEvent, type MountLauncherInstance, type MountLauncherStart, type MountMode, type MountSessionRequest, type MountSessionResponse, type MountSessionResult, type MountedWorkspaceHandle, type MountedWorkspaceStatus, type MountWorkspaceInput, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
6
+ export { createDefaultMountLauncher, defaultMountLauncher, readMountedWorkspaceStatus } from "./mount-launcher.js";
6
7
  export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState, type RelayFileSyncTokenProvider } from "./sync.js";
7
8
  export { onWrite, pathMatches, type OnWriteClient, type OnWriteHandler, type OnWriteHandlerError, type OnWriteOptions } from "./onWrite.js";
8
9
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL } from "./client.js";
2
2
  export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
- export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
3
+ export { CloudAbortError, CloudApiError, CloudTimeoutError, InvalidLocalDirError, InvalidMountModeError, InvalidRemotePathError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, MountModeUnavailableError, MountReadyTimeoutError, MountSessionInputError, ProviderNotConnectedError, ProviderNotReadyError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
4
  export { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
+ export { createDefaultMountLauncher, defaultMountLauncher, readMountedWorkspaceStatus } from "./mount-launcher.js";
5
6
  export { RelayFileSync } from "./sync.js";
6
7
  export { onWrite, pathMatches } from "./onWrite.js";
7
8
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
@@ -0,0 +1,22 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { MountLauncher, MountMode, MountedWorkspaceStatus } from "./setup-types.js";
3
+ interface DefaultMountLauncherOptions {
4
+ spawnImpl?: typeof spawn;
5
+ now?: () => number;
6
+ readyPollIntervalMs?: number;
7
+ }
8
+ interface ReadMountedWorkspaceStatusInput {
9
+ localDir: string;
10
+ workspaceId: string;
11
+ remotePath: string;
12
+ mode: MountMode;
13
+ relayfileBaseUrl: string;
14
+ relayfileToken: string;
15
+ expiresAt: string | null;
16
+ suggestedRefreshAt: string | null;
17
+ pid?: number;
18
+ }
19
+ export declare const defaultMountLauncher: MountLauncher;
20
+ export declare function createDefaultMountLauncher(options?: DefaultMountLauncherOptions): MountLauncher;
21
+ export declare function readMountedWorkspaceStatus(input: ReadMountedWorkspaceStatusInput): Promise<MountedWorkspaceStatus>;
22
+ export {};
@@ -0,0 +1,371 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants as fsConstants, createWriteStream } from "node:fs";
3
+ import { access, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { RelayFileClient } from "./client.js";
8
+ import { CloudAbortError, MountModeUnavailableError, MountReadyTimeoutError, RelayfileSetupError } from "./setup-errors.js";
9
+ const DEFAULT_READY_POLL_INTERVAL_MS = 250;
10
+ const DEFAULT_STOP_TIMEOUT_MS = 10_000;
11
+ const LOG_ROTATION_MAX_BYTES = 10 * 1024 * 1024;
12
+ const LOG_ROTATION_FILES = 3;
13
+ const FUSE_UNAVAILABLE_SIGNATURE = "fuse mode is not available in this build";
14
+ export const defaultMountLauncher = createDefaultMountLauncher();
15
+ export function createDefaultMountLauncher(options = {}) {
16
+ return {
17
+ async start(input) {
18
+ return startRelayfileMount(input, options);
19
+ }
20
+ };
21
+ }
22
+ export async function readMountedWorkspaceStatus(input) {
23
+ const state = await readMountStateFile(input.localDir);
24
+ if (state && !isMountStateStale(state)) {
25
+ return {
26
+ ready: isMountStateReady(state),
27
+ mode: normalizeMountMode(state.mode) ?? input.mode,
28
+ pid: state.daemon?.pid ?? input.pid,
29
+ lastHeartbeatAt: normalizeIsoString(state.lastHeartbeatAt),
30
+ lastReconcileAt: normalizeIsoString(state.lastReconcileAt),
31
+ lastEventAt: normalizeIsoString(state.lastEventAt),
32
+ expiresAt: input.expiresAt,
33
+ suggestedRefreshAt: input.suggestedRefreshAt,
34
+ pendingWriteback: normalizeInteger(state.pendingWriteback),
35
+ pendingConflicts: normalizeInteger(state.pendingConflicts)
36
+ };
37
+ }
38
+ const ready = await probeMountedWorkspace(input);
39
+ return {
40
+ ready,
41
+ mode: input.mode,
42
+ pid: input.pid,
43
+ expiresAt: input.expiresAt,
44
+ suggestedRefreshAt: input.suggestedRefreshAt
45
+ };
46
+ }
47
+ async function startRelayfileMount(input, options) {
48
+ const localDir = path.resolve(input.env.RELAYFILE_LOCAL_DIR ?? process.cwd());
49
+ const relayDir = path.join(localDir, ".relay");
50
+ const logPath = path.join(relayDir, "mount.log");
51
+ const pidPath = path.join(relayDir, "mount.pid");
52
+ await mkdir(relayDir, { recursive: true });
53
+ await rotateMountLogIfNeeded(logPath);
54
+ const command = await resolveRelayfileMountCommand();
55
+ const args = input.background === false ? ["--once"] : [];
56
+ const child = (options.spawnImpl ?? spawn)(command, args, {
57
+ cwd: input.cwd ?? localDir,
58
+ env: {
59
+ ...process.env,
60
+ ...input.env
61
+ },
62
+ stdio: ["ignore", "pipe", "pipe"]
63
+ });
64
+ const outputBuffer = [];
65
+ const logStream = createWriteStream(logPath, { flags: "a" });
66
+ pipeChildOutput(child, logStream, outputBuffer, input);
67
+ if (typeof child.pid === "number" && child.pid > 0) {
68
+ await writeAtomicFile(pidPath, `${child.pid}\n`);
69
+ }
70
+ return new RelayfileMountProcessInstance({
71
+ child,
72
+ logStream,
73
+ pidPath,
74
+ outputBuffer,
75
+ input,
76
+ localDir,
77
+ now: options.now ?? Date.now,
78
+ readyPollIntervalMs: options.readyPollIntervalMs ?? DEFAULT_READY_POLL_INTERVAL_MS
79
+ });
80
+ }
81
+ class RelayfileMountProcessInstance {
82
+ pid;
83
+ ready;
84
+ child;
85
+ logStream;
86
+ pidPath;
87
+ outputBuffer;
88
+ input;
89
+ localDir;
90
+ now;
91
+ readyPollIntervalMs;
92
+ exited = false;
93
+ stopping;
94
+ readyResolved = false;
95
+ constructor(input) {
96
+ this.child = input.child;
97
+ this.logStream = input.logStream;
98
+ this.pidPath = input.pidPath;
99
+ this.outputBuffer = input.outputBuffer;
100
+ this.input = input.input;
101
+ this.localDir = input.localDir;
102
+ this.pid = input.child.pid ?? undefined;
103
+ this.now = input.now;
104
+ this.readyPollIntervalMs = input.readyPollIntervalMs;
105
+ this.child.once("exit", () => {
106
+ this.exited = true;
107
+ });
108
+ this.ready = this.waitForReady();
109
+ }
110
+ async status() {
111
+ return readMountedWorkspaceStatus({
112
+ localDir: this.localDir,
113
+ workspaceId: this.input.env.RELAYFILE_WORKSPACE ?? "",
114
+ remotePath: this.input.env.RELAYFILE_REMOTE_PATH ?? "/",
115
+ mode: normalizeMountMode(this.input.env.RELAYFILE_MOUNT_MODE) ?? "poll",
116
+ relayfileBaseUrl: this.input.env.RELAYFILE_BASE_URL ?? "",
117
+ relayfileToken: this.input.env.RELAYFILE_TOKEN ?? "",
118
+ expiresAt: null,
119
+ suggestedRefreshAt: null,
120
+ pid: this.pid
121
+ });
122
+ }
123
+ async stop() {
124
+ if (!this.stopping) {
125
+ this.stopping = this.performStop();
126
+ }
127
+ await this.stopping;
128
+ }
129
+ async waitForReady() {
130
+ const startedAt = this.now();
131
+ const timeoutAt = startedAt + this.input.readyTimeoutMs;
132
+ for (;;) {
133
+ if (this.input.signal?.aborted) {
134
+ await this.stop();
135
+ throw new CloudAbortError("mountWorkspace");
136
+ }
137
+ const status = await this.status();
138
+ if (status.ready) {
139
+ this.readyResolved = true;
140
+ return;
141
+ }
142
+ if (this.isFuseUnavailable()) {
143
+ throw new MountModeUnavailableError("fuse");
144
+ }
145
+ if (this.exited) {
146
+ throw this.buildEarlyExitError();
147
+ }
148
+ if (this.now() >= timeoutAt) {
149
+ const error = new MountReadyTimeoutError(this.localDir, this.input.readyTimeoutMs);
150
+ await this.stop();
151
+ throw error;
152
+ }
153
+ await delay(this.readyPollIntervalMs);
154
+ }
155
+ }
156
+ buildEarlyExitError() {
157
+ if (this.isFuseUnavailable()) {
158
+ return new MountModeUnavailableError("fuse");
159
+ }
160
+ return new RelayfileSetupError("relayfile-mount exited before the workspace became ready.", "mount_launch_failed");
161
+ }
162
+ isFuseUnavailable() {
163
+ return (normalizeMountMode(this.input.env.RELAYFILE_MOUNT_MODE) === "fuse" &&
164
+ this.outputBuffer.join("").includes(FUSE_UNAVAILABLE_SIGNATURE));
165
+ }
166
+ async performStop() {
167
+ if (!this.exited && typeof this.child.pid === "number") {
168
+ this.child.kill("SIGTERM");
169
+ await waitForExit(this.child, DEFAULT_STOP_TIMEOUT_MS);
170
+ }
171
+ if (!this.exited && typeof this.child.pid === "number") {
172
+ this.child.kill("SIGKILL");
173
+ await waitForExit(this.child, 1_000);
174
+ }
175
+ if (!this.readyResolved) {
176
+ this.exited = true;
177
+ }
178
+ this.logStream.end();
179
+ await unlinkIfExists(this.pidPath);
180
+ }
181
+ }
182
+ function pipeChildOutput(child, logStream, outputBuffer, input) {
183
+ const appendChunk = (streamName, chunk) => {
184
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
185
+ logStream.write(text);
186
+ outputBuffer.push(text);
187
+ if (outputBuffer.length > 32) {
188
+ outputBuffer.splice(0, outputBuffer.length - 32);
189
+ }
190
+ input.onEvent?.({
191
+ type: streamName,
192
+ text
193
+ });
194
+ };
195
+ child.stdout?.on("data", (chunk) => {
196
+ appendChunk("stdout", chunk);
197
+ });
198
+ child.stderr?.on("data", (chunk) => {
199
+ appendChunk("stderr", chunk);
200
+ });
201
+ }
202
+ async function probeMountedWorkspace(input) {
203
+ const client = new RelayFileClient({
204
+ baseUrl: input.relayfileBaseUrl,
205
+ token: input.relayfileToken
206
+ });
207
+ try {
208
+ await client.listTree(input.workspaceId, {
209
+ path: input.remotePath,
210
+ depth: 1
211
+ });
212
+ return true;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ async function readMountStateFile(localDir) {
219
+ const statePath = path.join(localDir, ".relay", "state.json");
220
+ try {
221
+ const payload = await readFile(statePath, "utf8");
222
+ const parsed = JSON.parse(payload);
223
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
224
+ return null;
225
+ }
226
+ return parsed;
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ function isMountStateReady(state) {
233
+ if (!normalizeIsoString(state.lastReconcileAt)) {
234
+ return false;
235
+ }
236
+ const providers = Array.isArray(state.providers) ? state.providers : [];
237
+ return providers.every((provider) => {
238
+ const status = normalizeNonEmptyString(provider?.status);
239
+ return status === undefined || status === "ready" || status === "syncing" || status === "unknown";
240
+ });
241
+ }
242
+ function isMountStateStale(state) {
243
+ const lastReconcileAt = normalizeIsoString(state.lastReconcileAt);
244
+ const intervalMs = normalizeInteger(state.intervalMs);
245
+ if (!lastReconcileAt || !intervalMs || intervalMs <= 0) {
246
+ return false;
247
+ }
248
+ const reconciledAt = Date.parse(lastReconcileAt);
249
+ return !Number.isNaN(reconciledAt) && Date.now() - reconciledAt > intervalMs * 2;
250
+ }
251
+ function normalizeMountMode(mode) {
252
+ return mode === "fuse" ? "fuse" : mode === "poll" ? "poll" : undefined;
253
+ }
254
+ function normalizeIsoString(value) {
255
+ if (typeof value !== "string" || value.trim() === "") {
256
+ return undefined;
257
+ }
258
+ return Number.isNaN(Date.parse(value)) ? undefined : value;
259
+ }
260
+ function normalizeInteger(value) {
261
+ return typeof value === "number" && Number.isFinite(value)
262
+ ? Math.trunc(value)
263
+ : undefined;
264
+ }
265
+ function normalizeNonEmptyString(value) {
266
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
267
+ }
268
+ async function resolveRelayfileMountCommand() {
269
+ const executableFromPath = await findExecutableInPath("relayfile-mount");
270
+ if (executableFromPath) {
271
+ return executableFromPath;
272
+ }
273
+ const candidates = [
274
+ process.env.RELAYFILE_MOUNT_BIN,
275
+ fileURLToPath(new URL("../../../../bin/relayfile-mount", import.meta.url))
276
+ ];
277
+ for (const candidate of candidates) {
278
+ if (!candidate) {
279
+ continue;
280
+ }
281
+ if (await isExecutable(candidate)) {
282
+ return candidate;
283
+ }
284
+ }
285
+ return "relayfile-mount";
286
+ }
287
+ async function findExecutableInPath(command) {
288
+ const pathValue = process.env.PATH ?? "";
289
+ const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
290
+ for (const entry of pathEntries) {
291
+ const candidate = path.join(entry, command);
292
+ if (await isExecutable(candidate)) {
293
+ return candidate;
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+ async function isExecutable(candidate) {
299
+ try {
300
+ await access(candidate, fsConstants.X_OK);
301
+ return true;
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ async function rotateMountLogIfNeeded(logPath) {
308
+ try {
309
+ const info = await stat(logPath);
310
+ if (info.size < LOG_ROTATION_MAX_BYTES) {
311
+ return;
312
+ }
313
+ }
314
+ catch {
315
+ return;
316
+ }
317
+ const oldest = `${logPath}.${LOG_ROTATION_FILES}`;
318
+ await unlinkIfExists(oldest);
319
+ for (let index = LOG_ROTATION_FILES - 1; index >= 1; index -= 1) {
320
+ const source = `${logPath}.${index}`;
321
+ const target = `${logPath}.${index + 1}`;
322
+ try {
323
+ await rename(source, target);
324
+ }
325
+ catch {
326
+ // ignore missing rotation slots
327
+ }
328
+ }
329
+ try {
330
+ await rename(logPath, `${logPath}.1`);
331
+ }
332
+ catch {
333
+ // ignore missing active log
334
+ }
335
+ }
336
+ async function writeAtomicFile(targetPath, content) {
337
+ const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
338
+ await mkdir(path.dirname(targetPath), { recursive: true });
339
+ await writeFile(tempPath, content, "utf8");
340
+ await rename(tempPath, targetPath);
341
+ }
342
+ async function waitForExit(child, timeoutMs) {
343
+ if (child.exitCode !== null) {
344
+ return;
345
+ }
346
+ await new Promise((resolve) => {
347
+ const timer = setTimeout(() => {
348
+ child.removeListener("exit", onExit);
349
+ resolve();
350
+ }, timeoutMs);
351
+ const onExit = () => {
352
+ clearTimeout(timer);
353
+ child.removeListener("exit", onExit);
354
+ resolve();
355
+ };
356
+ child.once("exit", onExit);
357
+ });
358
+ }
359
+ async function unlinkIfExists(targetPath) {
360
+ try {
361
+ await unlink(targetPath);
362
+ }
363
+ catch {
364
+ // ignore
365
+ }
366
+ }
367
+ async function delay(delayMs) {
368
+ await new Promise((resolve) => {
369
+ setTimeout(resolve, delayMs);
370
+ });
371
+ }
package/dist/onWrite.js CHANGED
@@ -38,6 +38,7 @@ export function onWrite(pattern, handler, options = {}) {
38
38
  if (!workspaceId) {
39
39
  throw new Error("onWrite requires options.workspaceId or RELAYFILE_WORKSPACE_ID.");
40
40
  }
41
+ const baseUrl = resolveOnWriteBaseUrl(options, client);
41
42
  const operations = new Set(options.operations ?? DEFAULT_OPERATIONS);
42
43
  for (const operation of operations) {
43
44
  if (operation !== "create" && operation !== "update" && operation !== "delete") {
@@ -65,7 +66,7 @@ export function onWrite(pattern, handler, options = {}) {
65
66
  dispatcher.register(registration, {
66
67
  workspaceId,
67
68
  signal: options.signal,
68
- baseUrl: options.baseUrl,
69
+ baseUrl,
69
70
  token: options.token,
70
71
  webSocketFactory: options.webSocketFactory,
71
72
  pingIntervalMs: options.pingIntervalMs,
@@ -146,7 +147,7 @@ class OnWriteDispatcher {
146
147
  this.sync = RelayFileSync.connect({
147
148
  client: this.client,
148
149
  workspaceId: options.workspaceId,
149
- baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
150
+ baseUrl: options.baseUrl ?? DEFAULT_RELAYFILE_BASE_URL,
150
151
  token,
151
152
  reconnect: {
152
153
  minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
@@ -330,6 +331,18 @@ function getDefaultClient() {
330
331
  }
331
332
  return defaultClient;
332
333
  }
334
+ function resolveOnWriteBaseUrl(options, client) {
335
+ if (options.baseUrl) {
336
+ return options.baseUrl;
337
+ }
338
+ if (!options.client) {
339
+ return readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL;
340
+ }
341
+ if (client instanceof RelayFileClient) {
342
+ return client.getBaseUrl();
343
+ }
344
+ return DEFAULT_RELAYFILE_BASE_URL;
345
+ }
333
346
  function readEnv(name) {
334
347
  return globalThis.process?.env?.[name];
335
348
  }
@@ -42,3 +42,41 @@ export declare class MissingConnectionIdError extends RelayfileSetupError {
42
42
  readonly provider: WorkspaceIntegrationProvider;
43
43
  constructor(provider: WorkspaceIntegrationProvider);
44
44
  }
45
+ export declare class MountSessionInputError extends RelayfileSetupError {
46
+ constructor(message: string);
47
+ }
48
+ export declare class InvalidMountModeError extends RelayfileSetupError {
49
+ readonly mode: string;
50
+ constructor(mode: string);
51
+ }
52
+ export declare class InvalidLocalDirError extends RelayfileSetupError {
53
+ readonly localDir: string;
54
+ constructor(localDir: string, message?: string);
55
+ }
56
+ export declare class InvalidRemotePathError extends RelayfileSetupError {
57
+ readonly remotePath: string;
58
+ constructor(remotePath: string, message?: string);
59
+ }
60
+ export declare class MountModeUnavailableError extends RelayfileSetupError {
61
+ readonly mode: string;
62
+ constructor(mode: string, message?: string);
63
+ }
64
+ export declare class MountReadyTimeoutError extends RelayfileSetupError {
65
+ readonly localDir: string;
66
+ readonly timeoutMs: number;
67
+ constructor(localDir: string, timeoutMs: number);
68
+ }
69
+ export declare class ProviderNotConnectedError extends RelayfileSetupError {
70
+ readonly provider: WorkspaceIntegrationProvider;
71
+ constructor(provider: WorkspaceIntegrationProvider);
72
+ }
73
+ export declare class ProviderNotReadyError extends RelayfileSetupError {
74
+ readonly provider: WorkspaceIntegrationProvider;
75
+ readonly state?: string;
76
+ readonly initialSyncState?: string;
77
+ constructor(input: {
78
+ provider: WorkspaceIntegrationProvider;
79
+ state?: string;
80
+ initialSyncState?: string;
81
+ });
82
+ }
@@ -69,6 +69,71 @@ export class MissingConnectionIdError extends RelayfileSetupError {
69
69
  this.provider = provider;
70
70
  }
71
71
  }
72
+ export class MountSessionInputError extends RelayfileSetupError {
73
+ constructor(message) {
74
+ super(message, "mount_session_input_error");
75
+ }
76
+ }
77
+ export class InvalidMountModeError extends RelayfileSetupError {
78
+ mode;
79
+ constructor(mode) {
80
+ super(`Invalid mount mode "${mode}". Expected "poll" or "fuse".`, "invalid_mount_mode");
81
+ this.mode = mode;
82
+ }
83
+ }
84
+ export class InvalidLocalDirError extends RelayfileSetupError {
85
+ localDir;
86
+ constructor(localDir, message) {
87
+ super(message ?? `Invalid localDir "${localDir}" for mount session.`, "invalid_local_dir");
88
+ this.localDir = localDir;
89
+ }
90
+ }
91
+ export class InvalidRemotePathError extends RelayfileSetupError {
92
+ remotePath;
93
+ constructor(remotePath, message) {
94
+ super(message ?? `Invalid remotePath "${remotePath}" for mount session.`, "invalid_remote_path");
95
+ this.remotePath = remotePath;
96
+ }
97
+ }
98
+ export class MountModeUnavailableError extends RelayfileSetupError {
99
+ mode;
100
+ constructor(mode, message) {
101
+ super(message ?? `Mount mode "${mode}" is unavailable in this environment.`, "mount_mode_unavailable");
102
+ this.mode = mode;
103
+ }
104
+ }
105
+ export class MountReadyTimeoutError extends RelayfileSetupError {
106
+ localDir;
107
+ timeoutMs;
108
+ constructor(localDir, timeoutMs) {
109
+ super(`Timed out waiting for the mount at ${localDir} to become ready after ${timeoutMs}ms.`, "mount_ready_timeout");
110
+ this.localDir = localDir;
111
+ this.timeoutMs = timeoutMs;
112
+ }
113
+ }
114
+ export class ProviderNotConnectedError extends RelayfileSetupError {
115
+ provider;
116
+ constructor(provider) {
117
+ super(`Provider "${provider}" is not connected for this workspace.`, "provider_not_connected");
118
+ this.provider = provider;
119
+ }
120
+ }
121
+ export class ProviderNotReadyError extends RelayfileSetupError {
122
+ provider;
123
+ state;
124
+ initialSyncState;
125
+ constructor(input) {
126
+ const details = [input.state, input.initialSyncState]
127
+ .filter((value) => typeof value === "string" && value.trim() !== "")
128
+ .join(", ");
129
+ super(details
130
+ ? `Provider "${input.provider}" is connected but not ready (${details}).`
131
+ : `Provider "${input.provider}" is connected but not ready.`, "provider_not_ready");
132
+ this.provider = input.provider;
133
+ this.state = input.state;
134
+ this.initialSyncState = input.initialSyncState;
135
+ }
136
+ }
72
137
  function readCloudErrorMessage(httpBody) {
73
138
  if (!httpBody || typeof httpBody !== "object" || Array.isArray(httpBody)) {
74
139
  return undefined;
@@ -1,3 +1,4 @@
1
+ import type { WorkspaceHandle } from "./setup.js";
1
2
  import type { AccessTokenProvider } from "./client.js";
2
3
  export declare const WORKSPACE_INTEGRATION_PROVIDERS: readonly ["github", "slack-sage", "slack-my-senior-dev", "slack-nightcto", "notion", "linear"];
3
4
  export type WorkspaceIntegrationProvider = (typeof WORKSPACE_INTEGRATION_PROVIDERS)[number];
@@ -65,6 +66,108 @@ export interface WorkspaceMountEnvOptions {
65
66
  relaycastBaseUrl?: string;
66
67
  }
67
68
  export type WorkspaceMountEnv = Record<string, string>;
69
+ export type MountMode = "poll" | "fuse";
70
+ export interface MountSessionRequest {
71
+ localDir: string;
72
+ remotePath?: string;
73
+ mode?: MountMode;
74
+ agentName?: string;
75
+ scopes?: string[];
76
+ provider?: WorkspaceIntegrationProvider;
77
+ }
78
+ export interface MountSessionResponse {
79
+ workspaceId: string;
80
+ relayfileBaseUrl: string;
81
+ relayfileToken: string;
82
+ wsUrl: string;
83
+ remotePath: string;
84
+ localDir: string;
85
+ mode: MountMode;
86
+ scopes: string[];
87
+ tokenIssuedAt: string | null;
88
+ expiresAt: string | null;
89
+ suggestedRefreshAt: string | null;
90
+ relaycastApiKey: string;
91
+ relaycastBaseUrl?: string;
92
+ }
93
+ export interface MountSessionResult {
94
+ workspaceId: string;
95
+ relayfileBaseUrl: string;
96
+ relayfileToken: string;
97
+ wsUrl: string;
98
+ remotePath: string;
99
+ localDir: string;
100
+ mode: MountMode;
101
+ scopes: string[];
102
+ tokenIssuedAt: string | null;
103
+ expiresAt: string | null;
104
+ suggestedRefreshAt: string | null;
105
+ relaycastApiKey: string;
106
+ relaycastBaseUrl?: string;
107
+ }
108
+ export interface MountedWorkspaceStatus {
109
+ ready: boolean;
110
+ mode: MountMode;
111
+ pid?: number;
112
+ lastHeartbeatAt?: string;
113
+ lastReconcileAt?: string;
114
+ lastEventAt?: string;
115
+ expiresAt: string | null;
116
+ suggestedRefreshAt: string | null;
117
+ pendingWriteback?: number;
118
+ pendingConflicts?: number;
119
+ }
120
+ export interface MountedWorkspaceHandle {
121
+ readonly workspaceId: string;
122
+ readonly localDir: string;
123
+ readonly remotePath: string;
124
+ readonly mode: MountMode;
125
+ readonly ready: boolean;
126
+ readonly expiresAt: string | null;
127
+ readonly suggestedRefreshAt: string | null;
128
+ env(): Record<string, string>;
129
+ status(): Promise<MountedWorkspaceStatus>;
130
+ stop(): Promise<void>;
131
+ }
132
+ export interface MountLauncherEvent {
133
+ type: string;
134
+ [key: string]: unknown;
135
+ }
136
+ export interface MountLauncherStart {
137
+ env: Record<string, string>;
138
+ cwd?: string;
139
+ signal?: AbortSignal;
140
+ readyTimeoutMs: number;
141
+ onEvent?: (event: MountLauncherEvent) => void;
142
+ background?: boolean;
143
+ }
144
+ export interface MountLauncherInstance {
145
+ pid?: number;
146
+ ready: Promise<void>;
147
+ status(): Promise<MountedWorkspaceStatus>;
148
+ stop(): Promise<void>;
149
+ }
150
+ export interface MountLauncher {
151
+ start(input: MountLauncherStart): Promise<MountLauncherInstance>;
152
+ }
153
+ export interface MountWorkspaceInput {
154
+ workspace?: WorkspaceHandle;
155
+ workspaceId?: string;
156
+ localDir: string;
157
+ remotePath?: string;
158
+ mode?: MountMode;
159
+ background?: boolean;
160
+ agentName?: string;
161
+ scopes?: string[];
162
+ signal?: AbortSignal;
163
+ launcher?: MountLauncher;
164
+ readyTimeoutMs?: number;
165
+ }
166
+ export interface EnsureMountedWorkspaceInput extends MountWorkspaceInput {
167
+ provider?: WorkspaceIntegrationProvider;
168
+ verifyProvider?: boolean;
169
+ providerReadyTimeoutMs?: number;
170
+ }
68
171
  export interface AgentWorkspaceInviteOptions {
69
172
  agentName?: string;
70
173
  relaycastBaseUrl?: string;
package/dist/setup.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { RelayFileClient, type AccessTokenProvider } from "./client.js";
2
2
  import { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
3
- import { type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
3
+ import { type EnsureMountedWorkspaceInput, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type MountedWorkspaceHandle, type MountWorkspaceInput, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
4
4
  export { RELAYFILE_SDK_VERSION } from "./version.js";
5
5
  interface JoinWorkspaceResponse {
6
6
  workspaceId?: string;
@@ -16,6 +16,11 @@ interface NormalizedJoinWorkspaceOptions {
16
16
  scopes: string[];
17
17
  permissions?: WorkspacePermissions;
18
18
  }
19
+ interface ValidatedIntegrationStatusResponse {
20
+ ready: boolean;
21
+ state?: string;
22
+ initialSyncState?: string;
23
+ }
19
24
  interface CloudRequestOptions {
20
25
  operation: string;
21
26
  method: string;
@@ -41,11 +46,15 @@ export declare class RelayfileSetup {
41
46
  constructor(options?: RelayfileSetupOptions);
42
47
  createWorkspace(options?: CreateWorkspaceOptions): Promise<WorkspaceHandle>;
43
48
  joinWorkspace(workspaceId: string, options?: JoinWorkspaceOptions): Promise<WorkspaceHandle>;
49
+ mountWorkspace(input: MountWorkspaceInput): Promise<MountedWorkspaceHandle>;
50
+ ensureMountedWorkspace(input: EnsureMountedWorkspaceInput): Promise<MountedWorkspaceHandle>;
44
51
  joinWorkspaceResponse(workspaceId: string, options: NormalizedJoinWorkspaceOptions, overrides?: {
45
52
  tokenProvider?: AccessTokenProvider;
46
53
  }): Promise<ValidatedJoinWorkspaceResponse>;
47
54
  requestJson(options: CloudRequestOptions): Promise<unknown>;
48
55
  getCloudApiUrl(): string;
56
+ private resolveWorkspaceForMount;
57
+ private createMountSession;
49
58
  }
50
59
  export declare class WorkspaceHandle {
51
60
  readonly info: WorkspaceInfo;
@@ -66,6 +75,7 @@ export declare class WorkspaceHandle {
66
75
  isConnected(provider: WorkspaceIntegrationProvider, connectionId: string): Promise<boolean>;
67
76
  disconnectIntegration(provider: WorkspaceIntegrationProvider, _connectionId?: string): Promise<void>;
68
77
  getToken(): string;
78
+ requestJson(options: Omit<CloudRequestOptions, "tokenProvider">): Promise<unknown>;
69
79
  mountEnv(options?: WorkspaceMountEnvOptions): WorkspaceMountEnv;
70
80
  /**
71
81
  * Build an invite that hands a peer agent the calling workspace's existing
@@ -99,6 +109,9 @@ export declare class WorkspaceHandle {
99
109
  private performRefreshToken;
100
110
  private getOrRefreshToken;
101
111
  private resolveConnectionId;
102
- private getConnectionStatus;
112
+ getConnectionStatus(provider: WorkspaceIntegrationProvider, connectionId: string, options?: {
113
+ signal?: AbortSignal;
114
+ timeoutMs?: number;
115
+ }): Promise<ValidatedIntegrationStatusResponse>;
103
116
  private resolveRelaycastBaseUrl;
104
117
  }
package/dist/setup.js CHANGED
@@ -1,8 +1,10 @@
1
+ import path from "node:path";
1
2
  import { RelayFileClient } from "./client.js";
2
3
  import { createRelayfileCloudAccessTokenProvider, runRelayfileCloudLogin } from "./cloud-login.js";
3
- import { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, UnknownProviderError } from "./setup-errors.js";
4
+ import { CloudAbortError, CloudApiError, CloudTimeoutError, InvalidLocalDirError, InvalidMountModeError, InvalidRemotePathError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, MountSessionInputError, ProviderNotConnectedError, ProviderNotReadyError, UnknownProviderError } from "./setup-errors.js";
4
5
  import { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
6
  import { RELAYFILE_SDK_VERSION } from "./version.js";
7
+ import { defaultMountLauncher, readMountedWorkspaceStatus } from "./mount-launcher.js";
6
8
  export { RELAYFILE_SDK_VERSION } from "./version.js";
7
9
  const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud";
8
10
  const DEFAULT_RELAYCAST_BASE_URL = "https://api.relaycast.dev";
@@ -14,6 +16,8 @@ const DEFAULT_AGENT_NAME = "sdk-agent";
14
16
  const DEFAULT_SCOPES = ["fs:read", "fs:write"];
15
17
  const DEFAULT_WAIT_INTERVAL_MS = 2_000;
16
18
  const DEFAULT_WAIT_TIMEOUT_MS = 300_000;
19
+ const DEFAULT_MOUNT_READY_TIMEOUT_MS = 60_000;
20
+ const DEFAULT_MOUNT_AGENT_NAME = "relayfile-mount";
17
21
  const TOKEN_REFRESH_AGE_MS = 55 * 60 * 1000;
18
22
  export class RelayfileSetup {
19
23
  cloudApiUrl;
@@ -99,6 +103,57 @@ export class RelayfileSetup {
99
103
  joinOptions
100
104
  });
101
105
  }
106
+ async mountWorkspace(input) {
107
+ const normalized = normalizeMountWorkspaceInput(input);
108
+ const workspace = await this.resolveWorkspaceForMount(normalized);
109
+ const mountSession = await this.createMountSession(workspace, normalized);
110
+ const launcher = normalized.launcher;
111
+ const launcherInstance = await launcher.start({
112
+ env: buildMountLauncherEnv(mountSession),
113
+ signal: normalized.signal,
114
+ readyTimeoutMs: normalized.readyTimeoutMs,
115
+ background: normalized.background
116
+ });
117
+ try {
118
+ await waitForMountReady(launcherInstance.ready, normalized.signal);
119
+ }
120
+ catch (error) {
121
+ // If the abort signal fired before waitForMountReady got a chance to
122
+ // attach a Promise.race handler, ensureLauncher.ready will reject
123
+ // later. Attach a no-op rejection handler so Node 18+ does not surface
124
+ // an unhandledRejection (which terminates the process by default).
125
+ launcherInstance.ready.catch(() => { });
126
+ await safeStopLauncher(launcherInstance);
127
+ throw error;
128
+ }
129
+ return new MountedWorkspaceHandleImpl({
130
+ mountSession,
131
+ launcherInstance: normalized.background ? launcherInstance : undefined,
132
+ probeOnly: !normalized.background
133
+ });
134
+ }
135
+ async ensureMountedWorkspace(input) {
136
+ const normalized = normalizeEnsureMountedWorkspaceInput(input);
137
+ const workspace = await this.resolveWorkspaceForMount(normalized);
138
+ if (normalized.verifyProvider) {
139
+ if (!normalized.provider) {
140
+ throw new MountSessionInputError("provider required when verifyProvider=true");
141
+ }
142
+ await verifyWorkspaceProviderReady(workspace, normalized.provider, normalized.providerReadyTimeoutMs, normalized.signal);
143
+ }
144
+ return this.mountWorkspace({
145
+ workspace,
146
+ localDir: normalized.localDir,
147
+ remotePath: normalized.remotePath,
148
+ mode: normalized.mode,
149
+ background: normalized.background,
150
+ agentName: normalized.agentName,
151
+ scopes: normalized.scopes,
152
+ signal: normalized.signal,
153
+ launcher: normalized.launcher,
154
+ readyTimeoutMs: normalized.readyTimeoutMs
155
+ });
156
+ }
102
157
  async joinWorkspaceResponse(workspaceId, options, overrides = {}) {
103
158
  return validateJoinWorkspaceResponse(await this.requestJson({
104
159
  operation: "joinWorkspace",
@@ -163,6 +218,38 @@ export class RelayfileSetup {
163
218
  getCloudApiUrl() {
164
219
  return this.cloudApiUrl;
165
220
  }
221
+ async resolveWorkspaceForMount(input) {
222
+ if (input.workspace) {
223
+ return input.workspace;
224
+ }
225
+ return this.joinWorkspace(input.workspaceId, {
226
+ agentName: input.agentName,
227
+ scopes: input.scopes
228
+ });
229
+ }
230
+ async createMountSession(workspace, input) {
231
+ const request = compactObject({
232
+ localDir: input.localDir,
233
+ remotePath: input.remotePath,
234
+ mode: input.mode,
235
+ agentName: normalizeNonEmptyString(input.agentName) ?? DEFAULT_MOUNT_AGENT_NAME,
236
+ scopes: input.scopes && input.scopes.length > 0
237
+ ? [...input.scopes]
238
+ : undefined
239
+ });
240
+ try {
241
+ return validateMountSessionResponse(await workspace.requestJson({
242
+ operation: "mountWorkspace",
243
+ method: "POST",
244
+ path: `api/v1/workspaces/${encodeURIComponent(workspace.workspaceId)}/relayfile/mount-session`,
245
+ body: request,
246
+ signal: input.signal
247
+ }), input.localDir);
248
+ }
249
+ catch (error) {
250
+ throw mapMountSessionError(error, request);
251
+ }
252
+ }
166
253
  }
167
254
  export class WorkspaceHandle {
168
255
  info;
@@ -259,10 +346,11 @@ export class WorkspaceHandle {
259
346
  const remainingMs = timeoutMs - elapsedMs;
260
347
  let ready;
261
348
  try {
262
- ready = await this.getConnectionStatus(provider, connectionId, {
349
+ const status = await this.getConnectionStatus(provider, connectionId, {
263
350
  signal: options.signal,
264
351
  timeoutMs: remainingMs
265
352
  });
353
+ ready = status.ready;
266
354
  }
267
355
  catch (error) {
268
356
  if (error instanceof CloudTimeoutError) {
@@ -287,7 +375,8 @@ export class WorkspaceHandle {
287
375
  }
288
376
  async isConnected(provider, connectionId) {
289
377
  assertProvider(provider);
290
- return this.getConnectionStatus(provider, this.resolveConnectionId(provider, connectionId));
378
+ const status = await this.getConnectionStatus(provider, this.resolveConnectionId(provider, connectionId));
379
+ return status.ready;
291
380
  }
292
381
  async disconnectIntegration(provider, _connectionId) {
293
382
  assertProvider(provider);
@@ -302,6 +391,12 @@ export class WorkspaceHandle {
302
391
  getToken() {
303
392
  return this._token;
304
393
  }
394
+ async requestJson(options) {
395
+ return this._setup.requestJson({
396
+ ...options,
397
+ tokenProvider: async () => this.getOrRefreshToken()
398
+ });
399
+ }
305
400
  mountEnv(options = {}) {
306
401
  const relaycastBaseUrl = this.resolveRelaycastBaseUrl(options.relaycastBaseUrl);
307
402
  return compactStringRecord({
@@ -426,15 +521,13 @@ export class WorkspaceHandle {
426
521
  }
427
522
  async getConnectionStatus(provider, connectionId, options = {}) {
428
523
  const query = new URLSearchParams({ connectionId });
429
- const response = validateIntegrationStatusResponse(await this._setup.requestJson({
524
+ return validateIntegrationStatusResponse(await this.requestJson({
430
525
  operation: "getIntegrationStatus",
431
526
  method: "GET",
432
527
  path: `api/v1/workspaces/${encodeURIComponent(this.workspaceId)}/integrations/${encodeURIComponent(provider)}/status?${query.toString()}`,
433
528
  signal: options.signal,
434
- timeoutMs: options.timeoutMs,
435
- tokenProvider: async () => this.getOrRefreshToken()
529
+ timeoutMs: options.timeoutMs
436
530
  }));
437
- return response.ready;
438
531
  }
439
532
  resolveRelaycastBaseUrl(override) {
440
533
  return (normalizeNonEmptyString(override) ??
@@ -442,6 +535,63 @@ export class WorkspaceHandle {
442
535
  DEFAULT_RELAYCAST_BASE_URL);
443
536
  }
444
537
  }
538
+ class MountedWorkspaceHandleImpl {
539
+ workspaceId;
540
+ localDir;
541
+ remotePath;
542
+ mode;
543
+ expiresAt;
544
+ suggestedRefreshAt;
545
+ mountSession;
546
+ launcherInstance;
547
+ probeOnly;
548
+ readySnapshot = true;
549
+ stopPromise;
550
+ constructor(input) {
551
+ this.mountSession = input.mountSession;
552
+ this.workspaceId = input.mountSession.workspaceId;
553
+ this.localDir = input.mountSession.localDir;
554
+ this.remotePath = input.mountSession.remotePath;
555
+ this.mode = input.mountSession.mode;
556
+ this.expiresAt = input.mountSession.expiresAt;
557
+ this.suggestedRefreshAt = input.mountSession.suggestedRefreshAt;
558
+ this.launcherInstance = input.launcherInstance;
559
+ this.probeOnly = input.probeOnly;
560
+ }
561
+ get ready() {
562
+ return this.readySnapshot;
563
+ }
564
+ env() {
565
+ return buildMountedWorkspaceEnv(this.mountSession);
566
+ }
567
+ async status() {
568
+ const status = await readMountedWorkspaceStatus({
569
+ localDir: this.localDir,
570
+ workspaceId: this.workspaceId,
571
+ remotePath: this.remotePath,
572
+ mode: this.mode,
573
+ relayfileBaseUrl: this.mountSession.relayfileBaseUrl,
574
+ relayfileToken: this.mountSession.relayfileToken,
575
+ expiresAt: this.expiresAt,
576
+ suggestedRefreshAt: this.suggestedRefreshAt,
577
+ pid: this.launcherInstance?.pid
578
+ });
579
+ this.readySnapshot = status.ready;
580
+ return status;
581
+ }
582
+ async stop() {
583
+ if (!this.stopPromise) {
584
+ this.stopPromise = this.performStop();
585
+ }
586
+ await this.stopPromise;
587
+ }
588
+ async performStop() {
589
+ if (this.probeOnly || !this.launcherInstance) {
590
+ return;
591
+ }
592
+ await safeStopLauncher(this.launcherInstance);
593
+ }
594
+ }
445
595
  function assertProvider(provider) {
446
596
  if (!WORKSPACE_INTEGRATION_PROVIDERS.includes(provider)) {
447
597
  throw new UnknownProviderError(provider);
@@ -494,7 +644,26 @@ function validateConnectSessionResponse(payload) {
494
644
  }
495
645
  function validateIntegrationStatusResponse(payload) {
496
646
  return {
497
- ready: requireBooleanField(payload, "ready")
647
+ ready: requireBooleanField(payload, "ready"),
648
+ state: readOptionalStringField(payload, "state"),
649
+ initialSyncState: readOptionalNestedStringField(payload, ["initialSync", "state"])
650
+ };
651
+ }
652
+ function validateMountSessionResponse(payload, localDir) {
653
+ return {
654
+ workspaceId: requireStringField(payload, "workspaceId"),
655
+ relayfileBaseUrl: stripTrailingSlash(requireStringField(payload, "relayfileBaseUrl")),
656
+ relayfileToken: requireStringField(payload, "relayfileToken"),
657
+ wsUrl: requireStringField(payload, "wsUrl"),
658
+ remotePath: requireStringField(payload, "remotePath"),
659
+ localDir,
660
+ mode: requireMountModeField(payload, "mode"),
661
+ scopes: requireStringArrayField(payload, "scopes"),
662
+ tokenIssuedAt: readNullableStringField(payload, "tokenIssuedAt"),
663
+ expiresAt: readNullableStringField(payload, "expiresAt"),
664
+ suggestedRefreshAt: readNullableStringField(payload, "suggestedRefreshAt"),
665
+ relaycastApiKey: requireStringField(payload, "relaycastApiKey"),
666
+ relaycastBaseUrl: readOptionalStringField(payload, "relaycastBaseUrl")
498
667
  };
499
668
  }
500
669
  function requireStringField(payload, field) {
@@ -514,6 +683,30 @@ function readOptionalStringField(payload, field) {
514
683
  }
515
684
  return value;
516
685
  }
686
+ function readNullableStringField(payload, field) {
687
+ const value = readField(payload, field);
688
+ if (value === null || value === undefined) {
689
+ return null;
690
+ }
691
+ if (typeof value !== "string" || value.trim() === "") {
692
+ throw new MalformedCloudResponseError(field, payload);
693
+ }
694
+ return value;
695
+ }
696
+ function readOptionalNestedStringField(payload, pathSegments) {
697
+ const parent = readField(payload, pathSegments[0]);
698
+ if (!parent || typeof parent !== "object" || Array.isArray(parent)) {
699
+ return undefined;
700
+ }
701
+ const value = parent[pathSegments[1]];
702
+ if (value === undefined) {
703
+ return undefined;
704
+ }
705
+ if (typeof value !== "string" || value.trim() === "") {
706
+ throw new MalformedCloudResponseError(pathSegments.join("."), payload);
707
+ }
708
+ return value;
709
+ }
517
710
  function requireBooleanField(payload, field) {
518
711
  const value = readField(payload, field);
519
712
  if (typeof value !== "boolean") {
@@ -521,6 +714,20 @@ function requireBooleanField(payload, field) {
521
714
  }
522
715
  return value;
523
716
  }
717
+ function requireMountModeField(payload, field) {
718
+ const value = requireStringField(payload, field);
719
+ if (value !== "poll" && value !== "fuse") {
720
+ throw new MalformedCloudResponseError(field, payload);
721
+ }
722
+ return value;
723
+ }
724
+ function requireStringArrayField(payload, field) {
725
+ const value = readField(payload, field);
726
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
727
+ throw new MalformedCloudResponseError(field, payload);
728
+ }
729
+ return [...value];
730
+ }
524
731
  function readField(payload, field) {
525
732
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
526
733
  return undefined;
@@ -530,6 +737,59 @@ function readField(payload, field) {
530
737
  function normalizeConnectionId(connectionId) {
531
738
  return normalizeNonEmptyString(connectionId);
532
739
  }
740
+ function normalizeMountWorkspaceInput(input) {
741
+ if (!!input.workspace === !!input.workspaceId) {
742
+ throw new MountSessionInputError("Exactly one of workspace or workspaceId must be provided.");
743
+ }
744
+ const localDir = normalizeNonEmptyString(input.localDir);
745
+ if (!localDir) {
746
+ throw new InvalidLocalDirError(String(input.localDir ?? ""));
747
+ }
748
+ return {
749
+ workspace: input.workspace,
750
+ workspaceId: normalizeNonEmptyString(input.workspaceId),
751
+ localDir: path.resolve(localDir),
752
+ remotePath: normalizeMountRemotePath(input.remotePath),
753
+ mode: normalizeMountModeInput(input.mode),
754
+ background: input.background !== false,
755
+ agentName: normalizeNonEmptyString(input.agentName),
756
+ scopes: input.scopes && input.scopes.length > 0 ? [...input.scopes] : undefined,
757
+ signal: input.signal,
758
+ launcher: input.launcher ?? defaultMountLauncher,
759
+ readyTimeoutMs: Math.max(1, Math.floor(input.readyTimeoutMs ?? DEFAULT_MOUNT_READY_TIMEOUT_MS))
760
+ };
761
+ }
762
+ function normalizeEnsureMountedWorkspaceInput(input) {
763
+ const normalized = normalizeMountWorkspaceInput(input);
764
+ return {
765
+ ...normalized,
766
+ provider: input.provider,
767
+ verifyProvider: input.verifyProvider !== false,
768
+ providerReadyTimeoutMs: Math.max(0, Math.floor(input.providerReadyTimeoutMs ?? 0))
769
+ };
770
+ }
771
+ function normalizeMountModeInput(mode) {
772
+ const normalized = normalizeNonEmptyString(mode) ?? "poll";
773
+ if (normalized !== "poll" && normalized !== "fuse") {
774
+ throw new InvalidMountModeError(normalized);
775
+ }
776
+ return normalized;
777
+ }
778
+ function normalizeMountRemotePath(remotePath) {
779
+ const normalized = normalizeNonEmptyString(remotePath) ?? "/";
780
+ if (normalized.includes("\u0000")) {
781
+ throw new InvalidRemotePathError(normalized);
782
+ }
783
+ const segments = normalized.replace(/\\/g, "/").split("/");
784
+ if (segments.some((segment) => segment === "..")) {
785
+ throw new InvalidRemotePathError(normalized);
786
+ }
787
+ const resolved = path.posix.normalize(normalized.startsWith("/") ? normalized : `/${normalized}`);
788
+ if (!resolved.startsWith("/")) {
789
+ throw new InvalidRemotePathError(normalized);
790
+ }
791
+ return resolved === "/" ? "/" : resolved.replace(/\/+$/, "");
792
+ }
533
793
  function normalizeNonEmptyString(value) {
534
794
  const normalized = value?.trim();
535
795
  return normalized ? normalized : undefined;
@@ -552,6 +812,31 @@ function compactStringRecord(value) {
552
812
  return entryValue !== undefined;
553
813
  }));
554
814
  }
815
+ function buildMountedWorkspaceEnv(mountSession) {
816
+ const relaycastBaseUrl = normalizeNonEmptyString(mountSession.relaycastBaseUrl) ??
817
+ DEFAULT_RELAYCAST_BASE_URL;
818
+ return compactStringRecord({
819
+ RELAYFILE_BASE_URL: mountSession.relayfileBaseUrl,
820
+ RELAYFILE_TOKEN: mountSession.relayfileToken,
821
+ RELAYFILE_WORKSPACE: mountSession.workspaceId,
822
+ RELAYFILE_REMOTE_PATH: mountSession.remotePath,
823
+ RELAYFILE_LOCAL_DIR: mountSession.localDir,
824
+ RELAYFILE_MOUNT_MODE: mountSession.mode,
825
+ RELAYCAST_API_KEY: mountSession.relaycastApiKey,
826
+ RELAY_API_KEY: mountSession.relaycastApiKey,
827
+ RELAYCAST_BASE_URL: relaycastBaseUrl,
828
+ RELAY_BASE_URL: relaycastBaseUrl
829
+ });
830
+ }
831
+ function buildMountLauncherEnv(mountSession) {
832
+ return compactStringRecord({
833
+ ...buildMountedWorkspaceEnv(mountSession),
834
+ RELAYFILE_MOUNT_SCOPES: mountSession.scopes.join(" ")
835
+ });
836
+ }
837
+ function stripTrailingSlash(value) {
838
+ return value.replace(/\/+$/, "");
839
+ }
555
840
  function buildCloudUrl(baseUrl, path) {
556
841
  const base = new URL(baseUrl);
557
842
  if (!base.pathname.endsWith("/")) {
@@ -672,3 +957,100 @@ function throwIfAborted(signal, operation) {
672
957
  throw new CloudAbortError(operation);
673
958
  }
674
959
  }
960
+ async function waitForMountReady(ready, signal) {
961
+ if (!signal) {
962
+ await ready;
963
+ return;
964
+ }
965
+ if (signal.aborted) {
966
+ throw new CloudAbortError("mountWorkspace");
967
+ }
968
+ let onAbort;
969
+ try {
970
+ await Promise.race([
971
+ ready,
972
+ new Promise((_, reject) => {
973
+ onAbort = () => {
974
+ signal.removeEventListener("abort", onAbort);
975
+ reject(new CloudAbortError("mountWorkspace"));
976
+ };
977
+ signal.addEventListener("abort", onAbort, { once: true });
978
+ })
979
+ ]);
980
+ }
981
+ finally {
982
+ if (onAbort) {
983
+ signal.removeEventListener("abort", onAbort);
984
+ }
985
+ }
986
+ }
987
+ async function safeStopLauncher(launcherInstance) {
988
+ try {
989
+ await launcherInstance.stop();
990
+ }
991
+ catch {
992
+ // stop is best-effort during cleanup
993
+ }
994
+ }
995
+ async function verifyWorkspaceProviderReady(workspace, provider, providerReadyTimeoutMs, signal) {
996
+ const startedAt = Date.now();
997
+ const connectionId = workspace.workspaceId;
998
+ for (;;) {
999
+ throwIfAborted(signal, "ensureMountedWorkspace");
1000
+ const elapsedMs = Date.now() - startedAt;
1001
+ const remainingMs = providerReadyTimeoutMs <= 0
1002
+ ? undefined
1003
+ : Math.max(1, providerReadyTimeoutMs - elapsedMs);
1004
+ const status = await workspace.getConnectionStatus(provider, connectionId, {
1005
+ signal,
1006
+ timeoutMs: remainingMs
1007
+ });
1008
+ if (status.ready) {
1009
+ return;
1010
+ }
1011
+ // Integration status uses state="not_connected" to signal a provider that
1012
+ // has never been connected. Other non-ready states still indicate a
1013
+ // connected provider and map to ProviderNotReadyError.
1014
+ if (status.state === "not_connected") {
1015
+ throw new ProviderNotConnectedError(provider);
1016
+ }
1017
+ if (providerReadyTimeoutMs <= 0 || elapsedMs >= providerReadyTimeoutMs) {
1018
+ throw new ProviderNotReadyError({
1019
+ provider,
1020
+ state: status.state,
1021
+ initialSyncState: status.initialSyncState
1022
+ });
1023
+ }
1024
+ const sleepMs = Math.min(DEFAULT_WAIT_INTERVAL_MS, Math.max(1, providerReadyTimeoutMs - elapsedMs));
1025
+ await sleep(sleepMs, signal, "ensureMountedWorkspace");
1026
+ }
1027
+ }
1028
+ function mapMountSessionError(error, request) {
1029
+ if (!(error instanceof CloudApiError) || error.httpStatus !== 400) {
1030
+ return error;
1031
+ }
1032
+ const code = readCloudErrorCode(error.httpBody);
1033
+ switch (code) {
1034
+ case "invalid_mode":
1035
+ return new InvalidMountModeError(request.mode ?? "poll");
1036
+ case "invalid_local_dir":
1037
+ return new InvalidLocalDirError(request.localDir);
1038
+ case "invalid_remote_path":
1039
+ return new InvalidRemotePathError(request.remotePath ?? "/");
1040
+ default:
1041
+ return error;
1042
+ }
1043
+ }
1044
+ function readCloudErrorCode(httpBody) {
1045
+ if (!httpBody || typeof httpBody !== "object" || Array.isArray(httpBody)) {
1046
+ return undefined;
1047
+ }
1048
+ const payload = httpBody;
1049
+ for (const field of ["code", "error"]) {
1050
+ const value = payload[field];
1051
+ if (typeof value === "string" && value.trim() !== "") {
1052
+ return value;
1053
+ }
1054
+ }
1055
+ return undefined;
1056
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,6 +13,8 @@
13
13
  "typecheck": "tsc --noEmit",
14
14
  "test": "vitest run",
15
15
  "test:e2e": "node scripts/setup-e2e.mjs",
16
+ "post-auth-mount:e2e": "node scripts/post-auth-mount-session-e2e.mjs",
17
+ "e2e:post-auth-mount-session": "node scripts/post-auth-mount-session-e2e.mjs",
16
18
  "agent-workspace:e2e": "node scripts/agent-workspace-golden-path-e2e.mjs",
17
19
  "test:e2e:golden-path": "node scripts/agent-workspace-golden-path-e2e.mjs",
18
20
  "demo:agent-workspace": "npm run build && node scripts/agent-workspace-demo.mjs",
@@ -20,7 +22,7 @@
20
22
  "prepublishOnly": "npm run build"
21
23
  },
22
24
  "dependencies": {
23
- "@relayfile/core": "0.7.1"
25
+ "@relayfile/core": "0.7.2"
24
26
  },
25
27
  "devDependencies": {
26
28
  "typescript": "^5.7.3",