@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.
- package/dist/commands/fetch.js +108 -43
- package/dist/commands/init.js +271 -53
- package/dist/commands/link.js +117 -16
- package/dist/commands/run.js +192 -38
- package/dist/companion-projects.js +375 -0
- package/dist/companion-ui.js +1204 -0
- package/dist/companion.js +422 -0
- package/dist/dispatch.js +37 -0
- package/dist/git.js +74 -4
- package/dist/index.js +12 -0
- package/dist/mcp.js +124 -97
- package/dist/relink.js +31 -0
- package/dist/resolver.js +9 -0
- package/dist/role-detection.js +101 -0
- package/dist/roles.js +18 -0
- package/dist/runtime-control.js +253 -0
- package/dist/supabase.js +11 -0
- package/dist/topology.js +22 -4
- package/package.json +6 -1
package/dist/commands/link.js
CHANGED
|
@@ -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 (
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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) => ({
|
|
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(
|
|
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));
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
.
|
|
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 ?? [])
|
|
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,
|
|
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
|
|
314
|
+
const repository = [...repositories.entries()].find(([, value]) => value.id === repositoryId);
|
|
315
|
+
const expectedRemote = repository?.[0];
|
|
228
316
|
if (!expectedRemote)
|
|
229
317
|
return;
|
|
230
|
-
const
|
|
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
|
-
:
|
|
233
|
-
? '
|
|
234
|
-
:
|
|
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
|
|
252
|
-
if (
|
|
253
|
-
record(
|
|
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()].
|
|
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
|
-
|
|
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
|
-
|
|
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'}`);
|