runwork 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/bundled-types/core-endpoints.d.ts +16 -4
  2. package/bundled-types/core-workflow-instance.d.ts +6 -0
  3. package/bundled-types/workflows.d.ts +1 -0
  4. package/dist/agents/claude-desktop-plugin-tree.js +23 -8
  5. package/dist/agents/claude-desktop.js +6 -3
  6. package/dist/agents/codex.js +15 -7
  7. package/dist/api/client.d.ts +5 -0
  8. package/dist/api/client.js +8 -0
  9. package/dist/commands/__tests__/mcp-entries.test.d.ts +1 -0
  10. package/dist/commands/__tests__/mcp-entries.test.js +48 -0
  11. package/dist/commands/deploy.js +101 -22
  12. package/dist/commands/dev.d.ts +1 -0
  13. package/dist/commands/dev.js +145 -155
  14. package/dist/commands/logs.js +10 -3
  15. package/dist/commands/mcp-entries.d.ts +13 -0
  16. package/dist/commands/mcp-entries.js +26 -0
  17. package/dist/commands/sync.js +6 -10
  18. package/dist/dev/__tests__/session.test.js +60 -0
  19. package/dist/dev/session.d.ts +8 -0
  20. package/dist/dev/session.js +4 -1
  21. package/dist/generated/bundled-types.js +3 -3
  22. package/dist/generated/version.d.ts +1 -1
  23. package/dist/generated/version.js +1 -1
  24. package/dist/git/__tests__/auto-commit.test.js +59 -1
  25. package/dist/git/__tests__/deploy-guard.test.d.ts +1 -0
  26. package/dist/git/__tests__/deploy-guard.test.js +61 -0
  27. package/dist/git/auto-commit.d.ts +22 -0
  28. package/dist/git/auto-commit.js +72 -11
  29. package/dist/git/critical-files.d.ts +20 -0
  30. package/dist/git/critical-files.js +68 -0
  31. package/dist/git/deploy-guard.d.ts +53 -0
  32. package/dist/git/deploy-guard.js +78 -0
  33. package/dist/types.d.ts +3 -0
  34. package/dist/utils/agent-guidance.d.ts +2 -0
  35. package/dist/utils/ignore-matcher.d.ts +9 -0
  36. package/dist/utils/ignore-matcher.js +16 -0
  37. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { requireAuth } from '../auth/store.js';
6
6
  import { ApiClient } from '../api/client.js';
7
7
  import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
8
8
  import { syncWithRemote } from '../git/sync.js';
9
+ import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
9
10
  import { ensureGitIdentity } from '../git/identity.js';
10
11
  import { requireGit } from '../git/preflight.js';
11
12
  import { startLogTailer } from '../logs/tailer.js';
@@ -43,69 +44,14 @@ function readConfig() {
43
44
  }
44
45
  return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
45
46
  }
46
- // Files the CLI cannot function without and which the user did not author.
47
- // `.runwork.json` is the local app identity; `blueprint.json` is the app's
48
- // canonical feature definition; `.gitignore` keeps caches out of git. If a
49
- // sync from the remote silently removes any of these (which has happened
50
- // when a server-side agent commits with a stale index — see
51
- // worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
52
- // project usable and unblocks `runwork deploy`.
53
- const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
54
- function snapshotCriticalFiles(cwd) {
55
- const snapshots = [];
56
- for (const rel of CRITICAL_FILES) {
57
- const abs = join(cwd, rel);
58
- if (!existsSync(abs))
59
- continue;
60
- try {
61
- snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
62
- }
63
- catch {
64
- // Best-effort: skip unreadable files.
65
- }
66
- }
67
- return snapshots;
68
- }
69
- function restoreMissingCriticalFiles(cwd, snapshots) {
70
- const restored = [];
71
- for (const snap of snapshots) {
72
- const abs = join(cwd, snap.path);
73
- if (existsSync(abs))
74
- continue;
75
- try {
76
- writeFileSync(abs, snap.contents, 'utf-8');
77
- restored.push(snap.path);
78
- }
79
- catch {
80
- // Best-effort: skip files we cannot write back.
81
- }
82
- }
83
- return restored;
84
- }
85
- function commitAndPushRestoredFiles(cwd, files) {
86
- if (files.length === 0)
87
- return false;
88
- try {
89
- execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
90
- execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
91
- }
92
- catch {
93
- return false;
94
- }
95
- try {
96
- // `HEAD:main` so the push works regardless of local branch name.
97
- execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
98
- return true;
99
- }
100
- catch {
101
- // Push failures are non-fatal: the local copy is restored, and the
102
- // restoration commit will be pushed with the next auto-sync cycle.
103
- return false;
104
- }
105
- }
106
47
  export async function execDev(options) {
107
48
  const useJson = options?.json ?? false;
108
49
  const mode = options?.mode ?? 'foreground';
50
+ // No-sync mode: hold the preview open but never auto-touch the user's
51
+ // git or working tree. No file watcher, no auto-commit, and no
52
+ // working-tree-mutating startup (template auto-update / syncWithRemote).
53
+ // The user drives git manually; `git push` updates the preview.
54
+ const noSync = options?.noSync ?? false;
109
55
  requireGit('dev');
110
56
  const config = readConfig();
111
57
  const creds = requireAuth();
@@ -199,30 +145,34 @@ export async function execDev(options) {
199
145
  }
200
146
  }
201
147
  }
202
- // Download fresh template to ensure latest version
203
- if (useJson) {
204
- jsonLine({ event: 'startup', phase: 'template_update', timestamp: ts() });
205
- }
206
- else {
207
- console.log(dim('Updating template...'));
208
- }
209
- try {
210
- const zipData = await client.downloadSkeleton();
211
- extractZip(zipData, cwd);
212
- removeNestedGitDirs(cwd);
213
- const newManifest = await generateManifest(cwd);
214
- await saveManifest(cwd, newManifest);
148
+ // Download fresh template to ensure latest version. Skipped entirely in
149
+ // no-sync mode: this block mutates the working tree (`git checkout -- .`),
150
+ // which would fight the user's manual git workflow.
151
+ if (!noSync) {
152
+ if (useJson) {
153
+ jsonLine({ event: 'startup', phase: 'template_update', timestamp: ts() });
154
+ }
155
+ else {
156
+ console.log(dim('Updating template...'));
157
+ }
215
158
  try {
216
- execFileSync('git', ['checkout', '--', '.'], { cwd, stdio: 'pipe' });
159
+ const zipData = await client.downloadSkeleton();
160
+ extractZip(zipData, cwd);
161
+ removeNestedGitDirs(cwd);
162
+ const newManifest = await generateManifest(cwd);
163
+ await saveManifest(cwd, newManifest);
164
+ try {
165
+ execFileSync('git', ['checkout', '--', '.'], { cwd, stdio: 'pipe' });
166
+ }
167
+ catch {
168
+ // May fail if no commits yet
169
+ }
217
170
  }
218
171
  catch {
219
- // May fail if no commits yet
172
+ if (!useJson)
173
+ console.warn(yellow('Template update failed. Continuing with current files.'));
220
174
  }
221
175
  }
222
- catch {
223
- if (!useJson)
224
- console.warn(yellow('Template update failed. Continuing with current files.'));
225
- }
226
176
  // Populate type definitions
227
177
  await populateTypes(cwd);
228
178
  if (useJson)
@@ -239,89 +189,108 @@ export async function execDev(options) {
239
189
  await populateSkill(cwd, client, config.appId);
240
190
  if (useJson)
241
191
  jsonLine({ event: 'startup', phase: 'skill_fetched', timestamp: ts() });
242
- // Sync
243
- if (!useJson)
244
- console.log(dim('Syncing...'));
245
- const criticalSnapshot = snapshotCriticalFiles(cwd);
246
- const syncResult = syncWithRemote(cwd);
247
- const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
248
- if (restoredCriticalFiles.length > 0) {
249
- const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
192
+ // Sync. In no-sync mode we never mutate the working tree: instead of
193
+ // syncWithRemote (rebase/merge/commit/push) we do a best-effort,
194
+ // read-only `git fetch` so remote-tracking refs stay current and
195
+ // `runwork info` can still report ahead/behind accurately.
196
+ if (noSync) {
197
+ try {
198
+ execFileSync('git', ['fetch', 'runwork'], { cwd, stdio: 'pipe' });
199
+ }
200
+ catch {
201
+ // Offline, or the `runwork` remote does not exist yet. Non-fatal.
202
+ }
250
203
  if (useJson) {
251
- jsonLine({
252
- event: 'sync_restored_critical_files',
253
- files: restoredCriticalFiles,
254
- pushed,
255
- timestamp: ts(),
256
- });
204
+ jsonLine({ event: 'startup', phase: 'fetch', timestamp: ts() });
257
205
  }
258
206
  else {
259
- console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
260
- if (!pushed) {
261
- console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
262
- }
207
+ console.log(dim('Fetched remote refs (no-sync mode).'));
263
208
  }
264
209
  }
265
- if (useJson) {
266
- jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
267
- if (syncResult.status === 'sync-failed') {
268
- if (syncResult.error === 'stash-conflict') {
210
+ else {
211
+ if (!useJson)
212
+ console.log(dim('Syncing...'));
213
+ const criticalSnapshot = snapshotCriticalFiles(cwd);
214
+ const syncResult = syncWithRemote(cwd);
215
+ const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
216
+ if (restoredCriticalFiles.length > 0) {
217
+ const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
218
+ if (useJson) {
219
+ jsonLine({
220
+ event: 'sync_restored_critical_files',
221
+ files: restoredCriticalFiles,
222
+ pushed,
223
+ timestamp: ts(),
224
+ });
225
+ }
226
+ else {
227
+ console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
228
+ if (!pushed) {
229
+ console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
230
+ }
231
+ }
232
+ }
233
+ if (useJson) {
234
+ jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
235
+ if (syncResult.status === 'sync-failed') {
236
+ if (syncResult.error === 'stash-conflict') {
237
+ jsonLine({
238
+ event: 'error',
239
+ phase: 'sync',
240
+ timestamp: ts(),
241
+ error: {
242
+ message: 'Merge conflicts detected after stash pop',
243
+ diagnosis: 'Local changes could not be automatically merged with the remote state. The stash pop resulted in conflicts that require manual resolution.',
244
+ suggestions: [
245
+ 'Resolve the merge conflicts in the affected files',
246
+ 'Run git add on resolved files, then git commit',
247
+ 'Run runwork dev again after resolving conflicts',
248
+ ],
249
+ },
250
+ });
251
+ process.exit(1);
252
+ }
269
253
  jsonLine({
270
254
  event: 'error',
271
255
  phase: 'sync',
272
256
  timestamp: ts(),
273
257
  error: {
274
- message: 'Merge conflicts detected after stash pop',
275
- diagnosis: 'Local changes could not be automatically merged with the remote state. The stash pop resulted in conflicts that require manual resolution.',
258
+ message: syncResult.error ?? 'File sync failed',
259
+ diagnosis: 'Could not sync files to the sandbox. This usually means the sandbox session expired or the network connection was interrupted.',
276
260
  suggestions: [
277
- 'Resolve the merge conflicts in the affected files',
278
- 'Run git add on resolved files, then git commit',
279
- 'Run runwork dev again after resolving conflicts',
261
+ 'Run runwork dev again to start a fresh session',
262
+ 'Check your network connection',
263
+ 'Run runwork doctor to diagnose environment issues',
280
264
  ],
281
265
  },
282
266
  });
283
- process.exit(1);
284
267
  }
285
- jsonLine({
286
- event: 'error',
287
- phase: 'sync',
288
- timestamp: ts(),
289
- error: {
290
- message: syncResult.error ?? 'File sync failed',
291
- diagnosis: 'Could not sync files to the sandbox. This usually means the sandbox session expired or the network connection was interrupted.',
292
- suggestions: [
293
- 'Run runwork dev again to start a fresh session',
294
- 'Check your network connection',
295
- 'Run runwork doctor to diagnose environment issues',
296
- ],
297
- },
298
- });
299
268
  }
300
- }
301
- else {
302
- switch (syncResult.status) {
303
- case 'skipped':
304
- console.log(dim(' No commits yet. Skipping sync.'));
305
- break;
306
- case 'synced':
307
- console.log(green(' Synced with Runwork.'));
308
- break;
309
- case 'merged':
310
- console.log(yellow(' Merged with Runwork (histories diverged).'));
311
- break;
312
- case 'sync-failed':
313
- if (syncResult.error === 'stash-conflict') {
314
- console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
315
- process.exit(1);
316
- }
317
- console.warn(yellow(`Sync failed. Continuing...`));
318
- if (syncResult.error) {
319
- console.warn(dim(` ${syncResult.error}`));
320
- }
321
- break;
322
- }
323
- if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
324
- console.warn(yellow('Push failed. Continuing with current state...'));
269
+ else {
270
+ switch (syncResult.status) {
271
+ case 'skipped':
272
+ console.log(dim(' No commits yet. Skipping sync.'));
273
+ break;
274
+ case 'synced':
275
+ console.log(green(' Synced with Runwork.'));
276
+ break;
277
+ case 'merged':
278
+ console.log(yellow(' Merged with Runwork (histories diverged).'));
279
+ break;
280
+ case 'sync-failed':
281
+ if (syncResult.error === 'stash-conflict') {
282
+ console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
283
+ process.exit(1);
284
+ }
285
+ console.warn(yellow(`Sync failed. Continuing...`));
286
+ if (syncResult.error) {
287
+ console.warn(dim(` ${syncResult.error}`));
288
+ }
289
+ break;
290
+ }
291
+ if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
292
+ console.warn(yellow('Push failed. Continuing with current state...'));
293
+ }
325
294
  }
326
295
  }
327
296
  // The session POST returns a snapshot of the preview URL at boot time.
@@ -341,6 +310,7 @@ export async function execDev(options) {
341
310
  previewUrl: currentPreviewUrl,
342
311
  cliVersion: VERSION,
343
312
  mode,
313
+ noSync,
344
314
  }));
345
315
  // Emit session_started (JSON) or show banner (human)
346
316
  if (useJson) {
@@ -457,15 +427,32 @@ export async function execDev(options) {
457
427
  }
458
428
  });
459
429
  }
460
- // Start file watcher
461
- await watchAndAutoCommit(process.cwd(), client, config.appId, {
462
- onFastSync: useJson
463
- ? (count) => { jsonLine({ event: 'files_synced', count, target: 'preview', timestamp: ts() }); }
464
- : (count) => { syncedCount += count; updateStatus(); },
465
- onGitPush: useJson
466
- ? (count) => { jsonLine({ event: 'files_pushed', count, target: 'git', timestamp: ts() }); }
467
- : (count) => { syncedCount += count; updateStatus(); },
468
- });
430
+ // Start file watcher. In no-sync mode we never watch the working tree:
431
+ // no fast-sync to the sandbox, no git auto-commit. The user drives git
432
+ // manually and `git push` is what updates the preview. We print a loud
433
+ // notice so it is unmistakable that edits are not flowing automatically.
434
+ if (noSync) {
435
+ if (useJson) {
436
+ jsonLine({ event: 'startup', phase: 'no_sync_mode', timestamp: ts() });
437
+ }
438
+ else {
439
+ console.log('');
440
+ console.log(yellow(' Auto-sync is OFF (--no-sync).'));
441
+ console.log(dim(' The preview reflects pushed commits. Run `git push` to update it;'));
442
+ console.log(dim(' uncommitted or unpushed changes are not shown.'));
443
+ console.log('');
444
+ }
445
+ }
446
+ else {
447
+ await watchAndAutoCommit(process.cwd(), client, config.appId, {
448
+ onFastSync: useJson
449
+ ? (count) => { jsonLine({ event: 'files_synced', count, target: 'preview', timestamp: ts() }); }
450
+ : (count) => { syncedCount += count; updateStatus(); },
451
+ onGitPush: useJson
452
+ ? (count) => { jsonLine({ event: 'files_pushed', count, target: 'git', timestamp: ts() }); }
453
+ : (count) => { syncedCount += count; updateStatus(); },
454
+ });
455
+ }
469
456
  // Start log tailing (unless --no-logs)
470
457
  if (options?.logs !== false) {
471
458
  logTailer = startLogTailer({
@@ -487,12 +474,14 @@ export const devCommand = new Command('dev')
487
474
  .option('--logs-only-file', 'Write logs to file only, not terminal')
488
475
  .option('--detach', 'Start the dev session in the background and exit. Logs go to .runwork/dev-{stdout,stderr}.log')
489
476
  .option('--restart', 'Stop any existing dev session first, then start a fresh one')
477
+ .option('--no-sync', 'Hold the preview open without auto-syncing files or git; you push manually to update the preview')
490
478
  // Hidden internal flag passed by `runwork dev --detach` when it spawns
491
479
  // its detached child. Not for end users -- registered here only so
492
480
  // commander does not throw "unknown option" when the child receives it.
493
481
  .addOption(new Option(INTERNAL_DETACHED_CHILD_FLAG).hideHelp())
494
482
  .action(async (options, command) => {
495
483
  const globalJson = shouldOutputJson(command.optsWithGlobals().json);
484
+ const noSync = options.sync === false;
496
485
  // The internal child marker is parsed manually because we want it
497
486
  // hidden from --help and from commander's option list. Its presence
498
487
  // means: "this process is the detached child of a `--detach` parent;
@@ -510,6 +499,7 @@ export const devCommand = new Command('dev')
510
499
  json: globalJson,
511
500
  mode: isChild ? 'detached' : 'foreground',
512
501
  restart: options.restart,
502
+ noSync,
513
503
  });
514
504
  });
515
505
  /**
@@ -21,13 +21,20 @@ export const logsCommand = new Command('logs')
21
21
  .option('--production', 'Show production runtime logs')
22
22
  .option('--events', 'Show app activity/events')
23
23
  .option('--all', 'Show both runtime logs and events')
24
- .option('--follow', 'Poll for new logs every 3s')
24
+ .option('--follow', 'Poll for new logs every 3s (TTY only; ignored when piped or with --json)')
25
+ .option('--once', 'Fetch once and exit, even if --follow is set (forced in non-TTY/agent contexts)')
25
26
  .option('--limit <n>', 'Number of log entries to show', '25')
26
27
  .option('--level <level>', 'Filter by log level (production only)')
27
28
  .option('--search <text>', 'Search log content (production only)')
28
29
  .option('--type <type>', 'Filter by event type (events only)')
29
30
  .action(async (opts, command) => {
30
31
  const useJson = shouldOutputJson(command.optsWithGlobals().json);
32
+ // Follow mode blocks forever (until SIGINT/SIGTERM), which deadlocks
33
+ // non-interactive callers: piped shells, CI, and AI agents never send
34
+ // those signals and hang waiting for the command to exit. Only follow
35
+ // when explicitly requested AND stdout is an interactive TTY AND we are
36
+ // not emitting JSON. `--once` forces single-shot even on a TTY.
37
+ const shouldFollow = Boolean(opts.follow) && !opts.once && !useJson && Boolean(process.stdout.isTTY);
31
38
  const config = readConfig();
32
39
  const creds = requireAuth();
33
40
  const client = new ApiClient(creds);
@@ -79,7 +86,7 @@ export const logsCommand = new Command('logs')
79
86
  // Preview logs (default)
80
87
  try {
81
88
  const { stdout, stderr } = await client.getPreviewLogs(appId);
82
- if (opts.follow && !isFirstPoll) {
89
+ if (shouldFollow && !isFirstPoll) {
83
90
  // Incremental mode: only print new content since last poll
84
91
  // Reset detection: if buffer was truncated (sandbox restart), reset cursor
85
92
  if (stdout && stdout.length < lastStdoutLength) {
@@ -198,7 +205,7 @@ export const logsCommand = new Command('logs')
198
205
  isFirstPoll = false;
199
206
  };
200
207
  await fetchAndPrint();
201
- if (opts.follow) {
208
+ if (shouldFollow) {
202
209
  let stopped = false;
203
210
  let timer;
204
211
  const scheduleNext = () => {
@@ -0,0 +1,13 @@
1
+ import type { McpServerConfig } from '../types.js';
2
+ import type { McpServerEntry } from '../agents/types.js';
3
+ /**
4
+ * Build local-agent MCP entries for external (third-party) servers added to the
5
+ * workspace. Auth handling by `authType`:
6
+ * - 'header': inject the user's resolved per-server headers (decrypted server-side).
7
+ * - 'oauth': emit no auth header so the local agent runs its own browser flow.
8
+ * - 'none'/unset: no auth header.
9
+ *
10
+ * Critically, external entries never receive the Runwork platform API key. That key
11
+ * belongs only on the Runwork workspace-tools entry, which is added separately.
12
+ */
13
+ export declare function buildExternalMcpEntries(mcpServers: McpServerConfig[], resolvedCredentials: Record<string, Record<string, string>>): McpServerEntry[];
@@ -0,0 +1,26 @@
1
+ import { RUNWORK_MCP_PREFIX } from '../agents/types.js';
2
+ /**
3
+ * Build local-agent MCP entries for external (third-party) servers added to the
4
+ * workspace. Auth handling by `authType`:
5
+ * - 'header': inject the user's resolved per-server headers (decrypted server-side).
6
+ * - 'oauth': emit no auth header so the local agent runs its own browser flow.
7
+ * - 'none'/unset: no auth header.
8
+ *
9
+ * Critically, external entries never receive the Runwork platform API key. That key
10
+ * belongs only on the Runwork workspace-tools entry, which is added separately.
11
+ */
12
+ export function buildExternalMcpEntries(mcpServers, resolvedCredentials) {
13
+ return mcpServers
14
+ .filter(s => s.enabled)
15
+ .map(s => {
16
+ const resolved = s.authType === 'header' ? resolvedCredentials[s.id] : undefined;
17
+ const headers = resolved && Object.keys(resolved).length > 0 ? resolved : undefined;
18
+ return {
19
+ name: `${RUNWORK_MCP_PREFIX}${s.name}`,
20
+ url: s.url,
21
+ transport: s.transport,
22
+ ...(headers ? { headers } : {}),
23
+ description: `Runwork MCP server: ${s.name}. Provides tools for interacting with this workspace resource.`,
24
+ };
25
+ });
26
+ }
@@ -7,6 +7,7 @@ import { ApiClient } from '../api/client.js';
7
7
  import { getAdapterBySlug, detectAgents } from '../agents/detect.js';
8
8
  import { CodexAdapter } from '../agents/codex.js';
9
9
  import { RUNWORK_MCP_PREFIX, RUNWORK_WORKSPACE_MCP_NAME } from '../agents/types.js';
10
+ import { buildExternalMcpEntries } from './mcp-entries.js';
10
11
  import { RUNWORK_AGENT_DEFAULTS, AGENT_DEFAULTS_SCHEMA_VERSION } from '../agents/default-config.js';
11
12
  import { resolveAgentDefaults } from '../agents/defaults-merge.js';
12
13
  import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
@@ -135,7 +136,7 @@ export async function syncFromState(state, statePath, credentials, opts) {
135
136
  }
136
137
  console.log(' Fetching workspace data...');
137
138
  // Fetch latest data (includeContent=true gets all skill content in one request)
138
- const [allSkills, mcpServers, externalSkills, registries, connectedIntegrations] = await Promise.all([
139
+ const [allSkills, mcpServers, externalSkills, registries, connectedIntegrations, resolvedMcpCredentials] = await Promise.all([
139
140
  client.listWorkspaceSkills(state.workspaceId, true),
140
141
  client.listMcpServers(state.workspaceId),
141
142
  client.listExternalSkills(state.workspaceId),
@@ -143,6 +144,9 @@ export async function syncFromState(state, statePath, credentials, opts) {
143
144
  client.listConnectedIntegrations(state.workspaceId)
144
145
  .then(list => list.map(i => i.canonicalId ?? i.integrationId))
145
146
  .catch(() => []),
147
+ // Decrypted auth headers for header-auth servers, scoped to this user.
148
+ // Tolerate older backends that lack the endpoint.
149
+ client.resolveMcpCredentials(state.workspaceId).catch(() => ({})),
146
150
  ]);
147
151
  const appCount = allSkills.filter(s => s.type === 'app').length;
148
152
  const parts = [];
@@ -167,15 +171,7 @@ export async function syncFromState(state, statePath, credentials, opts) {
167
171
  }
168
172
  // Build MCP entries (pull-only)
169
173
  const baseUrl = credentials.baseUrl || 'https://runwork.ai';
170
- const mcpEntries = mcpServers
171
- .filter(s => s.enabled)
172
- .map(s => ({
173
- name: `${RUNWORK_MCP_PREFIX}${s.name}`,
174
- url: s.url,
175
- transport: s.transport,
176
- headers: { Authorization: `Bearer ${credentials.apiKey}` },
177
- description: `Runwork MCP server: ${s.name}. Provides tools for interacting with this workspace resource.`,
178
- }));
174
+ const mcpEntries = buildExternalMcpEntries(mcpServers, resolvedMcpCredentials);
179
175
  mcpEntries.push({
180
176
  name: RUNWORK_WORKSPACE_MCP_NAME,
181
177
  url: `${baseUrl}/api/workspaces/${state.workspaceId}/mcp`,
@@ -343,5 +343,65 @@ describe('session lifecycle primitives', () => {
343
343
  });
344
344
  expect(file.startedAt).toBe(12345);
345
345
  });
346
+ it('defaults noSync to false when omitted', () => {
347
+ const file = buildSessionFile({
348
+ pid: 1,
349
+ sessionId: 's',
350
+ appId: 'a',
351
+ previewUrl: '',
352
+ cliVersion: '0.0.0',
353
+ mode: 'foreground',
354
+ deps: { bootTime: () => 0 },
355
+ });
356
+ expect(file.noSync).toBe(false);
357
+ });
358
+ it('sets noSync when provided', () => {
359
+ const file = buildSessionFile({
360
+ pid: 1,
361
+ sessionId: 's',
362
+ appId: 'a',
363
+ previewUrl: '',
364
+ cliVersion: '0.0.0',
365
+ mode: 'foreground',
366
+ noSync: true,
367
+ deps: { bootTime: () => 0 },
368
+ });
369
+ expect(file.noSync).toBe(true);
370
+ });
371
+ });
372
+ describe('noSync field back-compat', () => {
373
+ it('round-trips noSync through write/read', () => {
374
+ const file = buildSessionFile({
375
+ pid: process.pid,
376
+ sessionId: 's',
377
+ appId: 'a',
378
+ previewUrl: 'https://x',
379
+ cliVersion: '0.0.0',
380
+ mode: 'detached',
381
+ noSync: true,
382
+ deps: { bootTime: () => 0 },
383
+ });
384
+ writeSessionFile(appDir, file);
385
+ expect(readSessionFile(appDir)?.noSync).toBe(true);
386
+ });
387
+ it('validates a v1-style file that lacks noSync and reads it back falsy', () => {
388
+ const { dir, file } = getSessionPaths(appDir);
389
+ fs.mkdirSync(dir, { recursive: true });
390
+ // v1 file shape: every required field present, no noSync key at all.
391
+ const v1 = makeValidSessionFile();
392
+ // makeValidSessionFile does not add noSync, so this is already v1-shaped.
393
+ expect('noSync' in v1).toBe(false);
394
+ fs.writeFileSync(file, JSON.stringify(v1), 'utf-8');
395
+ const read = readSessionFile(appDir);
396
+ expect(read).not.toBeNull();
397
+ expect(read?.noSync).toBeFalsy();
398
+ });
399
+ it('rejects a file whose noSync field is the wrong type', () => {
400
+ const { dir, file } = getSessionPaths(appDir);
401
+ fs.mkdirSync(dir, { recursive: true });
402
+ const bad = { ...makeValidSessionFile(), noSync: 'yes' };
403
+ fs.writeFileSync(file, JSON.stringify(bad), 'utf-8');
404
+ expect(readSessionFile(appDir)).toBeNull();
405
+ });
346
406
  });
347
407
  });
@@ -33,6 +33,13 @@ export interface SessionFile {
33
33
  bootTime: number;
34
34
  cliVersion: string;
35
35
  mode: SessionMode;
36
+ /**
37
+ * Whether this session runs in no-sync mode (no file watcher, no
38
+ * auto-commit, no working-tree-mutating startup). Optional for
39
+ * back-compat with v1 session files that predate the field; absent is
40
+ * treated as `false`.
41
+ */
42
+ noSync?: boolean;
36
43
  }
37
44
  export type SessionStaleReason = 'malformed' | 'version-mismatch' | 'app-id-mismatch' | 'boot-time-mismatch' | 'pid-dead';
38
45
  export type SessionState = {
@@ -153,6 +160,7 @@ export declare function buildSessionFile(input: {
153
160
  previewUrl: string;
154
161
  cliVersion: string;
155
162
  mode: SessionMode;
163
+ noSync?: boolean;
156
164
  startedAt?: number;
157
165
  deps?: SessionDeps;
158
166
  }): SessionFile;
@@ -111,7 +111,9 @@ function validateSessionFile(raw) {
111
111
  typeof r.startedAt === 'number' &&
112
112
  typeof r.bootTime === 'number' &&
113
113
  typeof r.cliVersion === 'string' &&
114
- (r.mode === 'foreground' || r.mode === 'detached'));
114
+ (r.mode === 'foreground' || r.mode === 'detached') &&
115
+ // Optional for back-compat: v1 files lack the field. Accept absent or boolean.
116
+ (r.noSync === undefined || typeof r.noSync === 'boolean'));
115
117
  }
116
118
  /**
117
119
  * Read and parse the session file. Returns `null` for a missing file or
@@ -248,5 +250,6 @@ export function buildSessionFile(input) {
248
250
  bootTime: d.bootTime(),
249
251
  cliVersion: input.cliVersion,
250
252
  mode: input.mode,
253
+ noSync: input.noSync ?? false,
251
254
  };
252
255
  }