@shardflux/cli 0.24.0 → 0.25.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +263 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-journal.js +23 -1
  6. package/dist/acp-permission-elsewhere.d.ts +38 -0
  7. package/dist/acp-permission-elsewhere.js +85 -0
  8. package/dist/acp-rpc.d.ts +2 -1
  9. package/dist/acp-rpc.js +11 -2
  10. package/dist/acp.js +95 -22
  11. package/dist/agent-credentials.d.ts +3 -0
  12. package/dist/agent-credentials.js +7 -0
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +172 -9
  15. package/dist/board-args.d.ts +6 -0
  16. package/dist/board-args.js +172 -0
  17. package/dist/board-commands.d.ts +62 -0
  18. package/dist/board-commands.js +412 -0
  19. package/dist/codex-cloud.d.ts +30 -3
  20. package/dist/codex-cloud.js +86 -12
  21. package/dist/conversation-commands.d.ts +30 -0
  22. package/dist/conversation-commands.js +328 -9
  23. package/dist/conversation-transcript.d.ts +44 -0
  24. package/dist/conversation-transcript.js +206 -0
  25. package/dist/conversations.d.ts +7 -3
  26. package/dist/conversations.js +20 -10
  27. package/dist/device-approvals.js +4 -3
  28. package/dist/device-bridge.d.ts +42 -1
  29. package/dist/device-bridge.js +223 -12
  30. package/dist/device-folders.d.ts +66 -0
  31. package/dist/device-folders.js +236 -0
  32. package/dist/device-helper.d.ts +6 -0
  33. package/dist/device-helper.js +98 -14
  34. package/dist/drive-commands.d.ts +25 -0
  35. package/dist/drive-commands.js +201 -0
  36. package/dist/errors.js +2 -1
  37. package/dist/format.js +6 -2
  38. package/dist/handlers.js +7 -1
  39. package/dist/land.d.ts +116 -0
  40. package/dist/land.js +238 -0
  41. package/dist/main.js +10 -1
  42. package/dist/move-files.d.ts +39 -0
  43. package/dist/move-files.js +167 -0
  44. package/dist/move.js +12 -11
  45. package/dist/onboarding.d.ts +10 -6
  46. package/dist/onboarding.js +24 -6
  47. package/dist/project-git.d.ts +7 -1
  48. package/dist/project-git.js +16 -3
  49. package/dist/project-preparation.d.ts +7 -0
  50. package/dist/project-preparation.js +14 -0
  51. package/dist/project-template-setup.js +32 -11
  52. package/dist/router-commands.d.ts +33 -0
  53. package/dist/router-commands.js +179 -0
  54. package/dist/task-land-hub.py +150 -0
  55. package/dist/transcribe-commands.d.ts +7 -0
  56. package/dist/transcribe-commands.js +80 -0
  57. package/dist/worktree-twin-engine.d.ts +8 -0
  58. package/dist/worktree-twin-engine.js +28 -22
  59. package/package.json +2 -2
@@ -7,9 +7,9 @@ import { AgentStateStore } from "./agent-state.js";
7
7
  import { describeError } from "./errors.js";
8
8
  import { t3PhoneLink } from "./t3-phone.js";
9
9
  import { t3NeedsProject, t3SignInProject } from "./account-scope.js";
10
- import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
10
+ import { credentialFingerprint, readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
11
11
  import { getConversation } from "./conversations.js";
12
- import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
12
+ import { CODEX_SIGN_IN_LINE, codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
13
13
  import { applicationDirs } from "./agent-targets.js";
14
14
  import { configureT3Provider } from "./t3-provider.js";
15
15
  import { t3Install } from "./t3-mode.js";
@@ -198,8 +198,11 @@ export function claudeConnection(row) {
198
198
  return { state: 'action', fact: 'Claude no longer accepts this sign-in.', action: { ...signIn, title: 'Sign in to Claude again' } };
199
199
  return { state: 'verified', fact: 'Signed in. Works in every conversation, on web and phone.' };
200
200
  }
201
- /** Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected. */
202
- export function codexConnection(local, synced, connector, health) {
201
+ /**
202
+ * Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected.
203
+ * `renewed`: this computer has a Codex login other than the one in the cloud (a new sign-in not copied yet).
204
+ */
205
+ export function codexConnection(local, synced, connector, health, renewed = false) {
203
206
  const enable = { title: 'Connect Codex', command: shard(['agents', 'codex', 'enable']) };
204
207
  if (!synced) {
205
208
  return local ? { state: 'action', fact: 'Available on web and phone once connected.', action: enable }
@@ -215,6 +218,11 @@ export function codexConnection(local, synced, connector, health) {
215
218
  case 'failed': return { state: 'action', fact: 'Codex’s remote device didn’t start.', action: retry };
216
219
  case 'stuck': return { state: 'action', fact: 'Codex’s remote device is taking too long to start.', action: retry };
217
220
  case 'attention': return { state: 'action', fact: connector?.last_error ? `Codex needs attention: ${connector.last_error}` : 'Codex needs attention.', action: retry };
221
+ // Codex Cloud refused the copied ChatGPT login (0.25.0+): sign in to Codex again here, then copy that sign-in, which
222
+ // starts the device again by itself. Never Retry: a restart cannot fix a sign-in.
223
+ case 'sign_in': return renewed
224
+ ? { state: 'action', fact: 'Your new Codex sign-in is ready to copy to the cloud.', action: { title: 'Copy sign-in', command: shard(['agents', 'credentials', 'sync']) } }
225
+ : { state: 'action', fact: CODEX_SIGN_IN_LINE, action: { title: 'Sign in to Codex again', command: ['codex', 'login'] } };
218
226
  }
219
227
  }
220
228
  /**
@@ -237,7 +245,10 @@ export async function onboardingReport(ctx, values, platform = process.platform)
237
245
  let connector = null;
238
246
  let health = 'missing';
239
247
  const home = ctx.io.env.HOME ?? ctx.io.env.USERPROFILE;
240
- const localCodex = home ? (await readAgentCredentials({ home, redact: value => ctx.addSecret(value) })).some(v => v.app === 'codex') : false;
248
+ const localLogin = home ? (await readAgentCredentials({ home, redact: value => ctx.addSecret(value) })).find(v => v.app === 'codex') : undefined;
249
+ const localCodex = localLogin !== undefined;
250
+ /** Whether this computer's Codex login differs from the copy in the cloud. */
251
+ const renewed = (rows) => localLogin !== undefined && rows.find(row => row.app === 'codex')?.fingerprint !== credentialFingerprint(localLogin.files);
241
252
  try {
242
253
  ctx.apiKey();
243
254
  const principal = await ctx.cloud().me();
@@ -284,6 +295,13 @@ export async function onboardingReport(ctx, values, platform = process.platform)
284
295
  read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
285
296
  health = codexHealth(read.connector, null);
286
297
  }
298
+ else if (connect && synced && health === 'sign_in' && renewed(credentials) && home) {
299
+ // A new Codex sign-in on this computer: copy it, and the device starts again with it (never a restart).
300
+ await syncAgentCredentials(ctx.cloud(), { home, redact: value => ctx.addSecret(value) });
301
+ credentials = await listCredentials();
302
+ read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
303
+ health = codexHealth(read.connector, null);
304
+ }
287
305
  else if (connect && synced && values['retry-codex'] === true && (health === 'failed' || health === 'stuck' || health === 'attention')) {
288
306
  read = { connector: await restartCodexConnector(ctx), workspaceState: null };
289
307
  health = codexHealth(read.connector, null);
@@ -304,7 +322,7 @@ export async function onboardingReport(ctx, values, platform = process.platform)
304
322
  ...(!connected ? signIn : codexError instanceof ShardfluxApiError && codexError.reason === 'codex_chatgpt_login_required'
305
323
  ? { state: 'action', fact: codexError.message, action: { title: 'Sign in with ChatGPT', command: ['codex', 'login'] } }
306
324
  : (credentialError ?? codexError) !== undefined ? failure(credentialError ?? codexError)
307
- : codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health)) };
325
+ : codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health, renewed(credentials))) };
308
326
  const t3 = await t3Connection(ctx, connected || ctx.sessionSource() !== null, undefined, undefined, values['t3-connect'] === true, values);
309
327
  const connections = [account, claude, codex, ...(t3 ? [t3] : [])];
310
328
  // The cloud machine (0.20.2+), the same row `shard machine setup --status` reports: whether what is installed carries
@@ -1,3 +1,9 @@
1
1
  import type { Ctx } from './commands.js';
2
- /** Prepare only the selected project root. Never stage files or initialize a nested repository. */
2
+ /**
3
+ * Prepare only the selected project root; never a nested repository. A folder without Git becomes a repository whose
4
+ * first commit is its files (.gitignore respected; large untracked media stay untracked, as a twin snapshot leaves
5
+ * them): a conversation then starts from the same commit as the folder and its work fast-forwards back, as in a
6
+ * repository the person made (0.25.0+; before, an empty first commit left every file to a snapshot, and landings
7
+ * conflicted). A repository of the person's own keeps its staged and untracked files as they are.
8
+ */
3
9
  export declare function ensureProjectGit(ctx: Ctx, folder: string): Promise<void>;
@@ -1,8 +1,16 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { existsSync, realpathSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { gitRunner } from "./agent-git.js";
4
5
  import { GitError } from "./errors.js";
5
- /** Prepare only the selected project root. Never stage files or initialize a nested repository. */
6
+ import { largeUntrackedMedia, outsideSpecs } from "./large-media.js";
7
+ /**
8
+ * Prepare only the selected project root; never a nested repository. A folder without Git becomes a repository whose
9
+ * first commit is its files (.gitignore respected; large untracked media stay untracked, as a twin snapshot leaves
10
+ * them): a conversation then starts from the same commit as the folder and its work fast-forwards back, as in a
11
+ * repository the person made (0.25.0+; before, an empty first commit left every file to a snapshot, and landings
12
+ * conflicted). A repository of the person's own keeps its staged and untracked files as they are.
13
+ */
6
14
  export async function ensureProjectGit(ctx, folder) {
7
15
  const git = gitRunner(folder, { ...process.env, ...ctx.io.env, LC_ALL: 'C' }, ctx.signal, false);
8
16
  let initialized = false;
@@ -37,7 +45,12 @@ export async function ensureProjectGit(ctx, folder) {
37
45
  throw error;
38
46
  }
39
47
  // --only with no paths makes an empty commit even when the user already has staged files.
40
- const commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '--only', '-m', 'Initial commit'];
48
+ let commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '--only', '-m', 'Initial commit'];
49
+ if (initialized) {
50
+ const media = await largeUntrackedMedia(folder, ctx.store.dir, `init-${createHash('sha256').update(folder).digest('hex').slice(0, 16)}`, ctx.signal);
51
+ await git(['add', '-A', '--', '.', ...outsideSpecs(media)]);
52
+ commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '-m', 'Initial commit'];
53
+ }
41
54
  try {
42
55
  await git(commit);
43
56
  }
@@ -47,6 +60,6 @@ export async function ensureProjectGit(ctx, folder) {
47
60
  await git(['-c', 'user.name=Shardflux', '-c', 'user.email=noreply@shardflux.dev', ...commit]);
48
61
  }
49
62
  ctx.io.stderr.write(initialized
50
- ? `Initialized a git repository in ${folder} (shard tracks your project with git).\n`
63
+ ? `Initialized a git repository in ${folder} with its files as the first commit (shard tracks your project with git).\n`
51
64
  : `Created an initial empty commit in ${folder}.\n`);
52
65
  }
@@ -38,6 +38,13 @@ export interface SetupStatus {
38
38
  }[];
39
39
  }
40
40
  export declare function setupFailure(status: SetupStatus): CodedError;
41
+ /**
42
+ * A setup that failed only because the project has no build or test command (the guest's `.:checks` step, unresolved
43
+ * at detection) has nothing left to prepare: its files are in the cache and every other step succeeded. That is a
44
+ * clean ready, never an error that waits for an edit (0.25.0+; before, a plain folder from `projects link` stayed
45
+ * flagged).
46
+ */
47
+ export declare function nothingToCheck(status: SetupStatus): boolean;
41
48
  /** New content past this, or a dependency change, makes a quiet project worth preparing again. */
42
49
  export declare const PREPARE_AGAIN_BYTES: number;
43
50
  /** Quiet this long after the last edit before preparing again. */
@@ -40,6 +40,18 @@ export function setupFailure(status) {
40
40
  const fix = `${first}${step ? `, or set run for ${step.name} under steps in .shardflux/setup.yaml` : ''}; the project is prepared again after the change`;
41
41
  return new SetupFailed('project_setup_failed', message, fix, step ? { step: step.name, ...(step.error?.code ? { error_code: step.error.code } : {}) } : undefined);
42
42
  }
43
+ /**
44
+ * A setup that failed only because the project has no build or test command (the guest's `.:checks` step, unresolved
45
+ * at detection) has nothing left to prepare: its files are in the cache and every other step succeeded. That is a
46
+ * clean ready, never an error that waits for an edit (0.25.0+; before, a plain folder from `projects link` stayed
47
+ * flagged).
48
+ */
49
+ export function nothingToCheck(status) {
50
+ const failed = status.tasks?.filter(t => t.state === 'failed') ?? [];
51
+ return status.state === 'failed' && failed.length > 0
52
+ && failed.every(t => t.error?.code === 'setup_detection_unresolved' && /(^|:)checks$/.test(t.name))
53
+ && (status.tasks ?? []).every(t => t.state === 'failed' ? failed.includes(t) : ['succeeded', 'skipped'].includes(t.state));
54
+ }
43
55
  /** New content past this, or a dependency change, makes a quiet project worth preparing again. */
44
56
  export const PREPARE_AGAIN_BYTES = 8 << 20;
45
57
  /** Quiet this long after the last edit before preparing again. */
@@ -158,6 +170,8 @@ export async function prepareSelectedProject(ctx, selected) {
158
170
  if (status.exitCode !== 0)
159
171
  throw new Error(`Project setup status failed: ${status.stderr.trim()}`);
160
172
  const state = JSON.parse(status.stdout);
173
+ if (nothingToCheck(state))
174
+ break;
161
175
  if (state.state === 'failed')
162
176
  throw setupFailure(state);
163
177
  if (Date.now() > deadline)
@@ -23,20 +23,41 @@ export function dependencyHash(folder) {
23
23
  throw error;
24
24
  }
25
25
  const files = [];
26
- const walk = (relative, devcontainer) => {
27
- for (const name of readdirSync(join(folder, relative)).sort()) {
28
- const path = relative ? `${relative}/${name}` : name;
29
- const st = lstatSync(join(folder, path));
30
- const inside = devcontainer || name === '.devcontainer';
31
- if (st.isDirectory()) {
32
- if (!skippedDirs.has(name))
33
- walk(path, inside);
26
+ const wanted = (path, name, inside) => name !== '.env' && !name.startsWith('.env.') && (inside || path.startsWith('.github/workflows/') || dependencyName(name));
27
+ if (selected) {
28
+ // Git's list is the whole input: walking the folder visited every ignored and untracked tree too (a folder of
29
+ // agent worktrees held 1.2 million entries and cost 29 s of every start).
30
+ for (const path of selected) {
31
+ const parts = path.split('/'), name = parts.at(-1);
32
+ if (!name || parts.slice(0, -1).some(dir => skippedDirs.has(dir)) || !wanted(path, name, parts.includes('.devcontainer')))
33
+ continue;
34
+ let st;
35
+ try {
36
+ st = lstatSync(join(folder, path));
34
37
  }
35
- else if ((st.isFile() || st.isSymbolicLink()) && (!selected || selected.has(path)) && name !== '.env' && !name.startsWith('.env.') && (inside || path.startsWith('.github/workflows/') || dependencyName(name)))
38
+ catch {
39
+ continue;
40
+ }
41
+ if (st.isFile() || st.isSymbolicLink())
36
42
  files.push(path);
37
43
  }
38
- };
39
- walk('', false);
44
+ }
45
+ else {
46
+ const walk = (relative, devcontainer) => {
47
+ for (const name of readdirSync(join(folder, relative)).sort()) {
48
+ const path = relative ? `${relative}/${name}` : name;
49
+ const st = lstatSync(join(folder, path));
50
+ const inside = devcontainer || name === '.devcontainer';
51
+ if (st.isDirectory()) {
52
+ if (!skippedDirs.has(name))
53
+ walk(path, inside);
54
+ }
55
+ else if ((st.isFile() || st.isSymbolicLink()) && wanted(path, name, inside))
56
+ files.push(path);
57
+ }
58
+ };
59
+ walk('', false);
60
+ }
40
61
  const hash = createHash('sha256').update('shardflux-deps-v2\0');
41
62
  for (const path of files.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) {
42
63
  const st = lstatSync(join(folder, path));
@@ -0,0 +1,33 @@
1
+ import type { RouterResult, Shardflux, ShardfluxAccount } from '@shardflux/sdk';
2
+ import type { Values } from './args.js';
3
+ import type { Ctx, Handler } from './commands.js';
4
+ /** The API's bound on one input or question (characters). */
5
+ export declare const BOARD_TEXT_MAX = 4000;
6
+ interface BoardClient {
7
+ client: Shardflux | ShardfluxAccount;
8
+ /** The signed-in session: the organization's whole board. */
9
+ session: boolean;
10
+ }
11
+ /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
12
+ export declare function boardClient(ctx: Ctx): BoardClient;
13
+ interface Scope {
14
+ organizationId?: string;
15
+ projectId?: string;
16
+ }
17
+ /**
18
+ * Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
19
+ * only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
20
+ * theirs and names the fix (resolveOrg), and an organization that resolves is used.
21
+ */
22
+ export declare function inScope<T>(ctx: Ctx, values: Values, call: (client: Shardflux | ShardfluxAccount, scope: Scope) => Promise<T>): Promise<T>;
23
+ /**
24
+ * The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
25
+ * their titles).
26
+ *
27
+ * 2 tasks in site
28
+ * 1. Fix the landing page hero image
29
+ * fix the hero image on the landing page
30
+ */
31
+ export declare function routeText(r: RouterResult, names: ReadonlyMap<string, string>): string;
32
+ export declare const ROUTER_HANDLERS: Record<string, Handler>;
33
+ export {};
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `shard route` and `shard ask` (0.25.0+): the board's input bar from a terminal, through @shardflux/sdk's
3
+ * `router.route()` and `questions.stream()` / `questions.ask()`. The signed-in session reads every project of an
4
+ * organization; SHARDFLUX_API_KEY in the environment (or a saved key without a session) reads that key's project.
5
+ */
6
+ import { ShardfluxApiError } from '@shardflux/sdk';
7
+ import { UUID } from "./args.js";
8
+ import { findOrg, resolveOrg, resolveProject } from "./account-scope.js";
9
+ import { out, str } from "./commands.js";
10
+ import { EXIT, UsageError } from "./errors.js";
11
+ /** The API's bound on one input or question (characters). */
12
+ export const BOARD_TEXT_MAX = 4000;
13
+ const TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
14
+ /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
15
+ export function boardClient(ctx) {
16
+ if (!ctx.io.env.SHARDFLUX_API_KEY?.trim() && ctx.sessionSource() !== null)
17
+ return { client: ctx.account(), session: true };
18
+ return { client: ctx.cloud(), session: false };
19
+ }
20
+ /** --org / --project as ids (slugs resolved with the session); with neither, the saved default organization. */
21
+ async function scopeOf(ctx, values, b) {
22
+ const org = str(values.org), project = str(values.project);
23
+ if (!b.session) {
24
+ for (const [flag, v] of [['--org', org], ['--project', project]]) {
25
+ if (v !== undefined && !UUID.test(v))
26
+ throw new UsageError(`${flag} must be an id (UUID) with a project API key`, 'or sign in (shard auth login) to name it by slug');
27
+ }
28
+ return { scope: { ...(org === undefined ? {} : { organizationId: org }), ...(project === undefined ? {} : { projectId: project }) }, defaulted: false };
29
+ }
30
+ const account = b.client;
31
+ const scope = {};
32
+ if (org !== undefined)
33
+ scope.organizationId = UUID.test(org) ? org : (await findOrg(account, org)).id;
34
+ if (project !== undefined)
35
+ scope.projectId = UUID.test(project) ? project : (await resolveProject(ctx, account, values)).id;
36
+ if (org !== undefined || project !== undefined)
37
+ return { scope, defaulted: false };
38
+ const saved = ctx.store.profile(ctx.apiUrl).organization_id;
39
+ return { scope: saved ? { organizationId: saved } : {}, defaulted: true };
40
+ }
41
+ /**
42
+ * Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
43
+ * only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
44
+ * theirs and names the fix (resolveOrg), and an organization that resolves is used.
45
+ */
46
+ export async function inScope(ctx, values, call) {
47
+ const b = boardClient(ctx);
48
+ const { scope, defaulted } = await scopeOf(ctx, values, b);
49
+ try {
50
+ return await call(b.client, scope);
51
+ }
52
+ catch (err) {
53
+ if (!b.session || !defaulted || !(err instanceof ShardfluxApiError))
54
+ throw err;
55
+ const several = err.status === 422 && scope.organizationId === undefined && /organization/i.test(err.message);
56
+ const gone = err.status === 404 && scope.organizationId !== undefined;
57
+ if (!several && !gone)
58
+ throw err;
59
+ const org = await resolveOrg(ctx, b.client, {});
60
+ if (org.id === scope.organizationId)
61
+ throw err;
62
+ return call(b.client, { organizationId: org.id });
63
+ }
64
+ }
65
+ function textOf(words, what, command) {
66
+ const text = words.join(' ').trim();
67
+ if (!text)
68
+ throw new UsageError(`missing <${what}> for "shard ${command}"`);
69
+ if (text.length > BOARD_TEXT_MAX)
70
+ throw new UsageError(`the ${what} is ${text.length} characters; the limit is ${BOARD_TEXT_MAX}`);
71
+ return text;
72
+ }
73
+ function taskOf(values) {
74
+ const task = str(values.task);
75
+ if (task !== undefined && !TASK_ID.test(task))
76
+ throw new UsageError('--task must be a task id (task_…)');
77
+ return task;
78
+ }
79
+ /** Project names for the readable form (GET /v1/projects/{id}); an id that cannot be read prints as itself. */
80
+ async function projectNames(client, parts, signal) {
81
+ const ids = [...new Set(parts.filter((p) => !p.home && p.project_id).map((p) => p.project_id))];
82
+ const names = await Promise.all(ids.map((id) => client.request('GET', `/v1/projects/${encodeURIComponent(id)}`, { signal }).then((p) => p.name, () => id)));
83
+ return new Map(ids.map((id, i) => [id, names[i]]));
84
+ }
85
+ /**
86
+ * The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
87
+ * their titles).
88
+ *
89
+ * 2 tasks in site
90
+ * 1. Fix the landing page hero image
91
+ * fix the hero image on the landing page
92
+ */
93
+ export function routeText(r, names) {
94
+ const where = (p) => (p.home ? 'on your home' : p.project_id ? `in ${names.get(p.project_id) ?? p.project_id}` : '');
95
+ const parts = r.parts;
96
+ const same = parts.every((p) => where(p) === where(parts[0]));
97
+ let head = r.kind === 'task' && parts.length > 1 ? `${parts.length} tasks` : r.kind;
98
+ if (r.task_ref)
99
+ head += r.kind === 'steer' ? ` ${r.task_ref}` : ` about ${r.task_ref}`;
100
+ if (same && where(parts[0]))
101
+ head += ` ${where(parts[0])}`;
102
+ const notes = [r.forced === 'question' ? 'asked as a question' : '', r.uncertain ? 'project uncertain' : ''].filter(Boolean);
103
+ if (notes.length)
104
+ head += ` (${notes.join(', ')})`;
105
+ const lines = [head];
106
+ if (parts.length === 1) {
107
+ const p = parts[0];
108
+ if (p.title)
109
+ lines.push(` ${p.title}`);
110
+ lines.push(` ${p.text}${same ? '' : ` (${where(p)})`}`);
111
+ }
112
+ else {
113
+ parts.forEach((p, i) => {
114
+ lines.push(` ${i + 1}. ${p.title ?? p.text}${same ? '' : ` (${where(p)})`}`);
115
+ if (p.title)
116
+ lines.push(` ${p.text}`);
117
+ });
118
+ }
119
+ return `${lines.join('\n')}\n`;
120
+ }
121
+ const interrupted = (ctx, err) => {
122
+ if (ctx.signal.aborted)
123
+ return EXIT.interrupted;
124
+ throw err;
125
+ };
126
+ export const ROUTER_HANDLERS = {
127
+ async route(ctx, values, words) {
128
+ const text = textOf(words, 'text', 'route');
129
+ const selectedTaskId = taskOf(values);
130
+ try {
131
+ const { result, client } = await inScope(ctx, values, async (client, scope) => ({
132
+ client,
133
+ result: await client.router.route({ text, ...(selectedTaskId === undefined ? {} : { selectedTaskId }), ...scope, signal: ctx.signal }),
134
+ }));
135
+ if (ctx.json) {
136
+ out(ctx, '', result);
137
+ return EXIT.ok;
138
+ }
139
+ ctx.io.stdout.write(routeText(result, await projectNames(client, result.parts, ctx.signal)));
140
+ return EXIT.ok;
141
+ }
142
+ catch (err) {
143
+ return interrupted(ctx, err);
144
+ }
145
+ },
146
+ async ask(ctx, values, words) {
147
+ const text = textOf(words, 'question', 'ask');
148
+ const taskId = taskOf(values);
149
+ const params = { text, ...(taskId === undefined ? {} : { taskId }), signal: ctx.signal };
150
+ try {
151
+ if (ctx.json) {
152
+ out(ctx, '', await inScope(ctx, values, (client, scope) => client.questions.ask({ ...params, ...scope })));
153
+ return EXIT.ok;
154
+ }
155
+ // Standard output carries the answer as it is written; the place in line (while it waits) goes to stderr.
156
+ await inScope(ctx, values, async (client, scope) => {
157
+ let written = '';
158
+ for await (const e of client.questions.stream({ ...params, ...scope })) {
159
+ if (e.type === 'queued')
160
+ ctx.io.stderr.write(`Queued: position ${e.position}, about ${Math.max(1, Math.round(e.expectedWaitMs / 1000))} s\n`);
161
+ else if (e.type === 'delta') {
162
+ ctx.io.stdout.write(e.text);
163
+ written += e.text;
164
+ }
165
+ else {
166
+ if (!written)
167
+ ctx.io.stdout.write((written = e.answer.text));
168
+ if (!written.endsWith('\n'))
169
+ ctx.io.stdout.write('\n');
170
+ }
171
+ }
172
+ });
173
+ return EXIT.ok;
174
+ }
175
+ catch (err) {
176
+ return interrupted(ctx, err);
177
+ }
178
+ },
179
+ };
@@ -0,0 +1,150 @@
1
+ """Shardflux result landing on the project hub: runs in a VM of the project (shipped and started by the shard CLI,
2
+ land.ts), next to the hub on shared storage.
3
+
4
+ Input: one JSON object on stdin. Output: one JSON object on stdout (exit 0; "state" says what happened).
5
+
6
+ hub the project hub (a bare repository)
7
+ result the result branch (shard/<task id>): the master merged its workers' branches into it
8
+ sources worker branches; when the result branch is missing they are merged into it here, each only when the merge
9
+ is clean (a conflict is the master's to resolve: state "conflict", stage "integrate")
10
+ default the branch to land in (default: the hub's HEAD branch, else main or master)
11
+ title for the merge commit's message
12
+
13
+ The result commit T lands in the default branch D: a fast-forward when D is an ancestor of T, nothing when T is already
14
+ in D, else a merge commit when the merge is clean (a conflict: state "conflict", stage "default"; nothing changes). The
15
+ hub's branches move only by `git push` from a temporary repository, never by force, so a branch someone else moved
16
+ meanwhile is refused (stage "raced") instead of overwritten. Before answering "landed" it checks on the hub itself that
17
+ T is contained in D, and which sources are contained in T.
18
+ """
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+
26
+ IDENTITY = {'GIT_AUTHOR_NAME': 'Shardflux', 'GIT_AUTHOR_EMAIL': 'landing@shardflux.dev',
27
+ 'GIT_COMMITTER_NAME': 'Shardflux', 'GIT_COMMITTER_EMAIL': 'landing@shardflux.dev'}
28
+
29
+
30
+ class Fail(Exception):
31
+ def __init__(self, reason, message):
32
+ super().__init__(message)
33
+ self.reason = reason
34
+
35
+
36
+ def git(cwd, *args, ok=(0,), data=None):
37
+ env = dict(os.environ, GIT_TERMINAL_PROMPT='0', LC_ALL='C', **IDENTITY)
38
+ p = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', *args], cwd=cwd, env=env, input=data,
39
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
40
+ if p.returncode not in ok:
41
+ raise Fail('git', 'git %s failed (%d): %s' % (args[0], p.returncode, p.stderr.decode('utf-8', 'replace').strip()[-400:]))
42
+ return p
43
+
44
+
45
+ def text(cwd, *args, **kw):
46
+ return git(cwd, *args, **kw).stdout.decode('utf-8', 'surrogateescape').strip()
47
+
48
+
49
+ def tip(cwd, ref):
50
+ p = git(cwd, 'rev-parse', '-q', '--verify', ref + '^{commit}', ok=(0, 1, 128))
51
+ return p.stdout.decode().strip() if p.returncode == 0 else None
52
+
53
+
54
+ def ancestor(cwd, a, b):
55
+ return git(cwd, 'merge-base', '--is-ancestor', a, b, ok=(0, 1)).returncode == 0
56
+
57
+
58
+ def merge(repo, ours, theirs, message):
59
+ """A merge commit of theirs into ours when the merge is clean, else (None, conflicting paths)."""
60
+ r = git(repo, 'merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', ours, theirs, ok=(0, 1))
61
+ parts = r.stdout.decode('utf-8', 'surrogateescape').split('\0')
62
+ if r.returncode == 1:
63
+ return None, sorted(set(p for p in parts[1:] if p))
64
+ tree = parts[0].strip()
65
+ return text(repo, 'commit-tree', tree, '-p', ours, '-p', theirs, '-m', message), []
66
+
67
+
68
+ def push(repo, hub, sha, branch, old):
69
+ """Moves the hub's branch from old (None: create) to sha, never by force; False when it moved meanwhile."""
70
+ lease = '--force-with-lease=refs/heads/%s:%s' % (branch, old or '')
71
+ p = git(repo, 'push', '-q', '--no-verify', lease, hub, '%s:refs/heads/%s' % (sha, branch), ok=(0, 1))
72
+ return p.returncode == 0
73
+
74
+
75
+ def default_branch(hub, wanted):
76
+ if wanted:
77
+ return wanted
78
+ head = git(hub, 'symbolic-ref', '-q', 'HEAD', ok=(0, 1)).stdout.decode().strip()
79
+ if head.startswith('refs/heads/') and tip(hub, head):
80
+ return head[len('refs/heads/'):]
81
+ for name in ('main', 'master'):
82
+ if tip(hub, 'refs/heads/' + name):
83
+ return name
84
+ return head[len('refs/heads/'):] if head.startswith('refs/heads/') else 'main'
85
+
86
+
87
+ def land(a):
88
+ hub, result, title = a['hub'], a['result'], a.get('title') or a['result']
89
+ sources = [s for s in a.get('sources', []) if s and s != result]
90
+ into = default_branch(hub, a.get('default'))
91
+ work = tempfile.mkdtemp(prefix='shard-result-')
92
+ try:
93
+ git(work, 'init', '-q', '--bare')
94
+ wanted = [result, into] + sources
95
+ refs = ['+refs/heads/%s:refs/heads/%s' % (b, b) for b in wanted if tip(hub, 'refs/heads/' + b)]
96
+ if refs:
97
+ git(work, 'fetch', '-q', '--no-tags', hub, *refs)
98
+ have = {b: tip(work, 'refs/heads/' + b) for b in wanted}
99
+ before = have.get(into)
100
+ t, created = have.get(result), False
101
+ if not t:
102
+ # A master that did not merge its workers: merge them here, each only when it merges cleanly.
103
+ for s in sources:
104
+ st = have.get(s)
105
+ if not st:
106
+ continue
107
+ if not t or ancestor(work, t, st):
108
+ t = st
109
+ elif not ancestor(work, st, t):
110
+ t, conflicts = merge(work, t, st, 'Merge %s into %s' % (s, result))
111
+ if not t:
112
+ return {'state': 'conflict', 'stage': 'integrate', 'into': into, 'source': s, 'conflicts': conflicts}
113
+ if not t:
114
+ return {'state': 'missing', 'into': into, 'message': 'the project hub has no %s and no worker branch to make it from' % result}
115
+ if not push(work, hub, t, result, None):
116
+ return {'state': 'raced', 'stage': 'integrate', 'into': into, 'message': '%s appeared on the project hub meanwhile' % result}
117
+ created = True
118
+ if before and ancestor(work, t, before):
119
+ method, after = 'already', before
120
+ elif not before or ancestor(work, before, t):
121
+ method, after = ('created' if not before else 'ff'), t
122
+ else:
123
+ after, conflicts = merge(work, before, t, 'Land %s (%s)' % (title, result))
124
+ if not after:
125
+ return {'state': 'conflict', 'stage': 'default', 'into': into, 'result': t, 'before': before, 'conflicts': conflicts}
126
+ method = 'merge'
127
+ if after != before and not push(work, hub, after, into, before):
128
+ return {'state': 'raced', 'stage': 'default', 'into': into, 'result': t, 'message': '%s moved on the project hub meanwhile' % into}
129
+ # Checked on the hub itself: the result is in the branch it landed in.
130
+ now = tip(hub, 'refs/heads/' + into)
131
+ if not now or not ancestor(hub, t, now):
132
+ raise Fail('unverified', '%s does not contain %s on the project hub after landing' % (into, t))
133
+ contained = [s for s in sources if have.get(s) and ancestor(hub, have[s], t)]
134
+ base = (text(hub, 'merge-base', before, t, ok=(0, 1)) or None) if before else None
135
+ return {'state': 'landed', 'into': into, 'before': before, 'after': now, 'result': t, 'method': method,
136
+ 'created_result': created, 'contained': contained, 'merge_base': base}
137
+ finally:
138
+ shutil.rmtree(work, ignore_errors=True)
139
+
140
+
141
+ def main():
142
+ try:
143
+ out = land(json.load(sys.stdin))
144
+ except Fail as e:
145
+ out = {'state': 'error', 'reason': e.reason, 'message': str(e)}
146
+ sys.stdout.write(json.dumps(out) + '\n')
147
+
148
+
149
+ if __name__ == '__main__':
150
+ main()
@@ -0,0 +1,7 @@
1
+ import type { Transcript } from '@shardflux/sdk';
2
+ import type { Handler } from './commands.js';
3
+ /** What one transcription holds (the API's bound; past it the rest is not transcribed and `truncated` says so). */
4
+ export declare const TRANSCRIBE_MAX_BYTES: number;
5
+ /** The readable form: the text, then (with --words) one line per word with its start and end. */
6
+ export declare function transcriptText(t: Transcript, words: boolean): string;
7
+ export declare const TRANSCRIBE_HANDLERS: Record<string, Handler>;