gigarag-cursor 0.1.1 → 0.2.1
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/.cursor-plugin/plugin.json +1 -1
- package/cli/auth/account.js +106 -0
- package/cli/auth/credentials.js +125 -16
- package/cli/binding.js +117 -0
- package/cli/cli.js +10 -0
- package/cli/commands/authHeader.js +5 -8
- package/cli/commands/connect.js +4 -2
- package/cli/commands/gitHooks.js +367 -0
- package/cli/commands/indexSync.js +9 -5
- package/cli/commands/login.js +47 -12
- package/cli/commands/mcp.js +10 -1
- package/cli/commands/repo.js +1 -0
- package/cli/commands/scan.js +21 -1
- package/cli/commands/status.js +33 -15
- package/cli/commands/sync.js +223 -0
- package/cli/commands/trust.js +31 -0
- package/cli/commands/use.js +81 -0
- package/cli/commands/workspaces.js +40 -0
- package/cli/git/git.js +187 -0
- package/cli/git/hookScript.js +83 -0
- package/cli/hooks.js +32 -15
- package/cli/mcp/client.js +4 -0
- package/cli/mcp/session.js +66 -7
- package/cli/package.json +1 -1
- package/cli/prompts.generated.js +2 -2
- package/cli/scan/repo.js +16 -2
- package/cli/scan/scan.js +1 -1
- package/cli/state.js +71 -11
- package/cli/sync/notes.js +50 -0
- package/cli/sync/plan.js +76 -0
- package/cli/sync/remote.js +72 -0
- package/cli/sync/rewriter.js +125 -0
- package/cli/sync/slug.js +30 -0
- package/cli/sync/stage.js +323 -0
- package/cli/sync/target.js +14 -0
- package/cli/sync/worker.js +159 -0
- package/package.json +1 -1
- package/skills/gigaindex/SKILL.md +6 -3
- package/skills/gigasync/SKILL.md +6 -3
package/cli/sync/plan.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a sync does without a model, decided from two maps: what the manifest
|
|
3
|
+
* says was indexed, and what is on disk now. Pure, so every rule here has a
|
|
4
|
+
* test that doesn't need git or a server.
|
|
5
|
+
*/
|
|
6
|
+
/** More than this many gone paths, and more than half the manifest, is what a broken checkout looks like. */
|
|
7
|
+
export const MASS_DELETE_MIN = 20;
|
|
8
|
+
function byHash(paths, hashOf) {
|
|
9
|
+
const m = new Map();
|
|
10
|
+
for (const p of paths) {
|
|
11
|
+
const h = hashOf(p);
|
|
12
|
+
m.set(h, [...(m.get(h) ?? []), p]);
|
|
13
|
+
}
|
|
14
|
+
return m;
|
|
15
|
+
}
|
|
16
|
+
export function planSync(input) {
|
|
17
|
+
const { known, current } = input;
|
|
18
|
+
const gone = input.truncated ? [] : [...known.keys()].filter(p => !current.has(p) && !(input.atHead?.has(p) ?? false));
|
|
19
|
+
const added = [...current.keys()].filter(p => !known.has(p));
|
|
20
|
+
// A rename only when the hash is unique on both sides. Two copies of one LICENSE are not evidence.
|
|
21
|
+
const goneByHash = byHash(gone, p => known.get(p).hash);
|
|
22
|
+
const addedByHash = byHash(added, p => current.get(p));
|
|
23
|
+
const renames = [];
|
|
24
|
+
for (const [hash, from] of goneByHash) {
|
|
25
|
+
const to = addedByHash.get(hash);
|
|
26
|
+
if (from.length === 1 && to?.length === 1)
|
|
27
|
+
renames.push({ from: from[0], to: to[0], hash, nodeIds: known.get(from[0]).nodeIds });
|
|
28
|
+
}
|
|
29
|
+
const renamedTo = new Map(renames.map(r => [r.from, r.to]));
|
|
30
|
+
const newPaths = new Set(renames.map(r => r.to));
|
|
31
|
+
const trulyGone = gone.filter(p => !renamedTo.has(p));
|
|
32
|
+
const suspicious = trulyGone.length > MASS_DELETE_MIN && trulyGone.length * 2 > known.size;
|
|
33
|
+
// Which live paths feed each memo once renames are applied.
|
|
34
|
+
const feeders = new Map();
|
|
35
|
+
for (const [path, file] of known) {
|
|
36
|
+
const live = renamedTo.get(path) ?? path;
|
|
37
|
+
for (const id of file.nodeIds)
|
|
38
|
+
feeders.set(id, [...(feeders.get(id) ?? []), live]);
|
|
39
|
+
}
|
|
40
|
+
const goneSet = new Set(trulyGone);
|
|
41
|
+
const deleteNodeIds = new Set();
|
|
42
|
+
const deletedPaths = [];
|
|
43
|
+
const partial = [];
|
|
44
|
+
if (!suspicious || input.allowMassDelete) {
|
|
45
|
+
for (const path of trulyGone) {
|
|
46
|
+
const ids = known.get(path).nodeIds;
|
|
47
|
+
const orphaned = ids.filter(id => (feeders.get(id) ?? []).every(p => goneSet.has(p)));
|
|
48
|
+
for (const id of orphaned)
|
|
49
|
+
deleteNodeIds.add(id);
|
|
50
|
+
const shared = ids.filter(id => !orphaned.includes(id));
|
|
51
|
+
if (shared.length > 0)
|
|
52
|
+
partial.push({ path, nodeIds: shared });
|
|
53
|
+
else
|
|
54
|
+
deletedPaths.push(path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const changed = [...current].filter(([p, h]) => known.has(p) && known.get(p).hash !== h).map(([p]) => p);
|
|
58
|
+
const rewrite = [...changed, ...added.filter(p => !newPaths.has(p))].sort();
|
|
59
|
+
const stale = new Set();
|
|
60
|
+
for (const p of changed)
|
|
61
|
+
for (const id of known.get(p).nodeIds)
|
|
62
|
+
stale.add(id);
|
|
63
|
+
for (const p of partial)
|
|
64
|
+
for (const id of p.nodeIds)
|
|
65
|
+
stale.add(id);
|
|
66
|
+
return {
|
|
67
|
+
renames,
|
|
68
|
+
deleteNodeIds: [...deleteNodeIds].sort(),
|
|
69
|
+
deletedPaths: deletedPaths.sort(),
|
|
70
|
+
partial,
|
|
71
|
+
stale: [...stale].sort(),
|
|
72
|
+
rewrite,
|
|
73
|
+
suspicious,
|
|
74
|
+
goneCount: trulyGone.length,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const RECORD_BATCH = 500;
|
|
2
|
+
const STALE_BATCH = 200;
|
|
3
|
+
function chunks(items, size) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (let i = 0; i < items.length; i += size)
|
|
6
|
+
out.push(items.slice(i, i + size));
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
/** The bucket whose slug is exactly `slug`. list_buckets filters by substring, so the match is checked here. */
|
|
10
|
+
export async function findBucketId(rag, slug) {
|
|
11
|
+
const page = await rag.listBuckets({ q: slug, limit: 50 });
|
|
12
|
+
const hit = page.items.find(b => b['slug'] === slug);
|
|
13
|
+
return typeof hit?.['id'] === 'string' ? hit['id'] : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** repo_sync for one bucket, with core's limits applied by batching rather than by the caller. */
|
|
16
|
+
export class RepoSyncRemote {
|
|
17
|
+
rag;
|
|
18
|
+
bucketId;
|
|
19
|
+
constructor(rag, bucketId) {
|
|
20
|
+
this.rag = rag;
|
|
21
|
+
this.bucketId = bucketId;
|
|
22
|
+
}
|
|
23
|
+
call(action, args = {}) {
|
|
24
|
+
return this.rag.callTool('repo_sync', { bucket_id: this.bucketId, action, ...args });
|
|
25
|
+
}
|
|
26
|
+
state() {
|
|
27
|
+
return this.call('state');
|
|
28
|
+
}
|
|
29
|
+
async manifest() {
|
|
30
|
+
const all = [];
|
|
31
|
+
let after;
|
|
32
|
+
do {
|
|
33
|
+
const page = await this.call('manifest', { limit: 1000, ...(after ? { after } : {}) });
|
|
34
|
+
all.push(...page.items);
|
|
35
|
+
after = page.next_cursor ?? undefined;
|
|
36
|
+
} while (after);
|
|
37
|
+
return all;
|
|
38
|
+
}
|
|
39
|
+
claim(holder, token) {
|
|
40
|
+
return this.call('claim', { holder, ttl_seconds: 600, ...(token ? { token } : {}) });
|
|
41
|
+
}
|
|
42
|
+
/** Never throws: a lease that can't be released lapses on its own in ten minutes. */
|
|
43
|
+
async release(token) {
|
|
44
|
+
try {
|
|
45
|
+
await this.call('release', { token });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* lapses */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async record(upserts, removes) {
|
|
52
|
+
const entries = [
|
|
53
|
+
...upserts.map(u => ({ up: { path: u.path, hash: u.hash, node_ids: u.nodeIds } })),
|
|
54
|
+
...removes.map(rm => ({ rm })),
|
|
55
|
+
];
|
|
56
|
+
for (const batch of chunks(entries, RECORD_BATCH)) {
|
|
57
|
+
await this.call('record', {
|
|
58
|
+
upserts: batch.flatMap(e => ('up' in e ? [e.up] : [])),
|
|
59
|
+
removes: batch.flatMap(e => ('rm' in e ? [e.rm] : [])),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async advance(token, commit, branch) {
|
|
64
|
+
await this.call('advance', { token, commit, ...(branch ? { branch } : {}) });
|
|
65
|
+
}
|
|
66
|
+
async markStale(nodeIds, commit) {
|
|
67
|
+
let marked = 0;
|
|
68
|
+
for (const batch of chunks(nodeIds, STALE_BATCH))
|
|
69
|
+
marked += (await this.call('mark_stale', { node_ids: batch, commit })).marked;
|
|
70
|
+
return marked;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* The GigaRAG MCP tools the gigasync/gigaindex flow actually calls (see
|
|
6
|
+
* plugins/claude-code/agents/gigarag-indexer.md and commands/gigasync.md): find or create the
|
|
7
|
+
* bucket and its threads, write and remove memos, and resolve a link's ref. Nothing here can
|
|
8
|
+
* delete a bucket or thread, link two memos, or read outside what `gigarag scan` already named.
|
|
9
|
+
*/
|
|
10
|
+
const GIGARAG_TOOLS = ['list_buckets', 'create_bucket', 'list_threads', 'create_thread', 'find_nodes', 'create_node', 'update_node', 'delete_node'];
|
|
11
|
+
/**
|
|
12
|
+
* Starts Claude Code to rewrite stale memos, with nothing but the GigaRAG tools the flow needs,
|
|
13
|
+
* reading tools and three gigarag subcommands. dontAsk denies anything else rather than waiting
|
|
14
|
+
* for a person who isn't there. Both prefixes are granted per tool, never as `mcp__*__*`, because
|
|
15
|
+
* the plugin and the bare MCP server register the same tool names under different prefixes and an
|
|
16
|
+
* unattended agent should not gain whatever else either server exposes later.
|
|
17
|
+
*/
|
|
18
|
+
export const CLAUDE_ARGS = [
|
|
19
|
+
'/gigarag:gigasync',
|
|
20
|
+
'--print',
|
|
21
|
+
'--permission-mode',
|
|
22
|
+
'dontAsk',
|
|
23
|
+
'--max-turns',
|
|
24
|
+
'300',
|
|
25
|
+
'--output-format',
|
|
26
|
+
'json',
|
|
27
|
+
'--no-session-persistence',
|
|
28
|
+
'--allowedTools',
|
|
29
|
+
...GIGARAG_TOOLS.map(t => `mcp__plugin_gigarag_gigarag__${t}`),
|
|
30
|
+
...GIGARAG_TOOLS.map(t => `mcp__gigarag__${t}`),
|
|
31
|
+
'Read',
|
|
32
|
+
'Grep',
|
|
33
|
+
'Glob',
|
|
34
|
+
'Agent',
|
|
35
|
+
'Bash(gigarag scan *)',
|
|
36
|
+
'Bash(gigarag record *)',
|
|
37
|
+
'Bash(gigarag sync *)',
|
|
38
|
+
'--disallowedTools',
|
|
39
|
+
'Write',
|
|
40
|
+
'Edit',
|
|
41
|
+
'NotebookEdit',
|
|
42
|
+
'WebFetch',
|
|
43
|
+
'WebSearch',
|
|
44
|
+
];
|
|
45
|
+
/** One cmd.exe command line, every part double-quoted. Refuses what quoting can't make literal. */
|
|
46
|
+
export function cmdLine(parts) {
|
|
47
|
+
for (const p of parts) {
|
|
48
|
+
if (/["%\r\n]/.test(p))
|
|
49
|
+
throw new Error(`An argument for claude holds a character cmd.exe would read as syntax, so the rewrite did not start: ${p}`);
|
|
50
|
+
}
|
|
51
|
+
return parts.map(p => `"${p}"`).join(' ');
|
|
52
|
+
}
|
|
53
|
+
/** GIGARAG_CLAUDE (a path, for tests and odd installs), then claude on PATH, preferring an .exe on Windows. */
|
|
54
|
+
export function findClaude(env, platform) {
|
|
55
|
+
const override = env['GIGARAG_CLAUDE'];
|
|
56
|
+
if (override)
|
|
57
|
+
return existsSync(override) ? override : undefined;
|
|
58
|
+
const names = platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude'];
|
|
59
|
+
for (const dir of (env['PATH'] ?? env['Path'] ?? '').split(delimiter).filter(Boolean)) {
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const candidate = join(dir, name);
|
|
62
|
+
if (existsSync(candidate))
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function claudeInvocation(path, platform) {
|
|
69
|
+
if (/\.(mjs|js)$/i.test(path))
|
|
70
|
+
return { command: process.execPath, args: [path, ...CLAUDE_ARGS] };
|
|
71
|
+
if (platform === 'win32' && /\.(cmd|bat)$/i.test(path)) {
|
|
72
|
+
return { command: process.env['ComSpec'] ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmdLine([path, ...CLAUDE_ARGS])}"`], verbatim: true };
|
|
73
|
+
}
|
|
74
|
+
return { command: path, args: CLAUDE_ARGS };
|
|
75
|
+
}
|
|
76
|
+
function killTree(pid) {
|
|
77
|
+
if (!pid)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
if (process.platform === 'win32')
|
|
81
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
82
|
+
else
|
|
83
|
+
process.kill(-pid, 'SIGTERM');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 'SIGKILL');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* already gone */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function runRewriter(inv, cwd, logPath, opts) {
|
|
95
|
+
mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
|
|
96
|
+
const fd = openSync(logPath, 'w', 0o600);
|
|
97
|
+
return new Promise(resolvePromise => {
|
|
98
|
+
let settled = false;
|
|
99
|
+
let timedOut = false;
|
|
100
|
+
const child = spawn(inv.command, inv.args, {
|
|
101
|
+
cwd,
|
|
102
|
+
env: opts.env,
|
|
103
|
+
stdio: ['ignore', fd, fd],
|
|
104
|
+
windowsHide: true,
|
|
105
|
+
windowsVerbatimArguments: inv.verbatim ?? false,
|
|
106
|
+
detached: process.platform !== 'win32',
|
|
107
|
+
});
|
|
108
|
+
const tick = setInterval(() => void opts.onTick().catch(() => { }), opts.tickMs);
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
timedOut = true;
|
|
111
|
+
killTree(child.pid);
|
|
112
|
+
}, opts.timeoutMs);
|
|
113
|
+
const done = (code) => {
|
|
114
|
+
if (settled)
|
|
115
|
+
return;
|
|
116
|
+
settled = true;
|
|
117
|
+
clearInterval(tick);
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
resolvePromise({ code, timedOut });
|
|
121
|
+
};
|
|
122
|
+
child.on('error', () => done(null));
|
|
123
|
+
child.on('close', code => done(code));
|
|
124
|
+
});
|
|
125
|
+
}
|
package/cli/sync/slug.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isTrusted, readBinding } from '../binding.js';
|
|
2
|
+
import { findRepoRoot, localSlug, readOriginUrl, repoInfo, slugFromRemote } from '../scan/repo.js';
|
|
3
|
+
/**
|
|
4
|
+
* Which bucket a repository syncs into. The only file in sync that reads the
|
|
5
|
+
* binding from the sharing levels spec: with one that is trusted, its bucket and workspace;
|
|
6
|
+
* without, the slug from the remote, which is how everything worked before. An untrusted
|
|
7
|
+
* binding's bucket and workspace are both ignored, not just its workspace: reporting
|
|
8
|
+
* `untrustedWorkspace` instead so callers refuse rather than sync under the unreviewed name.
|
|
9
|
+
* Kept free of the SDK so a hook can call it without loading a client.
|
|
10
|
+
*
|
|
11
|
+
* Deviation from the plan: the sharing levels binding is `readBinding(start): { binding, root } | undefined`
|
|
12
|
+
* with `binding.workspace` (a uuid), not `readBinding(root): { workspaceId, bucket } | undefined`. Adapted here.
|
|
13
|
+
*/
|
|
14
|
+
export function syncSlug(start) {
|
|
15
|
+
const { root, git } = findRepoRoot(start);
|
|
16
|
+
const found = readBinding(root);
|
|
17
|
+
if (found && !isTrusted(found.root, found.binding.workspace)) {
|
|
18
|
+
// Never `repoInfo(root).slug`: it reads the same binding and would still hand back the
|
|
19
|
+
// untrusted file's own bucket name. Recomputed the way an unbound repository resolves,
|
|
20
|
+
// ignoring the binding entirely, same as `workspaceId` below.
|
|
21
|
+
const remote = git ? readOriginUrl(root) : undefined;
|
|
22
|
+
const slug = (remote && slugFromRemote(remote)) || localSlug(root);
|
|
23
|
+
return { root, git, slug, untrustedWorkspace: found.binding.workspace };
|
|
24
|
+
}
|
|
25
|
+
// Trusted or absent: delegate entirely to repoInfo, which applies the same trust check
|
|
26
|
+
// (`trustedBinding` in binding.ts) rather than re-reading `found.binding.bucket` here, so the
|
|
27
|
+
// two functions cannot drift on what a trusted binding hands back.
|
|
28
|
+
const info = repoInfo(root);
|
|
29
|
+
return { root, git, slug: info.slug, ...(info.workspace ? { workspaceId: info.workspace } : {}) };
|
|
30
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { scanTree } from '../scan/scan.js';
|
|
4
|
+
import { hooksOptedIn } from '../commands/gitHooks.js';
|
|
5
|
+
import { currentBranch, headCommit, isAncestor, pathsAtHead } from '../git/git.js';
|
|
6
|
+
import { planSync } from './plan.js';
|
|
7
|
+
import { RepoSyncRemote, findBucketId } from './remote.js';
|
|
8
|
+
import { clearNote, readNote } from './notes.js';
|
|
9
|
+
/** How long the worker's local run lock (`sync_run:<slug>`) is honoured before it is assumed stale. */
|
|
10
|
+
export const WORKER_LOCK_MS = 45 * 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* How long an interactive `sync begin` blocks the hook-mode worker for, before the marker is
|
|
13
|
+
* assumed abandoned by a crashed session. Wider than the worker's own lock because the model
|
|
14
|
+
* runs in between `begin` and `end`, off the machine's own clock.
|
|
15
|
+
*/
|
|
16
|
+
export const INTERACTIVE_LOCK_MS = 60 * 60_000;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the background worker holds this repository's local run lock right now. The worker and
|
|
19
|
+
* an interactive run share one holder identity (this machine's), so core's lease alone can't tell
|
|
20
|
+
* them apart: it would let an interactive `begin` renew straight over a worker mid-rewrite, and
|
|
21
|
+
* either side's `end` would release a lease the other still needs. This lock is what actually
|
|
22
|
+
* separates them.
|
|
23
|
+
*/
|
|
24
|
+
export function workerRunning(state, slug) {
|
|
25
|
+
const startedAt = Number(state.getMeta(`sync_run:${slug}`) ?? 0);
|
|
26
|
+
return startedAt > 0 && Date.now() - startedAt < WORKER_LOCK_MS;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether an interactive `sync begin` is in flight for this repository on this machine. Hook mode
|
|
30
|
+
* checks this before touching anything, because `begin` and the hook's worker would otherwise
|
|
31
|
+
* claim core's lease under the same holder name and token key, so the second one in just renews
|
|
32
|
+
* over the first: both stages run against the same manifest and both rewrite the same memos, and
|
|
33
|
+
* whichever side's `finally` runs last releases a lease the other still needs. The marker carries
|
|
34
|
+
* its own TTL, since nothing renews it while the model runs between `begin` and `end`, so a
|
|
35
|
+
* crashed interactive session doesn't block sync forever.
|
|
36
|
+
*/
|
|
37
|
+
export function interactiveRunning(state, slug) {
|
|
38
|
+
const startedAt = Number(state.getMeta(`sync_interactive:${slug}`) ?? 0);
|
|
39
|
+
return startedAt > 0 && Date.now() - startedAt < INTERACTIVE_LOCK_MS;
|
|
40
|
+
}
|
|
41
|
+
/** Marks an interactive `sync begin` as in flight. Paired with `clearInteractiveRun`. */
|
|
42
|
+
export function markInteractiveRun(state, slug) {
|
|
43
|
+
state.setMeta(`sync_interactive:${slug}`, String(Date.now()));
|
|
44
|
+
}
|
|
45
|
+
/** Clears the interactive marker, letting hook mode run again. Idempotent. */
|
|
46
|
+
export function clearInteractiveRun(state, slug) {
|
|
47
|
+
state.setMeta(`sync_interactive:${slug}`, '');
|
|
48
|
+
}
|
|
49
|
+
/** `<hostname> <8 characters of a random id kept in state.db>`, in the characters core accepts for a holder. */
|
|
50
|
+
export function holderName(state) {
|
|
51
|
+
let id = state.getMeta('machine_id');
|
|
52
|
+
if (!id) {
|
|
53
|
+
id = randomUUID();
|
|
54
|
+
state.setMeta('machine_id', id);
|
|
55
|
+
}
|
|
56
|
+
const host = hostname().replace(/[^A-Za-z0-9 .:@_-]/g, '-').slice(0, 100) || 'machine';
|
|
57
|
+
return `${host} ${id.slice(0, 8)}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The holder name and the state.db key holding the lease token, kept separate per mode so the
|
|
61
|
+
* hook-mode worker never sends the interactive run's cached token to core, and so core's lease
|
|
62
|
+
* itself tells the two apart by holder name if they ever do overlap.
|
|
63
|
+
*/
|
|
64
|
+
export function holderFor(state, mode) {
|
|
65
|
+
const base = holderName(state);
|
|
66
|
+
return mode === 'hook' ? `${base} worker` : base;
|
|
67
|
+
}
|
|
68
|
+
/** @internal exported for finishSync and the worker, which must clear the same key they claimed. */
|
|
69
|
+
export function leaseMetaKey(slug, mode) {
|
|
70
|
+
return mode === 'hook' ? `lease:${slug}:worker` : `lease:${slug}`;
|
|
71
|
+
}
|
|
72
|
+
export async function pushUnpushed(state, slug, remote) {
|
|
73
|
+
const rows = state.unpushedFiles(slug);
|
|
74
|
+
if (rows.length === 0)
|
|
75
|
+
return 0;
|
|
76
|
+
await remote.record(rows, []);
|
|
77
|
+
state.markPushed(slug, rows.map(r => r.path));
|
|
78
|
+
return rows.length;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A slug is only unique within a workspace, so the cache of which bucket it resolved to must be
|
|
82
|
+
* too: rebinding this repository to another workspace under the same slug must not reuse the
|
|
83
|
+
* first workspace's id.
|
|
84
|
+
*/
|
|
85
|
+
export function bucketCacheKey(target) {
|
|
86
|
+
return `bucket_id:${target.workspaceId ?? 'default'}:${target.slug}`;
|
|
87
|
+
}
|
|
88
|
+
async function bucketIdOf(state, target, opts = {}) {
|
|
89
|
+
const key = bucketCacheKey(target);
|
|
90
|
+
if (!opts.forceRefresh) {
|
|
91
|
+
const cached = state.getMeta(key);
|
|
92
|
+
if (cached)
|
|
93
|
+
return cached;
|
|
94
|
+
}
|
|
95
|
+
const found = await findBucketId(target.rag(), target.slug);
|
|
96
|
+
state.setMeta(key, found ?? '');
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Core's message for a bucket this key cannot reach, kept in one place so the cache-drop below
|
|
101
|
+
* matches it. core.CLAUDE.md's "Errors are instructions" fix belongs to core's wording; this only
|
|
102
|
+
* has to recognise it, since the MCP SDK strips every field but the message off a thrown error.
|
|
103
|
+
*/
|
|
104
|
+
const BUCKET_UNREACHABLE = /cannot reach bucket .+ to sync it/;
|
|
105
|
+
/**
|
|
106
|
+
* A bucket id cached under the right key can still be wrong: the bucket behind it was deleted and
|
|
107
|
+
* recreated with a new id, under the same slug and workspace. Nothing invalidates that on its own,
|
|
108
|
+
* so the first call that finds out drops the cache and resolves once more before giving up, rather
|
|
109
|
+
* than failing bucket_unreachable on every sync from here on.
|
|
110
|
+
*/
|
|
111
|
+
async function resolveBucket(state, target, bucketId) {
|
|
112
|
+
try {
|
|
113
|
+
const remote = new RepoSyncRemote(target.rag(), bucketId);
|
|
114
|
+
return { bucketId, remote, remoteState: await remote.state() };
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (!(e instanceof Error) || !BUCKET_UNREACHABLE.test(e.message))
|
|
118
|
+
throw e;
|
|
119
|
+
state.setMeta(bucketCacheKey(target), '');
|
|
120
|
+
const refreshed = await bucketIdOf(state, target, { forceRefresh: true });
|
|
121
|
+
if (!refreshed || refreshed === bucketId)
|
|
122
|
+
throw e;
|
|
123
|
+
const remote = new RepoSyncRemote(target.rag(), refreshed);
|
|
124
|
+
return { bucketId: refreshed, remote, remoteState: await remote.state() };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Everything a sync does that needs no model: guards, lease, the manifest
|
|
129
|
+
* round trip, then renames, deletions and stale marks. Returns 'stop' with a
|
|
130
|
+
* message for a person (interactive) or a note (hook), or 'ok' holding the
|
|
131
|
+
* lease, which the caller must pass to finishSync or release.
|
|
132
|
+
*/
|
|
133
|
+
export async function runStage(state, target, opts) {
|
|
134
|
+
const hook = opts.mode === 'hook';
|
|
135
|
+
const slug = target.slug;
|
|
136
|
+
const head = target.git ? headCommit(target.root) : undefined;
|
|
137
|
+
const branch = target.git ? currentBranch(target.root) : undefined;
|
|
138
|
+
// A hook-mode stop that doesn't write its own note (every one below except the ones that
|
|
139
|
+
// return a `note`) must still clear a stale `deferred` note left by an earlier run: otherwise
|
|
140
|
+
// once this run stops for some other reason (not opted in, no bucket, not set up, another
|
|
141
|
+
// branch) that leftover note keeps claiming a sync is queued forever, telling every session
|
|
142
|
+
// that opens meanwhile to wait for a rewrite that will never come, and every commit still spawns
|
|
143
|
+
// a worker whose only job is to discover that and give up silently. It must clear only a
|
|
144
|
+
// `deferred` note, and never any other kind: `needs_approval`, `no_agent`, `dirty` or `failed`
|
|
145
|
+
// can be sitting there from a run on the branch GigaRAG actually syncs, and a silent stop here
|
|
146
|
+
// (a commit made on some other branch in the meantime, say) must not wipe it out from under a
|
|
147
|
+
// developer who later comes back to the branch that note was actually about.
|
|
148
|
+
const stop = (message, note) => {
|
|
149
|
+
if (hook && !note && readNote(state, slug)?.kind === 'deferred')
|
|
150
|
+
clearNote(state, slug);
|
|
151
|
+
return note ? { kind: 'stop', message, note } : { kind: 'stop', message };
|
|
152
|
+
};
|
|
153
|
+
// Gated on this machine's own record of having run `gigarag hooks install` here, never on the
|
|
154
|
+
// bucket's shared `initialised` flag: that flag is the same for every teammate, so a hook file
|
|
155
|
+
// that reaches this machine without this person running install here themselves (checked into
|
|
156
|
+
// git, or copied) must do nothing and touch neither the network nor state.db beyond this read.
|
|
157
|
+
// Checked first, before even the untrusted-binding check below: a machine that never opted in
|
|
158
|
+
// must do nothing and write no note of any kind for a hook-triggered sync, untrusted binding or
|
|
159
|
+
// not. Otherwise a machine that never ran install, on receiving a tracked hook file plus a
|
|
160
|
+
// committed .gigarag.json naming some other workspace (an attacker's, or simply one nobody here
|
|
161
|
+
// reviewed), would still write an `untrusted` note and have SessionStart nudge whoever opens
|
|
162
|
+
// this repository next to run `gigarag trust`, encouraging them to trust a binding on a machine
|
|
163
|
+
// where nothing GigaRAG does was ever asked to run in the first place. Interactive `begin` has
|
|
164
|
+
// no such gate, so its untrusted refusal below still fires first for it.
|
|
165
|
+
if (hook && !hooksOptedIn(target.root, state)) {
|
|
166
|
+
return stop('not opted in on this machine');
|
|
167
|
+
}
|
|
168
|
+
// A binding at this repository's root that has not been trusted (`gigarag use` or `gigarag
|
|
169
|
+
// trust`) must never steer a sync anywhere, checked before anything below touches core: unlike
|
|
170
|
+
// `resolveWorkspace`, which falls back to the machine default and says so, sync has no safe
|
|
171
|
+
// fallback, since the file is committed and read before anyone has reviewed it. Reaching this
|
|
172
|
+
// far with `target.untrustedWorkspace` set would otherwise still resolve and claim a bucket
|
|
173
|
+
// (under the default workspace, since `workspaceId` is unset for this target) rather than
|
|
174
|
+
// refuse outright. See binding.ts and GUIDE.md.
|
|
175
|
+
if (target.untrustedWorkspace) {
|
|
176
|
+
const ws = target.untrustedWorkspace;
|
|
177
|
+
return stop(`${target.root}'s .gigarag.json links this repository to workspace ${ws}, which has not been trusted, so sync did not run and will not until it is. Retrying will not help. If you placed or reviewed this file yourself, run: gigarag trust. To use a different workspace instead: gigarag use <workspace>.`, hook ? { kind: 'untrusted', detail: ws } : undefined);
|
|
178
|
+
}
|
|
179
|
+
// The background worker and an interactive run on this machine claim core's lease under the
|
|
180
|
+
// same holder name, so core alone would let this begin renew straight over a worker mid-rewrite
|
|
181
|
+
// and then release the lease the worker still holds. Step aside instead of racing it.
|
|
182
|
+
if (!hook && workerRunning(state, slug)) {
|
|
183
|
+
return stop(`A background sync is already running for ${slug} on this machine, so starting one here would take over its lease mid-rewrite. It finishes on its own within 45 minutes; run /gigasync again after that, or gigarag sync status to check.`);
|
|
184
|
+
}
|
|
185
|
+
// The reverse of the worker check above: an interactive /gigasync can run for as long as the
|
|
186
|
+
// model takes, off any lock the worker itself holds, so the hook's worker checks the marker
|
|
187
|
+
// /gigasync sets before it does anything else. The `deferred` note tells the worker's own loop
|
|
188
|
+
// (and, failing that, a session that opens meanwhile) that this request is queued, not dropped:
|
|
189
|
+
// the commit that triggered it still gets synced once the marker clears, rather than only the
|
|
190
|
+
// next one to arrive.
|
|
191
|
+
if (hook && interactiveRunning(state, slug)) {
|
|
192
|
+
return stop(`An interactive /gigasync is running for ${slug} on this machine, so the background sync steps aside until it finishes.`, {
|
|
193
|
+
kind: 'deferred',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const firstBucketId = await bucketIdOf(state, target);
|
|
197
|
+
if (!firstBucketId) {
|
|
198
|
+
return stop(`GigaRAG has no bucket for ${slug} yet, so there is nothing to sync. Run /gigaindex in this repository first.`);
|
|
199
|
+
}
|
|
200
|
+
const { bucketId, remote, remoteState: s } = await resolveBucket(state, target, firstBucketId);
|
|
201
|
+
if (hook && (!s.initialised || !s.branch))
|
|
202
|
+
return stop('not set up for automatic sync');
|
|
203
|
+
if (s.branch && branch !== s.branch && !opts.force) {
|
|
204
|
+
const where = branch ? `branch ${branch}` : 'a detached HEAD';
|
|
205
|
+
return stop(hook
|
|
206
|
+
? 'another branch'
|
|
207
|
+
: `This checkout is on ${where}, and GigaRAG syncs ${s.branch} for ${slug}. Switch to ${s.branch} and run /gigasync again, or run gigarag sync begin --force to sync this checkout anyway, which rewrites the memos to match it.`);
|
|
208
|
+
}
|
|
209
|
+
// An amend, a rebase (post-rewrite runs this hook) or a --force sync of another branch leaves
|
|
210
|
+
// HEAD holding last_commit's content without HEAD containing it as an ancestor. That is a
|
|
211
|
+
// rewrite, not a regression: last_commit is not an ancestor of HEAD either, so refusing here
|
|
212
|
+
// forever would strand the repository with no way for "git pull" to help, as the old note
|
|
213
|
+
// claimed. Only stop when HEAD is genuinely behind: last_commit is still an ancestor of HEAD
|
|
214
|
+
// (nothing to do, handled above) rules that out, and otherwise this is behind only when HEAD
|
|
215
|
+
// is an ancestor of last_commit, or last_commit is unknown here at all (not fetched or gc'd,
|
|
216
|
+
// where neither direction can be checked). A true divergence proceeds and lets the manifest's
|
|
217
|
+
// content hashes, not commit ancestry, decide what changed.
|
|
218
|
+
if (s.last_commit && head && !opts.force) {
|
|
219
|
+
const containsLastSynced = isAncestor(target.root, s.last_commit);
|
|
220
|
+
const genuinelyBehind = containsLastSynced === undefined || (containsLastSynced === false && isAncestor(target.root, head, s.last_commit) === true);
|
|
221
|
+
if (genuinelyBehind) {
|
|
222
|
+
const short = s.last_commit.slice(0, 7);
|
|
223
|
+
return {
|
|
224
|
+
kind: 'stop',
|
|
225
|
+
note: { kind: 'behind', commit: s.last_commit },
|
|
226
|
+
message: `This checkout doesn't contain ${short}, the commit GigaRAG last synced for ${slug}, so syncing it would rewrite memos to older code. Pull ${s.branch ?? 'the branch'} (git pull) and run /gigasync again, or run gigarag sync begin --force to sync this checkout anyway.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const leaseKey = leaseMetaKey(slug, opts.mode);
|
|
231
|
+
const claim = await remote.claim(holderFor(state, opts.mode), state.getMeta(leaseKey) || undefined);
|
|
232
|
+
if (!claim.granted)
|
|
233
|
+
return { kind: 'stop', note: { kind: 'held' }, message: claim.message };
|
|
234
|
+
state.setMeta(leaseKey, claim.token);
|
|
235
|
+
try {
|
|
236
|
+
await pushUnpushed(state, slug, remote);
|
|
237
|
+
state.replaceManifest(slug, (await remote.manifest()).map(m => ({ path: m.path, hash: m.hash, nodeIds: m.node_ids })));
|
|
238
|
+
const manifest = scanTree(target.root, { state, slug, all: true });
|
|
239
|
+
const plan = planSync({
|
|
240
|
+
known: state.manifestOf(slug),
|
|
241
|
+
current: new Map(manifest.files.map(f => [f.path, f.hash])),
|
|
242
|
+
truncated: manifest.truncated,
|
|
243
|
+
allowMassDelete: !hook && Boolean(opts.force),
|
|
244
|
+
...(hook && target.git ? { atHead: pathsAtHead(target.root) } : {}),
|
|
245
|
+
});
|
|
246
|
+
if (plan.suspicious && !opts.force) {
|
|
247
|
+
if (!hook) {
|
|
248
|
+
await remote.release(claim.token);
|
|
249
|
+
state.setMeta(leaseKey, '');
|
|
250
|
+
return {
|
|
251
|
+
kind: 'stop',
|
|
252
|
+
note: { kind: 'mass_delete', files: plan.goneCount },
|
|
253
|
+
message: `${plan.goneCount} files in ${slug} look deleted, more than half of what was indexed, so no memos were deleted. If they really are gone, run gigarag sync begin --force to delete their memos. If not, restore the checkout and run /gigasync again.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// A memo a teammate already deleted answers like a forbidden one, so a failed delete is counted, not fatal.
|
|
258
|
+
let deleteErrors = 0;
|
|
259
|
+
for (const id of plan.deleteNodeIds) {
|
|
260
|
+
try {
|
|
261
|
+
await target.rag().deleteNode(id);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
deleteErrors++;
|
|
265
|
+
}
|
|
266
|
+
state.deleteNode(id);
|
|
267
|
+
}
|
|
268
|
+
for (const r of plan.renames)
|
|
269
|
+
state.renameFile(slug, r.from, r.to);
|
|
270
|
+
for (const p of plan.deletedPaths)
|
|
271
|
+
state.forgetFile(slug, p);
|
|
272
|
+
await remote.record(plan.renames.map(r => ({ path: r.to, hash: r.hash, nodeIds: r.nodeIds })), [...plan.renames.map(r => r.from), ...plan.deletedPaths]);
|
|
273
|
+
state.markPushed(slug, plan.renames.map(r => r.to));
|
|
274
|
+
if (head && plan.stale.length > 0)
|
|
275
|
+
await remote.markStale(plan.stale, head);
|
|
276
|
+
const message = `Synced the manifest for ${slug}: ${plan.renames.length} renamed, ${plan.deleteNodeIds.length} memos deleted, ` +
|
|
277
|
+
`${plan.stale.length} memos marked stale. ${plan.rewrite.length + plan.partial.length} files are left for the indexer.`;
|
|
278
|
+
return { kind: 'ok', bucketId, token: claim.token, ...(head ? { head } : {}), plan, deleteErrors, message };
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
await remote.release(claim.token);
|
|
282
|
+
state.setMeta(leaseKey, '');
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Push what the indexer recorded, move the last synced commit when nothing is
|
|
288
|
+
* left to do, and release the lease. `init` also sets the branch, which is how
|
|
289
|
+
* /gigaindex opts a repository in.
|
|
290
|
+
*/
|
|
291
|
+
export async function finishSync(state, target, opts) {
|
|
292
|
+
const slug = target.slug;
|
|
293
|
+
const leaseKey = leaseMetaKey(slug, opts.mode ?? 'interactive');
|
|
294
|
+
const remote = new RepoSyncRemote(target.rag(), opts.bucketId);
|
|
295
|
+
await pushUnpushed(state, slug, remote);
|
|
296
|
+
const manifest = scanTree(target.root, { state, slug });
|
|
297
|
+
const remaining = manifest.summary.new + manifest.summary.changed + manifest.removed.length;
|
|
298
|
+
const head = target.git ? headCommit(target.root) : undefined;
|
|
299
|
+
// An --init that could not advance (the indexer left files) is remembered, so the next end that
|
|
300
|
+
// does advance also records the branch. Without this the repository never opts in.
|
|
301
|
+
const initPending = Boolean(opts.init) || state.getMeta(`sync_init:${slug}`) === '1';
|
|
302
|
+
const branch = initPending && target.git ? currentBranch(target.root) : undefined;
|
|
303
|
+
let advanced = false;
|
|
304
|
+
try {
|
|
305
|
+
if (remaining === 0 && head && opts.token) {
|
|
306
|
+
await remote.advance(opts.token, head, branch);
|
|
307
|
+
advanced = true;
|
|
308
|
+
clearNote(state, slug);
|
|
309
|
+
}
|
|
310
|
+
state.setMeta(`sync_init:${slug}`, initPending && !advanced ? '1' : '');
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
if (opts.token)
|
|
314
|
+
await remote.release(opts.token);
|
|
315
|
+
state.setMeta(leaseKey, '');
|
|
316
|
+
}
|
|
317
|
+
const message = advanced
|
|
318
|
+
? `GigaRAG is in sync with ${slug} at ${head.slice(0, 7)}${branch ? ` on ${branch}` : ''}.`
|
|
319
|
+
: remaining > 0
|
|
320
|
+
? `${remaining} files in ${slug} are still new, changed or gone, so the last synced commit stays where it was. Run /gigasync to finish.`
|
|
321
|
+
: `${slug} is not a git checkout, so there is no commit to record. The manifest is saved.`;
|
|
322
|
+
return { advanced, remaining, message };
|
|
323
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { GigaRag } from '../sdk.js';
|
|
2
|
+
import { syncSlug } from './slug.js';
|
|
3
|
+
export function syncTarget(start) {
|
|
4
|
+
const { root, git, slug, workspaceId, untrustedWorkspace } = syncSlug(start);
|
|
5
|
+
let client;
|
|
6
|
+
return {
|
|
7
|
+
root,
|
|
8
|
+
slug,
|
|
9
|
+
git,
|
|
10
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
11
|
+
...(untrustedWorkspace ? { untrustedWorkspace } : {}),
|
|
12
|
+
rag: () => (client ??= new GigaRag({ actor: 'agent:gigarag-sync', ...(workspaceId ? { workspaceId } : {}) })),
|
|
13
|
+
};
|
|
14
|
+
}
|