amalgm 0.1.158 → 0.1.159
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +43 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +35 -0
- package/runtime/scripts/amalgm-mcp/state/presence.js +175 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +41 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +20 -1
- package/runtime/scripts/amalgm-mcp/tests/state-presence.test.js +130 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +86 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +326 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +401 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +148 -0
- package/runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/card.js +199 -0
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +437 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +286 -0
- package/runtime/scripts/amalgm-mcp/workspace/identity.js +88 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +64 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +308 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only git facts.
|
|
5
|
+
*
|
|
6
|
+
* The one place the workspace layer talks to git. Rules:
|
|
7
|
+
* - Queries only. A call here never mutates a repo — GIT_OPTIONAL_LOCKS=0
|
|
8
|
+
* keeps even `status` from refreshing the index on disk.
|
|
9
|
+
* - Observation never executes repo-configured programs, and repo config
|
|
10
|
+
* never reshapes what is observed. GIT_CONFIG_* below outranks any
|
|
11
|
+
* repo/user/system setting for exactly those keys: core.fsmonitor is a
|
|
12
|
+
* command git would otherwise run during `status`, and
|
|
13
|
+
* status.showUntrackedFiles could hide untracked files from the count.
|
|
14
|
+
* - No shell, pinned cwd, no prompts, C locale.
|
|
15
|
+
* - Failure is an answer. "Not a repo" and "bare" are classification
|
|
16
|
+
* results the caller matches on; anything unexpected throws GitError
|
|
17
|
+
* carrying the full command and stderr.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { execFile } = require('child_process');
|
|
23
|
+
const { promisify } = require('util');
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
const GIT_ENV = {
|
|
28
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
29
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
30
|
+
LC_ALL: 'C',
|
|
31
|
+
GIT_CONFIG_COUNT: '2',
|
|
32
|
+
GIT_CONFIG_KEY_0: 'core.fsmonitor',
|
|
33
|
+
GIT_CONFIG_VALUE_0: 'false',
|
|
34
|
+
GIT_CONFIG_KEY_1: 'status.showUntrackedFiles',
|
|
35
|
+
GIT_CONFIG_VALUE_1: 'normal',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
class GitError extends Error {
|
|
39
|
+
constructor(args, cwd, exitCode, stderr) {
|
|
40
|
+
super(`git ${args.join(' ')} in ${cwd} exited ${exitCode}: ${stderr.trim()}`);
|
|
41
|
+
this.name = 'GitError';
|
|
42
|
+
this.args = args;
|
|
43
|
+
this.cwd = cwd;
|
|
44
|
+
this.exitCode = exitCode;
|
|
45
|
+
this.stderr = stderr;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function git(args, cwd, { okCodes = [0] } = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
52
|
+
cwd,
|
|
53
|
+
env: { ...process.env, ...GIT_ENV },
|
|
54
|
+
timeout: 15_000,
|
|
55
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
return { stdout, exitCode: 0 };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const exitCode = typeof error.code === 'number' ? error.code : 1;
|
|
60
|
+
if (okCodes.includes(exitCode)) {
|
|
61
|
+
return { stdout: error.stdout ?? '', exitCode };
|
|
62
|
+
}
|
|
63
|
+
throw new GitError(args, cwd, exitCode, String(error.stderr ?? error.message));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NOT_A_REPO_RE = /not a git repository/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What is this directory, in git's own eyes?
|
|
71
|
+
*
|
|
72
|
+
* Returns one of:
|
|
73
|
+
* { kind: 'not-a-repo', path }
|
|
74
|
+
* { kind: 'bare', path }
|
|
75
|
+
* { kind: 'worktree', path, toplevel, gitDir, commonDir,
|
|
76
|
+
* linked: boolean, superproject: string | null }
|
|
77
|
+
*
|
|
78
|
+
* `path` is always the realpath of the input. `linked` means a linked
|
|
79
|
+
* worktree (`git worktree add`); a non-empty `superproject` means the
|
|
80
|
+
* toplevel is a submodule of that superproject.
|
|
81
|
+
*/
|
|
82
|
+
async function classifyDirectory(dirPath) {
|
|
83
|
+
const realPath = await fs.promises.realpath(dirPath);
|
|
84
|
+
|
|
85
|
+
let probe;
|
|
86
|
+
try {
|
|
87
|
+
probe = await git(['-C', realPath, 'rev-parse', '--is-inside-work-tree', '--is-bare-repository'], realPath);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof GitError && NOT_A_REPO_RE.test(error.stderr)) {
|
|
90
|
+
return { kind: 'not-a-repo', path: realPath };
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
const [insideWorkTree, bare] = probe.stdout.trim().split('\n');
|
|
95
|
+
if (bare === 'true') return { kind: 'bare', path: realPath };
|
|
96
|
+
if (insideWorkTree !== 'true') {
|
|
97
|
+
// Inside a .git directory itself; the containing repo owns this path.
|
|
98
|
+
return { kind: 'not-a-repo', path: realPath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { stdout } = await git(
|
|
102
|
+
['-C', realPath, 'rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'],
|
|
103
|
+
realPath,
|
|
104
|
+
);
|
|
105
|
+
const [toplevel, gitDir, commonDirRaw] = stdout.trim().split('\n');
|
|
106
|
+
const commonDir = path.resolve(realPath, commonDirRaw); // --git-common-dir prints relative to the cwd
|
|
107
|
+
|
|
108
|
+
const superprojectOut = await git(['-C', realPath, 'rev-parse', '--show-superproject-working-tree'], realPath);
|
|
109
|
+
const superproject = superprojectOut.stdout.trim() || null;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
kind: 'worktree',
|
|
113
|
+
path: realPath,
|
|
114
|
+
toplevel,
|
|
115
|
+
gitDir,
|
|
116
|
+
commonDir,
|
|
117
|
+
linked: gitDir !== commonDir && !superproject,
|
|
118
|
+
superproject,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* All worktrees of the repo that owns `worktreeRoot`, from
|
|
124
|
+
* `git worktree list --porcelain`. Bare placeholder entries are dropped —
|
|
125
|
+
* they have no working files to card. Prunable entries (registered but
|
|
126
|
+
* gone from disk) are reported as git states them; callers decide.
|
|
127
|
+
*/
|
|
128
|
+
async function listWorktrees(worktreeRoot) {
|
|
129
|
+
const { stdout } = await git(['-C', worktreeRoot, 'worktree', 'list', '--porcelain'], worktreeRoot);
|
|
130
|
+
const worktrees = [];
|
|
131
|
+
let current = null;
|
|
132
|
+
for (const line of stdout.split('\n')) {
|
|
133
|
+
if (line.startsWith('worktree ')) {
|
|
134
|
+
current = {
|
|
135
|
+
path: line.slice('worktree '.length),
|
|
136
|
+
bare: false,
|
|
137
|
+
detached: false,
|
|
138
|
+
branch: null,
|
|
139
|
+
head: null,
|
|
140
|
+
prunable: false,
|
|
141
|
+
locked: false,
|
|
142
|
+
};
|
|
143
|
+
worktrees.push(current);
|
|
144
|
+
} else if (line === 'bare') {
|
|
145
|
+
current.bare = true;
|
|
146
|
+
} else if (line === 'detached') {
|
|
147
|
+
current.detached = true;
|
|
148
|
+
} else if (line === 'prunable' || line.startsWith('prunable ')) {
|
|
149
|
+
current.prunable = true;
|
|
150
|
+
} else if (line === 'locked' || line.startsWith('locked ')) {
|
|
151
|
+
current.locked = true;
|
|
152
|
+
} else if (line.startsWith('HEAD ')) {
|
|
153
|
+
current.head = line.slice('HEAD '.length);
|
|
154
|
+
} else if (line.startsWith('branch ')) {
|
|
155
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return worktrees.filter((worktree) => !worktree.bare);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* HEAD position and working-tree cleanliness in one `git status
|
|
163
|
+
* --porcelain=v2 -z --branch` call.
|
|
164
|
+
*
|
|
165
|
+
* head.oid and head.branch are null for an unborn branch and a detached
|
|
166
|
+
* HEAD respectively. ahead/behind are 0 whenever there is no upstream.
|
|
167
|
+
*
|
|
168
|
+
* Dirty counts are git status entries, exactly as git reports them: an
|
|
169
|
+
* untracked directory of many files is one entry, and a file that is
|
|
170
|
+
* staged and then edited again counts once as staged and once as
|
|
171
|
+
* unstaged. Counts answer "is there uncommitted work, roughly how much" —
|
|
172
|
+
* they are not a file census.
|
|
173
|
+
*/
|
|
174
|
+
async function headAndDirty(worktreeRoot) {
|
|
175
|
+
const { stdout } = await git(['-C', worktreeRoot, 'status', '--porcelain=v2', '-z', '--branch'], worktreeRoot);
|
|
176
|
+
|
|
177
|
+
const head = { branch: null, oid: null, detached: false, upstream: null, ahead: 0, behind: 0 };
|
|
178
|
+
const dirty = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, submodulesChanged: 0 };
|
|
179
|
+
|
|
180
|
+
const tokens = stdout.split('\0');
|
|
181
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
182
|
+
const token = tokens[i];
|
|
183
|
+
if (token.startsWith('# branch.oid ')) {
|
|
184
|
+
const oid = token.slice('# branch.oid '.length);
|
|
185
|
+
head.oid = oid === '(initial)' ? null : oid;
|
|
186
|
+
} else if (token.startsWith('# branch.head ')) {
|
|
187
|
+
const branch = token.slice('# branch.head '.length);
|
|
188
|
+
head.detached = branch === '(detached)';
|
|
189
|
+
head.branch = head.detached ? null : branch;
|
|
190
|
+
} else if (token.startsWith('# branch.upstream ')) {
|
|
191
|
+
head.upstream = token.slice('# branch.upstream '.length);
|
|
192
|
+
} else if (token.startsWith('# branch.ab ')) {
|
|
193
|
+
const [ahead, behind] = token.slice('# branch.ab '.length).split(' ');
|
|
194
|
+
head.ahead = Number(ahead.slice(1));
|
|
195
|
+
head.behind = Number(behind.slice(1));
|
|
196
|
+
} else if (token.startsWith('1 ') || token.startsWith('2 ')) {
|
|
197
|
+
const [, xy, sub] = token.split(' ');
|
|
198
|
+
if (xy[0] !== '.') dirty.staged += 1;
|
|
199
|
+
if (xy[1] !== '.') dirty.unstaged += 1;
|
|
200
|
+
if (sub[0] === 'S') dirty.submodulesChanged += 1;
|
|
201
|
+
if (token.startsWith('2 ')) i += 1; // rename records carry origPath in the next token
|
|
202
|
+
} else if (token.startsWith('u ')) {
|
|
203
|
+
dirty.conflicted += 1;
|
|
204
|
+
} else if (token.startsWith('? ')) {
|
|
205
|
+
dirty.untracked += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { head, dirty };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** HEAD's commit right now, or null on an unborn branch. The coherence probe. */
|
|
212
|
+
async function headOid(worktreeRoot) {
|
|
213
|
+
const result = await git(['-C', worktreeRoot, 'rev-parse', '--verify', '--quiet', 'HEAD'], worktreeRoot, {
|
|
214
|
+
okCodes: [0, 1],
|
|
215
|
+
});
|
|
216
|
+
return result.exitCode === 0 ? result.stdout.trim() : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const OPERATION_MARKERS = [
|
|
220
|
+
['rebase-merge', 'rebase'],
|
|
221
|
+
['rebase-apply', 'rebase'],
|
|
222
|
+
['MERGE_HEAD', 'merge'],
|
|
223
|
+
['CHERRY_PICK_HEAD', 'cherry-pick'],
|
|
224
|
+
['REVERT_HEAD', 'revert'],
|
|
225
|
+
['BISECT_LOG', 'bisect'],
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
/** Which multi-step git operation is mid-flight, if any. */
|
|
229
|
+
async function operationState(worktreeRoot) {
|
|
230
|
+
const markers = OPERATION_MARKERS.map(([marker]) => marker);
|
|
231
|
+
const { stdout } = await git(
|
|
232
|
+
['-C', worktreeRoot, 'rev-parse', ...markers.flatMap((marker) => ['--git-path', marker])],
|
|
233
|
+
worktreeRoot,
|
|
234
|
+
);
|
|
235
|
+
const resolved = stdout.trim().split('\n');
|
|
236
|
+
for (let i = 0; i < OPERATION_MARKERS.length; i++) {
|
|
237
|
+
if (fs.existsSync(path.resolve(worktreeRoot, resolved[i]))) return OPERATION_MARKERS[i][1];
|
|
238
|
+
}
|
|
239
|
+
return 'none';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The configured origin URL, or null when the repo has no origin. */
|
|
243
|
+
async function originUrl(worktreeRoot) {
|
|
244
|
+
const result = await git(['-C', worktreeRoot, 'config', '--get', 'remote.origin.url'], worktreeRoot, {
|
|
245
|
+
okCodes: [0, 1],
|
|
246
|
+
});
|
|
247
|
+
return result.exitCode === 0 ? result.stdout.trim() : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The repo's root commit — the lexically first parentless commit, a stable
|
|
252
|
+
* identity for "same history" across clones. Only call on a born HEAD.
|
|
253
|
+
*/
|
|
254
|
+
async function rootCommit(worktreeRoot) {
|
|
255
|
+
const { stdout } = await git(['-C', worktreeRoot, 'rev-list', '--max-parents=0', 'HEAD'], worktreeRoot);
|
|
256
|
+
return stdout.trim().split('\n').sort()[0];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Does git own this file? A file is git-owned when the worktree around it
|
|
261
|
+
* tracks it (`ls-files --error-unmatch`). Untracked files inside a repo
|
|
262
|
+
* and files outside any repo have no git owner.
|
|
263
|
+
* Returns { toplevel } for a git-owned file, null otherwise.
|
|
264
|
+
*/
|
|
265
|
+
async function gitOwnerOf(filePath) {
|
|
266
|
+
const classified = await classifyDirectory(path.dirname(filePath));
|
|
267
|
+
if (classified.kind !== 'worktree') return null;
|
|
268
|
+
const tracked = await git(
|
|
269
|
+
['ls-files', '--error-unmatch', '--', path.basename(filePath)],
|
|
270
|
+
path.dirname(filePath),
|
|
271
|
+
{ okCodes: [0, 1] },
|
|
272
|
+
);
|
|
273
|
+
return tracked.exitCode === 0 ? { toplevel: classified.toplevel } : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = {
|
|
277
|
+
GitError,
|
|
278
|
+
classifyDirectory,
|
|
279
|
+
gitOwnerOf,
|
|
280
|
+
headAndDirty,
|
|
281
|
+
headOid,
|
|
282
|
+
listWorktrees,
|
|
283
|
+
operationState,
|
|
284
|
+
originUrl,
|
|
285
|
+
rootCommit,
|
|
286
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who observes, and which checkout is observed.
|
|
5
|
+
*
|
|
6
|
+
* machineId is the enrolled computer identity this runtime runs as — the
|
|
7
|
+
* same id the platform knows this machine by. replicaId is minted once
|
|
8
|
+
* per checkout and keyed by the checkout's own git directory, the one
|
|
9
|
+
* path git itself dedicates to exactly that checkout: two worktrees of a
|
|
10
|
+
* repo are two replicas, and a moved checkout is a new replica. Ids live
|
|
11
|
+
* only in our database; nothing is ever written into a repo.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { AMALGM_COMPUTER_ID, AMALGM_DIR } = require('../config');
|
|
19
|
+
const { cachedStatement, openLocalDb } = require('../state/db');
|
|
20
|
+
|
|
21
|
+
let resolvedMachineId = null;
|
|
22
|
+
let resolvedUserId;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The identity other machines know this one by: the container/computer id
|
|
26
|
+
* from the environment, the enrolled computer record, or — on a machine
|
|
27
|
+
* that was never enrolled — a visibly local hostname identity.
|
|
28
|
+
*/
|
|
29
|
+
function machineId() {
|
|
30
|
+
if (resolvedMachineId) return resolvedMachineId;
|
|
31
|
+
if (AMALGM_COMPUTER_ID) {
|
|
32
|
+
resolvedMachineId = AMALGM_COMPUTER_ID;
|
|
33
|
+
return resolvedMachineId;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const record = JSON.parse(fs.readFileSync(path.join(AMALGM_DIR, 'computer.json'), 'utf8'));
|
|
37
|
+
if (record?.computer_id) {
|
|
38
|
+
resolvedMachineId = String(record.computer_id);
|
|
39
|
+
return resolvedMachineId;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Not enrolled; fall through to the visibly-local identity.
|
|
43
|
+
}
|
|
44
|
+
resolvedMachineId = `unenrolled:${os.hostname()}`;
|
|
45
|
+
return resolvedMachineId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The account this machine is enrolled to, or null on a machine that was
|
|
50
|
+
* never enrolled. Cloud-facing features key on this; without it they
|
|
51
|
+
* simply do not start.
|
|
52
|
+
*/
|
|
53
|
+
function userId() {
|
|
54
|
+
if (resolvedUserId !== undefined) return resolvedUserId;
|
|
55
|
+
try {
|
|
56
|
+
const record = JSON.parse(fs.readFileSync(path.join(AMALGM_DIR, 'computer.json'), 'utf8'));
|
|
57
|
+
resolvedUserId = record?.user_id ? String(record.user_id) : null;
|
|
58
|
+
} catch {
|
|
59
|
+
resolvedUserId = null;
|
|
60
|
+
}
|
|
61
|
+
return resolvedUserId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The replica record for a checkout, minted on first sight.
|
|
66
|
+
*
|
|
67
|
+
* rootCommit is cached here the first time the repo's history has one —
|
|
68
|
+
* it is the repo's permanent identity anchor, so it is computed once per
|
|
69
|
+
* replica, never re-walked on refresh.
|
|
70
|
+
*/
|
|
71
|
+
function replicaForGitDir(gitDir) {
|
|
72
|
+
const db = openLocalDb();
|
|
73
|
+
const existing = cachedStatement(db, 'SELECT * FROM repo_replicas WHERE git_dir = ?').get(gitDir);
|
|
74
|
+
if (existing) return { replicaId: existing.replica_id, rootCommit: existing.root_commit };
|
|
75
|
+
const replicaId = `rpl_${crypto.randomBytes(9).toString('base64url')}`;
|
|
76
|
+
cachedStatement(db, `
|
|
77
|
+
INSERT INTO repo_replicas (git_dir, replica_id, root_commit, created_at)
|
|
78
|
+
VALUES (?, ?, NULL, ?)
|
|
79
|
+
`).run(gitDir, replicaId, new Date().toISOString());
|
|
80
|
+
return { replicaId, rootCommit: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cacheRootCommit(gitDir, rootCommitOid) {
|
|
84
|
+
cachedStatement(openLocalDb(), 'UPDATE repo_replicas SET root_commit = ? WHERE git_dir = ?')
|
|
85
|
+
.run(rootCommitOid, gitDir);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { cacheRootCommit, machineId, replicaForGitDir, userId };
|
|
@@ -14,6 +14,9 @@ const {
|
|
|
14
14
|
renameWorkspace,
|
|
15
15
|
upsertWorkspace,
|
|
16
16
|
} = require('./store');
|
|
17
|
+
const { listRepoCards, refreshRepoCards, repoCardServiceStats } = require('./cards');
|
|
18
|
+
const { machineId } = require('./identity');
|
|
19
|
+
const { readWall, wallStats } = require('./wall');
|
|
17
20
|
|
|
18
21
|
const execFileAsync = promisify(execFile);
|
|
19
22
|
const MAX_BUFFER = 10 * 1024 * 1024;
|
|
@@ -625,6 +628,63 @@ async function handleWorktreeDelete(body, sendJson) {
|
|
|
625
628
|
sendJson(200, { success: true });
|
|
626
629
|
}
|
|
627
630
|
|
|
631
|
+
function handleRepoCards(sendJson) {
|
|
632
|
+
try {
|
|
633
|
+
sendJson(200, { cards: listRepoCards() });
|
|
634
|
+
} catch (error) {
|
|
635
|
+
sendJson(500, { error: error instanceof Error ? error.message : 'Failed to list repo cards' });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function handleRepoCardsRefresh(sendJson) {
|
|
640
|
+
try {
|
|
641
|
+
sendJson(200, await refreshRepoCards());
|
|
642
|
+
} catch (error) {
|
|
643
|
+
sendJson(500, { error: error instanceof Error ? error.message : 'Failed to refresh repo cards' });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function handleWorkspaceSyncStats(sendJson) {
|
|
648
|
+
try {
|
|
649
|
+
const cards = listRepoCards();
|
|
650
|
+
const health = { healthy: 0, missing: 0, broken: 0, inaccessible: 0 };
|
|
651
|
+
for (const card of cards) health[card.health] = (health[card.health] || 0) + 1;
|
|
652
|
+
sendJson(200, {
|
|
653
|
+
machine: machineId(),
|
|
654
|
+
cards: { total: cards.length, ...health },
|
|
655
|
+
service: repoCardServiceStats(),
|
|
656
|
+
wall: wallStats(),
|
|
657
|
+
});
|
|
658
|
+
} catch (error) {
|
|
659
|
+
sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read sync stats' });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The whole wall: this machine's cards straight from the live store, every
|
|
665
|
+
* peer's cards as last published. One call answers "what code does each of
|
|
666
|
+
* my machines have right now".
|
|
667
|
+
*/
|
|
668
|
+
function handleWorkspaceMachines(sendJson) {
|
|
669
|
+
try {
|
|
670
|
+
const self = machineId();
|
|
671
|
+
const machines = [
|
|
672
|
+
{ machine: self, self: true, publishedAt: null, cards: listRepoCards() },
|
|
673
|
+
...readWall()
|
|
674
|
+
.filter((entry) => entry.machine !== self)
|
|
675
|
+
.map((entry) => ({
|
|
676
|
+
machine: entry.machine,
|
|
677
|
+
self: false,
|
|
678
|
+
publishedAt: entry.publishedAt,
|
|
679
|
+
cards: entry.cards,
|
|
680
|
+
})),
|
|
681
|
+
];
|
|
682
|
+
sendJson(200, { self, machines });
|
|
683
|
+
} catch (error) {
|
|
684
|
+
sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read the machine wall' });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
628
688
|
module.exports = {
|
|
629
689
|
getWorkspaceRoot,
|
|
630
690
|
handleProjects,
|
|
@@ -639,4 +699,8 @@ module.exports = {
|
|
|
639
699
|
handleWorktreeList,
|
|
640
700
|
handleWorktreeCreate,
|
|
641
701
|
handleWorktreeDelete,
|
|
702
|
+
handleRepoCards,
|
|
703
|
+
handleRepoCardsRefresh,
|
|
704
|
+
handleWorkspaceMachines,
|
|
705
|
+
handleWorkspaceSyncStats,
|
|
642
706
|
};
|