@shardflux/cli 0.24.1 → 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.
package/dist/land.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { Ctx } from './commands.js';
2
+ import { conversationClient } from './conversation-commands.js';
3
+ import type { TwinCell } from './worktree-twin.js';
4
+ /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
5
+ export declare function hubScript(): string;
6
+ export interface HubLanding {
7
+ state: 'landed' | 'conflict' | 'missing' | 'raced' | 'error';
8
+ into: string;
9
+ before?: string | null;
10
+ after?: string;
11
+ result?: string;
12
+ method?: 'ff' | 'merge' | 'already' | 'created';
13
+ created_result?: boolean;
14
+ contained?: string[];
15
+ merge_base?: string | null;
16
+ stage?: 'integrate' | 'default';
17
+ conflicts?: string[];
18
+ source?: string;
19
+ message?: string;
20
+ reason?: string;
21
+ }
22
+ /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
23
+ export declare function landOnHub(cell: TwinCell, input: {
24
+ result: string;
25
+ sources: string[];
26
+ title: string;
27
+ into?: string;
28
+ hub?: string;
29
+ }, signal?: AbortSignal): Promise<HubLanding>;
30
+ export interface FolderLanding {
31
+ path: string;
32
+ into: string;
33
+ state: 'landed' | 'already' | 'conflict' | 'waiting' | 'skipped';
34
+ sha?: string;
35
+ method?: string;
36
+ files?: number;
37
+ kept?: string[];
38
+ conflicts?: string[];
39
+ backup?: string | null;
40
+ reason?: string;
41
+ }
42
+ export interface FolderInput {
43
+ folder: string;
44
+ into: string;
45
+ name: string;
46
+ title: string;
47
+ branch: string;
48
+ base: string;
49
+ device: string;
50
+ stateDir: string;
51
+ hub?: string;
52
+ quietMs?: number;
53
+ signal?: AbortSignal;
54
+ /** How long a busy folder (a file just saved, a Git operation) is waited for before the answer is "waiting". */
55
+ patienceMs?: number;
56
+ }
57
+ /** The branch to land in: the one checked out in the folder, else the hub's default branch when the folder has it. */
58
+ export declare function folderBranch(folder: string, fallback: string): Promise<string | null>;
59
+ /**
60
+ * The result into the folder through the twin's landing (from the hub): the branch moves, the person's uncommitted
61
+ * edits stay, and the landed commit is checked to be on the branch.
62
+ */
63
+ export declare function landInFolder(cell: TwinCell, f: FolderInput): Promise<FolderLanding>;
64
+ /** The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started. */
65
+ export declare function linkedFolder(ctx: Pick<Ctx, 'store' | 'apiUrl'>, projectId: string, conversationId?: string): string | null;
66
+ export declare const displayPath: (path: string) => string;
67
+ export interface LandOutcome {
68
+ landed: true;
69
+ task_id?: string;
70
+ conversation_id?: string;
71
+ title: string;
72
+ result: {
73
+ branch: string;
74
+ sha: string;
75
+ };
76
+ hub: {
77
+ into: string;
78
+ sha: string;
79
+ method: string;
80
+ };
81
+ folder: (FolderLanding & {
82
+ display: string;
83
+ }) | null;
84
+ recorded: boolean;
85
+ record_error?: string;
86
+ /** One line for people: "Landed in ~/code/best-todo · 3f2a1c9". */
87
+ message: string;
88
+ }
89
+ /** What a landing needs: the API, a VM that reads the project hub, the person's folder for a project, this device. */
90
+ export interface LandDeps {
91
+ client: {
92
+ tasks: Pick<ReturnType<typeof conversationClient>['tasks'], 'get' | 'recordLanding' | 'steer'>;
93
+ conversations: Pick<ReturnType<typeof conversationClient>['conversations'], 'get' | 'land'>;
94
+ };
95
+ hubCell(projectId: string): Promise<{
96
+ cell: TwinCell;
97
+ close(): void;
98
+ }>;
99
+ folder(projectId: string, conversationId?: string): string | null;
100
+ device: string;
101
+ stateDir: string;
102
+ hub?: string;
103
+ quietMs?: number;
104
+ signal?: AbortSignal;
105
+ }
106
+ export declare function landDeps(ctx: Ctx): LandDeps;
107
+ /** What a landing did, for people: the line, then what was not recorded. */
108
+ export declare function landedText(o: LandOutcome): string;
109
+ /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
110
+ export declare function landTask(d: LandDeps, taskId: string, opts?: {
111
+ path?: string;
112
+ }): Promise<LandOutcome>;
113
+ /** `shard conversations land <id> merge`: the conversation's branch into the default branch and its folder, then recorded. */
114
+ export declare function landConversation(d: LandDeps, id: string, opts?: {
115
+ path?: string;
116
+ }): Promise<LandOutcome>;
package/dist/land.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Landing a result for real (0.25.0+): `shard task land <task>` and `shard conversations land <id> merge`.
3
+ *
4
+ * A board task has one result: its master merges its workers' branches into shard/<task id> on the project hub before it
5
+ * seals done (a master that did not is helped here: its workers' branches are merged into it when they merge cleanly).
6
+ * Landing then
7
+ * 1. merges the result into the project's default branch on the hub, in a VM of the project (task-land-hub.py): a
8
+ * fast-forward, or a merge commit when the merge is clean, never by force; and checks there that the result
9
+ * commit is contained in the branch;
10
+ * 2. brings it into the person's linked folder on this computer through the live twin's own landing
11
+ * (worktree-twin.ts landingRound from the hub): the branch moves to the new commits, uncommitted edits stay, every
12
+ * rewritten file is backed up first, and a Git operation in progress or a file being edited waits;
13
+ * 3. only then records it (POST /v1/tasks/{id}/land with the commit; for a conversation POST /land merge into the
14
+ * branch). A conflict changes nothing: the task's master is asked to merge (a steer) and nothing is recorded.
15
+ */
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+ import { conversationClient } from "./conversation-commands.js";
21
+ import { localConversations } from "./conversations.js";
22
+ import { CodedError } from "./errors.js";
23
+ import { selectedProjects } from "./project-preparation.js";
24
+ import { HUB_PATH, backupRoot, deviceId, git, gitText, inspectWorktree, landingRound, listWorktrees } from "./worktree-twin.js";
25
+ import { projectHubCell } from "./worktree-twin-engine.js";
26
+ let script;
27
+ /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
28
+ export function hubScript() { return script ??= readFileSync(new URL('./task-land-hub.py', import.meta.url), 'utf8'); }
29
+ /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
30
+ export async function landOnHub(cell, input, signal) {
31
+ const body = { hub: input.hub ?? HUB_PATH, result: input.result, sources: input.sources, title: input.title, ...(input.into ? { default: input.into } : {}) };
32
+ const r = await cell.exec.run(['python3', '-c', hubScript()], { stdin: JSON.stringify(body), timeoutMs: 300_000, maxOutputBytes: 1 << 20, ...(signal ? { signal } : {}) });
33
+ const line = r.stdout.trim().split('\n').at(-1) ?? '';
34
+ if (r.exitCode !== 0 || !line.startsWith('{'))
35
+ return { state: 'error', into: input.into ?? '', reason: 'script', message: `the landing script exited ${r.exitCode}: ${r.stderr.trim().slice(-400)}` };
36
+ return JSON.parse(line);
37
+ }
38
+ /** The branch to land in: the one checked out in the folder, else the hub's default branch when the folder has it. */
39
+ export async function folderBranch(folder, fallback) {
40
+ const info = await inspectWorktree(folder);
41
+ const own = info.branch?.replace(/^refs\/heads\//, '');
42
+ if (own && !own.startsWith('shard/'))
43
+ return own;
44
+ const has = await git(folder, ['rev-parse', '-q', '--verify', `refs/heads/${fallback}^{commit}`], { ok: [0, 1, 128] });
45
+ return has.code === 0 ? fallback : null;
46
+ }
47
+ /**
48
+ * The result into the folder through the twin's landing (from the hub): the branch moves, the person's uncommitted
49
+ * edits stay, and the landed commit is checked to be on the branch.
50
+ */
51
+ export async function landInFolder(cell, f) {
52
+ const deadline = Date.now() + (f.patienceMs ?? 20_000);
53
+ const target = (await listWorktrees(f.folder)).find(w => w.branch === `refs/heads/${f.into}`);
54
+ const repo = target?.root ?? f.folder;
55
+ for (let attempt = 0;; attempt++) {
56
+ const worktree = target ? await inspectWorktree(target.root) : null;
57
+ const o = await landingRound(cell, {
58
+ conversation: { id: f.name, name: f.name, title: f.title, summary: '', branch: f.branch, base_commit: f.base, worktree_path: '' },
59
+ into: f.into, mode: 'auto', device: f.device, repo, worktree, stateDir: f.stateDir, backupRoot: backupRoot(f.stateDir), fromHub: {},
60
+ ...(f.hub ? { hub: f.hub } : {}), ...(f.quietMs !== undefined ? { quietMs: f.quietMs } : {}), ...(f.signal ? { signal: f.signal } : {}),
61
+ });
62
+ const tip = async () => gitText(repo, ['rev-parse', `refs/heads/${f.into}`]);
63
+ switch (o.state) {
64
+ case 'landed': {
65
+ await o.confirmed.catch(() => undefined);
66
+ const landed = o.land.tip, now = await tip();
67
+ const on = await git(repo, ['merge-base', '--is-ancestor', landed, `refs/heads/${f.into}`], { ok: [0, 1] });
68
+ if (on.code !== 0)
69
+ throw new CodedError('land_unverified', `${f.into} in ${f.folder} does not contain the landed commit ${landed.slice(0, 7)}.`);
70
+ return { path: f.folder, into: f.into, state: 'landed', sha: now, method: o.land.method ?? 'landed', files: o.applied.files.length, kept: o.applied.raced, backup: o.applied.backup };
71
+ }
72
+ case 'noop': return { path: f.folder, into: f.into, state: 'already', sha: await tip() };
73
+ case 'conflict': return { path: f.folder, into: f.into, state: 'conflict', conflicts: o.land.conflicts ?? [] };
74
+ case 'error': throw new CodedError('land_failed', `Could not land into ${f.folder}: ${o.land.message ?? o.land.reason ?? 'the landing failed'}.`);
75
+ case 'pr':
76
+ case 'deferred': return { path: f.folder, into: f.into, state: 'waiting', reason: o.state };
77
+ default: {
78
+ // paused (a Git operation), moved (the branch or a file changed while landing), busy (a file just saved).
79
+ const wait = o.state === 'busy' ? Math.min(o.waitMs ?? 1000, 3000) : o.state === 'moved' ? 200 : 1000;
80
+ if (Date.now() + wait > deadline || attempt >= 20)
81
+ return { path: f.folder, into: f.into, state: 'waiting', reason: o.reason };
82
+ await delay(wait, undefined, f.signal ? { signal: f.signal } : {});
83
+ }
84
+ }
85
+ }
86
+ }
87
+ /** The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started. */
88
+ export function linkedFolder(ctx, projectId, conversationId) {
89
+ const repo = (folder) => folder && existsSync(join(folder, '.git')) ? folder : null;
90
+ const records = localConversations(ctx);
91
+ const own = conversationId ? repo(records[conversationId]?.folder) : null;
92
+ if (own)
93
+ return own;
94
+ // A folder linked with `shard projects link` belongs to the signed-in project until its preparation names one.
95
+ const profile = ctx.store.profile(ctx.apiUrl), mine = profile.project_id ?? profile.api_key?.project_id;
96
+ const linked = selectedProjects(ctx).filter(p => (p.project_id ?? mine) === projectId).map(p => repo(p.folder)).find(Boolean);
97
+ if (linked)
98
+ return linked;
99
+ return Object.values(records).filter(r => r.project_id === projectId).map(r => repo(r.folder)).find(Boolean) ?? null;
100
+ }
101
+ export const displayPath = (path) => { const home = homedir(); return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; };
102
+ const short = (sha) => (sha ?? '').slice(0, 7);
103
+ const list = (files) => files.length > 4 ? `${files.slice(0, 4).join(', ')} and ${files.length - 4} more` : files.join(', ');
104
+ function message(o) {
105
+ const f = o.folder;
106
+ if (f && (f.state === 'landed' || f.state === 'already'))
107
+ return `Landed in ${f.display} · ${short(f.sha)}`;
108
+ const hub = `Landed in ${o.hub.into} on the project hub · ${short(o.hub.sha)}`;
109
+ if (f?.state === 'conflict')
110
+ return `${hub}. Not in ${f.display}: your commits on ${f.into} change ${list(f.conflicts ?? [])} too.`;
111
+ if (f?.state === 'waiting')
112
+ return `${hub}. ${f.display} gets it once it is not busy (${f.reason ?? 'busy'}).`;
113
+ return hub;
114
+ }
115
+ export function landDeps(ctx) {
116
+ return { client: conversationClient(ctx), hubCell: p => projectHubCell(ctx, p, ctx.signal, 'shard-land'), folder: (p, c) => linkedFolder(ctx, p, c),
117
+ device: deviceId(ctx.store.dir), stateDir: ctx.store.dir, signal: ctx.signal };
118
+ }
119
+ async function land(d, l, onConflict) {
120
+ const vm = await d.hubCell(l.projectId);
121
+ try {
122
+ const input = { result: l.result, sources: l.sources.map(s => s.branch), title: l.title, ...(d.hub ? { hub: d.hub } : {}) };
123
+ let hub = await landOnHub(vm.cell, input, d.signal);
124
+ if (hub.state === 'raced')
125
+ hub = await landOnHub(vm.cell, input, d.signal);
126
+ if (hub.state === 'conflict') {
127
+ await onConflict(hub);
128
+ throw new CodedError('land_conflict', `Not landed: ${list(hub.conflicts ?? [])} changed on both sides (${hub.stage === 'integrate' ? `${hub.source} and ${l.result}` : `${l.result} and ${hub.into}`}).`, 'its master merges them and asks to land again', { stage: hub.stage, into: hub.into, conflicts: hub.conflicts ?? [] });
129
+ }
130
+ if (hub.state === 'missing')
131
+ throw new CodedError('land_nothing', `Nothing to land: the project hub has no ${l.result} yet.`, 'land after the work is done', { branch: l.result });
132
+ if (hub.state !== 'landed')
133
+ throw new CodedError('land_failed', `Could not land on the project hub: ${hub.message ?? hub.reason ?? hub.state}.`, 'try again in a moment', { stage: hub.stage ?? null });
134
+ const contained = l.sources.filter(s => hub.contained?.includes(s.branch)).map(s => s.conversation_id);
135
+ let folder = null;
136
+ if (l.folder) {
137
+ const into = await folderBranch(l.folder, hub.into);
138
+ if (!into)
139
+ folder = { path: l.folder, display: displayPath(l.folder), into: hub.into, state: 'skipped', reason: 'detached' };
140
+ else {
141
+ const f = await landInFolder(vm.cell, { folder: l.folder, into, name: l.name, title: l.title, branch: l.result, base: l.base ?? hub.merge_base ?? hub.before ?? hub.result,
142
+ device: d.device, stateDir: d.stateDir, ...(d.hub ? { hub: d.hub } : {}), ...(d.quietMs !== undefined ? { quietMs: d.quietMs } : {}), ...(d.signal ? { signal: d.signal } : {}) });
143
+ folder = { ...f, display: displayPath(f.path) };
144
+ }
145
+ }
146
+ return { title: l.title, result: { branch: l.result, sha: hub.result }, hub: { into: hub.into, sha: hub.after, method: hub.method }, folder, contained };
147
+ }
148
+ finally {
149
+ vm.close();
150
+ }
151
+ }
152
+ /** What a landing did, for people: the line, then what was not recorded. */
153
+ export function landedText(o) {
154
+ return `${o.message}\n${o.recorded ? '' : `Not recorded on the board: ${o.record_error ?? 'unknown'}.\n`}`;
155
+ }
156
+ const recordFailure = (error) => {
157
+ const e = error;
158
+ return e.status === 404 ? 'this API does not record task landings yet' : (e.message ?? String(error));
159
+ };
160
+ /** The task's current result: its master's latest final report, when it is done. */
161
+ function currentResult(d) {
162
+ const own = d.task.master_id ? d.reports.filter(r => r.author_id === d.task.master_id) : d.reports;
163
+ const final = [...own].reverse().find(r => r.milestone === 'done' || r.milestone === 'failed' || r.milestone === 'planned');
164
+ return final?.milestone === 'done' ? final.id : null;
165
+ }
166
+ const resultBranch = (taskId) => `shard/${taskId}`;
167
+ /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
168
+ export async function landTask(d, taskId, opts = {}) {
169
+ const client = d.client, signal = d.signal ? { signal: d.signal } : {};
170
+ const detail = await client.tasks.get(taskId, signal);
171
+ const task = detail.task, title = task.title ?? task.text.split('\n')[0].slice(0, 80);
172
+ const report = currentResult(detail);
173
+ if (!report || (task.state !== 'done' && task.state !== 'unverified'))
174
+ throw new CodedError('task_not_done', 'This task has no finished result to land yet.', 'land it after its master’s done report', { state: task.state });
175
+ const workers = [];
176
+ for (const c of task.conversations) {
177
+ if (c.role === 'master' || c.role === 'reviewer')
178
+ continue;
179
+ const w = await client.conversations.get(c.id);
180
+ if (w.join_mode !== 'reviewer' && (w.changes?.commits ?? 0) > 0 && w.landed?.how !== 'discarded')
181
+ workers.push(w);
182
+ }
183
+ const folder = opts.path ?? d.folder(task.project_id);
184
+ const done = await land(d, { projectId: task.project_id, title, name: taskId, result: resultBranch(taskId), sources: workers.map(w => ({ branch: w.branch, conversation_id: w.id })),
185
+ base: workers.find(w => w.base_commit)?.base_commit ?? null, folder }, async (hub) => {
186
+ const what = hub.stage === 'integrate' ? `${hub.source} into ${resultBranch(taskId)}` : `${resultBranch(taskId)} into ${hub.into}`;
187
+ await client.tasks.steer(taskId, `Land could not merge ${what}: ${list(hub.conflicts ?? [])} changed on both sides. Merge ${hub.into} from the project hub and every worker branch into ${resultBranch(taskId)}, resolve the conflicts so both changes work (ask the person only if the choice is theirs), run the tests, push ${resultBranch(taskId)} to the hub and seal done again.`, signal);
188
+ });
189
+ let recorded = false, record_error;
190
+ try {
191
+ await client.tasks.recordLanding(taskId, { sha: done.result.sha, into: done.hub.into, branch: done.result.branch, reportId: report, conversationIds: done.contained }, signal);
192
+ recorded = true;
193
+ }
194
+ catch (error) {
195
+ record_error = recordFailure(error);
196
+ // An API without task landings (before train 41) keeps a land item per worker: the workers whose branches are in
197
+ // the landed commit are recorded as merged into the branch, which is true, so those items go.
198
+ if (error.status === 404 && done.contained.length) {
199
+ let all = true;
200
+ for (const id of done.contained) {
201
+ try {
202
+ await client.conversations.land(id, 'merge', { into: done.hub.into, ...signal });
203
+ }
204
+ catch {
205
+ all = false;
206
+ }
207
+ }
208
+ if (all) {
209
+ recorded = true;
210
+ record_error = undefined;
211
+ }
212
+ }
213
+ }
214
+ const { title: t, result, hub, folder: f } = done;
215
+ const o = { title: t, result, hub, folder: f, landed: true, task_id: taskId, recorded, ...(record_error ? { record_error } : {}) };
216
+ return { ...o, message: message(o) };
217
+ }
218
+ /** `shard conversations land <id> merge`: the conversation's branch into the default branch and its folder, then recorded. */
219
+ export async function landConversation(d, id, opts = {}) {
220
+ const client = d.client;
221
+ const c = await client.conversations.get(id);
222
+ if (c.changes?.uncommitted)
223
+ throw new CodedError('uncommitted_changes', 'The conversation has uncommitted changes.', 'ask its agent to commit them, then land');
224
+ const folder = opts.path ?? d.folder(c.project_id, c.id);
225
+ const done = await land(d, { projectId: c.project_id, title: c.title || c.branch, name: c.name ?? c.branch.replace(/^shard\//, ''), result: c.branch, sources: [],
226
+ base: c.base_commit ?? null, folder }, () => Promise.resolve());
227
+ let recorded = false, record_error;
228
+ try {
229
+ await client.conversations.land(id, 'merge', { into: done.hub.into, ...(d.signal ? { signal: d.signal } : {}) });
230
+ recorded = true;
231
+ }
232
+ catch (error) {
233
+ record_error = recordFailure(error);
234
+ }
235
+ const { title: t, result, hub, folder: f } = done;
236
+ const o = { title: t, result, hub, folder: f, landed: true, conversation_id: id, recorded, ...(record_error ? { record_error } : {}) };
237
+ return { ...o, message: message(o) };
238
+ }
package/dist/main.js CHANGED
@@ -66,7 +66,11 @@ function hintFor(info, cred, file, command = '') {
66
66
  if (info.code === 'timeout' && info.operation_id)
67
67
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
68
68
  if (info.code === 'operation_failed' && info.retryable) {
69
- return info.details?.error_code === 'capacity_unavailable' ? 'nothing was started; run the same command again' : 'the operation may succeed if you retry it later.';
69
+ if (info.details?.error_code === 'capacity_unavailable') {
70
+ const retry = info.details.retry_after_seconds;
71
+ return `nothing was started; run the same command again${typeof retry === 'number' && retry > 0 ? ` after ${retry} s` : ''}`;
72
+ }
73
+ return 'the operation may succeed if you retry it later.';
70
74
  }
71
75
  if (info.code === 'interrupted' && info.operation_id)
72
76
  return `keep waiting with: shard operations wait ${info.operation_id}`;
@@ -175,6 +179,8 @@ function allowanceHint(info) {
175
179
  const resets = typeof info.details?.resets_at === 'string' ? ` (the period resets ${time(info.details.resets_at)})` : '';
176
180
  switch (info.details?.reason) {
177
181
  case 'allowance_used':
182
+ if (info.details?.spend_cap === null)
183
+ return 'choose a paid plan: shard billing plans; overage is unavailable on the current plan';
178
184
  return `upgrade (shard billing plans), or turn on overage: shard billing overage on --cap <dollars>${resets}`;
179
185
  case 'spend_cap_reached':
180
186
  return `the spend cap is reached: raise it (shard billing overage cap <dollars>, at most the plan price) or upgrade${resets}`;
@@ -111,7 +111,7 @@ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: No
111
111
  };
112
112
  conversation: {
113
113
  id: string;
114
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
114
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
115
115
  summary: string;
116
116
  } | null;
117
117
  commands: never[];
@@ -142,7 +142,7 @@ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: No
142
142
  };
143
143
  conversation: {
144
144
  id: string;
145
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
145
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
146
146
  summary: string;
147
147
  } | null;
148
148
  commands: string[];
@@ -195,7 +195,7 @@ export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
195
195
  };
196
196
  conversation: {
197
197
  id: string;
198
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
198
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
199
199
  summary: string;
200
200
  } | null;
201
201
  commands: never[];
@@ -226,7 +226,7 @@ export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
226
226
  };
227
227
  conversation: {
228
228
  id: string;
229
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
229
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
230
230
  summary: string;
231
231
  } | null;
232
232
  commands: string[];
@@ -1,4 +1,5 @@
1
1
  import type { RouterResult, Shardflux, ShardfluxAccount } from '@shardflux/sdk';
2
+ import type { Values } from './args.js';
2
3
  import type { Ctx, Handler } from './commands.js';
3
4
  /** The API's bound on one input or question (characters). */
4
5
  export declare const BOARD_TEXT_MAX = 4000;
@@ -9,6 +10,16 @@ interface BoardClient {
9
10
  }
10
11
  /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
11
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>;
12
23
  /**
13
24
  * The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
14
25
  * their titles).
@@ -43,7 +43,7 @@ async function scopeOf(ctx, values, b) {
43
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
44
  * theirs and names the fix (resolveOrg), and an organization that resolves is used.
45
45
  */
46
- async function inScope(ctx, values, call) {
46
+ export async function inScope(ctx, values, call) {
47
47
  const b = boardClient(ctx);
48
48
  const { scope, defaulted } = await scopeOf(ctx, values, b);
49
49
  try {
@@ -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>;