@ctrl-spc/cli 1.3.2 → 1.3.4

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.
@@ -1,19 +1,34 @@
1
1
  import { getMachineIdentity } from '../config.js';
2
+ import { isLocalRepositoryKey, readLocalRepositoryIdentity, writeLocalRepositoryIdentity } from '../git.js';
3
+ import { resolveRelinkRoots } from '../relink.js';
2
4
  import { getClient } from '../supabase.js';
3
5
  import { select } from '../prompt.js';
4
- import { observeLocalWorkspace, registerMachineWorkspace, } from './run.js';
6
+ import { inspectLocalCheckout, observeLocalWorkspace, registerMachineWorkspace, } from './run.js';
5
7
  export function parseLinkArgs(argv) {
6
8
  const flags = {};
7
9
  for (let i = 0; i < argv.length; i++) {
8
10
  const flag = argv[i];
9
- if (flag !== '--project')
11
+ if (flag !== '--project' && flag !== '--folder')
10
12
  throw new Error(`Unknown argument: ${flag}`);
11
13
  const value = argv[i + 1];
12
14
  if (!value || value.startsWith('--'))
13
15
  throw new Error(`${flag} requires a value`);
14
- if (flags.project)
15
- throw new Error('--project may only be provided once');
16
- flags.project = value;
16
+ if (flag === '--project') {
17
+ if (flags.project)
18
+ throw new Error('--project may only be provided once');
19
+ flags.project = value;
20
+ }
21
+ else {
22
+ const equals = value.indexOf('=');
23
+ if (equals < 1 || equals === value.length - 1)
24
+ throw new Error('--folder requires <repository-id>=<path>');
25
+ const repositoryId = value.slice(0, equals);
26
+ const folders = flags.folders ?? {};
27
+ if (folders[repositoryId])
28
+ throw new Error(`--folder may only be provided once for ${repositoryId}`);
29
+ folders[repositoryId] = value.slice(equals + 1);
30
+ flags.folders = folders;
31
+ }
17
32
  i++;
18
33
  }
19
34
  return flags;
@@ -25,11 +40,12 @@ function one(value) {
25
40
  export async function listTopologyProjects(client) {
26
41
  const { data, error } = await client
27
42
  .from('project_repository_scopes')
28
- .select('project_id,' +
43
+ .select('project_id,role_slug,role_label,' +
29
44
  'project:projects!project_repository_scopes_project_id_fkey(' +
30
- 'id,name,org:organizations!projects_org_id_fkey(name)),' +
45
+ 'id,name,archived_at,org:organizations!projects_org_id_fkey(name)),' +
31
46
  'scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
32
- 'repository:repositories!repository_scopes_repository_id_fkey(remote_key))')
47
+ 'repository:repositories!repository_scopes_repository_id_fkey(' +
48
+ 'id,name,remote_key,identity_key,prior_identity_key,root_commit_sha,source_kind))')
33
49
  .eq('active', true);
34
50
  if (error)
35
51
  throw new Error(`Could not load project repository topology: ${error.message}`);
@@ -38,26 +54,44 @@ export async function listTopologyProjects(client) {
38
54
  const project = one(raw.project);
39
55
  const scope = one(raw.scope);
40
56
  const repository = scope ? one(scope.repository) : null;
41
- if (!project || !repository)
57
+ if (!project || project.archived_at || !repository)
42
58
  continue;
43
59
  const existing = projects.get(project.id) ?? {
44
60
  id: project.id,
45
61
  name: project.name,
46
62
  orgName: one(project.org)?.name ?? '',
47
63
  remoteKeys: [],
64
+ repositories: [],
48
65
  };
49
66
  if (!existing.remoteKeys.includes(repository.remote_key))
50
67
  existing.remoteKeys.push(repository.remote_key);
68
+ if (!existing.repositories.some((candidate) => candidate.id === repository.id)) {
69
+ existing.repositories.push({
70
+ id: repository.id,
71
+ name: repository.name,
72
+ remoteKey: repository.remote_key,
73
+ sourceKind: repository.source_kind ?? (isLocalRepositoryKey(repository.remote_key) ? 'local' : 'remote'),
74
+ identityKey: repository.identity_key ?? repository.remote_key,
75
+ priorIdentityKey: repository.prior_identity_key ?? null,
76
+ rootCommitSha: repository.root_commit_sha ?? null,
77
+ ...(raw.role_slug === undefined ? {} : { roleSlug: raw.role_slug }),
78
+ ...(raw.role_label === undefined ? {} : { roleLabel: raw.role_label }),
79
+ });
80
+ }
51
81
  projects.set(project.id, existing);
52
82
  }
53
83
  return [...projects.values()]
54
- .map((project) => ({ ...project, remoteKeys: project.remoteKeys.sort() }))
84
+ .map((project) => ({
85
+ ...project,
86
+ remoteKeys: project.remoteKeys.sort(),
87
+ repositories: project.repositories.sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)),
88
+ }))
55
89
  .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
56
90
  }
57
91
  export async function loadTopologyProject(client, projectId) {
58
92
  const { data, error } = await client
59
93
  .from('projects')
60
- .select('id,name,org:organizations!projects_org_id_fkey(name)')
94
+ .select('id,name,archived_at,org:organizations!projects_org_id_fkey(name)')
61
95
  .eq('id', projectId)
62
96
  .maybeSingle();
63
97
  if (error)
@@ -65,6 +99,9 @@ export async function loadTopologyProject(client, projectId) {
65
99
  if (!data)
66
100
  return null;
67
101
  const row = data;
102
+ if (row.archived_at) {
103
+ throw new Error(`Project "${row.name}" is archived. Restore it in the web app before linking this machine.`);
104
+ }
68
105
  const all = await listTopologyProjects(client);
69
106
  const topology = all.find((project) => project.id === row.id);
70
107
  return topology ?? {
@@ -72,6 +109,7 @@ export async function loadTopologyProject(client, projectId) {
72
109
  name: row.name,
73
110
  orgName: one(row.org)?.name ?? '',
74
111
  remoteKeys: [],
112
+ repositories: [],
75
113
  };
76
114
  }
77
115
  export async function chooseTopologyProject(client, explicitProjectId, observedRemoteKeys, options = { action: 'link' }) {
@@ -79,6 +117,9 @@ export async function chooseTopologyProject(client, explicitProjectId, observedR
79
117
  const explicit = await loadTopologyProject(client, explicitProjectId);
80
118
  if (!explicit)
81
119
  throw new Error(`No accessible project found for id "${explicitProjectId}".`);
120
+ if (explicit.repositories.length === 0) {
121
+ throw new Error(`Project "${explicit.name}" has no codebases yet. Run \`ctrl-spc init --project ${explicit.id} --repo <path>\` first.`);
122
+ }
82
123
  return explicit;
83
124
  }
84
125
  const observed = new Set(observedRemoteKeys);
@@ -118,7 +159,7 @@ export async function link(argv = [], deps = {}) {
118
159
  }
119
160
  catch (err) {
120
161
  console.error(err instanceof Error ? err.message : String(err));
121
- console.error('Usage: ctrl-spc link [--project <project-id>]');
162
+ console.error('Usage: ctrl-spc link [--project <project-id>] [--folder <repository-id>=<path>]');
122
163
  process.exitCode = 1;
123
164
  return;
124
165
  }
@@ -137,13 +178,73 @@ export async function link(argv = [], deps = {}) {
137
178
  .map((checkout) => checkout.remoteKey)
138
179
  .filter((remote) => remote !== null);
139
180
  const project = await chooseTopologyProject(client, flags.project, remoteKeys);
181
+ const chosenPaths = new Set();
182
+ for (const [repositoryId, path] of Object.entries(flags.folders ?? {})) {
183
+ if (chosenPaths.has(path))
184
+ throw new Error('Choose a different folder for each missing item.');
185
+ chosenPaths.add(path);
186
+ const repository = project.repositories.find(item => item.id === repositoryId);
187
+ if (!repository)
188
+ throw new Error('That folder is not part of this project.');
189
+ const inspection = (deps.inspectCheckout ?? inspectLocalCheckout)(path);
190
+ if (inspection.status !== 'observed')
191
+ throw new Error('CTRL+SPC could not open that folder.');
192
+ const checkout = inspection.checkout;
193
+ if (repository.sourceKind === 'local') {
194
+ writeLocalRepositoryIdentity(checkout.localPath, repository.remoteKey, repository.priorIdentityKey);
195
+ }
196
+ else if (checkout.remoteKey !== repository.remoteKey) {
197
+ throw new Error(`That is not the ${repository.roleLabel ?? repository.name} folder.`);
198
+ }
199
+ checkout.remoteKey = repository.remoteKey;
200
+ const existingIndex = observation.checkouts.findIndex(item => item.localPath === checkout.localPath);
201
+ if (existingIndex >= 0)
202
+ observation.checkouts.splice(existingIndex, 1, checkout);
203
+ else
204
+ observation.checkouts.push(checkout);
205
+ }
206
+ const resolutions = resolveRelinkRoots(project.repositories.map(repository => ({
207
+ repositoryId: repository.id,
208
+ remoteKey: repository.remoteKey,
209
+ identityKey: repository.identityKey ?? repository.remoteKey,
210
+ priorIdentityKey: repository.priorIdentityKey ?? null,
211
+ rootCommitSha: repository.rootCommitSha ?? null,
212
+ })), observation.checkouts.map(checkout => ({
213
+ path: checkout.localPath,
214
+ remoteKey: checkout.remoteKey,
215
+ stampedIdentityKey: readLocalRepositoryIdentity(checkout.localPath),
216
+ rootCommitSha: checkout.rootCommitSha ?? null,
217
+ })));
218
+ for (const resolution of resolutions) {
219
+ if (!resolution.candidate)
220
+ continue;
221
+ const checkout = observation.checkouts.find(item => item.localPath === resolution.candidate.path);
222
+ const repository = project.repositories.find(item => item.id === resolution.root.repositoryId);
223
+ if (repository.sourceKind === 'local') {
224
+ writeLocalRepositoryIdentity(checkout.localPath, repository.remoteKey, repository.priorIdentityKey);
225
+ }
226
+ checkout.remoteKey = repository.remoteKey;
227
+ }
228
+ console.log(`CTRL_SPC_LINK_RESULT=${JSON.stringify({
229
+ folders: resolutions.map((resolution) => {
230
+ const repository = project.repositories.find(item => item.id === resolution.root.repositoryId);
231
+ return {
232
+ repositoryId: repository.id,
233
+ label: repository.roleLabel ?? repository.name,
234
+ status: resolution.candidate ? 'ready' : 'unresolved',
235
+ sourceKind: repository.sourceKind,
236
+ action: resolution.candidate
237
+ ? null
238
+ : resolution.manualReason === 'not_found' && repository.sourceKind === 'remote'
239
+ ? 'fetch'
240
+ : 'choose',
241
+ };
242
+ }),
243
+ })}`);
140
244
  const machine = getMachineIdentity();
141
245
  const result = await registerMachineWorkspace(client, userData.user.id, project.id, machine, cwd, { observeWorkspace: () => observation });
142
246
  await upsertProjectLink(client, userData.user.id, project.id, observation.workspaceRoot);
143
- console.log(`Linked "${project.name}"${project.orgName ? ` (${project.orgName})` : ''} — ` +
144
- `${result.availableRepositories} available` +
145
- (result.missingRepositories > 0 ? `, ${result.missingRepositories} missing` : '') +
146
- `; workspace ${observation.workspaceRoot}`);
247
+ console.log(`${result.availableRepositories} of ${project.repositories.length} folders ready.`);
147
248
  }
148
249
  catch (err) {
149
250
  console.error(err instanceof Error ? err.message : String(err));
@@ -2,9 +2,9 @@ import { execFileSync } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
- import { dirname, isAbsolute, join, relative, sep } from 'node:path';
5
+ import { dirname, isAbsolute, join, parse, relative, sep } from 'node:path';
6
6
  import { getClient } from '../supabase.js';
7
- import { currentRepoRemote, normalizeRemoteUrl } from '../git.js';
7
+ import { currentRepoIdentity, currentRepoRemote, normalizeRemoteUrl, readRootCommitSha } from '../git.js';
8
8
  import { detectAgents, resolveAgentCommand } from '../agents.js';
9
9
  import { claudeSkillPath, codexSkillPath, installSkills } from '../skills.js';
10
10
  import { startPresence } from '../presence.js';
@@ -61,6 +61,18 @@ export async function existingBrokerStatus(machineId, port = MCP_PORT, fetcher =
61
61
  return 'not_running';
62
62
  }
63
63
  }
64
+ export function selectPrimaryCheckout(candidates, workspaceRoot, projectRootPaths = candidates.map(candidate => candidate.localPath)) {
65
+ const available = candidates.filter(candidate => candidate.availability === 'available');
66
+ const sticky = available.find(candidate => candidate.isPrimary);
67
+ if (sticky)
68
+ return sticky;
69
+ const nested = (candidate) => projectRootPaths.some(path => (path !== candidate.localPath && isWithin(path, candidate.localPath)));
70
+ return available.sort((left, right) => (Number(left.isWorktree) - Number(right.isWorktree)
71
+ || Number(isWithin(workspaceRoot, right.localPath)) - Number(isWithin(workspaceRoot, left.localPath))
72
+ || Number(nested(left)) - Number(nested(right))
73
+ || String(right.verifiedAt ?? '').localeCompare(String(left.verifiedAt ?? ''))
74
+ || left.localPath.localeCompare(right.localPath)))[0] ?? null;
75
+ }
64
76
  function one(value) {
65
77
  return Array.isArray(value) ? value[0] ?? null : value;
66
78
  }
@@ -82,18 +94,52 @@ function gitText(cwd, args) {
82
94
  }
83
95
  }
84
96
  /** Inspect only a path already discovered or previously registered. */
85
- export function inspectLocalCheckout(path) {
86
- if (!existsSync(path))
87
- return null;
88
- const remote = currentRepoRemote(path);
97
+ const DEFAULT_MOUNT_PREFIXES = process.platform === 'darwin'
98
+ ? ['/Volumes']
99
+ : process.platform === 'linux'
100
+ ? ['/mnt', '/media', '/run/media']
101
+ : [];
102
+ export function inspectLocalCheckout(path, options = {}) {
103
+ const inspectStat = options.stat ?? statSync;
104
+ try {
105
+ inspectStat(path);
106
+ }
107
+ catch (error) {
108
+ const code = error.code;
109
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EIO' || code === 'ENOTDIR') {
110
+ return { status: 'unreachable', reason: 'permission' };
111
+ }
112
+ if (code !== 'ENOENT')
113
+ return { status: 'unreachable', reason: 'permission' };
114
+ let ancestor = dirname(path);
115
+ while (ancestor !== parse(ancestor).root) {
116
+ try {
117
+ inspectStat(ancestor);
118
+ break;
119
+ }
120
+ catch (ancestorError) {
121
+ const ancestorCode = ancestorError.code;
122
+ if (ancestorCode !== 'ENOENT')
123
+ return { status: 'unreachable', reason: 'permission' };
124
+ ancestor = dirname(ancestor);
125
+ }
126
+ }
127
+ const parentExists = ancestor === dirname(path);
128
+ const underMount = (options.mountPrefixes ?? DEFAULT_MOUNT_PREFIXES).some(prefix => isWithin(prefix, path));
129
+ return parentExists || (!underMount && ancestor !== parse(path).root)
130
+ ? { status: 'missing' }
131
+ : { status: 'unreachable', reason: 'drive_missing' };
132
+ }
133
+ const identity = currentRepoIdentity(path);
89
134
  const gitDir = gitText(path, ['rev-parse', '--path-format=absolute', '--git-dir']);
90
135
  const commonDir = gitText(path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
91
- return {
92
- localPath: path,
93
- remoteKey: remote ? normalizeRemoteUrl(remote) : null,
94
- headSha: gitText(path, ['rev-parse', 'HEAD']),
95
- isWorktree: Boolean(gitDir && commonDir && gitDir !== commonDir),
96
- };
136
+ return { status: 'observed', checkout: {
137
+ localPath: path,
138
+ remoteKey: identity,
139
+ headSha: gitText(path, ['rev-parse', 'HEAD']),
140
+ isWorktree: Boolean(gitDir && commonDir && gitDir !== commonDir),
141
+ rootCommitSha: readRootCommitSha(path),
142
+ } };
97
143
  }
98
144
  /**
99
145
  * Discovers checkouts from real Git roots only. Alternate linked worktrees are
@@ -112,8 +158,8 @@ export function observeLocalWorkspace(cwd) {
112
158
  workspaceRoot: topology.scanRoot,
113
159
  checkouts: [...paths]
114
160
  .sort((left, right) => left.localeCompare(right))
115
- .map(inspectLocalCheckout)
116
- .filter((checkout) => checkout !== null),
161
+ .map(path => inspectLocalCheckout(path))
162
+ .flatMap(result => result.status === 'observed' ? [result.checkout] : []),
117
163
  };
118
164
  }
119
165
  export async function registerMachineIdentity(client, userId, machine, now = new Date().toISOString()) {
@@ -129,7 +175,7 @@ export async function registerMachineIdentity(client, userId, machine, now = new
129
175
  export async function loadMachineProjects(client, userId, machineId) {
130
176
  const { data, error } = await client
131
177
  .from('machine_workspaces')
132
- .select('project_id,project:projects!machine_workspaces_project_id_fkey(id,name)')
178
+ .select('project_id,project:projects!machine_workspaces_project_id_fkey(id,name,archived_at)')
133
179
  .eq('user_id', userId)
134
180
  .eq('machine_id', machineId);
135
181
  if (error)
@@ -137,11 +183,46 @@ export async function loadMachineProjects(client, userId, machineId) {
137
183
  const projects = new Map();
138
184
  for (const row of (data ?? [])) {
139
185
  const project = one(row.project);
140
- if (project)
141
- projects.set(project.id, project);
186
+ if (project && !project.archived_at)
187
+ projects.set(project.id, { id: project.id, name: project.name });
142
188
  }
143
189
  return [...projects.values()].sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
144
190
  }
191
+ /** Only projects whose required repositories are currently usable should be
192
+ * advertised as available to remote agents. Connection state is separate. */
193
+ export async function loadRunnableMachineProjects(client, userId, machineId) {
194
+ const projects = await loadMachineProjects(client, userId, machineId);
195
+ const { data, error } = await client.rpc('get_my_project_machine_availability');
196
+ if (error)
197
+ throw new Error(`Could not load project readiness: ${error.message}`);
198
+ const runnable = new Set((data ?? []).filter((row) => (row.machine_id === machineId && (row.readiness === 'ready' || row.readiness === 'partial'))).map((row) => row.project_id));
199
+ return projects.filter((project) => runnable.has(project.id));
200
+ }
201
+ /** Re-checks every folder registered on this machine. A missing workspace is
202
+ * represented by an empty observation so its prior checkouts become missing. */
203
+ export async function reconcileMachineWorkspaces(client, userId, machine) {
204
+ const { data, error } = await client
205
+ .from('machine_workspaces')
206
+ .select('project_id,workspace_root,project:projects!machine_workspaces_project_id_fkey(archived_at)')
207
+ .eq('user_id', userId)
208
+ .eq('machine_id', machine.id);
209
+ if (error)
210
+ throw new Error(`Could not load machine workspaces for refresh: ${error.message}`);
211
+ for (const workspace of (data ?? [])) {
212
+ if (one(workspace.project)?.archived_at)
213
+ continue;
214
+ let observation;
215
+ try {
216
+ observation = observeLocalWorkspace(workspace.workspace_root);
217
+ }
218
+ catch {
219
+ observation = { workspaceRoot: workspace.workspace_root, checkouts: [] };
220
+ }
221
+ await registerMachineWorkspace(client, userId, workspace.project_id, machine, workspace.workspace_root, {
222
+ observeWorkspace: () => observation,
223
+ });
224
+ }
225
+ }
145
226
  /** Legacy fallback only: multiple projects sharing one remote are deliberately
146
227
  * returned as multiple candidates, never collapsed to an arbitrary project. */
147
228
  export async function legacyCurrentProjectCandidates(client, cwd) {
@@ -150,11 +231,14 @@ export async function legacyCurrentProjectCandidates(client, cwd) {
150
231
  return [];
151
232
  const { data, error } = await client
152
233
  .from('projects')
153
- .select('id,name')
234
+ .select('id,name,archived_at')
154
235
  .eq('git_remote_url', normalizeRemoteUrl(remote));
155
236
  if (error)
156
237
  throw new Error(`Could not resolve the current repository: ${error.message}`);
157
- return (data ?? []).sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
238
+ return (data ?? [])
239
+ .filter((project) => !project.archived_at)
240
+ .map(({ id, name }) => ({ id, name }))
241
+ .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
158
242
  }
159
243
  async function loadActiveProjectRemoteKeys(client, projectId) {
160
244
  const { data, error } = await client
@@ -183,7 +267,7 @@ async function loadActiveProjectRemoteKeys(client, projectId) {
183
267
  export async function recoverLegacyProjectLinks(client, userId, machine, initializedProjectIds, deps = {}) {
184
268
  const { data, error } = await client
185
269
  .from('project_links')
186
- .select('project_id,local_path')
270
+ .select('project_id,local_path,project:projects!project_links_project_id_fkey(archived_at)')
187
271
  .eq('user_id', userId);
188
272
  if (error)
189
273
  throw new Error(`Could not load legacy project links: ${error.message}`);
@@ -194,6 +278,8 @@ export async function recoverLegacyProjectLinks(client, userId, machine, initial
194
278
  const links = [...(data ?? [])]
195
279
  .sort((left, right) => left.project_id.localeCompare(right.project_id) || left.local_path.localeCompare(right.local_path));
196
280
  for (const link of links) {
281
+ if (one(link.project ?? null)?.archived_at)
282
+ continue;
197
283
  if (initializedProjectIds.has(link.project_id))
198
284
  continue;
199
285
  let observation;
@@ -220,18 +306,28 @@ export async function recoverLegacyProjectLinks(client, userId, machine, initial
220
306
  function checkoutPlan(workspaceId, repositories, observation, existing, inspectCheckout, verifiedAt) {
221
307
  const rows = new Map();
222
308
  const availableRepositoryIds = new Set();
223
- const record = (repositoryId, checkout, fallback) => {
309
+ const record = (repositoryId, inspection, fallback) => {
310
+ const checkout = inspection.status === 'observed' ? inspection.checkout : null;
224
311
  const localPath = checkout?.localPath ?? fallback?.local_path;
225
312
  if (!localPath)
226
313
  return; // A missing repository without a known path is not fabricated.
227
- const expectedRemote = [...repositories.entries()].find(([, id]) => id === repositoryId)?.[0];
314
+ const repository = [...repositories.entries()].find(([, value]) => value.id === repositoryId);
315
+ const expectedRemote = repository?.[0];
228
316
  if (!expectedRemote)
229
317
  return;
230
- const availability = checkout === null
318
+ const forked = checkout?.remoteKey === expectedRemote
319
+ && repository?.[1].rootCommitSha
320
+ && checkout.rootCommitSha
321
+ && repository[1].rootCommitSha !== checkout.rootCommitSha;
322
+ const availability = inspection.status === 'missing'
231
323
  ? 'missing'
232
- : checkout.remoteKey === expectedRemote
233
- ? 'available'
234
- : 'mismatched';
324
+ : inspection.status === 'unreachable'
325
+ ? 'unreachable'
326
+ : forked
327
+ ? 'mismatched'
328
+ : checkout.remoteKey === expectedRemote
329
+ ? 'available'
330
+ : 'mismatched';
235
331
  if (availability === 'available')
236
332
  availableRepositoryIds.add(repositoryId);
237
333
  rows.set(`${repositoryId}\u0000${localPath}`, {
@@ -239,8 +335,16 @@ function checkoutPlan(workspaceId, repositories, observation, existing, inspectC
239
335
  repository_id: repositoryId,
240
336
  local_path: localPath,
241
337
  availability,
338
+ unavailable_reason: inspection.status === 'missing'
339
+ ? 'not_found'
340
+ : inspection.status === 'unreachable'
341
+ ? inspection.reason
342
+ : forked
343
+ ? 'confirm_lineage'
344
+ : null,
242
345
  verified_remote_key: checkout?.remoteKey ?? fallback?.verified_remote_key ?? null,
243
346
  head_sha: checkout?.headSha ?? fallback?.head_sha ?? null,
347
+ observed_root_commit_sha: checkout?.rootCommitSha ?? null,
244
348
  is_worktree: checkout?.isWorktree ?? fallback?.is_worktree ?? false,
245
349
  verified_at: verifiedAt,
246
350
  });
@@ -248,20 +352,25 @@ function checkoutPlan(workspaceId, repositories, observation, existing, inspectC
248
352
  for (const checkout of observation.checkouts) {
249
353
  if (!checkout.remoteKey)
250
354
  continue;
251
- const repositoryId = repositories.get(checkout.remoteKey);
252
- if (repositoryId)
253
- record(repositoryId, checkout, null);
355
+ const repository = repositories.get(checkout.remoteKey);
356
+ if (repository)
357
+ record(repository.id, { status: 'observed', checkout }, null);
254
358
  }
255
359
  // A previous mapping is deterministic evidence for a now-missing checkout.
256
360
  // Re-inspect it instead of assuming that an absent discovery result means
257
361
  // the directory disappeared; it may simply sit outside today's scan root.
258
362
  for (const prior of existing) {
259
- if (![...repositories.values()].includes(prior.repository_id))
363
+ if (![...repositories.values()].some(repository => repository.id === prior.repository_id))
260
364
  continue;
261
365
  const key = `${prior.repository_id}\u0000${prior.local_path}`;
262
366
  if (rows.has(key))
263
367
  continue;
264
- record(prior.repository_id, inspectCheckout(prior.local_path), prior);
368
+ const inspected = inspectCheckout(prior.local_path);
369
+ record(prior.repository_id, inspected === null
370
+ ? { status: 'missing' }
371
+ : 'status' in inspected
372
+ ? inspected
373
+ : { status: 'observed', checkout: inspected }, prior);
265
374
  }
266
375
  return { rows: [...rows.values()], availableRepositoryIds };
267
376
  }
@@ -276,7 +385,7 @@ export async function registerMachineWorkspace(client, userId, projectId, machin
276
385
  const { data: scopeData, error: scopeError } = await client
277
386
  .from('project_repository_scopes')
278
387
  .select('required,scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
279
- 'repository_id,repository:repositories!repository_scopes_repository_id_fkey(id,remote_key))')
388
+ 'repository_id,repository:repositories!repository_scopes_repository_id_fkey(id,remote_key,root_commit_sha))')
280
389
  .eq('project_id', projectId)
281
390
  .eq('active', true);
282
391
  if (scopeError)
@@ -287,7 +396,7 @@ export async function registerMachineWorkspace(client, userId, projectId, machin
287
396
  const scope = one(row.scope);
288
397
  const repository = scope ? one(scope.repository) : null;
289
398
  if (repository) {
290
- repositories.set(repository.remote_key, repository.id);
399
+ repositories.set(repository.remote_key, { id: repository.id, rootCommitSha: repository.root_commit_sha ?? null });
291
400
  if (row.required)
292
401
  requiredRepositoryIds.add(repository.id);
293
402
  }
@@ -342,6 +451,41 @@ export async function registerMachineWorkspace(client, userId, projectId, machin
342
451
  if (error)
343
452
  throw new Error(`Could not register repository checkout ${row.local_path}: ${error.message}`);
344
453
  }
454
+ if (typeof client.rpc === 'function') {
455
+ const { data: reconciledData, error: reconciledError } = await client
456
+ .from('repository_checkouts')
457
+ .select('id,repository_id,local_path,availability,is_worktree,is_primary,verified_at')
458
+ .eq('machine_workspace_id', workspaceId);
459
+ if (reconciledError)
460
+ throw new Error(`Could not reconcile folder copies: ${reconciledError.message}`);
461
+ const reconciled = (reconciledData ?? []);
462
+ for (const repositoryId of new Set(reconciled.map(row => row.repository_id))) {
463
+ const rows = reconciled.filter(row => row.repository_id === repositoryId);
464
+ const primary = selectPrimaryCheckout(rows.map(row => ({
465
+ id: row.id,
466
+ localPath: row.local_path,
467
+ availability: row.availability,
468
+ isWorktree: row.is_worktree,
469
+ isPrimary: row.is_primary,
470
+ verifiedAt: row.verified_at,
471
+ })), workspaceRoot, reconciled
472
+ .filter(row => row.availability === 'available')
473
+ .map(row => row.local_path));
474
+ if (primary && !rows.find(row => row.id === primary.id)?.is_primary) {
475
+ const { error } = await client.rpc('set_primary_checkout', { p_checkout_id: primary.id });
476
+ if (error)
477
+ throw new Error(`Could not choose the primary folder copy: ${error.message}`);
478
+ }
479
+ if (primary) {
480
+ const missingIds = rows.filter(row => row.availability === 'missing').map(row => row.id);
481
+ if (missingIds.length > 0) {
482
+ const { error } = await client.from('repository_checkouts').delete().in('id', missingIds);
483
+ if (error)
484
+ throw new Error(`Could not remove stale folder copies: ${error.message}`);
485
+ }
486
+ }
487
+ }
488
+ }
345
489
  // Preserve a combine/separate relink marker until the entire reconciliation
346
490
  // succeeds and every repository used by a required scope is available.
347
491
  // Optional-only repositories are still inspected above, but do not make a
@@ -553,7 +697,9 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
553
697
  const intervalMs = deps.intervalMs ?? 30_000;
554
698
  let timer;
555
699
  let chain = Promise.resolve();
556
- const runReconcile = async () => {
700
+ const runReconcile = async (refreshWorkspaces) => {
701
+ if (refreshWorkspaces)
702
+ await deps.refreshWorkspaces?.();
557
703
  const projects = await loadProjects(client, userId, machine.id);
558
704
  const currentIds = new Set(projects.map((project) => project.id));
559
705
  for (const project of projects) {
@@ -577,10 +723,10 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
577
723
  }
578
724
  };
579
725
  return {
580
- reconcile() {
726
+ reconcile(options = {}) {
581
727
  // Recover the queue after a transient failed poll; the caller still
582
728
  // receives this run's rejection, but later intervals keep reconciling.
583
- chain = chain.catch(() => { }).then(runReconcile);
729
+ chain = chain.catch(() => { }).then(() => runReconcile(options.refreshWorkspaces !== false));
584
730
  return chain;
585
731
  },
586
732
  start() {
@@ -692,8 +838,11 @@ export async function run() {
692
838
  }
693
839
  // Project channels reconcile independently from the one MCP listener, so
694
840
  // `ctrl-spc init` can add a project without restarting this broker.
695
- const presence = createProjectPresenceReconciler(client, userId, profileName, machine, registeredAgents);
696
- await presence.reconcile();
841
+ const presence = createProjectPresenceReconciler(client, userId, profileName, machine, registeredAgents, {
842
+ loadProjects: loadRunnableMachineProjects,
843
+ refreshWorkspaces: () => reconcileMachineWorkspaces(client, userId, machine),
844
+ });
845
+ await presence.reconcile({ refreshWorkspaces: false });
697
846
  presence.start();
698
847
  let mcpServer;
699
848
  try {
@@ -703,6 +852,10 @@ export async function run() {
703
852
  machineId: machine.id,
704
853
  presence,
705
854
  port: MCP_PORT,
855
+ runtime: {
856
+ kind: process.env.CTRL_SPC_RUNTIME_KIND || 'unlabeled',
857
+ label: process.env.CTRL_SPC_RUNTIME_LABEL || 'Unlabeled connection',
858
+ },
706
859
  });
707
860
  }
708
861
  catch (err) {
@@ -714,6 +867,7 @@ export async function run() {
714
867
  }
715
868
  console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
716
869
  'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
870
+ 'set_task_role_slugs, ' +
717
871
  'ask_question, record_user_input, submit_workflow_stage, decide_workflow_review, ' +
718
872
  'end_work, create_task, update_task, create_artifact, add_comment)');
719
873
  console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);