@shardflux/cli 0.28.1 → 0.28.3

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.
@@ -6,6 +6,9 @@
6
6
  * a session) reaches the orchestrator of the member who created that key (person-scope.ts). Text output is one line
7
7
  * per input, tab-separated, id first; --json prints the API's objects.
8
8
  */
9
+ import { mkdtemp, open, unlink, rmdir } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join, basename } from 'node:path';
9
12
  import { inputSettled } from '@shardflux/sdk';
10
13
  import { parseDuration } from "./args.js";
11
14
  import { out, str } from "./commands.js";
@@ -13,6 +16,7 @@ import { EXIT, UsageError } from "./errors.js";
13
16
  import { humanBytes } from "./files-mirror.js";
14
17
  import { inScope } from "./person-scope.js";
15
18
  import { progress } from "./progress.js";
19
+ import { deviceOutcomeLines } from "./device-outcomes.js";
16
20
  /** --wait's default: how long `say --wait` follows an input before it stops (the input goes on). */
17
21
  export const SAY_WAIT_MS = 10 * 60_000;
18
22
  const oneLine = (s, max) => {
@@ -198,17 +202,54 @@ const idOf = (id) => {
198
202
  return id;
199
203
  };
200
204
  export const ORCHESTRATOR_HANDLERS = {
205
+ async 'orchestrator show'(ctx, values, [id]) {
206
+ const detail = await inScope(ctx, values, (client, scope) => client.orchestrator.input(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
207
+ out(ctx, [`${detail.input.id}\t${stateLabel(detail.input)}`, resultText(detail.input), ...deviceOutcomeLines(detail.device_requests)].filter(Boolean).join('\n') + '\n', detail);
208
+ return EXIT.ok;
209
+ },
201
210
  async 'orchestrator say'(ctx, values, words) {
202
211
  const text = words.join(' ');
203
- if (!text.trim())
204
- throw new UsageError('missing <words...> for "shard orchestrator say"');
212
+ const paths = Array.isArray(values.file) ? values.file : typeof values.file === 'string' ? [values.file] : [];
213
+ if (!text.trim() && !paths.length)
214
+ throw new UsageError('missing <words> for "shard orchestrator say"');
205
215
  const selection = selectionOf(values);
206
216
  const wait = values.wait === true;
207
217
  const timeoutMs = str(values.timeout) === undefined ? SAY_WAIT_MS : parseDuration(str(values.timeout), 'timeout');
208
218
  if (!wait && str(values.timeout) !== undefined)
209
219
  throw new UsageError('--timeout goes with --wait.');
210
220
  return inScope(ctx, values, async (client, scope) => {
211
- const input = await client.orchestrator.say(text, { ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
221
+ let spool;
222
+ let input;
223
+ const files = [];
224
+ try {
225
+ for (const path of paths) {
226
+ if (path !== '-') {
227
+ files.push({ path, name: basename(path) });
228
+ continue;
229
+ }
230
+ if (!spool) {
231
+ spool = await mkdtemp(join(ctx.io.env.HOME ?? tmpdir(), 'shard-input-'));
232
+ const sink = await open(join(spool, 'stdin'), 'wx', 0o600);
233
+ try {
234
+ for await (const chunk of ctx.io.stdin) {
235
+ ctx.signal.throwIfAborted();
236
+ await sink.writeFile(chunk);
237
+ }
238
+ }
239
+ finally {
240
+ await sink.close();
241
+ }
242
+ }
243
+ files.push({ path: join(spool, 'stdin'), name: str(values['file-name']) ?? 'stdin' });
244
+ }
245
+ input = await client.orchestrator.say({ text, files, ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
246
+ }
247
+ finally {
248
+ if (spool) {
249
+ await unlink(join(spool, 'stdin')).catch(() => undefined);
250
+ await rmdir(spool);
251
+ }
252
+ }
212
253
  if (!wait) {
213
254
  out(ctx, `${input.id}\t${stateLabel(input)}\n`, { input });
214
255
  return EXIT.ok;
@@ -0,0 +1,22 @@
1
+ import type { NeedsYouItem } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import type { LandDeps } from './land.js';
4
+ interface TaskAutoLandOptions {
5
+ needs(signal: AbortSignal): Promise<NeedsYouItem[]>;
6
+ land: LandDeps;
7
+ enabled(project: string): boolean;
8
+ log(line: string): void;
9
+ now?: () => number;
10
+ stateDir?: string;
11
+ }
12
+ export declare class TaskAutoLand {
13
+ #private;
14
+ readonly options: TaskAutoLandOptions;
15
+ constructor(options: TaskAutoLandOptions);
16
+ /** One serial pass. A conflict waits for a new result; a disconnected/busy folder or failed receipt retries. */
17
+ tick(signal: AbortSignal): Promise<void>;
18
+ }
19
+ /** Only projects actually linked on this computer; a task's repository is checked again before any VM is opened. */
20
+ export declare function taskLandProjects(ctx: Ctx): string[];
21
+ export declare function runTaskAutoLand(ctx: Ctx): Promise<void>;
22
+ export {};
@@ -0,0 +1,162 @@
1
+ /** Finished board tasks reach their linked folders even with the desktop board closed (next CLI release). */
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { conversationClient } from "./conversation-commands.js";
4
+ import { localConversations } from "./conversations.js";
5
+ import { landDeps, landTask } from "./land.js";
6
+ import { selectedProjects } from "./project-preparation.js";
7
+ import { TwinLedger } from "./worktree-twin.js";
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { writePrivateFile } from "./credentials.js";
11
+ export class TaskAutoLand {
12
+ #attempts = new Map();
13
+ options;
14
+ constructor(options) {
15
+ this.options = options;
16
+ if (options.stateDir) {
17
+ try {
18
+ const rows = JSON.parse(readFileSync(join(options.stateDir, 'task-auto-land.json'), 'utf8'));
19
+ for (const [id, row] of Object.entries(rows))
20
+ if (typeof row.key === 'string' && Number.isSafeInteger(row.tries)
21
+ && Number.isFinite(row.retryAt) && typeof row.blocked === 'boolean')
22
+ this.#attempts.set(id, row);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== 'ENOENT')
26
+ throw error;
27
+ }
28
+ }
29
+ }
30
+ #save() {
31
+ if (this.options.stateDir)
32
+ writePrivateFile(this.options.stateDir, join(this.options.stateDir, 'task-auto-land.json'), JSON.stringify(Object.fromEntries(this.#attempts)) + '\n');
33
+ }
34
+ /** One serial pass. A conflict waits for a new result; a disconnected/busy folder or failed receipt retries. */
35
+ async tick(signal) {
36
+ const now = this.options.now ?? Date.now, needs = await this.options.needs(signal);
37
+ // Keep the result key through disconnects and service restarts. A missing discovery page is not permission to
38
+ // repeat a conflict's steer or an already recorded landing. A master's next done report has a new key.
39
+ for (const item of needs) {
40
+ if (signal.aborted)
41
+ return;
42
+ if (item.kind !== 'land' || !item.task_id || !item.project_id || !this.options.enabled(item.project_id))
43
+ continue;
44
+ const key = `${item.id}@${item.since}`, prior = this.#attempts.get(item.id);
45
+ if (prior?.key === key && (prior.blocked || prior.retryAt > now()))
46
+ continue;
47
+ try {
48
+ const result = await landTask({ ...this.options.land, signal }, item.task_id, { automatic: true });
49
+ if (!result.recorded)
50
+ throw new Error(result.record_error ?? 'The task landing receipt was not recorded.');
51
+ this.#attempts.set(item.id, { key, tries: 0, retryAt: 0, blocked: true });
52
+ this.#save();
53
+ this.options.log(result.message);
54
+ }
55
+ catch (error) {
56
+ if (signal.aborted)
57
+ return;
58
+ const code = error.code;
59
+ const tries = prior?.key === key ? prior.tries + 1 : 1;
60
+ const blocked = code === 'land_conflict' || code === 'land_unrelated' || code === 'land_result_unready';
61
+ this.#attempts.set(item.id, { key, tries, blocked, retryAt: now() + Math.min(600_000, 1000 * 2 ** Math.min(tries, 9)) });
62
+ this.#save();
63
+ // Off and a folder on another computer need no warning. The existing one-per-task land item stays intact.
64
+ if (code !== 'land_automatic_off' && code !== 'land_device_unlinked')
65
+ this.options.log(`Task ${item.task_id}: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ /** Only projects actually linked on this computer; a task's repository is checked again before any VM is opened. */
71
+ export function taskLandProjects(ctx) {
72
+ const profile = ctx.store.profile(ctx.apiUrl), fallback = profile.project_id ?? profile.api_key?.project_id;
73
+ return [...new Set([
74
+ ...selectedProjects(ctx).map(p => p.project_id ?? fallback),
75
+ ...Object.values(localConversations(ctx)).filter(c => !c.handed_off).map(c => c.project_id),
76
+ ].filter((p) => Boolean(p)))];
77
+ }
78
+ export async function runTaskAutoLand(ctx) {
79
+ if (ctx.signal.aborted)
80
+ return;
81
+ const client = conversationClient(ctx), ledger = new TwinLedger(ctx.store.dir);
82
+ const profile = ctx.store.profile(ctx.apiUrl);
83
+ const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || profile.api_key !== undefined;
84
+ const log = (line) => { ctx.io.stderr.write(line + '\n'); };
85
+ const needs = new Map();
86
+ const watches = new Map();
87
+ const engine = new TaskAutoLand({
88
+ land: landDeps(ctx), enabled: project => ledger.mode(project) === 'auto', log, stateDir: ctx.store.dir,
89
+ needs: () => Promise.resolve([...needs.values()].flat()),
90
+ });
91
+ let landing, again = false;
92
+ const kick = () => {
93
+ again = true;
94
+ if (!landing)
95
+ landing = (async () => {
96
+ while (again && !ctx.signal.aborted) {
97
+ again = false;
98
+ await engine.tick(ctx.signal);
99
+ }
100
+ })().catch(error => { if (!ctx.signal.aborted)
101
+ log(`Task landing: ${error instanceof Error ? error.message : String(error)}`); })
102
+ .finally(() => { landing = undefined; });
103
+ return landing;
104
+ };
105
+ const follow = async (projectId, signal) => {
106
+ let failures = 0;
107
+ while (!signal.aborted) {
108
+ try {
109
+ const organizationId = key ? undefined : (await ctx.account().projects.get(projectId)).organization_id;
110
+ const scope = { projectId, ...(organizationId ? { organizationId } : {}), signal };
111
+ // Read once (including results that finished while offline), then use the held feed. No repeated whole-board
112
+ // reads while idle, and a slow project's feed cannot delay another project's landing or device heartbeats.
113
+ const board = await client.board.get(scope);
114
+ needs.set(projectId, board.needs_you);
115
+ void kick();
116
+ failures = 0;
117
+ for await (const update of client.board.watch({ ...scope, cursor: board.cursor })) {
118
+ if (update.reset)
119
+ needs.set(projectId, update.board.needs_you);
120
+ else if (update.needs_you !== null)
121
+ needs.set(projectId, update.needs_you);
122
+ void kick();
123
+ }
124
+ if (!signal.aborted)
125
+ throw new Error('The board feed ended before its device helper stopped.');
126
+ }
127
+ catch (error) {
128
+ if (signal.aborted)
129
+ return;
130
+ log(`Task landing discovery: ${error instanceof Error ? error.message : String(error)}`);
131
+ await delay(Math.min(60_000, 1000 * 2 ** Math.min(failures++, 6)), undefined, { signal }).catch(() => undefined);
132
+ }
133
+ }
134
+ };
135
+ try {
136
+ while (!ctx.signal.aborted) {
137
+ const projects = new Set(taskLandProjects(ctx).filter(p => ledger.mode(p) === 'auto'));
138
+ for (const [project, watch] of watches)
139
+ if (!projects.has(project)) {
140
+ watch.abort.abort();
141
+ await watch.work;
142
+ watches.delete(project);
143
+ needs.delete(project);
144
+ }
145
+ for (const project of projects)
146
+ if (!watches.has(project)) {
147
+ const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
148
+ const work = follow(project, signal);
149
+ watches.set(project, { abort, work });
150
+ }
151
+ // The local setting and deferred folder/receipt retries do not wait for a remote board event.
152
+ await kick();
153
+ await delay(2000, undefined, { signal: ctx.signal }).catch(() => undefined);
154
+ }
155
+ }
156
+ finally {
157
+ for (const watch of watches.values())
158
+ watch.abort.abort();
159
+ await Promise.all([...watches.values()].map(w => w.work));
160
+ await landing;
161
+ }
162
+ }
@@ -106,6 +106,12 @@ def land(a):
106
106
  have = {b: tip(work, 'refs/heads/' + b) for b in wanted}
107
107
  before = have.get(into)
108
108
  t, created = have.get(result), False
109
+ if a.get('require_result'):
110
+ if not t:
111
+ return {'state': 'unready', 'into': into, 'reason': 'result_missing'}
112
+ missing = [s for s in sources if not have.get(s) or not ancestor(work, have[s], t)]
113
+ if missing:
114
+ return {'state': 'unready', 'into': into, 'reason': 'workers_not_integrated', 'unintegrated': missing}
109
115
  if not t:
110
116
  # A master that did not merge its workers: merge them here, each only when it merges cleanly.
111
117
  for s in sources:
package/dist/vault.js CHANGED
@@ -28,20 +28,15 @@ export function vaultOutput(vault, name, options = {}) {
28
28
  return new Promise((resolve, reject) => {
29
29
  const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], env: options.env, signal: options.signal });
30
30
  const chunks = [];
31
- let size = 0;
32
31
  let failed = false;
33
32
  const timer = setTimeout(() => {
34
33
  failed = true;
35
34
  child.kill('SIGKILL');
36
35
  }, 30_000);
37
36
  child.stdout.on('data', (chunk) => {
38
- size += chunk.length;
39
- if (size > 65_538) {
40
- failed = true;
41
- child.kill('SIGKILL');
42
- }
43
- else
44
- chunks.push(chunk);
37
+ // Provider JSON includes metadata as well as the selected value. Enforce the secret store's value limit
38
+ // after extraction; a transport cap here rejected valid small values in larger items.
39
+ chunks.push(chunk);
45
40
  });
46
41
  child.on('error', () => {
47
42
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.28.1",
3
+ "version": "0.28.3",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -53,7 +53,7 @@
53
53
  "snappyjs": "0.7.0",
54
54
  "ws": "8.22.0",
55
55
  "yaml": "2.9.1",
56
- "@shardflux/sdk": "^0.30.1"
56
+ "@shardflux/sdk": "^0.30.3"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@eslint/js": "10.0.1",
@@ -65,10 +65,10 @@
65
65
  "typescript-eslint": "8.70.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@shardflux/desktop-darwin-arm64": "0.9.2",
69
- "@shardflux/desktop-darwin-x64": "0.9.2",
70
- "@shardflux/desktop-linux-arm64": "0.9.2",
71
- "@shardflux/desktop-linux-x64": "0.9.2"
68
+ "@shardflux/desktop-darwin-arm64": "0.9.4",
69
+ "@shardflux/desktop-darwin-x64": "0.9.4",
70
+ "@shardflux/desktop-linux-arm64": "0.9.4",
71
+ "@shardflux/desktop-linux-x64": "0.9.4"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "node scripts/build.mjs",