@shardflux/cli 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +214 -1
- package/README.md +215 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-login.d.ts +4 -3
- package/dist/acp-login.js +7 -5
- package/dist/acp-models.js +20 -15
- package/dist/acp.js +66 -13
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +86 -32
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.js +24 -13
- package/dist/args.d.ts +1 -1
- package/dist/args.js +94 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-forward.d.ts +2 -0
- package/dist/cli-proxy-forward.js +9 -2
- package/dist/cli-proxy-hub.d.ts +69 -25
- package/dist/cli-proxy-hub.js +231 -54
- package/dist/cli-proxy.d.ts +70 -1
- package/dist/cli-proxy.js +298 -32
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +5 -0
- package/dist/codex-cloud.js +88 -17
- package/dist/command-center-actions.js +5 -1
- package/dist/commands.js +171 -22
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +77 -13
- package/dist/conversations.d.ts +73 -1
- package/dist/conversations.js +257 -23
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +27 -1
- package/dist/errors.js +45 -5
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +108 -54
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +63 -0
- package/dist/move-worktrees.js +201 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +116 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +59 -27
- package/dist/prepared-repository.d.ts +202 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1306 -0
- package/dist/project-preparation.d.ts +48 -0
- package/dist/project-preparation.js +304 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +19 -4
- package/dist/project-sync.js +31 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.js +1 -4
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/terminal-apps.d.ts +157 -0
- package/dist/terminal-apps.js +561 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.js +11 -3
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Ctx, Handler } from './commands.js';
|
|
2
|
+
import type { PreparedTransfer } from './prepared-repository.js';
|
|
3
|
+
export interface SelectedProject {
|
|
4
|
+
api_url: string;
|
|
5
|
+
folder: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
state: 'queued' | 'syncing' | 'setup' | 'ready' | 'paused' | 'error';
|
|
8
|
+
revision?: string;
|
|
9
|
+
deps_hash?: string;
|
|
10
|
+
workspace_id?: string;
|
|
11
|
+
project_id?: string;
|
|
12
|
+
/** What the last preparation saw: HEAD, the index, and each changed file's size and times (a change detector only). */
|
|
13
|
+
observed?: {
|
|
14
|
+
head: string;
|
|
15
|
+
index: string;
|
|
16
|
+
files: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
updated_at?: string;
|
|
19
|
+
prepared_at?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
transfer?: PreparedTransfer;
|
|
22
|
+
/** The project's setup failed as it stands (no check to run, a failing install): tried again once it changes. */
|
|
23
|
+
blocked?: boolean;
|
|
24
|
+
/** Preparation VMs this revision's attempts deleted: the next open is a new one, never the deleted one again. */
|
|
25
|
+
attempt?: number;
|
|
26
|
+
}
|
|
27
|
+
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
28
|
+
export declare const PREPARE_AGAIN_BYTES: number;
|
|
29
|
+
/** Quiet this long after the last edit before preparing again. */
|
|
30
|
+
export declare const QUIET_MS = 30000;
|
|
31
|
+
export declare const selectionId: (api: string, folder: string) => string;
|
|
32
|
+
export declare function selectedProjects(ctx: Pick<Ctx, 'store' | 'apiUrl'>): SelectedProject[];
|
|
33
|
+
export declare function selectProject(ctx: Pick<Ctx, 'store' | 'apiUrl'>, path: string): SelectedProject;
|
|
34
|
+
/** HEAD, the index and each changed file's metadata: cheap (no hashing), a detector of change, never of content. */
|
|
35
|
+
export declare function observeProject(folder: string, signal?: AbortSignal): Promise<NonNullable<SelectedProject['observed']>>;
|
|
36
|
+
/** Bytes of new or changed content since `before` (sizes of files whose metadata moved), and whether Git state moved. */
|
|
37
|
+
export declare function changedSince(before: SelectedProject['observed'], now: NonNullable<SelectedProject['observed']>): {
|
|
38
|
+
bytes: number;
|
|
39
|
+
git: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** One selected project, one short-lived VM. Never touches a running chat's worktree. */
|
|
42
|
+
export declare function prepareSelectedProject(ctx: Ctx, selected: SelectedProject): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Watches the selected projects only. Prepares a new selection at once; after edits, waits until the project is quiet
|
|
45
|
+
* and prepares again only when that would make a start faster. Offline failures retry with backoff.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runProjectPreparation(ctx: Ctx): Promise<void>;
|
|
48
|
+
export declare const PROJECT_PREPARATION_HANDLERS: Record<string, Handler>;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selected projects, prepared ahead of a start (0.22.0+). The user picks them (onboarding, settings, or
|
|
3
|
+
* `shard projects sync add`); nothing else is scanned or sent. For each, a short-lived preparation VM puts the
|
|
4
|
+
* project's current revision into the project cache, and when its dependencies changed, installs them and saves the
|
|
5
|
+
* project template, so a new chat sends only what changed since and boots with its dependencies installed.
|
|
6
|
+
*
|
|
7
|
+
* Preparation follows edits without chasing them: once a project has been quiet for a while it is prepared again
|
|
8
|
+
* when the change would slow a start (new content past a threshold, or a dependency change), never for a few lines
|
|
9
|
+
* a start sends in milliseconds anyway. No VM stays up: each preparation deletes its own.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { watch } from 'node:fs';
|
|
13
|
+
import { lstat } from 'node:fs/promises';
|
|
14
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
17
|
+
import { out } from "./commands.js";
|
|
18
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
19
|
+
import { localConversations } from "./conversations.js";
|
|
20
|
+
import { projectIdentity } from "./project-identity.js";
|
|
21
|
+
import { localChanges, preflightProjectSeed } from "./agent-repository.js";
|
|
22
|
+
import { projectGit } from "./project-sync-manifest.js";
|
|
23
|
+
import { inspectWorktree, pauseReason, twinSeed, worktreeId } from "./worktree-twin.js";
|
|
24
|
+
import { applyPreparedSnapshot, capturePreparedSnapshot, preparedCachePath, preparedGitConfig, pruneLocalPrepared, prunePreparedCache, releaseLocalSnapshot, transferPreparedSnapshot } from "./prepared-repository.js";
|
|
25
|
+
import { withTargetLock } from "./target-lock.js";
|
|
26
|
+
import { UsageError } from "./errors.js";
|
|
27
|
+
import { dependencyHash } from "./project-template-setup.js";
|
|
28
|
+
/** A setup that ran and failed: the same files fail again, so it waits for a change instead of a timer. */
|
|
29
|
+
class SetupFailed extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
32
|
+
export const PREPARE_AGAIN_BYTES = 8 << 20;
|
|
33
|
+
/** Quiet this long after the last edit before preparing again. */
|
|
34
|
+
export const QUIET_MS = 30_000;
|
|
35
|
+
export const selectionId = (api, folder) => createHash('sha256').update(api).update('\0').update(folder).digest('hex');
|
|
36
|
+
export function selectedProjects(ctx) {
|
|
37
|
+
return Object.values(new AgentStateStore(ctx.store.dir).read().prepared_projects ?? {}).filter(p => p.api_url === ctx.apiUrl);
|
|
38
|
+
}
|
|
39
|
+
function patch(ctx, folder, update) {
|
|
40
|
+
new AgentStateStore(ctx.store.dir).update(doc => {
|
|
41
|
+
const selections = (doc.prepared_projects ?? {}), id = selectionId(ctx.apiUrl, folder), current = selections[id];
|
|
42
|
+
if (current)
|
|
43
|
+
selections[id] = { ...current, ...update, ...(!current.enabled && update.enabled === undefined ? { state: 'paused' } : {}), updated_at: new Date().toISOString() };
|
|
44
|
+
doc.prepared_projects = selections;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function selectProject(ctx, path) {
|
|
48
|
+
const { folder } = projectIdentity(path), id = selectionId(ctx.apiUrl, folder);
|
|
49
|
+
new AgentStateStore(ctx.store.dir).update(doc => {
|
|
50
|
+
const selections = (doc.prepared_projects ?? {});
|
|
51
|
+
selections[id] = { ...selections[id], api_url: ctx.apiUrl, folder, enabled: true, state: 'queued', error: undefined, blocked: undefined };
|
|
52
|
+
doc.prepared_projects = selections;
|
|
53
|
+
});
|
|
54
|
+
return selectedProjects(ctx).find(p => p.folder === folder);
|
|
55
|
+
}
|
|
56
|
+
/** HEAD, the index and each changed file's metadata: cheap (no hashing), a detector of change, never of content. */
|
|
57
|
+
export async function observeProject(folder, signal) {
|
|
58
|
+
const head = (await projectGit(folder, ['rev-parse', 'HEAD'], signal)).trim();
|
|
59
|
+
const index = createHash('sha256').update(await projectGit(folder, ['ls-files', '--stage', '-z'], signal)).digest('hex');
|
|
60
|
+
const files = {};
|
|
61
|
+
for (const path of (await localChanges(folder, signal)).copy) {
|
|
62
|
+
try {
|
|
63
|
+
const st = await lstat(join(folder, path));
|
|
64
|
+
files[path] = `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}:${st.mode}`;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== 'ENOENT')
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { head, index, files };
|
|
72
|
+
}
|
|
73
|
+
/** Bytes of new or changed content since `before` (sizes of files whose metadata moved), and whether Git state moved. */
|
|
74
|
+
export function changedSince(before, now) {
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
for (const [path, meta] of Object.entries(now.files))
|
|
77
|
+
if (before?.files[path] !== meta)
|
|
78
|
+
bytes += Number(meta.split(':')[0]) || 0;
|
|
79
|
+
return { bytes, git: !before || before.head !== now.head || before.index !== now.index || Object.keys(before.files).some(path => !(path in now.files)) };
|
|
80
|
+
}
|
|
81
|
+
/** One selected project, one short-lived VM. Never touches a running chat's worktree. */
|
|
82
|
+
export async function prepareSelectedProject(ctx, selected) {
|
|
83
|
+
await withTargetLock(join(ctx.store.dir, 'project-prepare-locks', selectionId(ctx.apiUrl, selected.folder)), async () => {
|
|
84
|
+
const current = selectedProjects(ctx).find(p => p.folder === selected.folder);
|
|
85
|
+
if (!current?.enabled)
|
|
86
|
+
return;
|
|
87
|
+
const cancel = new AbortController();
|
|
88
|
+
const watcher = watch(ctx.store.dir, (_event, name) => {
|
|
89
|
+
if (name?.toString() === 'agents.json' && !selectedProjects(ctx).find(p => p.folder === current.folder)?.enabled)
|
|
90
|
+
cancel.abort(new Error('Project preparation paused or removed.'));
|
|
91
|
+
});
|
|
92
|
+
ctx = { ...ctx, signal: AbortSignal.any([ctx.signal, cancel.signal]) };
|
|
93
|
+
let owned, setupStarted = false, revision, observed;
|
|
94
|
+
const identity = projectIdentity(current.folder), remote = identity.remote ?? `file://${identity.folder}`;
|
|
95
|
+
try {
|
|
96
|
+
const info = await inspectWorktree(identity.folder);
|
|
97
|
+
const paused = pauseReason(info);
|
|
98
|
+
if (paused)
|
|
99
|
+
throw new UsageError(paused);
|
|
100
|
+
await preflightProjectSeed(identity.folder, ctx.signal);
|
|
101
|
+
if (!info.head)
|
|
102
|
+
throw new UsageError('Commit this project once before preparing it.');
|
|
103
|
+
observed = await observeProject(identity.folder, ctx.signal);
|
|
104
|
+
const seed = await twinSeed(ctx.store.dir, identity.folder, info.head, ctx.store.profile(ctx.apiUrl).project_id);
|
|
105
|
+
const snapshot = await capturePreparedSnapshot(ctx.store.dir, identity.folder, seed, identity.remote, ctx.signal);
|
|
106
|
+
revision = snapshot.revision;
|
|
107
|
+
const deps = snapshot.manifest.deps_hash;
|
|
108
|
+
patch(ctx, identity.folder, { state: 'syncing', error: undefined });
|
|
109
|
+
const opened = await ctx.cloud().projectTemplates.prepare({ repoRemote: remote, depsHash: deps, worktreeId: worktreeId(identity.folder),
|
|
110
|
+
projectId: ctx.store.profile(ctx.apiUrl).project_id }, { signal: ctx.signal, idempotencyKey: `prepare-${snapshot.revision}-${current.attempt ?? 0}` });
|
|
111
|
+
owned = opened.workspace_id;
|
|
112
|
+
patch(ctx, identity.folder, { workspace_id: owned });
|
|
113
|
+
const ws = await ctx.cloud().workspaces.get(owned);
|
|
114
|
+
if (!ws.ready)
|
|
115
|
+
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
116
|
+
if (ws.labels.shard_preparation !== 'true')
|
|
117
|
+
throw new Error('The API did not bind a preparation workspace.');
|
|
118
|
+
const cell = ws.cell(), cache = preparedCachePath(remote);
|
|
119
|
+
const pin = `prepare-${selectionId(ctx.apiUrl, identity.folder).slice(0, 32)}`;
|
|
120
|
+
const transfer = await transferPreparedSnapshot(cell, identity.folder, cache, snapshot, { signal: ctx.signal, pin });
|
|
121
|
+
if (opened.setup_required) {
|
|
122
|
+
// Dependencies changed: install them in a checkout of this revision and save the project template from it. The
|
|
123
|
+
// checkout sits where a conversation's worktree does, so a chat from the template starts with them in place.
|
|
124
|
+
const root = ws.labels.repo_path;
|
|
125
|
+
if (!root?.startsWith('/home/user/'))
|
|
126
|
+
throw new Error('Preparation workspace omitted its repository path.');
|
|
127
|
+
patch(ctx, identity.folder, { state: 'setup' });
|
|
128
|
+
await applyPreparedSnapshot(cell, cache, snapshot.revision, root, `shard/prepare-${worktreeId(identity.folder)}`, { signal: ctx.signal, preparation: true,
|
|
129
|
+
replacePreparation: true, dissociate: 'now', git: await preparedGitConfig(identity.folder, snapshot.manifest, ctx.signal), releasePin: pin });
|
|
130
|
+
const setup = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', deps], { cwd: root, timeoutMs: 30_000, maxOutputBytes: 8192, signal: ctx.signal });
|
|
131
|
+
if (setup.exitCode !== 0)
|
|
132
|
+
throw new Error(`Project setup could not start: ${setup.stderr.trim()}`);
|
|
133
|
+
setupStarted = true;
|
|
134
|
+
// Ready means the dependency-keyed template is published, not just that an install command exited.
|
|
135
|
+
for (const deadline = Date.now() + 30 * 60_000;;) {
|
|
136
|
+
ctx.signal.throwIfAborted();
|
|
137
|
+
try {
|
|
138
|
+
if ((await ctx.cloud().projectTemplates.get(remote, { signal: ctx.signal })).deps_hash === deps)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
const status = await cell.exec.run(['sf-agent', 'template', '--json'], { cwd: root, timeoutMs: 30_000, maxOutputBytes: 32768, signal: ctx.signal });
|
|
146
|
+
if (status.exitCode !== 0)
|
|
147
|
+
throw new Error(`Project setup status failed: ${status.stderr.trim()}`);
|
|
148
|
+
const state = JSON.parse(status.stdout);
|
|
149
|
+
if (state.state === 'failed')
|
|
150
|
+
throw new SetupFailed(state.tasks?.find(t => t.state === 'failed')?.error?.message ?? 'Project setup failed.');
|
|
151
|
+
if (Date.now() > deadline)
|
|
152
|
+
throw new Error('The project template is still being saved; the next preparation picks it up.');
|
|
153
|
+
await delay(5000, undefined, { signal: ctx.signal });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await prunePreparedCache(cell, cache, ctx.signal).catch(() => undefined);
|
|
157
|
+
patch(ctx, identity.folder, { state: 'ready', revision: snapshot.revision, deps_hash: deps, observed, transfer, project_id: ws.data.project_id,
|
|
158
|
+
prepared_at: new Date().toISOString(), error: undefined, blocked: undefined, attempt: undefined });
|
|
159
|
+
await ctx.cloud().workspaces.delete(owned);
|
|
160
|
+
owned = undefined;
|
|
161
|
+
patch(ctx, identity.folder, { workspace_id: undefined });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// An interrupted setup keeps its VM: the same keyed open resumes its install. A failed one, and anything else,
|
|
165
|
+
// leaves nothing behind.
|
|
166
|
+
const failed = error instanceof SetupFailed;
|
|
167
|
+
if (owned && (!setupStarted || failed)) {
|
|
168
|
+
await ctx.cloud().workspaces.delete(owned).catch(() => undefined);
|
|
169
|
+
patch(ctx, current.folder, { workspace_id: undefined, attempt: (current.attempt ?? 0) + 1 });
|
|
170
|
+
}
|
|
171
|
+
patch(ctx, current.folder, { state: ctx.signal.aborted ? 'queued' : 'error', error: (error instanceof Error ? error.message : String(error)).slice(0, 2000),
|
|
172
|
+
...(failed ? { blocked: true, observed } : {}) });
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
watcher.close();
|
|
177
|
+
if (revision)
|
|
178
|
+
await releaseLocalSnapshot(identity.folder, revision);
|
|
179
|
+
await pruneLocalPrepared(ctx.store.dir).catch(() => undefined);
|
|
180
|
+
}
|
|
181
|
+
}, ctx.signal);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Watches the selected projects only. Prepares a new selection at once; after edits, waits until the project is quiet
|
|
185
|
+
* and prepares again only when that would make a start faster. Offline failures retry with backoff.
|
|
186
|
+
*/
|
|
187
|
+
export async function runProjectPreparation(ctx) {
|
|
188
|
+
const watchers = new Map(), changed = new Map(), retry = new Map();
|
|
189
|
+
try {
|
|
190
|
+
while (!ctx.signal.aborted) {
|
|
191
|
+
const projects = selectedProjects(ctx), active = new Set(projects.filter(p => p.enabled).map(p => p.folder));
|
|
192
|
+
for (const [folder, watcher] of watchers)
|
|
193
|
+
if (!active.has(folder)) {
|
|
194
|
+
watcher.close();
|
|
195
|
+
watchers.delete(folder);
|
|
196
|
+
changed.delete(folder);
|
|
197
|
+
}
|
|
198
|
+
for (const p of projects) {
|
|
199
|
+
if (!p.enabled)
|
|
200
|
+
continue;
|
|
201
|
+
if (!watchers.has(p.folder)) {
|
|
202
|
+
try {
|
|
203
|
+
const watcher = watch(p.folder, { recursive: true }, (_event, path) => {
|
|
204
|
+
const name = path?.toString() ?? '';
|
|
205
|
+
if (name.startsWith('.git/') && !/^\.git\/(?:index|HEAD|refs\/heads\/|packed-refs)$/.test(name))
|
|
206
|
+
return;
|
|
207
|
+
changed.set(p.folder, Date.now());
|
|
208
|
+
});
|
|
209
|
+
watcher.on('error', () => { watcher.close(); watchers.delete(p.folder); });
|
|
210
|
+
watchers.set(p.folder, watcher);
|
|
211
|
+
}
|
|
212
|
+
catch { /* the periodic pass below still notices changes */ }
|
|
213
|
+
}
|
|
214
|
+
const backoff = retry.get(p.folder);
|
|
215
|
+
if (backoff && backoff.at > Date.now())
|
|
216
|
+
continue;
|
|
217
|
+
const touched = changed.get(p.folder);
|
|
218
|
+
if (touched !== undefined && Date.now() - touched < QUIET_MS)
|
|
219
|
+
continue;
|
|
220
|
+
// A setup that failed as the project stands waits for an edit (its files, setup.yaml, a commit), not a timer.
|
|
221
|
+
if (p.state === 'error' && p.blocked) {
|
|
222
|
+
const delta = changedSince(p.observed, await observeProject(p.folder, ctx.signal).catch(() => p.observed));
|
|
223
|
+
if (!delta.bytes && !delta.git) {
|
|
224
|
+
changed.delete(p.folder);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// A ready project without edits is rechecked every ten minutes (a missed watcher event, a branch switch).
|
|
229
|
+
if (p.state === 'ready' && touched === undefined && Date.now() - Date.parse(p.updated_at ?? '1970-01-01') < 600_000)
|
|
230
|
+
continue;
|
|
231
|
+
try {
|
|
232
|
+
if (p.state === 'ready') {
|
|
233
|
+
const now = await observeProject(p.folder, ctx.signal), delta = changedSince(p.observed, now);
|
|
234
|
+
const settled = delta.bytes < PREPARE_AGAIN_BYTES && !delta.git;
|
|
235
|
+
if (settled) {
|
|
236
|
+
patch(ctx, p.folder, {});
|
|
237
|
+
changed.delete(p.folder);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Small edits travel with the start; only a dependency change or bulk content is worth a VM now.
|
|
241
|
+
if (delta.bytes < PREPARE_AGAIN_BYTES && dependencyHash(p.folder) === p.deps_hash) {
|
|
242
|
+
patch(ctx, p.folder, {});
|
|
243
|
+
changed.delete(p.folder);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const before = changed.get(p.folder);
|
|
248
|
+
await prepareSelectedProject(ctx, p);
|
|
249
|
+
retry.delete(p.folder);
|
|
250
|
+
if (changed.get(p.folder) === before)
|
|
251
|
+
changed.delete(p.folder);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (ctx.signal.aborted)
|
|
255
|
+
break;
|
|
256
|
+
if (error instanceof SetupFailed) {
|
|
257
|
+
retry.delete(p.folder);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const wait = Math.min((backoff?.wait ?? 30_000) * 2, 30 * 60_000);
|
|
261
|
+
retry.set(p.folder, { at: Date.now() + wait, wait });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
await delay(2000, undefined, { signal: ctx.signal }).catch(() => undefined);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
for (const watcher of watchers.values())
|
|
269
|
+
watcher.close();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export const PROJECT_PREPARATION_HANDLERS = {
|
|
273
|
+
'projects sync list'(ctx) {
|
|
274
|
+
const selected = selectedProjects(ctx), known = [...new Set(Object.values(localConversations(ctx)).map(r => r.folder))];
|
|
275
|
+
out(ctx, selected.map(p => `${p.folder}\t${p.state}${p.error ? `\t${p.error}` : ''}`).join('\n') + (selected.length ? '\n' : ''), { data: selected, known });
|
|
276
|
+
return Promise.resolve(0);
|
|
277
|
+
},
|
|
278
|
+
async 'projects sync add'(ctx, values, [path]) {
|
|
279
|
+
if (!path)
|
|
280
|
+
throw new UsageError('Choose a local project path.');
|
|
281
|
+
const p = selectProject(ctx, path);
|
|
282
|
+
if (values.wait === true)
|
|
283
|
+
await prepareSelectedProject(ctx, p);
|
|
284
|
+
out(ctx, `Selected ${p.folder}; it is prepared in the background.\n`, { project: selectedProjects(ctx).find(s => s.folder === p.folder) });
|
|
285
|
+
return 0;
|
|
286
|
+
},
|
|
287
|
+
'projects sync pause'(ctx, _values, [path]) {
|
|
288
|
+
if (!path)
|
|
289
|
+
throw new UsageError('Choose a selected project path.');
|
|
290
|
+
const folder = selectedProjects(ctx).find(p => p.folder === resolve(path))?.folder ?? projectIdentity(path).folder;
|
|
291
|
+
patch(ctx, folder, { enabled: false, state: 'paused' });
|
|
292
|
+
out(ctx, `Paused ${folder}.\n`, { project: selectedProjects(ctx).find(p => p.folder === folder) });
|
|
293
|
+
return Promise.resolve(0);
|
|
294
|
+
},
|
|
295
|
+
'projects sync remove'(ctx, _values, [path]) {
|
|
296
|
+
if (!path)
|
|
297
|
+
throw new UsageError('Choose a selected project path.');
|
|
298
|
+
const folder = selectedProjects(ctx).find(p => p.folder === resolve(path))?.folder ?? projectIdentity(path).folder;
|
|
299
|
+
new AgentStateStore(ctx.store.dir).update(doc => { const selections = (doc.prepared_projects ?? {}); delete selections[selectionId(ctx.apiUrl, folder)]; doc.prepared_projects = selections; });
|
|
300
|
+
out(ctx, `Removed ${folder} from background preparation.\n`, { removed: true, folder });
|
|
301
|
+
return Promise.resolve(0);
|
|
302
|
+
},
|
|
303
|
+
async 'projects sync run'(ctx) { await runProjectPreparation(ctx); return 0; },
|
|
304
|
+
};
|
|
@@ -8,6 +8,10 @@ export interface SyncChange {
|
|
|
8
8
|
}
|
|
9
9
|
export declare function validSyncPath(path: string): boolean;
|
|
10
10
|
export declare function excludedSyncPath(path: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* A symbolic link's identity is its target: its own mode is not content (Linux reports 0777, macOS the mode it was
|
|
13
|
+
* made with, 0755), so a link that is the same on both sides is never a change or a conflict (0.21.1+).
|
|
14
|
+
*/
|
|
11
15
|
export declare function contentIdentity(entry?: FileManifestEntry): string;
|
|
12
16
|
/** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
|
|
13
17
|
export declare function diffManifests(local: Manifest, remote: Manifest, common: Common, observedAt?: number, blocked?: Set<string>): SyncChange[];
|
|
@@ -8,8 +8,12 @@ export function validSyncPath(path) {
|
|
|
8
8
|
return path.length > 0 && Buffer.byteLength(path) <= 4096 && !path.includes('\0') && !path.includes('\\') && !path.startsWith('/') && !path.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git' || part === '.shard');
|
|
9
9
|
}
|
|
10
10
|
export function excludedSyncPath(path) { return path.split('/').some((part) => part === '.git' || part === '.shard' || part.includes('.shard-tmp-')); }
|
|
11
|
+
/**
|
|
12
|
+
* A symbolic link's identity is its target: its own mode is not content (Linux reports 0777, macOS the mode it was
|
|
13
|
+
* made with, 0755), so a link that is the same on both sides is never a change or a conflict (0.21.1+).
|
|
14
|
+
*/
|
|
11
15
|
export function contentIdentity(entry) {
|
|
12
|
-
return entry ? `${entry.sha256}:${entry.mode}:${entry.symlink ? 'link' : 'file'}` : 'absent';
|
|
16
|
+
return entry ? `${entry.sha256}:${entry.symlink ? 0o777 : entry.mode}:${entry.symlink ? 'link' : 'file'}` : 'absent';
|
|
13
17
|
}
|
|
14
18
|
/** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
|
|
15
19
|
export function diffManifests(local, remote, common, observedAt = Date.now(), blocked = new Set()) {
|
|
@@ -45,7 +45,7 @@ export async function readLocalSyncFile(folder, entry) {
|
|
|
45
45
|
await file.close();
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
if ((st.mode & 0o777) !== entry.mode)
|
|
48
|
+
if (!entry.symlink && (st.mode & 0o777) !== entry.mode)
|
|
49
49
|
throw new Error(`${entry.path} mode changed during sync`);
|
|
50
50
|
verifySyncBytes(entry, data);
|
|
51
51
|
return { entry, data };
|
package/dist/project-sync.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Workspace } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
3
|
import type { projectIdentity } from './project-identity.js';
|
|
4
|
-
import type { Common } from './project-sync-manifest.js';
|
|
4
|
+
import type { Common, LocalHashCache } from './project-sync-manifest.js';
|
|
5
5
|
import type { SyncCell } from './project-sync-transfer.js';
|
|
6
6
|
export interface ProjectSyncConflict {
|
|
7
7
|
path: string;
|
|
@@ -19,6 +19,8 @@ export interface ProjectSync {
|
|
|
19
19
|
root: string;
|
|
20
20
|
conflicts(): readonly ProjectSyncConflict[];
|
|
21
21
|
reconcile(): Promise<void>;
|
|
22
|
+
/** The last reconcile found both sides equal, so its baseline is current (0.21.1+). */
|
|
23
|
+
settled(): boolean;
|
|
22
24
|
stop(): Promise<void>;
|
|
23
25
|
}
|
|
24
26
|
export { projectWorkspacePath } from './agent-repository.js';
|
|
@@ -34,9 +36,22 @@ export declare function projectReconciler(options: {
|
|
|
34
36
|
save: (state: ProjectSyncState) => void;
|
|
35
37
|
report: (message: string) => void;
|
|
36
38
|
signal?: AbortSignal;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
vmBaseline?: boolean;
|
|
40
|
+
cache?: LocalHashCache;
|
|
41
|
+
}): (() => Promise<void>) & {
|
|
42
|
+
settled(): boolean;
|
|
43
|
+
};
|
|
44
|
+
export type ProjectSyncBoundary = Pick<ProjectSync, 'root' | 'conflicts' | 'reconcile' | 'settled'>;
|
|
45
|
+
export interface ProjectSyncOptions {
|
|
39
46
|
root?: string;
|
|
40
47
|
agent?: string;
|
|
41
48
|
seed?: boolean;
|
|
42
|
-
|
|
49
|
+
stateKey?: string;
|
|
50
|
+
/** The VM has run nothing since this checkout seeded it: a boundary with no saved state starts from its files (0.21.1+). */
|
|
51
|
+
vmBaseline?: boolean;
|
|
52
|
+
/** This computer's file hashes, filled ahead of the first reconcile (localManifest with the same map, 0.21.1+). */
|
|
53
|
+
cache?: LocalHashCache;
|
|
54
|
+
}
|
|
55
|
+
/** Editors reconcile between turns without a watcher keeping an idle VM active. */
|
|
56
|
+
export declare function projectSyncBoundary(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: ProjectSyncOptions): Promise<ProjectSyncBoundary>;
|
|
57
|
+
export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: ProjectSyncOptions): Promise<ProjectSync>;
|
package/dist/project-sync.js
CHANGED
|
@@ -15,9 +15,10 @@ export function projectSyncConflicts(dir, key) {
|
|
|
15
15
|
/** Core reconciler used by the attach module and by the real Linux harness. */
|
|
16
16
|
export function projectReconciler(options) {
|
|
17
17
|
const { folder, root, cell, agent, state, save, report, signal } = options;
|
|
18
|
+
let settled = false, vmBaseline = options.vmBaseline === true;
|
|
18
19
|
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
|
|
19
20
|
throw new Error('invalid conflict agent name');
|
|
20
|
-
const cache = new Map();
|
|
21
|
+
const cache = options.cache ?? new Map();
|
|
21
22
|
let serial = Promise.resolve();
|
|
22
23
|
const run = async () => {
|
|
23
24
|
signal?.throwIfAborted();
|
|
@@ -35,7 +36,18 @@ export function projectReconciler(options) {
|
|
|
35
36
|
report(`Project sync skipped ${side} ${skip.path}: ${skip.reason}`);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
// Until a pass completes, a VM that has run nothing since this checkout seeded it holds the baseline of every file
|
|
40
|
+
// both sides have: an edit made here before the first pass is this computer's change, not a conflict (0.21.1+).
|
|
41
|
+
let common = state.common;
|
|
42
|
+
if (vmBaseline) {
|
|
43
|
+
common = Object.assign(Object.create(null), state.common);
|
|
44
|
+
for (const path of Object.keys(remote))
|
|
45
|
+
if (local[path] && !Object.hasOwn(common, path))
|
|
46
|
+
common[path] = contentIdentity(remote[path]);
|
|
47
|
+
}
|
|
48
|
+
const changes = diffManifests(local, remote, common, Date.now(), blocked);
|
|
49
|
+
// A pass that moved files advances the baseline only for paths it already had: the next pass settles the rest.
|
|
50
|
+
settled = changes.length === 0;
|
|
39
51
|
// Laptop-side deletions run before writes and VM-side deletions before uploads, so a directory can become a file.
|
|
40
52
|
const pushes = [], removals = [];
|
|
41
53
|
const expected = Object.create(null);
|
|
@@ -105,10 +117,12 @@ export function projectReconciler(options) {
|
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
save(state);
|
|
120
|
+
vmBaseline = false;
|
|
108
121
|
};
|
|
109
|
-
return () => { const next = serial.then(run); serial = next.catch(() => undefined); return next; };
|
|
122
|
+
return Object.assign(() => { settled = false; const next = serial.then(run); serial = next.catch(() => undefined); return next; }, { settled: () => settled });
|
|
110
123
|
}
|
|
111
|
-
|
|
124
|
+
/** Editors reconcile between turns without a watcher keeping an idle VM active. */
|
|
125
|
+
export async function projectSyncBoundary(ctx, ws, project, options = {}) {
|
|
112
126
|
if (!options.root && ws.labels.agent && ws.labels.agent !== 'main')
|
|
113
127
|
throw new Error('two-way project sync requires a conversation worktree');
|
|
114
128
|
const root = options.root ?? projectWorkspacePath(project);
|
|
@@ -117,9 +131,10 @@ export async function startProjectSync(ctx, ws, project, options = {}) {
|
|
|
117
131
|
if (options.seed !== false)
|
|
118
132
|
await seedAgentRepository(ctx, ws, project.folder, { root });
|
|
119
133
|
const store = new AgentStateStore(ctx.store.dir);
|
|
120
|
-
const key = `${ws.id}:${project.folder}`;
|
|
134
|
+
const key = options.stateKey ?? `${ws.id}:${project.folder}`;
|
|
121
135
|
const states = store.read().project_sync;
|
|
122
|
-
const
|
|
136
|
+
const stored = states?.[key];
|
|
137
|
+
const state = stored ?? { common: Object.create(null), conflicts: [] };
|
|
123
138
|
const save = (value) => {
|
|
124
139
|
store.update(doc => {
|
|
125
140
|
const current = (doc.project_sync ?? {});
|
|
@@ -128,7 +143,15 @@ export async function startProjectSync(ctx, ws, project, options = {}) {
|
|
|
128
143
|
});
|
|
129
144
|
};
|
|
130
145
|
const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
|
|
131
|
-
const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal
|
|
146
|
+
const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal,
|
|
147
|
+
vmBaseline: options.vmBaseline === true && !stored, ...(options.cache ? { cache: options.cache } : {}) });
|
|
148
|
+
return { root, conflicts: () => state.conflicts.slice(), reconcile, settled: () => reconcile.settled() };
|
|
149
|
+
}
|
|
150
|
+
export async function startProjectSync(ctx, ws, project, options = {}) {
|
|
151
|
+
const boundary = await projectSyncBoundary(ctx, ws, project, options);
|
|
152
|
+
const { root, reconcile } = boundary;
|
|
153
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
154
|
+
const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
|
|
132
155
|
const abort = new AbortController();
|
|
133
156
|
const signal = ctx.signal ? AbortSignal.any([ctx.signal, abort.signal]) : abort.signal;
|
|
134
157
|
let stopped = false, dirty = false;
|
|
@@ -219,7 +242,7 @@ export async function startProjectSync(ctx, ws, project, options = {}) {
|
|
|
219
242
|
throw error;
|
|
220
243
|
}
|
|
221
244
|
return {
|
|
222
|
-
|
|
245
|
+
...boundary,
|
|
223
246
|
async stop() {
|
|
224
247
|
if (stopped)
|
|
225
248
|
return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Workspace } from '@shardflux/sdk';
|
|
1
|
+
import type { CellClient, Workspace } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx, Handler } from './commands.js';
|
|
3
3
|
import { projectIdentity } from './project-identity.js';
|
|
4
4
|
export declare function dependencyHash(folder: string): string;
|
|
@@ -14,4 +14,36 @@ export declare function prepareProjectTemplate(ctx: Ctx, project: ReturnType<typ
|
|
|
14
14
|
shard_repo_remote: string;
|
|
15
15
|
};
|
|
16
16
|
}>;
|
|
17
|
+
/** A new conversation's project setup: the hash its worktree is set up with, once its VM carries the setup labels. */
|
|
18
|
+
export interface ConversationSetup {
|
|
19
|
+
hash: string;
|
|
20
|
+
labelled: Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether a new conversation sets its repository up (0.21.1+): when the project has no template for the repository's
|
|
24
|
+
* current dependency hash. Read as the conversation starts, so the hash is of the files it is seeded with; the API
|
|
25
|
+
* read runs while its VM starts. Never fails the start: an unreadable answer is reported and the conversation runs
|
|
26
|
+
* without setup.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readConversationSetup(ctx: Ctx, project: ReturnType<typeof projectIdentity>): Promise<{
|
|
29
|
+
depsHash: string;
|
|
30
|
+
labels: Record<string, string>;
|
|
31
|
+
} | undefined>;
|
|
32
|
+
/**
|
|
33
|
+
* The conversation's VM gets the labels a project workspace has, with its worktree as the repository, so the setup
|
|
34
|
+
* that passes there saves the project template the next conversation starts from. Runs beside the agent's start; the
|
|
35
|
+
* setup itself is queued once it is done. A failed label write is reported: setup still prepares this conversation.
|
|
36
|
+
*/
|
|
37
|
+
export declare function labelConversationSetup(ctx: Ctx, ws: Workspace, worktree: string, setup: {
|
|
38
|
+
depsHash: string;
|
|
39
|
+
labels: Record<string, string>;
|
|
40
|
+
} | undefined): ConversationSetup | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The hash a new conversation's worktree is set up with, recorded in its VM before the seed. Its guest starts the agent
|
|
43
|
+
* as soon as the repository appears, and with this record starts it with the setup's context and toolchains on PATH
|
|
44
|
+
* (services/guest-agent conversation_runtime.go setupIntent; a guest without it ignores the record).
|
|
45
|
+
*/
|
|
46
|
+
export declare function recordSetupIntent(cell: Pick<CellClient, 'exec'>, c: {
|
|
47
|
+
id: string;
|
|
48
|
+
}, depsHash: string, signal?: AbortSignal): Promise<void>;
|
|
17
49
|
export declare const PROJECT_TEMPLATE_HANDLERS: Record<string, Handler>;
|
|
@@ -52,7 +52,9 @@ export function projectTemplateSlug(slug, identity) {
|
|
|
52
52
|
// Distinct dotted/underscored project keys must not collapse onto the same builder slug.
|
|
53
53
|
return `${slug.replace(/[._]/g, '-').slice(0, 89)}-${createHash('sha256').update(identity).digest('hex').slice(0, 10)}`;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/** The repository's dependency hash, its project template, whether setup runs (no template for this hash yet) and the
|
|
56
|
+
* labels a passed setup saves the project template from. The hash is read first, before any await. */
|
|
57
|
+
async function projectSetup(ctx, project) {
|
|
56
58
|
const depsHash = dependencyHash(project.folder);
|
|
57
59
|
const remote = project.remote ?? normalizeRemote(`file://${project.folder}`);
|
|
58
60
|
let binding;
|
|
@@ -68,13 +70,68 @@ export async function prepareProjectTemplate(ctx, project, existing) {
|
|
|
68
70
|
const setup = !binding || binding.deps_hash !== depsHash;
|
|
69
71
|
// The builder only accepts lowercase letters, digits and hyphens. Keep L4's workspace identity unchanged.
|
|
70
72
|
const templateSlug = binding?.template_slug ?? projectTemplateSlug(project.slug, remote);
|
|
73
|
+
return { depsHash, binding, setup,
|
|
74
|
+
labels: { shard_deps_hash: depsHash, shard_setup: String(setup), shard_setup_mode: 'assisted-v2', shard_template_slug: templateSlug, shard_repo_remote: remote } };
|
|
75
|
+
}
|
|
76
|
+
export async function prepareProjectTemplate(ctx, project, existing) {
|
|
77
|
+
const { depsHash, binding, setup, labels } = await projectSetup(ctx, project);
|
|
71
78
|
const machine = await savedMachine(ctx);
|
|
72
79
|
return {
|
|
73
80
|
template: existing?.template.slug ?? (binding ? `${binding.template_slug}@${binding.version}` : machine?.template ? `${machine.template.slug}@${machine.template.version}` : 'coding'),
|
|
74
81
|
setupHash: setup ? depsHash : undefined,
|
|
75
|
-
labels
|
|
82
|
+
labels,
|
|
76
83
|
};
|
|
77
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether a new conversation sets its repository up (0.21.1+): when the project has no template for the repository's
|
|
87
|
+
* current dependency hash. Read as the conversation starts, so the hash is of the files it is seeded with; the API
|
|
88
|
+
* read runs while its VM starts. Never fails the start: an unreadable answer is reported and the conversation runs
|
|
89
|
+
* without setup.
|
|
90
|
+
*/
|
|
91
|
+
export function readConversationSetup(ctx, project) {
|
|
92
|
+
let read;
|
|
93
|
+
try {
|
|
94
|
+
read = projectSetup(ctx, project);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
read = Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
98
|
+
}
|
|
99
|
+
return read.then(s => s.setup ? s : undefined, (error) => {
|
|
100
|
+
if (!ctx.signal.aborted)
|
|
101
|
+
ctx.io.stderr.write(`Project setup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
102
|
+
return undefined;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The conversation's VM gets the labels a project workspace has, with its worktree as the repository, so the setup
|
|
107
|
+
* that passes there saves the project template the next conversation starts from. Runs beside the agent's start; the
|
|
108
|
+
* setup itself is queued once it is done. A failed label write is reported: setup still prepares this conversation.
|
|
109
|
+
*/
|
|
110
|
+
export function labelConversationSetup(ctx, ws, worktree, setup) {
|
|
111
|
+
if (!setup)
|
|
112
|
+
return undefined;
|
|
113
|
+
const labelled = (async () => {
|
|
114
|
+
// Labels are replaced as a whole: merge into the VM's current ones.
|
|
115
|
+
const current = await ctx.cloud().workspaces.get(ws.id, { signal: ctx.signal });
|
|
116
|
+
await current.setLabels({ ...current.labels, ...setup.labels, repo_path: worktree });
|
|
117
|
+
})().catch((error) => {
|
|
118
|
+
if (!ctx.signal.aborted)
|
|
119
|
+
ctx.io.stderr.write(`Project template will not be saved from this conversation: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
120
|
+
});
|
|
121
|
+
return { hash: setup.depsHash, labelled };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The hash a new conversation's worktree is set up with, recorded in its VM before the seed. Its guest starts the agent
|
|
125
|
+
* as soon as the repository appears, and with this record starts it with the setup's context and toolchains on PATH
|
|
126
|
+
* (services/guest-agent conversation_runtime.go setupIntent; a guest without it ignores the record).
|
|
127
|
+
*/
|
|
128
|
+
export async function recordSetupIntent(cell, c, depsHash, signal) {
|
|
129
|
+
if (!/^conv_[a-z0-9]{26}$/.test(c.id) || !/^[a-f0-9]{64}$/.test(depsHash))
|
|
130
|
+
throw new Error('invalid conversation or dependency hash');
|
|
131
|
+
const r = await cell.exec.run(['sh', '-c', 'umask 077; d=/home/user/.local/share/shardflux/conversation-setup; mkdir -p "$d" && printf \'{"deps_hash":"%s"}\' "$2" > "$d/$1.json"', 'shard-setup', c.id, depsHash], { timeoutMs: 15_000, maxOutputBytes: 4096, ...(signal ? { signal } : {}) });
|
|
132
|
+
if (r.exitCode !== 0)
|
|
133
|
+
throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
|
|
134
|
+
}
|
|
78
135
|
export const PROJECT_TEMPLATE_HANDLERS = {
|
|
79
136
|
async 'projects template'(ctx, _values, [path = './']) {
|
|
80
137
|
const project = projectIdentity(path);
|