@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.
- package/CHANGELOG.md +33 -0
- package/README.md +49 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +16 -1
- package/dist/board-args.js +9 -1
- package/dist/board-commands.d.ts +1 -1
- package/dist/board-commands.js +50 -7
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +123 -6
- package/dist/conversations.d.ts +2 -0
- package/dist/conversations.js +1 -1
- package/dist/credential-device-capability.d.ts +8 -0
- package/dist/credential-device-capability.js +21 -0
- package/dist/credential-setup.d.ts +13 -0
- package/dist/credential-setup.js +170 -0
- package/dist/credential-sources.d.ts +34 -0
- package/dist/credential-sources.js +400 -0
- package/dist/device-bridge.d.ts +8 -1
- package/dist/device-bridge.js +46 -3
- package/dist/device-folders.d.ts +2 -1
- package/dist/device-folders.js +3 -3
- package/dist/device-helper.js +15 -7
- package/dist/device-mode.d.ts +7 -0
- package/dist/device-mode.js +13 -0
- package/dist/device-outcomes.d.ts +3 -0
- package/dist/device-outcomes.js +19 -0
- package/dist/handler-loader.js +2 -1
- package/dist/handlers.js +2 -1
- package/dist/land.d.ts +4 -1
- package/dist/land.js +32 -3
- package/dist/machine-setup.d.ts +1 -0
- package/dist/machine-setup.js +5 -2
- package/dist/main.js +30 -2
- package/dist/orchestrator-args.js +11 -1
- package/dist/orchestrator-commands.js +44 -3
- package/dist/task-auto-land.d.ts +22 -0
- package/dist/task-auto-land.js +162 -0
- package/dist/task-land-hub.py +6 -0
- package/dist/vault.js +3 -8
- package/package.json +6 -6
|
@@ -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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/task-land-hub.py
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
69
|
-
"@shardflux/desktop-darwin-x64": "0.9.
|
|
70
|
-
"@shardflux/desktop-linux-arm64": "0.9.
|
|
71
|
-
"@shardflux/desktop-linux-x64": "0.9.
|
|
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",
|