@shardflux/cli 0.13.2 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +19 -16
- package/dist/account-commands.js +6 -3
- package/dist/agent-commands.d.ts +1 -1
- package/dist/agent-commands.js +29 -8
- package/dist/agent-config-files.js +2 -1
- package/dist/agent-config-report.d.ts +8 -0
- package/dist/agent-config-report.js +21 -0
- package/dist/agent-config-sync.js +3 -2
- package/dist/agent-credentials.js +9 -2
- package/dist/agent-live.d.ts +29 -0
- package/dist/agent-live.js +82 -0
- package/dist/agent-repository.d.ts +12 -0
- package/dist/agent-repository.js +92 -16
- package/dist/agent-target-commands.js +31 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +7 -4
- package/dist/assisted-setup.d.ts +3 -1
- package/dist/assisted-setup.js +4 -1
- package/dist/claude-cloud-login.d.ts +7 -1
- package/dist/claude-cloud-login.js +36 -14
- package/dist/command-center-actions.js +4 -0
- package/dist/command-center.js +3 -0
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +6 -2
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +25 -2
- package/dist/filesystem-commands.d.ts +5 -0
- package/dist/filesystem-commands.js +51 -11
- package/dist/main.js +13 -2
- package/dist/progress.d.ts +6 -0
- package/dist/progress.js +8 -0
- package/dist/setup-cache.d.ts +5 -0
- package/dist/setup-cache.js +11 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.4
|
|
4
|
+
|
|
5
|
+
- Cloud Claude sign-in and reopening the machine editor preserve existing workspace labels while updating their own labels.
|
|
6
|
+
- `shard billing status` on the signup trial suggests `shard billing upgrade pro` when the live catalog offers Pro (the trial is 60 days of Pro from catalog live-v10); otherwise it points to `shard billing plans`.
|
|
7
|
+
- Reuse an account-private setup cache across projects after the agent starts; report cache scope with `shard template --json`.
|
|
8
|
+
- Machine and project filesystem status shows the current platform base and whether a rebase is pending.
|
|
9
|
+
|
|
10
|
+
## 0.13.3
|
|
11
|
+
|
|
12
|
+
- `shard ./` seeds the workspace repository with the checked-out branch and its upstream only, not every local branch, tag and stash. When that history is still above 256 MiB, the workspace fetches the branch from origin with its own Git credentials and receives only the commits origin does not have yet. The workspace branch tracks its origin branch.
|
|
13
|
+
- Cloud Claude sign-in merges the account into the workspace's `~/.claude.json` on its latest content, also when the platform delivers the new login to that workspace at the same moment.
|
|
14
|
+
- A request the edge firewall refuses (`403 forbidden`, reason `edge_refused`, or a bare `403`) comes with a hint that sends the data through a command's stdin instead.
|
|
15
|
+
- `shard <path> claude|codex` starts (or reattaches) that agent directly instead of opening the command center's start picker; with `--json` it starts the agent detached and prints the session.
|
|
16
|
+
- `shard agents list` shows an agent this computer started in the last five minutes before the API's session list has picked it up, read from the agent's workspace.
|
|
17
|
+
- `shard machine` prints a progress line for each long step of its first open: creating the machine VM, syncing Claude and Codex config, signing in to cloud Claude, starting the agent.
|
|
18
|
+
- `shard agents targets <agent>` says what it configured where: the SSH host and the file it lives in (included from `~/.ssh/config`), and the Claude Desktop and Codex app entries with their files.
|
|
19
|
+
- `shard machine save` and `shard template save` print one line naming the version being saved; `--wait` returns once that version is ready (or failed, exit 6 with `filesystem_save_failed`), with progress on stderr; `--timeout` bounds the wait (default 10 minutes). `shard machine` and `shard template` print one status line.
|
|
20
|
+
- Git history above the first copy's 256 MiB limit fails with `repository_too_large` and its next step in the hint, not `internal_error`.
|
|
21
|
+
- A `revision_mismatch` suggests `--expected-revision` only for the file commands that take it; elsewhere it says to run the same command again.
|
|
22
|
+
- Config sync reports project entries keyed by paths on this computer (Codex `config.projects.*`) as one summary line.
|
|
23
|
+
- The command center does not show a setup step that `.shardflux/setup.yaml` marks `required: false` as the setup failure while the template saves, and reads skipped steps (coding template 9+).
|
|
24
|
+
|
|
3
25
|
## 0.13.2
|
|
4
26
|
|
|
5
27
|
- On macOS, `shard` starts the menu bar by itself (with launch at login) the first time it runs; `shard menubar stop` turns that off and `shard menubar start` turns it back on.
|
package/README.md
CHANGED
|
@@ -43,17 +43,16 @@ Bulk transfers batch writes automatically. Active transfers retain their byte po
|
|
|
43
43
|
## Install
|
|
44
44
|
|
|
45
45
|
```sh
|
|
46
|
-
npm
|
|
46
|
+
npm i -g shardflux
|
|
47
47
|
shard --help
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`shardflux` and `shard`; install one package or the other globally, not both.
|
|
50
|
+
The [`shardflux`](https://www.npmjs.com/package/shardflux) package installs the `shard` command.
|
|
51
|
+
Use `shard` for your projects, machine, templates and account.
|
|
53
52
|
|
|
54
53
|
## Quick start
|
|
55
54
|
|
|
56
|
-
Start your coding agent on a local project (0.13.
|
|
55
|
+
Start your coding agent on a local project (0.13.2+):
|
|
57
56
|
|
|
58
57
|
```sh
|
|
59
58
|
shard setup
|
|
@@ -63,7 +62,8 @@ shard ./
|
|
|
63
62
|
|
|
64
63
|
Choose Claude Code or Codex. Claude asks for one cloud sign-in per Shardflux account; Codex uses your copied login.
|
|
65
64
|
The agent is usable immediately while setup prepares the project in the background. `shard` opens the command
|
|
66
|
-
center for every project; `shard ./` returns to this project.
|
|
65
|
+
center for every project; `shard ./` returns to this project. On macOS the menu bar starts automatically the first
|
|
66
|
+
time `shard` runs, with launch at login enabled; `shard menubar stop` turns it off. See [Cloud agents](https://docs.shardflux.dev/guides/cloud-agents).
|
|
67
67
|
|
|
68
68
|
For application workspace commands, `shard signup` creates an account and saves a project key, or set
|
|
69
69
|
`SHARDFLUX_API_KEY` to a key from the console and run `shard login`. `shard --help` lists every command.
|
|
@@ -264,9 +264,9 @@ plan only when Stripe confirms the payment, so `--wait` polls until the subscrip
|
|
|
264
264
|
open). An organization that already has a subscription changes plans in the billing portal: the
|
|
265
265
|
command prints the portal URL instead (exit 0).
|
|
266
266
|
|
|
267
|
-
On the signup trial (60 days
|
|
267
|
+
On the signup trial (60 days of Pro, no card), `billing status` shows when it ends and
|
|
268
268
|
what follows (0.6.2+): `plan trial, ends 2026-11-29 14:03:00Z, then free (trial, catalog ...)`, then
|
|
269
|
-
`keep these limits after the trial: shard billing upgrade
|
|
269
|
+
`keep these limits after the trial: shard billing upgrade pro` (0.13.4+). A subscription outranks the
|
|
270
270
|
trial and ends it. The trial is granted, never bought, so `billing plans` leaves it out.
|
|
271
271
|
|
|
272
272
|
`billing overage` shows and changes opt-in overage (owners and billing members). With it on,
|
|
@@ -283,7 +283,7 @@ already charged this period (`spend_cap_below_charges`); a plan without overage
|
|
|
283
283
|
```text
|
|
284
284
|
$ shard billing overage on --cap 9
|
|
285
285
|
Acme: overage is on, spend cap $9.00 per billing period ($1.00 to $9.00, the plan price).
|
|
286
|
-
Rates past the allowances: $0.
|
|
286
|
+
Rates past the allowances: $0.015 per RAM GiB-hour, $0.04 per CPU-hour, charged on the next invoice (this period: shard usage).
|
|
287
287
|
Saved (policy version 1).
|
|
288
288
|
```
|
|
289
289
|
|
|
@@ -351,7 +351,7 @@ and the session renewals of step-up and password changes).
|
|
|
351
351
|
`shard` tells you when a newer version exists, on stderr after the command's output:
|
|
352
352
|
|
|
353
353
|
```text
|
|
354
|
-
@shardflux/cli 0.5.0 is outdated: 0.6.0 is available. Update: npm
|
|
354
|
+
@shardflux/cli 0.5.0 is outdated: 0.6.0 is available. Update: npm i -g shardflux
|
|
355
355
|
```
|
|
356
356
|
|
|
357
357
|
or that the API no longer supports it (`... is no longer supported by the Shardflux API (minimum
|
|
@@ -828,12 +828,12 @@ amount:
|
|
|
828
828
|
```text
|
|
829
829
|
overage accruing (usage past the allowances is charged on the next invoice)
|
|
830
830
|
spend cap $9.00 (at most $9.00, the plan price)
|
|
831
|
-
overage charges $
|
|
831
|
+
overage charges $1.10 of $9.00 (12.22%), $7.90 left; billed on the next invoice
|
|
832
832
|
cap reached 2026-09-29 15:02:46Z (projected at this period's average use)
|
|
833
833
|
|
|
834
|
-
OVERAGE UNIT PAST ALLOWANCE BILLED RATE
|
|
835
|
-
ram_gib_hours gib_hours 70 60 $0.
|
|
836
|
-
cpu_hours hours 5 5 $0.
|
|
834
|
+
OVERAGE UNIT PAST ALLOWANCE BILLED RATE AMOUNT
|
|
835
|
+
ram_gib_hours gib_hours 70 60 $0.015 $0.90
|
|
836
|
+
cpu_hours hours 5 5 $0.04 $0.20
|
|
837
837
|
```
|
|
838
838
|
|
|
839
839
|
`--json` prints the API's summary, with `spend_cap` and `exhausted_reason`. Owners and billing members turn overage on
|
|
@@ -1114,12 +1114,12 @@ Use `shard ws computer paste-text <key> - --paste-chord shift+Insert --screensho
|
|
|
1114
1114
|
|
|
1115
1115
|
Template services accept `initialized_cpu` with `compatibility`, `reset`, and `timeout_seconds`. Use `cpu-service-v1` for immutable initialization, or `cpu-service-fresh-inputs-v1` with `fresh_env_names` for values supplied at each open. Declare an unprivileged user and command readiness. Each open runs the reset script and its owned startup hooks before returning. Initialization-dependent and secret inputs use ordinary startup.
|
|
1116
1116
|
|
|
1117
|
-
### Waiting agent reachability (0.13.
|
|
1117
|
+
### Waiting agent reachability (0.13.2+)
|
|
1118
1118
|
|
|
1119
1119
|
Set `--reachable 30m` when opening a workspace to choose how long waiting agents stay reachable through connections they opened. See [reachability settings](https://docs.shardflux.dev/limits#cloud-agent-reachability). Incoming replies wake the parked workspace, which answers them and parks again. These wakes count as parked time; agent work counts as awake time.
|
|
1120
1120
|
|
|
1121
1121
|
|
|
1122
|
-
## Cloud agents (0.13.
|
|
1122
|
+
## Cloud agents (0.13.2+)
|
|
1123
1123
|
|
|
1124
1124
|
Run `shard ./` in your project to start Claude Code or Codex, usable immediately while setup runs in the background.
|
|
1125
1125
|
Your **machine** carries credentials, tools, dotfiles, logins and agent config; the project's **template** carries
|
|
@@ -1127,6 +1127,9 @@ toolchains, dependencies, services and project secrets. Both are private filesys
|
|
|
1127
1127
|
as ordinary files. Browser logins and Claude/Codex config belong to the machine. Set up tools and secrets normally inside the VM;
|
|
1128
1128
|
your agent uses the same save/rollback commands there. Every agent starts from both. Save or roll back with `shard machine save|rollback` and
|
|
1129
1129
|
`shard template save|rollback`; changed files reach running agents live, software at the next start.
|
|
1130
|
+
`shard machine save --wait` and `shard template save --wait` return once the new version is ready (0.13.3+).
|
|
1131
|
+
`shard ./ codex` (or `claude`) starts that agent directly; `shard agents targets <project>/<agent>` lists each
|
|
1132
|
+
connection it configured and the file it is in (0.13.3+).
|
|
1130
1133
|
|
|
1131
1134
|
`shard agents fork` or `f` in the command center copies the whole running state into an independent workspace and
|
|
1132
1135
|
conversation. Open in your terminal, Claude Desktop or the Codex app, and the terminal Claude conversation in
|
package/dist/account-commands.js
CHANGED
|
@@ -1403,12 +1403,15 @@ export const ACCOUNT_HANDLERS = {
|
|
|
1403
1403
|
const account = ctx.account();
|
|
1404
1404
|
const org = await resolveOrg(ctx, account, values);
|
|
1405
1405
|
const s = await account.billing.subscription(org.id);
|
|
1406
|
-
// The trial has
|
|
1406
|
+
// The trial has Pro's limits and allowances (catalog live-v10): Pro keeps them (a subscription outranks the trial and ends it).
|
|
1407
|
+
// Name Pro only while the live catalog sells it; a catalog without Pro gets the general hint.
|
|
1408
|
+
const trialToPro = !s.subscription && s.purchase_available && s.plan?.plan_key === 'trial'
|
|
1409
|
+
&& (await fetchBillingCatalog({ baseUrl: ctx.apiUrl, fetch: ctx.accountOptions().fetch ?? fetch }).catch(() => null))?.plans?.some((p) => p.plan_key === 'pro') === true;
|
|
1407
1410
|
const next = s.subscription
|
|
1408
1411
|
? 'manage it: shard billing portal'
|
|
1409
1412
|
: s.purchase_available
|
|
1410
|
-
?
|
|
1411
|
-
? 'keep these limits after the trial: shard billing upgrade
|
|
1413
|
+
? trialToPro
|
|
1414
|
+
? 'keep these limits after the trial: shard billing upgrade pro'
|
|
1412
1415
|
: 'buy a plan: shard billing plans, then shard billing upgrade <plan>'
|
|
1413
1416
|
: '';
|
|
1414
1417
|
out(ctx, `${billingStatusText(s, org)}${next ? `${next}\n` : ''}`, s);
|
package/dist/agent-commands.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ import type { Ctx, Handler } from './commands.js';
|
|
|
3
3
|
/** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
|
|
4
4
|
export declare function disambiguatedSlug(slug: string, identity: string): string;
|
|
5
5
|
export declare function projectTemplate(ctx: Ctx, remote: string | null): Promise<string>;
|
|
6
|
-
export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string): Promise<PtySession>;
|
|
6
|
+
export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void, deferSetup?: boolean): Promise<PtySession>;
|
|
7
7
|
export declare const launchProject: Handler;
|
|
8
8
|
export declare const AGENT_HANDLERS: Record<string, Handler>;
|
package/dist/agent-commands.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prepareSetupCache } from "./setup-cache.js";
|
|
1
2
|
import { agentReachability } from "./agent-reachability.js";
|
|
2
3
|
import { configureCredentialFill, watchCredentialPrompts } from "./credential-prompts.js";
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
@@ -15,7 +16,9 @@ import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js
|
|
|
15
16
|
import { rememberGitAgent } from "./agent-git-commands.js";
|
|
16
17
|
import { projectWorkspacePath, seedAgentRepository, workspaceRepositoryPath } from "./agent-repository.js";
|
|
17
18
|
import { agentName } from "./agent-git.js";
|
|
18
|
-
import { cloudClaudeLogin,
|
|
19
|
+
import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
|
|
20
|
+
import { progress } from "./progress.js";
|
|
21
|
+
import { rememberLaunch } from "./agent-live.js";
|
|
19
22
|
const active = (session) => session.state === 'running' || session.state === 'starting';
|
|
20
23
|
/** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
|
|
21
24
|
export function disambiguatedSlug(slug, identity) {
|
|
@@ -37,11 +40,11 @@ export async function projectTemplate(ctx, remote) {
|
|
|
37
40
|
throw error;
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
|
-
export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName) {
|
|
43
|
+
export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName, onLaunch, deferSetup = false) {
|
|
41
44
|
const id = `shard-${ptyName || agent}-${app}`;
|
|
42
45
|
// Arrival and changed locks queue the same durable guest job even when the
|
|
43
46
|
// native agent is already alive. Setup never becomes its first user turn.
|
|
44
|
-
if (setupHash) {
|
|
47
|
+
if (setupHash && !deferSetup) {
|
|
45
48
|
const queued = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', setupHash], { timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}) });
|
|
46
49
|
if (queued.exitCode !== 0)
|
|
47
50
|
throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
|
|
@@ -55,8 +58,9 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
|
|
|
55
58
|
if (!missing(error))
|
|
56
59
|
throw error;
|
|
57
60
|
}
|
|
61
|
+
onLaunch?.();
|
|
58
62
|
const launch = await cell.exec.run(['sf-agent', 'run', app, '--name', agent, '--detach', ...(ptyName ? ['--pty-name', ptyName] : []), ...(approvals ? ['--approvals'] : []), ...(setupHash ? ['--setup', '--setup-deps-hash', setupHash] : [])], {
|
|
59
|
-
env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
|
|
63
|
+
env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
|
|
60
64
|
});
|
|
61
65
|
if (launch.exitCode !== 0)
|
|
62
66
|
throw new Error(`agent launch failed: ${launch.stderr.trim() || `exit ${launch.exitCode}`}`);
|
|
@@ -84,14 +88,26 @@ async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = w
|
|
|
84
88
|
const tunnels = attach ? await startAgentConfigTunnels(ctx, ws) : null;
|
|
85
89
|
try {
|
|
86
90
|
const credentialFill = await configureCredentialFill(ctx, ws);
|
|
87
|
-
|
|
91
|
+
// A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
|
|
92
|
+
const session = await agentPty(cell, app, agent, values.approvals === true, ctx.io.env.TERM || 'xterm-256color', ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true);
|
|
93
|
+
// The real agent is running before any cache provisioning or mount wait.
|
|
94
|
+
const preparation = prepareSetupCache(ctx, ws).catch((error) => {
|
|
95
|
+
ctx.io.stderr.write(`Account setup cache unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
96
|
+
}).then(async () => {
|
|
97
|
+
if (!setupHash)
|
|
98
|
+
return;
|
|
99
|
+
const queued = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', setupHash], { cwd, timeoutMs: 30_000, maxOutputBytes: 4096, signal: ctx.signal });
|
|
100
|
+
if (queued.exitCode !== 0)
|
|
101
|
+
throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
|
|
102
|
+
});
|
|
88
103
|
store.save(project, app);
|
|
89
104
|
if (!attach) {
|
|
105
|
+
await preparation;
|
|
90
106
|
ctx.io.stdout.write(json({ workspace: ws.data, session }));
|
|
91
107
|
return 0;
|
|
92
108
|
}
|
|
93
109
|
ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
|
|
94
|
-
|
|
110
|
+
const attached = attachTerminal({
|
|
95
111
|
...(credentialFill ? { credentialPrompts: (ask, signal) => watchCredentialPrompts(ctx, ws, ask, signal) } : {}),
|
|
96
112
|
io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id),
|
|
97
113
|
wake: (signal) => ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal }),
|
|
@@ -104,8 +120,11 @@ async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = w
|
|
|
104
120
|
done();
|
|
105
121
|
}),
|
|
106
122
|
});
|
|
123
|
+
const [code] = await Promise.all([attached, preparation]);
|
|
124
|
+
return code;
|
|
107
125
|
}
|
|
108
126
|
finally {
|
|
127
|
+
cell.close();
|
|
109
128
|
await tunnels?.stop();
|
|
110
129
|
}
|
|
111
130
|
}
|
|
@@ -212,8 +231,10 @@ export const AGENT_HANDLERS = {
|
|
|
212
231
|
ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
213
232
|
const projectKey = typeof values.project === 'string' ? values.project : ws.labels.repo_remote || ws.labels.shard_project || ws.key;
|
|
214
233
|
const app = values.app ?? new AgentStateStore(ctx.store.dir).app(projectKey, ws.labels.app === 'codex' ? 'codex' : 'claude');
|
|
215
|
-
if (app === 'claude')
|
|
216
|
-
|
|
234
|
+
if (app === 'claude' && !await cloudClaudeLogin(ctx)) {
|
|
235
|
+
progress(ctx, 'Signing in to cloud Claude (once per account)…');
|
|
236
|
+
await loginClaudeInWorkspace(ctx, ws);
|
|
237
|
+
}
|
|
217
238
|
if (!ctx.json && values.detach !== true)
|
|
218
239
|
requireTerminal(ctx);
|
|
219
240
|
if (ws.labels.shard_layer !== 'machine' && !(await savedMachine(ctx))?.template)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { LOCAL_PATH_REASON } from "./agent-config-report.js";
|
|
3
4
|
export const object = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
4
5
|
export const text = (v) => typeof v === 'string' ? v : undefined;
|
|
5
6
|
export const absent = (e) => e.code === 'ENOENT';
|
|
@@ -190,7 +191,7 @@ export function rewritePaths(value, home, remoteHome, files, issues, item) {
|
|
|
190
191
|
const result = {};
|
|
191
192
|
for (const [key, v] of Object.entries(value)) {
|
|
192
193
|
if (key.startsWith('/') || key.startsWith('~/')) {
|
|
193
|
-
issues.push({ item: `${item}.${key}`, reason:
|
|
194
|
+
issues.push({ item: `${item}.${key}`, reason: LOCAL_PATH_REASON });
|
|
194
195
|
continue;
|
|
195
196
|
}
|
|
196
197
|
const mapped = rewritePaths(v, home, remoteHome, files, issues, `${item}.${key}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConfigIssue } from './agent-config-files.js';
|
|
2
|
+
export declare const LOCAL_PATH_REASON = "local project path is not synced by agent config";
|
|
3
|
+
/**
|
|
4
|
+
* The human report of a config sync's skipped items. Per-project entries keyed by a laptop path (Codex
|
|
5
|
+
* `config.projects./Users/...`, Claude `projects./Users/...`) only describe this computer; they become one summary
|
|
6
|
+
* line per sync, and every other skipped item keeps its own line.
|
|
7
|
+
*/
|
|
8
|
+
export declare function configIssueLines(issues: ConfigIssue[]): string[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const LOCAL_PATH_REASON = 'local project path is not synced by agent config';
|
|
2
|
+
/**
|
|
3
|
+
* The human report of a config sync's skipped items. Per-project entries keyed by a laptop path (Codex
|
|
4
|
+
* `config.projects./Users/...`, Claude `projects./Users/...`) only describe this computer; they become one summary
|
|
5
|
+
* line per sync, and every other skipped item keeps its own line.
|
|
6
|
+
*/
|
|
7
|
+
export function configIssueLines(issues) {
|
|
8
|
+
const local = new Map();
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const issue of issues) {
|
|
11
|
+
const at = issue.reason === LOCAL_PATH_REASON ? issue.item.search(/\.(?:\/|~\/)/) : -1;
|
|
12
|
+
if (at > 0)
|
|
13
|
+
local.set(issue.item.slice(0, at), (local.get(issue.item.slice(0, at)) ?? 0) + 1);
|
|
14
|
+
else
|
|
15
|
+
lines.push(`Config skipped ${issue.item}: ${issue.reason}`);
|
|
16
|
+
}
|
|
17
|
+
const total = [...local.values()].reduce((a, b) => a + b, 0);
|
|
18
|
+
if (total)
|
|
19
|
+
lines.unshift(`Config kept ${total} project ${total === 1 ? 'entry' : 'entries'} for paths on this computer out of the VM (${[...local.keys()].join(', ')})`);
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
@@ -6,6 +6,7 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
6
6
|
import { UsageError } from "./errors.js";
|
|
7
7
|
import { collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths } from "./agent-config-files.js";
|
|
8
8
|
import { digest, prepareMcp } from "./agent-config-mcp.js";
|
|
9
|
+
import { configIssueLines } from "./agent-config-report.js";
|
|
9
10
|
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
10
11
|
const markerPath = '.config/shardflux/agent-config.json';
|
|
11
12
|
const safePath = (path) => !path.split('/').some(p => !p || p === '.' || p === '..') &&
|
|
@@ -302,8 +303,8 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
302
303
|
store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = manifest; doc.agent_config = all; });
|
|
303
304
|
const result = { ...empty, copied: applied.copied, removed: applied.removed, skipped: issues, mcp, synced_at };
|
|
304
305
|
if (!ctx.json) {
|
|
305
|
-
for (const
|
|
306
|
-
ctx.io.stderr.write(
|
|
306
|
+
for (const line of configIssueLines(issues))
|
|
307
|
+
ctx.io.stderr.write(`${line}\n`);
|
|
307
308
|
for (const server of mcp)
|
|
308
309
|
if (server.status !== 'copied')
|
|
309
310
|
ctx.io.stderr.write(`MCP ${server.app}/${server.name}: ${server.status}${server.reason ? ` (${server.reason})` : ''}\n`);
|
|
@@ -76,8 +76,15 @@ export const AGENT_CREDENTIAL_HANDLERS = {
|
|
|
76
76
|
}
|
|
77
77
|
requireClaudeTerminal(ctx);
|
|
78
78
|
const project = projectIdentity('./');
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
let ws;
|
|
80
|
+
if (typeof values.workspace === 'string')
|
|
81
|
+
ws = await resolveWorkspace(ctx, values.workspace);
|
|
82
|
+
else {
|
|
83
|
+
const cloud = ctx.cloud();
|
|
84
|
+
const key = `${project.slug}/main`;
|
|
85
|
+
const current = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
|
|
86
|
+
ws = await cloud.workspaces.open({ key, template: 'coding', labels: { ...current?.labels, ...projectLabels(project, 'main', 'claude') }, wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
|
|
87
|
+
}
|
|
81
88
|
output(ctx, await loginClaudeInWorkspace(ctx, ws));
|
|
82
89
|
return 0;
|
|
83
90
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Agent } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx } from './commands.js';
|
|
3
|
+
/** One record of `sf-agent list`: the guest's own session table, PID-reconciled on every read. */
|
|
4
|
+
interface GuestSession {
|
|
5
|
+
session_id: string;
|
|
6
|
+
app: 'claude' | 'codex';
|
|
7
|
+
state: Agent['state'];
|
|
8
|
+
since_unix_ms: number;
|
|
9
|
+
title: string;
|
|
10
|
+
remote_control_url: string;
|
|
11
|
+
}
|
|
12
|
+
/** Parses `sf-agent list` stdout; anything malformed yields no sessions rather than a wrong row. */
|
|
13
|
+
export declare function guestSessions(stdout: string): GuestSession[];
|
|
14
|
+
/** How long after a launch the listing may still miss it: the projection refreshes about once a minute. */
|
|
15
|
+
export declare const LAUNCH_WINDOW_MS: number;
|
|
16
|
+
/** Remembers that this computer started an agent in a workspace (a cold launch or a fork), for withLiveAgents. */
|
|
17
|
+
export declare function rememberLaunch(ctx: Pick<Ctx, 'store'>, workspaceId: string, now?: number): void;
|
|
18
|
+
/**
|
|
19
|
+
* GET /v1/agents is the cell's projection of each host's inventory, refreshed about once a minute, so an agent this
|
|
20
|
+
* computer started moments ago is not listed yet. For each running workspace where this computer started an agent in
|
|
21
|
+
* the last five minutes, after the newest session the listing has for it, read the guest's own table (`sf-agent list`,
|
|
22
|
+
* one short exec) and merge it: a guest row replaces the projected row with the same id. Nothing else is asked, so
|
|
23
|
+
* listing never keeps an idle workspace awake. A workspace whose guest cannot answer keeps its projected rows.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withLiveAgents(ctx: Ctx, agents: Agent[], filter: {
|
|
26
|
+
state?: string;
|
|
27
|
+
labels: Record<string, string>;
|
|
28
|
+
}, now?: number): Promise<Agent[]>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
2
|
+
const STATES = new Set(['working', 'waiting', 'exited']);
|
|
3
|
+
/** Parses `sf-agent list` stdout; anything malformed yields no sessions rather than a wrong row. */
|
|
4
|
+
export function guestSessions(stdout) {
|
|
5
|
+
let doc;
|
|
6
|
+
try {
|
|
7
|
+
doc = JSON.parse(stdout);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
if (!doc || typeof doc !== 'object')
|
|
13
|
+
return [];
|
|
14
|
+
const { ok, sessions } = doc;
|
|
15
|
+
if (ok !== true || !Array.isArray(sessions))
|
|
16
|
+
return [];
|
|
17
|
+
return sessions.filter((value) => {
|
|
18
|
+
if (!value || typeof value !== 'object')
|
|
19
|
+
return false;
|
|
20
|
+
const s = value;
|
|
21
|
+
return typeof s.session_id === 'string' && s.session_id.length > 0 && (s.app === 'claude' || s.app === 'codex') && typeof s.state === 'string' && STATES.has(s.state) &&
|
|
22
|
+
Number.isSafeInteger(s.since_unix_ms) && s.since_unix_ms > 0 && typeof s.title === 'string' && typeof s.remote_control_url === 'string';
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** How long after a launch the listing may still miss it: the projection refreshes about once a minute. */
|
|
26
|
+
export const LAUNCH_WINDOW_MS = 5 * 60_000;
|
|
27
|
+
/** Remembers that this computer started an agent in a workspace (a cold launch or a fork), for withLiveAgents. */
|
|
28
|
+
export function rememberLaunch(ctx, workspaceId, now = Date.now()) {
|
|
29
|
+
new AgentStateStore(ctx.store.dir).update(doc => {
|
|
30
|
+
const launches = { ...(doc.agent_launches && typeof doc.agent_launches === 'object' ? doc.agent_launches : {}) };
|
|
31
|
+
for (const [id, at] of Object.entries(launches))
|
|
32
|
+
if (typeof at !== 'number' || now - at > LAUNCH_WINDOW_MS)
|
|
33
|
+
delete launches[id];
|
|
34
|
+
launches[workspaceId] = now;
|
|
35
|
+
doc.agent_launches = launches;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const running = (w) => !w.deleted_at && w.observed_state === 'running';
|
|
39
|
+
/**
|
|
40
|
+
* GET /v1/agents is the cell's projection of each host's inventory, refreshed about once a minute, so an agent this
|
|
41
|
+
* computer started moments ago is not listed yet. For each running workspace where this computer started an agent in
|
|
42
|
+
* the last five minutes, after the newest session the listing has for it, read the guest's own table (`sf-agent list`,
|
|
43
|
+
* one short exec) and merge it: a guest row replaces the projected row with the same id. Nothing else is asked, so
|
|
44
|
+
* listing never keeps an idle workspace awake. A workspace whose guest cannot answer keeps its projected rows.
|
|
45
|
+
*/
|
|
46
|
+
export async function withLiveAgents(ctx, agents, filter, now = Date.now()) {
|
|
47
|
+
const launches = new AgentStateStore(ctx.store.dir).read().agent_launches;
|
|
48
|
+
const recent = Object.entries(launches ?? {}).filter(([id, at]) => typeof at === 'number' && now - at < LAUNCH_WINDOW_MS &&
|
|
49
|
+
agents.filter(a => a.workspace_id === id).every(a => Date.parse(a.since) < at));
|
|
50
|
+
if (!recent.length)
|
|
51
|
+
return agents;
|
|
52
|
+
const live = await Promise.all(recent.map(async ([id]) => {
|
|
53
|
+
try {
|
|
54
|
+
const ws = await ctx.cloud().workspaces.get(id);
|
|
55
|
+
const w = ws.data;
|
|
56
|
+
if (!running(w) || !Object.entries(filter.labels).every(([k, v]) => w.labels?.[k] === v))
|
|
57
|
+
return [];
|
|
58
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel });
|
|
59
|
+
const result = await cell.exec.run(['sf-agent', 'list'], { timeoutMs: 10_000, maxOutputBytes: 1 << 20, signal: ctx.signal });
|
|
60
|
+
if (result.exitCode !== 0)
|
|
61
|
+
return [];
|
|
62
|
+
return guestSessions(result.stdout).map(s => ({
|
|
63
|
+
id: `${w.id}:${s.session_id}`, workspace_id: w.id, workspace_key: w.workspace_key, app: s.app, state: s.state,
|
|
64
|
+
since: new Date(s.since_unix_ms).toISOString(), title: s.title, remote_control_url: s.remote_control_url, labels: w.labels,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// A deleted workspace, or a guest that cannot answer, has nothing to add.
|
|
69
|
+
if (ctx.signal.aborted)
|
|
70
|
+
throw error;
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
const rows = live.flat().filter(row => !filter.state || row.state === filter.state);
|
|
75
|
+
if (!rows.length)
|
|
76
|
+
return agents;
|
|
77
|
+
const merged = new Map(agents.map(a => [a.id, a]));
|
|
78
|
+
for (const row of rows)
|
|
79
|
+
merged.set(row.id, { ...merged.get(row.id), ...row });
|
|
80
|
+
// The API's order: workspace, then session.
|
|
81
|
+
return [...merged.values()].sort((a, b) => a.workspace_id.localeCompare(b.workspace_id) || a.id.localeCompare(b.id));
|
|
82
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Workspace } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
|
+
import { CodedError } from './errors.js';
|
|
4
|
+
import type { SyncCell } from './project-sync-transfer.js';
|
|
3
5
|
export declare function projectWorkspacePath(project: {
|
|
4
6
|
slug: string;
|
|
5
7
|
}): string;
|
|
@@ -11,3 +13,13 @@ export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: str
|
|
|
11
13
|
root?: string;
|
|
12
14
|
populate?: boolean;
|
|
13
15
|
}): Promise<void>;
|
|
16
|
+
/** History above this goes through the project's origin instead of the bounded files path. */
|
|
17
|
+
export declare const HISTORY_BUNDLE_LIMIT: number;
|
|
18
|
+
/** A history the first copy cannot carry (repository_too_large, 0.13.3+): the facts, and the next step as the hint. */
|
|
19
|
+
export declare function tooLarge(message: string, hint: string, branch: string, origin: string | null): CodedError;
|
|
20
|
+
/**
|
|
21
|
+
* Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
|
|
22
|
+
* upstream are bundled: other branches, worktrees and stashes stay on this computer. When that is still above `limit`,
|
|
23
|
+
* the workspace fetches `origin` itself (with its own Git credentials) and receives only the commits origin lacks.
|
|
24
|
+
*/
|
|
25
|
+
export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, limit?: number): Promise<void>;
|
package/dist/agent-repository.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
+
import { CodedError } from "./errors.js";
|
|
5
6
|
import { AgentStateStore } from "./agent-state.js";
|
|
6
7
|
import { projectIdentity } from "./project-identity.js";
|
|
7
8
|
import { localManifest, projectGit } from "./project-sync-manifest.js";
|
|
@@ -34,6 +35,87 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
|
|
|
34
35
|
await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); });
|
|
35
36
|
state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
|
|
36
37
|
}
|
|
38
|
+
/** History above this goes through the project's origin instead of the bounded files path. */
|
|
39
|
+
export const HISTORY_BUNDLE_LIMIT = 256 << 20;
|
|
40
|
+
async function optionalGit(folder, args, signal) {
|
|
41
|
+
try {
|
|
42
|
+
return (await projectGit(folder, args, signal)).trim();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (signal?.aborted)
|
|
46
|
+
throw error;
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const tail = (text) => text.trim().split('\n').slice(-3).join(' ').slice(-400);
|
|
51
|
+
// Fetches one origin branch (or origin's HEAD) into an empty workspace repository and prints its commit.
|
|
52
|
+
const FETCH_ORIGIN = 'set -eu; git init -q "$1"; case "$3" in HEAD) dst=refs/shard-sync/origin ;; *) dst="refs/remotes/origin/${3#refs/heads/}" ;; esac; ' +
|
|
53
|
+
'git -C "$1" -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags "$2" "+$3:$dst" >&2; git -C "$1" rev-parse --verify "$dst^{commit}"';
|
|
54
|
+
// Applies an optional bundle of HEAD (plus its branch and upstream) and points the workspace HEAD at the laptop's.
|
|
55
|
+
const APPLY_HISTORY = 'set -eu; git init -q "$1"; if [ -n "$2" ]; then git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial ' +
|
|
56
|
+
'"+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*" "+refs/remotes/*:refs/remotes/*"; fi; ' +
|
|
57
|
+
'if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; ' +
|
|
58
|
+
'for ref in refs/shard-sync/initial refs/shard-sync/origin; do if git -C "$1" rev-parse -q --verify "$ref" >/dev/null; then git -C "$1" update-ref -d "$ref"; fi; done';
|
|
59
|
+
/** A history the first copy cannot carry (repository_too_large, 0.13.3+): the facts, and the next step as the hint. */
|
|
60
|
+
export function tooLarge(message, hint, branch, origin) {
|
|
61
|
+
return new CodedError('repository_too_large', `${message}.`, hint, { limit_bytes: HISTORY_BUNDLE_LIMIT, branch, ...(origin ? { origin } : {}) });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
|
|
65
|
+
* upstream are bundled: other branches, worktrees and stashes stay on this computer. When that is still above `limit`,
|
|
66
|
+
* the workspace fetches `origin` itself (with its own Git credentials) and receives only the commits origin lacks.
|
|
67
|
+
*/
|
|
68
|
+
export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, limit = HISTORY_BUNDLE_LIMIT) {
|
|
69
|
+
const upstream = branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
|
|
70
|
+
const tracked = upstream.startsWith('refs/remotes/') ? upstream : '';
|
|
71
|
+
const tips = ['HEAD', ...(branch ? [branch] : []), ...(tracked ? [tracked] : [])];
|
|
72
|
+
const dir = await mkdtemp(join(tmpdir(), 'shard-sync-git-'));
|
|
73
|
+
const local = join(dir, 'history.bundle'), remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
|
|
74
|
+
try {
|
|
75
|
+
await projectGit(folder, ['bundle', 'create', local, ...tips], signal);
|
|
76
|
+
let bundle = true;
|
|
77
|
+
if ((await stat(local)).size > limit) {
|
|
78
|
+
const name = branch ? branch.slice(11) : 'HEAD';
|
|
79
|
+
if (!origin)
|
|
80
|
+
throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the project has no network origin the workspace can clone`, 'add an origin remote, then run shard again', name, origin);
|
|
81
|
+
const ref = upstream.startsWith('refs/remotes/origin/') ? `refs/heads/${upstream.slice(20)}` : 'HEAD';
|
|
82
|
+
const fetched = await cell.exec.run(['sh', '-c', FETCH_ORIGIN, 'shard-sync', root, origin, ref], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
|
|
83
|
+
const base = fetched.stdout.trim();
|
|
84
|
+
if (fetched.exitCode !== 0 || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(base)) {
|
|
85
|
+
throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the workspace could not fetch ${origin}: ${tail(fetched.stderr)}`, 'give the machine access to the repository (GitHub: run gh auth login in shard machine), then run shard again', name, origin);
|
|
86
|
+
}
|
|
87
|
+
// Commits origin already has are not sent again; a base this computer does not know falls back to its upstream.
|
|
88
|
+
const exclude = await optionalGit(folder, ['cat-file', '-t', base], signal) === 'commit' ? base : tracked;
|
|
89
|
+
if (!exclude)
|
|
90
|
+
throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and shares no known commit with ${origin}`, 'push the branch, then run shard again', name, origin);
|
|
91
|
+
let onOrigin = true;
|
|
92
|
+
try {
|
|
93
|
+
await projectGit(folder, ['merge-base', '--is-ancestor', head, exclude], signal);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (signal?.aborted || error.code !== 1)
|
|
97
|
+
throw error;
|
|
98
|
+
onOrigin = false;
|
|
99
|
+
}
|
|
100
|
+
bundle = !onOrigin;
|
|
101
|
+
if (bundle) {
|
|
102
|
+
await rm(local, { force: true });
|
|
103
|
+
await projectGit(folder, ['bundle', 'create', local, ...tips.filter(tip => tip !== tracked), '--not', exclude], signal);
|
|
104
|
+
if ((await stat(local)).size > limit)
|
|
105
|
+
throw tooLarge(`Commits of ${name} that ${origin} does not have exceed the 256 MiB sync limit`, 'push them, then run shard again', name, origin);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (bundle)
|
|
109
|
+
await writeSyncBlob(cell, remote, await readFile(local));
|
|
110
|
+
const result = await cell.exec.run(['sh', '-c', APPLY_HISTORY, 'shard-sync', root, bundle ? remote : '', head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
|
|
111
|
+
if (result.exitCode !== 0)
|
|
112
|
+
throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await rm(dir, { recursive: true, force: true });
|
|
116
|
+
await cell.files.remove(remote).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
37
119
|
async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined) {
|
|
38
120
|
let top;
|
|
39
121
|
try {
|
|
@@ -69,22 +151,8 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
69
151
|
throw error;
|
|
70
152
|
}
|
|
71
153
|
if (head && importHistory) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
await projectGit(folder, ['bundle', 'create', local, '--all'], signal);
|
|
76
|
-
if ((await stat(local)).size > 256 << 20)
|
|
77
|
-
throw new Error('Git history bundle exceeds the 256 MiB sync limit');
|
|
78
|
-
await writeSyncBlob(cell, remote, await readFile(local));
|
|
79
|
-
const script = 'set -eu; git init -q "$1"; git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial "+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*"; if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; git -C "$1" update-ref -d refs/shard-sync/initial';
|
|
80
|
-
const result = await cell.exec.run(['sh', '-c', script, 'shard-sync', root, remote, head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
|
|
81
|
-
if (result.exitCode !== 0)
|
|
82
|
-
throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
await rm(dir, { recursive: true, force: true });
|
|
86
|
-
await cell.files.remove(remote).catch(() => undefined);
|
|
87
|
-
}
|
|
154
|
+
const remote = projectIdentity(folder).remote;
|
|
155
|
+
await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal);
|
|
88
156
|
}
|
|
89
157
|
else if (importHistory) {
|
|
90
158
|
const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
|
|
@@ -111,6 +179,14 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
111
179
|
const result = await cell.exec.run(['git', '-C', root, 'config', 'remote.origin.url', origin], { timeoutMs: 5000, signal });
|
|
112
180
|
if (result.exitCode !== 0)
|
|
113
181
|
throw new Error('could not copy Git origin');
|
|
182
|
+
// The imported branch keeps tracking its origin branch, as on this computer.
|
|
183
|
+
const upstream = importHistory && branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
|
|
184
|
+
if (upstream.startsWith('refs/remotes/origin/'))
|
|
185
|
+
for (const [key, value] of [[`branch.${branch.slice(11)}.remote`, 'origin'], [`branch.${branch.slice(11)}.merge`, `refs/heads/${upstream.slice(20)}`]]) {
|
|
186
|
+
const set = await cell.exec.run(['git', '-C', root, 'config', key, value], { timeoutMs: 5000, signal });
|
|
187
|
+
if (set.exitCode !== 0)
|
|
188
|
+
throw new Error('could not copy Git upstream');
|
|
189
|
+
}
|
|
114
190
|
}
|
|
115
191
|
// Ignore files must exist before the first remote scan; otherwise a
|
|
116
192
|
// template's dependency outputs would look like agent-created source.
|