@velven/cli 0.4.0 → 0.5.1
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 +71 -0
- package/README.md +79 -23
- package/dist/velven.js +855 -182
- package/package.json +1 -1
package/dist/velven.js
CHANGED
|
@@ -23,14 +23,13 @@ class CliError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
// package.json
|
|
26
|
-
var version = "0.
|
|
26
|
+
var version = "0.5.1";
|
|
27
27
|
|
|
28
28
|
// src/version.ts
|
|
29
29
|
var VERSION = version;
|
|
30
30
|
|
|
31
31
|
// src/api.ts
|
|
32
32
|
var USER_AGENT = `velven-cli/${VERSION}`;
|
|
33
|
-
var REFUSED_CODES = new Set(["not_owner", "too_large", "too_many_files", "not_found", "invalid_fields", "forbidden"]);
|
|
34
33
|
function errorFromResponse(status, body, path, creds, retryAfter) {
|
|
35
34
|
const b = body && typeof body === "object" ? body : {};
|
|
36
35
|
const errorText = typeof b.error === "string" ? b.error : undefined;
|
|
@@ -59,7 +58,7 @@ function errorFromResponse(status, body, path, creds, retryAfter) {
|
|
|
59
58
|
if (!body && status === 404) {
|
|
60
59
|
return new CliError(`This Velven does not answer ${path}. Check VELVEN_API.`, EXIT.failed, "not_found");
|
|
61
60
|
}
|
|
62
|
-
if (
|
|
61
|
+
if (status >= 400) {
|
|
63
62
|
return new CliError(sentence ?? `Velven refused the request (HTTP ${status}).`, EXIT.refused, code, fields);
|
|
64
63
|
}
|
|
65
64
|
return new CliError(sentence ?? `Unexpected answer from Velven (HTTP ${status}).`, EXIT.failed, code);
|
|
@@ -75,8 +74,9 @@ function requestHeaders(api, accept) {
|
|
|
75
74
|
function causeOf(e) {
|
|
76
75
|
return e instanceof Error && e.cause instanceof Error ? e.cause.message : e instanceof Error ? e.message : String(e);
|
|
77
76
|
}
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
var RETRY_PAUSE_MS = 1000;
|
|
78
|
+
async function call(api, method, path, body, options = {}) {
|
|
79
|
+
return answerOf(api, path, body === undefined ? undefined : { type: "application/json", data: JSON.stringify(body) }, method, options.retryOnce?.sleep);
|
|
80
80
|
}
|
|
81
81
|
async function putBytes(url, bytes, type, tooLarge) {
|
|
82
82
|
let response;
|
|
@@ -91,30 +91,44 @@ async function putBytes(url, bytes, type, tooLarge) {
|
|
|
91
91
|
if (!response.ok)
|
|
92
92
|
throw new CliError(`The picture's upload was refused (HTTP ${response.status}). Try again in a moment.`, response.status >= 500 ? EXIT.failed : EXIT.refused, `http_${response.status}`);
|
|
93
93
|
}
|
|
94
|
-
async function answerOf(api, path, body, method) {
|
|
94
|
+
async function answerOf(api, path, body, method, retry) {
|
|
95
95
|
const headers = requestHeaders(api, "application/json");
|
|
96
96
|
if (body !== undefined)
|
|
97
97
|
headers["content-type"] = body.type;
|
|
98
|
-
let
|
|
99
|
-
|
|
100
|
-
response
|
|
101
|
-
|
|
102
|
-
throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
|
|
103
|
-
}
|
|
104
|
-
const text = await response.text();
|
|
105
|
-
let parsed = null;
|
|
106
|
-
if (text) {
|
|
98
|
+
for (let attempt = 0;; attempt++) {
|
|
99
|
+
const again = retry !== undefined && attempt === 0;
|
|
100
|
+
let response;
|
|
101
|
+
let text;
|
|
107
102
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
response = await fetch(`${api.base}${path}`, { method, headers, body: body?.data });
|
|
104
|
+
text = await response.text();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (again) {
|
|
107
|
+
await retry(RETRY_PAUSE_MS);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
|
|
111
|
+
}
|
|
112
|
+
let parsed = null;
|
|
113
|
+
if (text) {
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(text);
|
|
116
|
+
} catch {
|
|
117
|
+
parsed = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const error = errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
|
|
122
|
+
if (again && response.status >= 500 && error.code !== "workflow_unavailable") {
|
|
123
|
+
await retry(RETRY_PAUSE_MS);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
111
127
|
}
|
|
128
|
+
if (parsed === null)
|
|
129
|
+
throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
|
|
130
|
+
return parsed;
|
|
112
131
|
}
|
|
113
|
-
if (!response.ok)
|
|
114
|
-
throw errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
|
|
115
|
-
if (parsed === null)
|
|
116
|
-
throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
|
|
117
|
-
return parsed;
|
|
118
132
|
}
|
|
119
133
|
function seg(value) {
|
|
120
134
|
return encodeURIComponent(String(value));
|
|
@@ -132,7 +146,8 @@ async function stream(api, path, options, onEvent) {
|
|
|
132
146
|
async function connect() {
|
|
133
147
|
let got = false;
|
|
134
148
|
const connectionKeys = new Set;
|
|
135
|
-
const
|
|
149
|
+
const start = options.start && lastId === null ? "from" in options.start ? `from=${options.start.from}` : `after=${options.start.after}` : null;
|
|
150
|
+
const url = start ? `${path}${path.includes("?") ? "&" : "?"}${start}` : path;
|
|
136
151
|
const headers = requestHeaders(api, "text/event-stream");
|
|
137
152
|
if (lastId !== null)
|
|
138
153
|
headers["last-event-id"] = lastId;
|
|
@@ -335,10 +350,9 @@ async function login(ctx) {
|
|
|
335
350
|
ctx.out("Opened it in your browser. Waiting for you to approve...");
|
|
336
351
|
else
|
|
337
352
|
ctx.out("Open that link in a browser. Waiting for you to approve...");
|
|
338
|
-
|
|
339
|
-
const deadline = Date.now() + (started.expires_in || 900) * 1000;
|
|
353
|
+
const deadline = Date.now() + started.expires_in * 1000;
|
|
340
354
|
while (Date.now() < deadline) {
|
|
341
|
-
await ctx.sleep(interval * 1000);
|
|
355
|
+
await ctx.sleep(started.interval * 1000);
|
|
342
356
|
try {
|
|
343
357
|
const token = await call(api, "POST", "/api/agent/token", { device_code: started.device_code });
|
|
344
358
|
await saveToken(ctx, base, { token: token.access_token, handle: token.handle });
|
|
@@ -351,10 +365,6 @@ async function login(ctx) {
|
|
|
351
365
|
throw e;
|
|
352
366
|
if (e.code === "authorization_pending")
|
|
353
367
|
continue;
|
|
354
|
-
if (e.code === "slow_down") {
|
|
355
|
-
interval += 5;
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
368
|
if (e.code === "access_denied")
|
|
359
369
|
throw new CliError("The sign-in was denied. Nothing was saved.", EXIT.auth, "access_denied");
|
|
360
370
|
if (e.code === "expired_token" || e.code === "invalid_grant")
|
|
@@ -488,12 +498,20 @@ function nodeContext() {
|
|
|
488
498
|
}
|
|
489
499
|
|
|
490
500
|
// src/create.ts
|
|
501
|
+
import { randomUUID } from "node:crypto";
|
|
491
502
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
492
503
|
import { resolve } from "node:path";
|
|
493
504
|
|
|
494
505
|
// src/create-state.ts
|
|
495
506
|
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
496
507
|
import { dirname as dirname2, join as join2 } from "node:path";
|
|
508
|
+
function questionOf(value) {
|
|
509
|
+
if (!value || typeof value !== "object")
|
|
510
|
+
return;
|
|
511
|
+
const q = value;
|
|
512
|
+
const choices = Array.isArray(q.choices) && q.choices.every((c) => typeof c === "string") ? q.choices : null;
|
|
513
|
+
return typeof q.project === "string" && typeof q.item === "string" && choices?.length ? { project: q.project, item: q.item, choices } : undefined;
|
|
514
|
+
}
|
|
497
515
|
function createStatePath(ctx) {
|
|
498
516
|
return join2(ctx.home, ".config", "velven", "create.json");
|
|
499
517
|
}
|
|
@@ -513,9 +531,11 @@ async function readCreateFile(ctx) {
|
|
|
513
531
|
}
|
|
514
532
|
async function readCreateState(ctx, api) {
|
|
515
533
|
const state = (await readCreateFile(ctx)).apis[api] ?? {};
|
|
534
|
+
const question = questionOf(state.question);
|
|
516
535
|
return {
|
|
517
536
|
...typeof state.project === "string" && state.project ? { project: state.project } : {},
|
|
518
|
-
...Number.isInteger(state.turn) ? { turn: state.turn } : {}
|
|
537
|
+
...Number.isInteger(state.turn) ? { turn: state.turn } : {},
|
|
538
|
+
...question ? { question } : {}
|
|
519
539
|
};
|
|
520
540
|
}
|
|
521
541
|
async function saveCreateState(ctx, api, patch) {
|
|
@@ -523,6 +543,8 @@ async function saveCreateState(ctx, api, patch) {
|
|
|
523
543
|
const next = { ...file.apis[api], ...patch };
|
|
524
544
|
if (next.turn === undefined)
|
|
525
545
|
delete next.turn;
|
|
546
|
+
if (next.question === undefined)
|
|
547
|
+
delete next.question;
|
|
526
548
|
file.apis[api] = next;
|
|
527
549
|
const path = createStatePath(ctx);
|
|
528
550
|
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
@@ -531,13 +553,21 @@ async function saveCreateState(ctx, api, patch) {
|
|
|
531
553
|
await chmod2(path, 384);
|
|
532
554
|
}
|
|
533
555
|
|
|
556
|
+
// src/text.ts
|
|
557
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
558
|
+
function when(iso) {
|
|
559
|
+
if (!iso)
|
|
560
|
+
return "";
|
|
561
|
+
const d = new Date(iso);
|
|
562
|
+
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
563
|
+
}
|
|
564
|
+
|
|
534
565
|
// src/create-timeline.ts
|
|
535
566
|
var BAR_WIDTH = 60;
|
|
536
567
|
var FOLD_OVER = 20;
|
|
537
568
|
var LABEL_MAX = 44;
|
|
538
569
|
var CHAIN_SLACK_MS = 50;
|
|
539
570
|
var PHASE_ORDER = ["routing", "workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
|
|
540
|
-
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
541
571
|
function seconds(ms) {
|
|
542
572
|
return `${(ms / 1000).toFixed(1)} s`;
|
|
543
573
|
}
|
|
@@ -741,7 +771,7 @@ function renderTimeline(turn, spans, options = {}) {
|
|
|
741
771
|
return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
|
|
742
772
|
}
|
|
743
773
|
const spanEnd = Math.max(...spans.map(endOf));
|
|
744
|
-
const total = Math.max(spanEnd, turn.timing
|
|
774
|
+
const total = Math.max(spanEnd, turn.timing.total_ms ?? 0);
|
|
745
775
|
const critical = criticalPath(spans);
|
|
746
776
|
const { roots, children } = treeOf(spans);
|
|
747
777
|
const rows = [];
|
|
@@ -784,7 +814,7 @@ function renderTimeline(turn, spans, options = {}) {
|
|
|
784
814
|
visit(span, 0);
|
|
785
815
|
const cut = (label) => label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 3)}...` : label;
|
|
786
816
|
const width = Math.max(...rows.map((r) => cut(r.label).length), 4);
|
|
787
|
-
const turnTotal = turn.timing
|
|
817
|
+
const turnTotal = turn.timing.total_ms ?? spanEnd;
|
|
788
818
|
const lines = [
|
|
789
819
|
`Turn ${turn.id}, a ${kind}, ${turn.status}: ${duration(turnTotal)}, ${plural(spans.length, "span")}${firsts(turn.timing)}. * marks the critical path.`,
|
|
790
820
|
`${"span".padEnd(width)} ${"0 s".padEnd(BAR_WIDTH / 2 + 1)}${seconds(total).padStart(BAR_WIDTH / 2 + 1)} ${"start".padStart(8)} ${"time".padStart(8)}`
|
|
@@ -815,9 +845,7 @@ function turnKind(turn, spans) {
|
|
|
815
845
|
return `${turn.effort} ${work === "fast" || work === "full" ? `${work} ` : ""}${what}`;
|
|
816
846
|
}
|
|
817
847
|
function firsts(timing) {
|
|
818
|
-
|
|
819
|
-
return "";
|
|
820
|
-
const say = (ms) => typeof ms === "number" ? duration(ms) : "-";
|
|
848
|
+
const say = (ms) => ms === null ? "-" : duration(ms);
|
|
821
849
|
return `; live link ${say(timing.first_dev_ms)}, first change ${say(timing.first_write_ms)}, first playable ${say(timing.first_preview_ms)}`;
|
|
822
850
|
}
|
|
823
851
|
var isEngineRun = (s) => s.kind === "step" && s.name === "engine_programmer";
|
|
@@ -850,11 +878,7 @@ function engineSummary(spans) {
|
|
|
850
878
|
if (!run)
|
|
851
879
|
return null;
|
|
852
880
|
const a = run.attrs ?? {};
|
|
853
|
-
const
|
|
854
|
-
const steps = num(a.steps) ?? kids.filter((s) => s.kind === "model").length;
|
|
855
|
-
const tools = num(a.tools) ?? kids.filter((s) => s.kind === "tool").length;
|
|
856
|
-
const images = num(a.images) ?? kids.filter((s) => s.kind === "tool" && s.name === "make_image").length;
|
|
857
|
-
const figures = [plural(steps, "model step"), plural(tools, "tool call"), plural(images, "image")];
|
|
881
|
+
const figures = [plural(num(a.steps) ?? 0, "model step"), plural(num(a.tools) ?? 0, "tool call"), plural(num(a.images) ?? 0, "image")];
|
|
858
882
|
if (a.cut === true)
|
|
859
883
|
figures.push("cut short");
|
|
860
884
|
return `in ${duration(run.ms)}: ${figures.join(", ")}`;
|
|
@@ -878,19 +902,19 @@ function renderStats(groups, options) {
|
|
|
878
902
|
const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
|
|
879
903
|
if (groups.length === 0)
|
|
880
904
|
return [`No turns finished in ${over}.`];
|
|
881
|
-
const per = options.microsPerCredit
|
|
882
|
-
const credits = (micros) => micros === null ||
|
|
905
|
+
const per = options.microsPerCredit;
|
|
906
|
+
const credits = (micros) => micros === null || per === null ? "-" : String(Math.ceil(micros / per));
|
|
883
907
|
const cents = (micros) => micros === null ? "-" : (micros / 1e4).toFixed(1);
|
|
884
908
|
const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
|
|
885
909
|
for (const g of groups) {
|
|
886
910
|
lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
|
|
887
|
-
const reacted = REACTION_WORDS.filter(([key]) => (g.reactions
|
|
911
|
+
const reacted = REACTION_WORDS.filter(([key]) => (g.reactions[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
|
|
888
912
|
if (reacted.length)
|
|
889
913
|
lines.push(` the next message: ${reacted.join(", ")}`);
|
|
890
914
|
const figures = [
|
|
891
915
|
["figure", "p50", "p90", "turns"],
|
|
892
916
|
["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
|
|
893
|
-
["first change", sec(g.first_write_ms
|
|
917
|
+
["first change", sec(g.first_write_ms.p50), sec(g.first_write_ms.p90), ""],
|
|
894
918
|
["first playable", sec(g.first_preview_ms.p50), sec(g.first_preview_ms.p90), ""],
|
|
895
919
|
["whole turn", sec(g.total_ms.p50), sec(g.total_ms.p90), ""]
|
|
896
920
|
];
|
|
@@ -937,7 +961,9 @@ Usage:
|
|
|
937
961
|
velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn.
|
|
938
962
|
Velven picks how much to do (a quick change, a full build with a playtest,
|
|
939
963
|
a plan; on a new game a question or two, then a short plan to shape) unless
|
|
940
|
-
you pass --effort or a plan flag. Sent while a turn runs, it
|
|
964
|
+
you pass --effort or a plan flag. Sent while a turn runs, it joins that turn
|
|
965
|
+
when the turn is writing code, else waits for the turn to end (with
|
|
966
|
+
--effort, a plan flag, --build, --ref or --answer it always waits)
|
|
941
967
|
--project <id> A project other than the current one
|
|
942
968
|
--effort quick|standard|deep How much the turn may do, instead of Velven's pick. The project keeps it
|
|
943
969
|
for a later message that passes only a plan flag; a message with neither
|
|
@@ -947,15 +973,32 @@ Usage:
|
|
|
947
973
|
a new standard or deep, off for quick)
|
|
948
974
|
--build Build it: no more questions, Velven fills in what the plan leaves open
|
|
949
975
|
(Velven still picks how much)
|
|
976
|
+
--answer <n> Answer the open question with its choice n (its words are sent, unless
|
|
977
|
+
you give your own)
|
|
978
|
+
--queue Sent while a turn runs: wait for it to end instead of joining it
|
|
950
979
|
--ref <file|ref-n> A reference picture (PNG, JPEG or WebP, up to 10 MB) sent with the message, or
|
|
951
980
|
one the project keeps by its id (ref-3); up to 4
|
|
952
981
|
--yes, -y Do not ask before sending
|
|
953
982
|
--detach Send it and return at once; follow it later with watch
|
|
954
983
|
velven create watch [turn] Follow a turn to its end (default: the last one)
|
|
955
984
|
--from <n> Start from this event
|
|
956
|
-
velven create
|
|
985
|
+
velven create watch <project> Follow a project's chat: its turns, messages, pictures, queue and versions
|
|
986
|
+
--from <n> Only what came after this event
|
|
987
|
+
--until-idle End once no turn runs, no preview is being rebuilt and no message waits
|
|
988
|
+
to start (one on a paused queue waits for resume)
|
|
989
|
+
velven create cancel [turn] Stop a turn: what it finished is saved, unused credits come back, and the
|
|
990
|
+
messages waiting on the project pause until you resume them
|
|
991
|
+
velven create resume Start the messages waiting on the project again after a failed or stopped
|
|
992
|
+
turn paused them
|
|
993
|
+
--project <id> A project other than the current one
|
|
994
|
+
velven create versions The project's versions, newest first: each saved turn, and 0, the template
|
|
995
|
+
--project <id> A project other than the current one
|
|
996
|
+
velven create restore <version> Go back (or forward) to a version: instant and free; Velven rebuilds its
|
|
997
|
+
preview when it expired
|
|
998
|
+
--project <id> A project other than the current one
|
|
957
999
|
velven create list Your projects (* is the current one)
|
|
958
|
-
velven create show [project] A project, its newest turn and its preview link (a fresh
|
|
1000
|
+
velven create show [project] A project, what it is doing, its newest turn and its preview link (a fresh
|
|
1001
|
+
24-hour link)
|
|
959
1002
|
--timeline The newest turn's timeline instead: a waterfall of every step, model and
|
|
960
1003
|
tool call, the critical path marked (* at the line's end)
|
|
961
1004
|
--turn <id> With --timeline: this turn instead of the project's newest
|
|
@@ -967,8 +1010,10 @@ Usage:
|
|
|
967
1010
|
velven create refs rm <ref-n> Delete a picture, sent or not; the messages that carried it keep its number
|
|
968
1011
|
velven create plan The project's short plan: what the game is, how it plays, its look
|
|
969
1012
|
--project <id> A project other than the current one
|
|
970
|
-
velven create queue The messages waiting
|
|
1013
|
+
velven create queue The messages waiting on the project, in the order they run
|
|
971
1014
|
--project <id> A project other than the current one
|
|
1015
|
+
velven create queue edit <id> <text...> Change a waiting message's words (--build: send it as Build it)
|
|
1016
|
+
velven create queue move <id> <place> Move a waiting message to a place in line (1 runs next)
|
|
972
1017
|
velven create queue rm <id> Remove a waiting message
|
|
973
1018
|
velven create credits Your credits and the latest entries
|
|
974
1019
|
velven create usage What each of your turns held and used, newest first
|
|
@@ -978,14 +1023,14 @@ Usage:
|
|
|
978
1023
|
--days <n> Turns created in the last n days, 1 to 90 (default 7)
|
|
979
1024
|
--origin <origin> Only turns sent from web, api, cli, mcp or benchmark
|
|
980
1025
|
|
|
981
|
-
--json: new, send, list, show, plan, queue, refs, credits, usage and stats print one
|
|
982
|
-
show --timeline the timeline); watch prints one event per line.
|
|
983
|
-
Ctrl+C stops following a turn; the turn goes on.
|
|
1026
|
+
--json: new, send, list, show, plan, queue, refs, versions, restore, resume, credits, usage and stats print one
|
|
1027
|
+
JSON object (send prints the finished turn, show --timeline the timeline); watch prints one event per line.
|
|
1028
|
+
Ctrl+C stops following a turn; the turn goes on. On a project, it stops watching (exit 0).
|
|
984
1029
|
|
|
985
1030
|
Exit codes:
|
|
986
1031
|
0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
|
|
987
|
-
2 bad command 3 needs sign-in 4 refused (no credits, closed,
|
|
988
|
-
a picture refused, not found) 5 rate limited, try later`;
|
|
1032
|
+
2 bad command 3 needs sign-in 4 refused (no credits, closed, a turn is open, too many turns, effort
|
|
1033
|
+
unavailable, a picture refused, not found) 5 rate limited, try later`;
|
|
989
1034
|
var CREATE_FLAGS = {
|
|
990
1035
|
engine: "string",
|
|
991
1036
|
genre: "string",
|
|
@@ -994,10 +1039,13 @@ var CREATE_FLAGS = {
|
|
|
994
1039
|
plan: "boolean",
|
|
995
1040
|
"no-plan": "boolean",
|
|
996
1041
|
build: "boolean",
|
|
1042
|
+
answer: "string",
|
|
1043
|
+
queue: "boolean",
|
|
997
1044
|
ref: "strings",
|
|
998
1045
|
yes: "boolean",
|
|
999
1046
|
detach: "boolean",
|
|
1000
1047
|
from: "string",
|
|
1048
|
+
"until-idle": "boolean",
|
|
1001
1049
|
json: "boolean",
|
|
1002
1050
|
timeline: "boolean",
|
|
1003
1051
|
turn: "string",
|
|
@@ -1008,14 +1056,17 @@ var CREATE_FLAGS = {
|
|
|
1008
1056
|
};
|
|
1009
1057
|
var SUBCOMMANDS = {
|
|
1010
1058
|
new: ["engine", "genre", "json"],
|
|
1011
|
-
send: ["project", "effort", "plan", "no-plan", "build", "ref", "yes", "detach", "json"],
|
|
1012
|
-
watch: ["from", "json"],
|
|
1059
|
+
send: ["project", "effort", "plan", "no-plan", "build", "answer", "queue", "ref", "yes", "detach", "json"],
|
|
1060
|
+
watch: ["from", "until-idle", "json"],
|
|
1013
1061
|
cancel: [],
|
|
1062
|
+
resume: ["project", "json"],
|
|
1063
|
+
versions: ["project", "json"],
|
|
1064
|
+
restore: ["project", "json"],
|
|
1014
1065
|
list: ["json"],
|
|
1015
1066
|
show: ["json", "timeline", "turn", "all"],
|
|
1016
1067
|
refs: ["project", "json"],
|
|
1017
1068
|
plan: ["project", "json"],
|
|
1018
|
-
queue: ["project", "json"],
|
|
1069
|
+
queue: ["project", "build", "json"],
|
|
1019
1070
|
credits: ["json"],
|
|
1020
1071
|
usage: ["project", "before", "json"],
|
|
1021
1072
|
stats: ["days", "origin", "json"]
|
|
@@ -1025,6 +1076,14 @@ var REFS_PER_MESSAGE = 4;
|
|
|
1025
1076
|
var REFS_PER_PROJECT = 16;
|
|
1026
1077
|
var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
|
|
1027
1078
|
var ROOT = "/api/v1/create";
|
|
1079
|
+
var FAILURE_SENTENCES = {
|
|
1080
|
+
credits: "This turn could not start: not enough credits.",
|
|
1081
|
+
capacity: "Velven is at capacity; try again in a few minutes.",
|
|
1082
|
+
model: "The model service did not answer.",
|
|
1083
|
+
sandbox: "The workspace did not start.",
|
|
1084
|
+
build: "The build was refused, so no preview was made.",
|
|
1085
|
+
unknown: "Something went wrong on Velven's side."
|
|
1086
|
+
};
|
|
1028
1087
|
async function create(ctx, parsed) {
|
|
1029
1088
|
const [sub, ...args] = parsed.positionals;
|
|
1030
1089
|
if (!sub) {
|
|
@@ -1048,9 +1107,15 @@ async function create(ctx, parsed) {
|
|
|
1048
1107
|
case "send":
|
|
1049
1108
|
return send(ctx, api, args, parsed, json);
|
|
1050
1109
|
case "watch":
|
|
1051
|
-
return watch(ctx, api, args
|
|
1110
|
+
return watch(ctx, api, args, stringFlag(parsed, "from"), parsed.flags["until-idle"] === true, json);
|
|
1052
1111
|
case "cancel":
|
|
1053
1112
|
return cancel(ctx, api, args[0]);
|
|
1113
|
+
case "resume":
|
|
1114
|
+
return resume(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1115
|
+
case "versions":
|
|
1116
|
+
return versions(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1117
|
+
case "restore":
|
|
1118
|
+
return restore(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1054
1119
|
case "list":
|
|
1055
1120
|
return list(ctx, api, json);
|
|
1056
1121
|
case "show":
|
|
@@ -1069,7 +1134,7 @@ async function create(ctx, parsed) {
|
|
|
1069
1134
|
case "plan":
|
|
1070
1135
|
return showPlan(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1071
1136
|
case "queue":
|
|
1072
|
-
return queue(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1137
|
+
return queue(ctx, api, args, stringFlag(parsed, "project"), parsed.flags.build === true, json);
|
|
1073
1138
|
default:
|
|
1074
1139
|
return credits(ctx, api, json);
|
|
1075
1140
|
}
|
|
@@ -1097,8 +1162,9 @@ async function newProject(ctx, api, title, engine, genre, json) {
|
|
|
1097
1162
|
return EXIT.ok;
|
|
1098
1163
|
}
|
|
1099
1164
|
async function send(ctx, api, words, parsed, json) {
|
|
1100
|
-
|
|
1101
|
-
|
|
1165
|
+
let text = words.join(" ").trim();
|
|
1166
|
+
const answerFlag = stringFlag(parsed, "answer");
|
|
1167
|
+
if (!text && answerFlag === undefined)
|
|
1102
1168
|
throw new CliError('Say what to build or change, like velven create send "make the fox jump higher".', EXIT.usage, "usage");
|
|
1103
1169
|
const effort = stringFlag(parsed, "effort");
|
|
1104
1170
|
if (effort !== undefined && !EFFORTS.includes(effort)) {
|
|
@@ -1112,8 +1178,22 @@ async function send(ctx, api, words, parsed, json) {
|
|
|
1112
1178
|
const refs = stringsFlag(parsed, "ref");
|
|
1113
1179
|
if (refs.length > REFS_PER_MESSAGE)
|
|
1114
1180
|
throw new CliError(`A message takes up to ${REFS_PER_MESSAGE} pictures; ${refs.length} were given with --ref.`, EXIT.usage, "usage");
|
|
1115
|
-
const pictures = await Promise.all(refs.map((ref) => REF_ID.test(ref) ? ref : readPicture(ctx, ref)));
|
|
1116
1181
|
const project = await currentProject(ctx, api.base, stringFlag(parsed, "project"));
|
|
1182
|
+
let answer;
|
|
1183
|
+
if (answerFlag !== undefined) {
|
|
1184
|
+
const { question } = await readCreateState(ctx, api.base);
|
|
1185
|
+
if (!question || question.project !== project) {
|
|
1186
|
+
throw new CliError("There is no question to answer on this project. Send your answer in words instead.", EXIT.usage, "no_question");
|
|
1187
|
+
}
|
|
1188
|
+
const choice = Number(answerFlag);
|
|
1189
|
+
if (!/^\d+$/.test(answerFlag) || choice < 1 || choice > question.choices.length) {
|
|
1190
|
+
throw new CliError(`--answer is the number of a choice, 1 to ${question.choices.length}, not "${answerFlag}".`, EXIT.usage, "usage");
|
|
1191
|
+
}
|
|
1192
|
+
answer = { item: question.item, choice };
|
|
1193
|
+
if (!text)
|
|
1194
|
+
text = question.choices[choice - 1];
|
|
1195
|
+
}
|
|
1196
|
+
const pictures = await Promise.all(refs.map((ref) => REF_ID.test(ref) ? ref : readPicture(ctx, ref)));
|
|
1117
1197
|
const say = json ? () => {} : (line) => ctx.out(line);
|
|
1118
1198
|
const query = new URLSearchParams;
|
|
1119
1199
|
if (routed)
|
|
@@ -1124,41 +1204,49 @@ async function send(ctx, api, words, parsed, json) {
|
|
|
1124
1204
|
query.set("effort", effort);
|
|
1125
1205
|
if (plan !== undefined)
|
|
1126
1206
|
query.set("plan", String(plan));
|
|
1127
|
-
query.set("queue", "true");
|
|
1128
1207
|
const search = query.toString();
|
|
1129
1208
|
const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
|
|
1130
|
-
if (routed) {
|
|
1131
|
-
const what = estimate.shaping
|
|
1209
|
+
if (estimate.open_turn) {} else if (routed) {
|
|
1210
|
+
const what = estimate.shaping ? "a question or a short plan" : estimate.first_build ? "a first build (its pictures are made before the first preview)" : "a small change";
|
|
1132
1211
|
say(`Velven picks how much to do: about ${estimate.credits.typical} credits for ${what}, at most ${estimate.credits.cap} held. You have ${estimate.available} available.`);
|
|
1133
1212
|
} else {
|
|
1134
|
-
const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build
|
|
1213
|
+
const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build ? `${estimate.effort} first build (its pictures are made before the first preview)` : `${estimate.effort} build turn`;
|
|
1135
1214
|
say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
|
|
1136
1215
|
}
|
|
1137
1216
|
if (!estimate.enough) {
|
|
1138
1217
|
throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
|
|
1139
1218
|
}
|
|
1140
1219
|
if (ctx.interactive && !json && parsed.flags.yes !== true) {
|
|
1141
|
-
const
|
|
1142
|
-
if (
|
|
1220
|
+
const reply = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
|
|
1221
|
+
if (reply === "n" || reply === "no") {
|
|
1143
1222
|
ctx.out("Nothing was sent.");
|
|
1144
1223
|
return EXIT.failed;
|
|
1145
1224
|
}
|
|
1146
1225
|
}
|
|
1147
|
-
const body = { text,
|
|
1226
|
+
const body = { text, client_id: randomUUID() };
|
|
1227
|
+
if (parsed.flags.queue === true)
|
|
1228
|
+
body.delivery = "queue";
|
|
1148
1229
|
if (effort !== undefined)
|
|
1149
1230
|
body.effort = effort;
|
|
1150
1231
|
if (plan !== undefined)
|
|
1151
1232
|
body.plan = plan;
|
|
1152
1233
|
if (parsed.flags.build === true)
|
|
1153
1234
|
body.intent = "build";
|
|
1235
|
+
if (answer)
|
|
1236
|
+
body.answer = answer;
|
|
1154
1237
|
if (pictures.length) {
|
|
1155
1238
|
const ids = [];
|
|
1156
1239
|
for (const picture of pictures)
|
|
1157
1240
|
ids.push(typeof picture === "string" ? picture : await uploadPicture(api, project, picture));
|
|
1158
1241
|
body.references = ids;
|
|
1159
|
-
say(`Attached ${
|
|
1160
|
-
}
|
|
1161
|
-
const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body);
|
|
1242
|
+
say(`Attached ${plural(ids.length, "picture")}: ${ids.join(", ")}.`);
|
|
1243
|
+
}
|
|
1244
|
+
const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body, { retryOnce: { sleep: ctx.sleep } });
|
|
1245
|
+
if ((await readCreateState(ctx, api.base)).question?.project === project)
|
|
1246
|
+
await saveCreateState(ctx, api.base, { question: undefined });
|
|
1247
|
+
const already = sent.again ? "(already sent) " : "";
|
|
1248
|
+
if ("steer" in sent)
|
|
1249
|
+
return steered(ctx, api, project, sent, already, parsed.flags.detach === true, json);
|
|
1162
1250
|
let turn;
|
|
1163
1251
|
let stream2;
|
|
1164
1252
|
if ("queued" in sent) {
|
|
@@ -1167,9 +1255,9 @@ async function send(ctx, api, words, parsed, json) {
|
|
|
1167
1255
|
throw new CliError(queued.refused ?? "Velven could not start this message.", EXIT.refused, "refused");
|
|
1168
1256
|
if (queued.state === "waiting" || queued.turn === null) {
|
|
1169
1257
|
if (json)
|
|
1170
|
-
ctx.out(JSON.stringify({ ok: true, queued }));
|
|
1258
|
+
ctx.out(JSON.stringify({ ok: true, queued, why: sent.why, ...sent.again ? { again: true } : {} }));
|
|
1171
1259
|
else
|
|
1172
|
-
say(queuedLine(queued));
|
|
1260
|
+
say(`${already}${queuedLine(queued, sent.why)}`);
|
|
1173
1261
|
return EXIT.ok;
|
|
1174
1262
|
}
|
|
1175
1263
|
turn = { id: queued.turn, ...queued.capped === true ? { queued: true } : {} };
|
|
@@ -1179,19 +1267,143 @@ async function send(ctx, api, words, parsed, json) {
|
|
|
1179
1267
|
}
|
|
1180
1268
|
await saveCreateState(ctx, api.base, { project, turn: turn.id });
|
|
1181
1269
|
if (turn.queued)
|
|
1182
|
-
say(
|
|
1270
|
+
say(`${already}Turn ${turn.id} waits for room on Velven and starts on its own.`);
|
|
1183
1271
|
else
|
|
1184
|
-
say(
|
|
1272
|
+
say(`${already}Turn ${turn.id} is on its way.`);
|
|
1185
1273
|
if (parsed.flags.detach === true) {
|
|
1186
1274
|
if (json)
|
|
1187
|
-
ctx.out(JSON.stringify({ ok: true, turn }));
|
|
1275
|
+
ctx.out(JSON.stringify({ ok: true, turn, ...sent.again ? { again: true } : {} }));
|
|
1188
1276
|
else
|
|
1189
1277
|
say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
|
|
1190
1278
|
return EXIT.ok;
|
|
1191
1279
|
}
|
|
1192
|
-
const path = stream2
|
|
1280
|
+
const path = stream2 ?? `${ROOT}/turns/${seg(turn.id)}/stream`;
|
|
1193
1281
|
return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text", project);
|
|
1194
1282
|
}
|
|
1283
|
+
async function steered(ctx, api, project, sent, already, detach, json) {
|
|
1284
|
+
const turn = sent.steer.turn;
|
|
1285
|
+
const say = json ? () => {} : (line) => ctx.out(line);
|
|
1286
|
+
if (sent.steer.state === "dropped") {
|
|
1287
|
+
if (json)
|
|
1288
|
+
ctx.out(JSON.stringify({ ok: true, message: sent.message, steer: sent.steer, again: true }));
|
|
1289
|
+
else
|
|
1290
|
+
say(`${already}${STEER_DROPPED}`);
|
|
1291
|
+
return EXIT.ok;
|
|
1292
|
+
}
|
|
1293
|
+
await saveCreateState(ctx, api.base, { project, turn });
|
|
1294
|
+
say(`${already}Sent to the running turn (${turn}).`);
|
|
1295
|
+
if (sent.steer.state === "taken")
|
|
1296
|
+
say(STEER_TAKEN);
|
|
1297
|
+
if (detach) {
|
|
1298
|
+
if (json)
|
|
1299
|
+
ctx.out(JSON.stringify({ ok: true, message: sent.message, steer: sent.steer, ...sent.again ? { again: true } : {} }));
|
|
1300
|
+
else
|
|
1301
|
+
say(`Follow it: velven create watch ${turn}; stop it: velven create cancel ${turn}`);
|
|
1302
|
+
return EXIT.ok;
|
|
1303
|
+
}
|
|
1304
|
+
const mine = `m${sent.message.id}`;
|
|
1305
|
+
let state = sent.steer.state;
|
|
1306
|
+
let queuedWhy;
|
|
1307
|
+
let started = null;
|
|
1308
|
+
let replied = false;
|
|
1309
|
+
let ended = null;
|
|
1310
|
+
const controller = new AbortController;
|
|
1311
|
+
ctx.untilStopped().then(() => controller.abort());
|
|
1312
|
+
let final;
|
|
1313
|
+
try {
|
|
1314
|
+
final = await stream(api, `${ROOT}/projects/${seg(project)}/stream`, { start: { after: sent.seq }, signal: controller.signal, sleep: ctx.sleep }, (raw) => {
|
|
1315
|
+
const event = raw;
|
|
1316
|
+
const items = event.type === "snapshot" ? event.items : event.type === "item" ? [event.item] : [];
|
|
1317
|
+
for (const item of items) {
|
|
1318
|
+
const summary = item.summary;
|
|
1319
|
+
if (summary.kind !== "message")
|
|
1320
|
+
continue;
|
|
1321
|
+
if (item.id === mine && summary.steer) {
|
|
1322
|
+
const paused = summary.steer.state === "queued" && summary.steer.why === "paused" && queuedWhy !== "paused";
|
|
1323
|
+
if (summary.steer.state !== state || paused) {
|
|
1324
|
+
state = summary.steer.state;
|
|
1325
|
+
queuedWhy = summary.steer.why;
|
|
1326
|
+
if (state === "taken")
|
|
1327
|
+
say(STEER_TAKEN);
|
|
1328
|
+
if (state === "queued")
|
|
1329
|
+
say(steerQueuedLine(queuedWhy));
|
|
1330
|
+
}
|
|
1331
|
+
if (summary.steer.started && started === null)
|
|
1332
|
+
started = summary.steer.started;
|
|
1333
|
+
}
|
|
1334
|
+
if (item.turn_id === turn && summary.role === "agent" && !replied) {
|
|
1335
|
+
replied = true;
|
|
1336
|
+
say(summary.text);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const turns = event.type === "snapshot" ? event.turns : event.type === "turn" ? [event.turn] : [];
|
|
1340
|
+
for (const t of turns)
|
|
1341
|
+
if (t.id === turn && ENDED.has(t.status))
|
|
1342
|
+
ended = t;
|
|
1343
|
+
return event.type === "error" || ended !== null;
|
|
1344
|
+
});
|
|
1345
|
+
} catch (e) {
|
|
1346
|
+
if (e instanceof CliError && e.code === "stream_lost") {
|
|
1347
|
+
const lost = new CliError(`${e.message} The turn goes on: velven create watch ${turn}`, e.exitCode, e.code);
|
|
1348
|
+
if (!json)
|
|
1349
|
+
throw lost;
|
|
1350
|
+
ctx.out(JSON.stringify({ ok: false, error: lost.message, code: lost.code, turn: { id: turn } }));
|
|
1351
|
+
return lost.exitCode;
|
|
1352
|
+
}
|
|
1353
|
+
throw e;
|
|
1354
|
+
} finally {
|
|
1355
|
+
controller.abort();
|
|
1356
|
+
}
|
|
1357
|
+
if (final === null || final.type === "error") {
|
|
1358
|
+
const message = final === null ? `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}` : `${final.message} The turn may go on. Watch it: velven create watch ${turn}`;
|
|
1359
|
+
if (json)
|
|
1360
|
+
ctx.out(JSON.stringify({ ok: false, error: message, code: final === null ? "detached" : final.code, turn: { id: turn } }));
|
|
1361
|
+
else
|
|
1362
|
+
ctx.err(message);
|
|
1363
|
+
return EXIT.failed;
|
|
1364
|
+
}
|
|
1365
|
+
const end = ended;
|
|
1366
|
+
const code = end?.status === "done" ? EXIT.ok : EXIT.failed;
|
|
1367
|
+
if (json) {
|
|
1368
|
+
const read = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`).catch(() => null);
|
|
1369
|
+
ctx.out(JSON.stringify({ ok: code === EXIT.ok, message: sent.message, steer: { turn, state }, turn: read?.turn ?? end }));
|
|
1370
|
+
return code;
|
|
1371
|
+
}
|
|
1372
|
+
if (!replied) {
|
|
1373
|
+
const read = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`).catch(() => null);
|
|
1374
|
+
if (read?.turn.reply?.body)
|
|
1375
|
+
ctx.out(read.turn.reply.body);
|
|
1376
|
+
}
|
|
1377
|
+
if (end)
|
|
1378
|
+
for (const line of endLines(end))
|
|
1379
|
+
ctx.out(line);
|
|
1380
|
+
if (started !== null)
|
|
1381
|
+
ctx.out(`Your message is running: velven create watch ${started}`);
|
|
1382
|
+
return code;
|
|
1383
|
+
}
|
|
1384
|
+
var ENDED = new Set(["done", "cancelled", "failed"]);
|
|
1385
|
+
var STEER_TAKEN = "Taken into account.";
|
|
1386
|
+
var STEER_QUEUED = "Could not be taken this turn; queued as the next message.";
|
|
1387
|
+
var STEER_PAUSED = "Could not be taken this turn; queued, but the queue is paused after a failed or stopped turn. Start it: velven create resume";
|
|
1388
|
+
function steerQueuedLine(why) {
|
|
1389
|
+
return why === "paused" ? STEER_PAUSED : STEER_QUEUED;
|
|
1390
|
+
}
|
|
1391
|
+
var STEER_DROPPED = "Taken out of the queue; it will not run.";
|
|
1392
|
+
function endLines(turn) {
|
|
1393
|
+
if (turn.status === "done")
|
|
1394
|
+
return [`Turn ${turn.id} is done.`, ...stopLines(turn.stop_reason)];
|
|
1395
|
+
if (turn.status === "cancelled")
|
|
1396
|
+
return [`Turn ${turn.id} was cancelled. What it finished is saved.`];
|
|
1397
|
+
const sentence = turn.error ?? (turn.failure ? FAILURE_SENTENCES[turn.failure.class] : "Velven could not finish it.");
|
|
1398
|
+
return [`Turn ${turn.id} failed: ${sentence} A failed turn costs nothing.${turn.failure?.retryable ? " Try again." : ""}`];
|
|
1399
|
+
}
|
|
1400
|
+
function stopLines(stop) {
|
|
1401
|
+
if (stop === "cap")
|
|
1402
|
+
return ["It stopped at its credit cap. What it finished is saved: send another message to go on."];
|
|
1403
|
+
if (stop === "time")
|
|
1404
|
+
return ["It stopped at its time limit. What it finished is saved: send another message to go on."];
|
|
1405
|
+
return [];
|
|
1406
|
+
}
|
|
1195
1407
|
var REF_ID = /^ref-[1-9][0-9]{0,2}$/;
|
|
1196
1408
|
async function uploadPicture(api, project, picture) {
|
|
1197
1409
|
const { upload } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references/uploads`);
|
|
@@ -1222,21 +1434,27 @@ async function readPicture(ctx, file) {
|
|
|
1222
1434
|
return { file, bytes, type: PICTURE_TYPES[ext] ?? "application/octet-stream" };
|
|
1223
1435
|
}
|
|
1224
1436
|
async function turnProject(ctx, api, turn) {
|
|
1225
|
-
const { project: current } = await readCreateState(ctx, api.base);
|
|
1226
1437
|
try {
|
|
1227
1438
|
const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
|
|
1228
|
-
return view.project
|
|
1439
|
+
return view.project;
|
|
1229
1440
|
} catch {
|
|
1230
|
-
return
|
|
1441
|
+
return (await readCreateState(ctx, api.base)).project ?? null;
|
|
1231
1442
|
}
|
|
1232
1443
|
}
|
|
1233
|
-
async function watch(ctx, api,
|
|
1444
|
+
async function watch(ctx, api, args, fromFlag, untilIdle, json) {
|
|
1445
|
+
const [arg, ...rest] = args;
|
|
1446
|
+
if (rest.length)
|
|
1447
|
+
throw new CliError("Name one turn or one project to watch, like velven create watch 12.", EXIT.usage, "usage");
|
|
1234
1448
|
let from;
|
|
1235
1449
|
if (fromFlag !== undefined) {
|
|
1236
1450
|
if (!/^\d+$/.test(fromFlag))
|
|
1237
1451
|
throw new CliError(`--from is an event number, like 12, not "${fromFlag}".`, EXIT.usage, "usage");
|
|
1238
1452
|
from = Number(fromFlag);
|
|
1239
1453
|
}
|
|
1454
|
+
if (arg !== undefined && !/^\d+$/.test(arg))
|
|
1455
|
+
return watchProject(ctx, api, arg, from, untilIdle, json);
|
|
1456
|
+
if (untilIdle)
|
|
1457
|
+
throw new CliError("--until-idle goes with a project, like velven create watch <project> --until-idle.", EXIT.usage, "usage");
|
|
1240
1458
|
const turn = await turnId(ctx, api.base, arg, "watch");
|
|
1241
1459
|
const named = json ? null : await turnProject(ctx, api, turn);
|
|
1242
1460
|
let code;
|
|
@@ -1250,13 +1468,311 @@ async function watch(ctx, api, arg, fromFlag, json) {
|
|
|
1250
1468
|
await saveCreateState(ctx, api.base, { turn });
|
|
1251
1469
|
return code;
|
|
1252
1470
|
}
|
|
1471
|
+
async function watchProject(ctx, api, project, after, untilIdle, json) {
|
|
1472
|
+
const controller = new AbortController;
|
|
1473
|
+
ctx.untilStopped().then(() => controller.abort());
|
|
1474
|
+
const kept = (await readCreateState(ctx, api.base)).question;
|
|
1475
|
+
const before = kept?.project === project ? kept : null;
|
|
1476
|
+
const chat = chatView(ctx, api.base, project, before);
|
|
1477
|
+
let idle = false;
|
|
1478
|
+
let synced = false;
|
|
1479
|
+
let final;
|
|
1480
|
+
try {
|
|
1481
|
+
final = await stream(api, `${ROOT}/projects/${seg(project)}/stream`, { ...after !== undefined ? { start: { after } } : {}, signal: controller.signal, sleep: ctx.sleep }, (raw) => {
|
|
1482
|
+
const event = raw;
|
|
1483
|
+
if (json)
|
|
1484
|
+
ctx.out(JSON.stringify(raw));
|
|
1485
|
+
else
|
|
1486
|
+
chat.show(event);
|
|
1487
|
+
if (event.type === "summary") {
|
|
1488
|
+
const { open_turn, rebuilding, queue } = event.summary;
|
|
1489
|
+
idle = open_turn === null && rebuilding === null && (queue.waiting === 0 || queue.paused !== null);
|
|
1490
|
+
}
|
|
1491
|
+
if (event.type === "synchronized")
|
|
1492
|
+
synced = true;
|
|
1493
|
+
return event.type === "error" || untilIdle && synced && idle && (event.type === "synchronized" || event.type === "summary");
|
|
1494
|
+
});
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
if (e instanceof CliError && e.code === "stream_lost")
|
|
1497
|
+
throw new CliError(`${e.message} Watch it again: velven create watch ${project}`, e.exitCode, e.code);
|
|
1498
|
+
throw e;
|
|
1499
|
+
} finally {
|
|
1500
|
+
controller.abort();
|
|
1501
|
+
if (chat.question !== before)
|
|
1502
|
+
await saveCreateState(ctx, api.base, { question: chat.question ?? undefined });
|
|
1503
|
+
}
|
|
1504
|
+
if (final?.type === "error") {
|
|
1505
|
+
if (!json)
|
|
1506
|
+
ctx.err(final.message || "The project's stream answered with an error.");
|
|
1507
|
+
return EXIT.failed;
|
|
1508
|
+
}
|
|
1509
|
+
return EXIT.ok;
|
|
1510
|
+
}
|
|
1511
|
+
function chatView(ctx, base, project, kept) {
|
|
1512
|
+
const send2 = `velven create send --project ${project}`;
|
|
1513
|
+
const said = new Map;
|
|
1514
|
+
const turns = new Map;
|
|
1515
|
+
let headed = null;
|
|
1516
|
+
let queueSaid = "";
|
|
1517
|
+
let failureSaid = "";
|
|
1518
|
+
let synced = false;
|
|
1519
|
+
let olderSaid = false;
|
|
1520
|
+
const header = (turn, summary) => {
|
|
1521
|
+
if (turn === null || headed === turn) {
|
|
1522
|
+
headed = turn;
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
headed = turn;
|
|
1526
|
+
const marks = summary ? [summary.status, summary.version ? `version ${summary.version}` : null, summary.rolled_back ? "rolled back" : null].filter((m) => m !== null) : [];
|
|
1527
|
+
ctx.out(`Turn ${turn}${marks.length ? ` (${marks.join(", ")})` : ""}`);
|
|
1528
|
+
};
|
|
1529
|
+
const turnKey = (t) => `${t.status}|${t.phase ?? ""}`;
|
|
1530
|
+
const works = new Map;
|
|
1531
|
+
const item = (i, summaries) => {
|
|
1532
|
+
if (i.summary.kind === "route" && i.turn_id !== null)
|
|
1533
|
+
works.set(i.turn_id, i.summary.work);
|
|
1534
|
+
const lines = itemLines(i, base, send2, i.turn_id !== null && works.get(i.turn_id) === "draft");
|
|
1535
|
+
const known = said.get(i.id) ?? new Set;
|
|
1536
|
+
said.set(i.id, known);
|
|
1537
|
+
const fresh = [...lines.out.map((line) => ({ key: `out ${line}`, line, write: ctx.out })), ...lines.err.map((line) => ({ key: `err ${line}`, line, write: ctx.err }))].filter((l) => !known.has(l.key));
|
|
1538
|
+
const under = chatTurn(i);
|
|
1539
|
+
if (fresh.length)
|
|
1540
|
+
header(under, under !== null ? summaries.get(under) : undefined);
|
|
1541
|
+
for (const { key, line, write } of fresh) {
|
|
1542
|
+
known.add(key);
|
|
1543
|
+
write(line);
|
|
1544
|
+
}
|
|
1545
|
+
if (i.summary.kind === "question") {
|
|
1546
|
+
if (i.summary.state === "open")
|
|
1547
|
+
view.question = { project, item: i.id, choices: i.summary.choices };
|
|
1548
|
+
else if (view.question?.item === i.id)
|
|
1549
|
+
view.question = null;
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
const view = {
|
|
1553
|
+
question: kept,
|
|
1554
|
+
show(event) {
|
|
1555
|
+
switch (event.type) {
|
|
1556
|
+
case "snapshot": {
|
|
1557
|
+
const summaries = new Map(event.turns.map((t) => [t.id, t]));
|
|
1558
|
+
for (const t of event.turns)
|
|
1559
|
+
turns.set(t.id, turnKey(t));
|
|
1560
|
+
if (event.older && !olderSaid)
|
|
1561
|
+
ctx.err("Earlier turns are not shown.");
|
|
1562
|
+
olderSaid ||= event.older !== null;
|
|
1563
|
+
for (const i of chatOrder(event.items))
|
|
1564
|
+
item(i, summaries);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
case "item":
|
|
1568
|
+
item(event.item, new Map);
|
|
1569
|
+
return;
|
|
1570
|
+
case "turn": {
|
|
1571
|
+
const t = event.turn;
|
|
1572
|
+
const before = turns.get(t.id);
|
|
1573
|
+
if (before === turnKey(t))
|
|
1574
|
+
return;
|
|
1575
|
+
turns.set(t.id, turnKey(t));
|
|
1576
|
+
if (ENDED.has(t.status)) {
|
|
1577
|
+
if (before?.split("|")[0] !== t.status)
|
|
1578
|
+
for (const line of endLines(t))
|
|
1579
|
+
ctx.out(line);
|
|
1580
|
+
} else if (t.phase)
|
|
1581
|
+
ctx.err(`Turn ${t.id}: ${t.phase}`);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
case "queue": {
|
|
1585
|
+
const waiting = event.queue.queued.filter((q) => q.state === "waiting").length;
|
|
1586
|
+
const line = event.queue.paused ? `${plural(waiting, "message")} waiting. ${pausedLine(event.queue.paused)}` : waiting ? `${plural(waiting, "message")} waiting.` : queueSaid ? "Nothing is waiting." : "";
|
|
1587
|
+
if (line && line !== queueSaid)
|
|
1588
|
+
ctx.out(line);
|
|
1589
|
+
queueSaid = line;
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
case "summary": {
|
|
1593
|
+
const line = failureLine(event.summary) ?? "";
|
|
1594
|
+
if (line && line !== failureSaid)
|
|
1595
|
+
ctx.out(line);
|
|
1596
|
+
failureSaid = line;
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
case "synchronized":
|
|
1600
|
+
if (!synced)
|
|
1601
|
+
ctx.out("Up to date.");
|
|
1602
|
+
synced = true;
|
|
1603
|
+
return;
|
|
1604
|
+
default:
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
return view;
|
|
1610
|
+
}
|
|
1611
|
+
function chatTurn(i) {
|
|
1612
|
+
return i.summary.kind === "message" && i.summary.steer ? i.summary.steer.turn : i.turn_id;
|
|
1613
|
+
}
|
|
1614
|
+
function chatOrder(items) {
|
|
1615
|
+
const last = new Map;
|
|
1616
|
+
const byId = new Map(items.map((i) => [i.id, i]));
|
|
1617
|
+
for (const i of items) {
|
|
1618
|
+
for (let up = i.parent ? byId.get(i.parent) : undefined;up; up = up.parent ? byId.get(up.parent) : undefined) {
|
|
1619
|
+
last.set(up.id, Math.max(last.get(up.id) ?? up.pos, i.pos));
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
const at = (i) => i.summary.kind === "run" && i.status !== "running" ? Math.max(i.seq, last.get(i.id) ?? i.pos) + 0.5 : i.pos;
|
|
1623
|
+
return [...items].sort((a, b) => at(a) - at(b));
|
|
1624
|
+
}
|
|
1625
|
+
function itemLines(i, base, send2, draft) {
|
|
1626
|
+
const out = [];
|
|
1627
|
+
const err = [];
|
|
1628
|
+
const s = i.summary;
|
|
1629
|
+
const ended = i.status !== "running";
|
|
1630
|
+
switch (s.kind) {
|
|
1631
|
+
case "message":
|
|
1632
|
+
if (s.role === "agent") {
|
|
1633
|
+
out.push(s.text);
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
out.push(`> ${s.text}`);
|
|
1637
|
+
if (s.steer?.state === "sent")
|
|
1638
|
+
out.push(` Sent to the running turn (${s.steer.turn}).`);
|
|
1639
|
+
if (s.steer?.state === "taken")
|
|
1640
|
+
out.push(` ${STEER_TAKEN}`);
|
|
1641
|
+
if (s.steer?.state === "queued")
|
|
1642
|
+
out.push(` ${steerQueuedLine(s.steer.why)}`);
|
|
1643
|
+
if (s.steer?.state === "dropped")
|
|
1644
|
+
out.push(` ${STEER_DROPPED}`);
|
|
1645
|
+
if (s.steer?.started)
|
|
1646
|
+
out.push(` It is running as turn ${s.steer.started}.`);
|
|
1647
|
+
break;
|
|
1648
|
+
case "route":
|
|
1649
|
+
out.push(s.said);
|
|
1650
|
+
break;
|
|
1651
|
+
case "question":
|
|
1652
|
+
out.push(`${s.question} (question ${s.index} of ${s.of})`);
|
|
1653
|
+
for (const [n, choice] of s.choices.entries())
|
|
1654
|
+
out.push(` ${n + 1}. ${choice}`);
|
|
1655
|
+
if (s.state === "open")
|
|
1656
|
+
out.push(`Answer: ${send2} --answer 1 (a choice's number), or in your own words.`);
|
|
1657
|
+
if (s.state === "answered" && s.answered_choice !== null)
|
|
1658
|
+
out.push(` Answered: ${s.answered_choice}.`);
|
|
1659
|
+
break;
|
|
1660
|
+
case "plan":
|
|
1661
|
+
if (!ended)
|
|
1662
|
+
break;
|
|
1663
|
+
out.push(`Plan: ${s.title}${s.pitch ? `: ${s.pitch}` : ""}`);
|
|
1664
|
+
if (draft)
|
|
1665
|
+
out.push(`Build it: ${send2} --build "Build it"`);
|
|
1666
|
+
break;
|
|
1667
|
+
case "brief":
|
|
1668
|
+
if (i.status !== "done")
|
|
1669
|
+
break;
|
|
1670
|
+
out.push(`Designing ${s.title ?? "your game"}${s.pitch ? `: ${s.pitch}` : ""}`);
|
|
1671
|
+
if (s.style)
|
|
1672
|
+
out.push(` Style: ${s.style}`);
|
|
1673
|
+
if (s.palette.length)
|
|
1674
|
+
out.push(` Palette: ${s.palette.join(", ")}`);
|
|
1675
|
+
break;
|
|
1676
|
+
case "run": {
|
|
1677
|
+
const label = jobLabel(s.job);
|
|
1678
|
+
if (!ended)
|
|
1679
|
+
err.push(`${label} started${s.model ? ` (${s.model})` : ""}.`);
|
|
1680
|
+
else if (i.status === "done")
|
|
1681
|
+
err.push(`${label} done${s.summary ? `: ${s.summary}` : "."}`);
|
|
1682
|
+
else
|
|
1683
|
+
err.push(`${label} ${i.status}.`);
|
|
1684
|
+
break;
|
|
1685
|
+
}
|
|
1686
|
+
case "tool":
|
|
1687
|
+
if (!ended && !s.note)
|
|
1688
|
+
break;
|
|
1689
|
+
err.push(` ${s.name}: ${s.note ?? s.summary ?? ""}`.trimEnd());
|
|
1690
|
+
if (i.status === "failed")
|
|
1691
|
+
err.push(` ${s.name} failed${s.summary ? `: ${s.summary}` : "."}`);
|
|
1692
|
+
break;
|
|
1693
|
+
case "picture":
|
|
1694
|
+
if (s.placeholder)
|
|
1695
|
+
err.push(` A placeholder stands in for ${s.picture_id}.`);
|
|
1696
|
+
else if (s.media)
|
|
1697
|
+
out.push(`Painted ${s.picture_id} (${s.width} by ${s.height})${s.redraw ? " again" : ""} ${mediaLink(base, s.media)}`);
|
|
1698
|
+
break;
|
|
1699
|
+
case "files":
|
|
1700
|
+
if (s.changed.length)
|
|
1701
|
+
err.push(` changed: ${s.changed.map((c) => `${c.path} +${c.added} -${c.removed}`).join(", ")}${s.more > 0 ? `, and ${s.more} more` : ""}`);
|
|
1702
|
+
break;
|
|
1703
|
+
case "build":
|
|
1704
|
+
if (!ended)
|
|
1705
|
+
break;
|
|
1706
|
+
if (s.ok)
|
|
1707
|
+
err.push(`The build passed in ${(s.ms / 1000).toFixed(1)} s.`);
|
|
1708
|
+
else
|
|
1709
|
+
err.push("The build failed:", ...s.errors.map((e) => ` ${e}`));
|
|
1710
|
+
break;
|
|
1711
|
+
case "preview":
|
|
1712
|
+
if (i.status === "running") {
|
|
1713
|
+
if (s.rebuild)
|
|
1714
|
+
out.push("Rebuilding the preview (no credits).");
|
|
1715
|
+
} else if (i.status === "done") {
|
|
1716
|
+
out.push(`${s.rebuild ? "The preview is rebuilt" : s.purpose === "playtest" ? "A playtest preview is ready" : "A preview is ready"}: velven create show prints its link.`);
|
|
1717
|
+
} else if (i.status === "failed")
|
|
1718
|
+
out.push(`No preview was made${s.refused ? `: ${s.refused}` : "."}`);
|
|
1719
|
+
else
|
|
1720
|
+
err.push("The preview's rebuild stopped: the next turn makes its own.");
|
|
1721
|
+
break;
|
|
1722
|
+
case "playtest":
|
|
1723
|
+
if (!ended)
|
|
1724
|
+
break;
|
|
1725
|
+
err.push(`Playtest round ${s.round}: ${s.ok ? "every check passed" : plural(s.problems, "problem")}.`);
|
|
1726
|
+
if (s.judge_summary)
|
|
1727
|
+
err.push(` ${s.judge_summary}`);
|
|
1728
|
+
for (const shot of s.shots)
|
|
1729
|
+
err.push(` screenshot ${shot.label}: ${mediaLink(base, shot)}`);
|
|
1730
|
+
if (s.clip)
|
|
1731
|
+
out.push(`Clip: ${mediaLink(base, s.clip.media)}`);
|
|
1732
|
+
break;
|
|
1733
|
+
case "review":
|
|
1734
|
+
if (!ended)
|
|
1735
|
+
break;
|
|
1736
|
+
err.push(`Review: ${s.ok ? "no problems" : plural(s.problems, "problem")}.${s.summary ? ` ${s.summary}` : ""}`);
|
|
1737
|
+
break;
|
|
1738
|
+
case "commit":
|
|
1739
|
+
if (i.status === "done")
|
|
1740
|
+
out.push(`Committed ${s.sha.slice(0, 7)}: ${s.message}`);
|
|
1741
|
+
else if (ended)
|
|
1742
|
+
out.push(`Not saved (${s.sha.slice(0, 7)}): ${s.message}`);
|
|
1743
|
+
break;
|
|
1744
|
+
case "restore":
|
|
1745
|
+
out.push(restoreLine(s.version, s.commit, s.from_version));
|
|
1746
|
+
break;
|
|
1747
|
+
}
|
|
1748
|
+
return { out, err };
|
|
1749
|
+
}
|
|
1750
|
+
function restoreLine(version2, commit, from) {
|
|
1751
|
+
return `Went ${from !== null && version2 < from ? "back" : "forward"} to version ${version2} (commit ${commit.slice(0, 7)}).`;
|
|
1752
|
+
}
|
|
1753
|
+
function failureLine(summary) {
|
|
1754
|
+
const failure = summary.failure;
|
|
1755
|
+
if (!failure || failure.turn !== null && failure.turn !== summary.latest?.turn)
|
|
1756
|
+
return null;
|
|
1757
|
+
return `${failure.turn !== null ? `Turn ${failure.turn} failed: ` : ""}${FAILURE_SENTENCES[failure.class]}${failure.retryable ? " Try again." : ""}`;
|
|
1758
|
+
}
|
|
1759
|
+
var askedIn = (q) => Number(/^t(\d+)\./.exec(q.item)?.[1] ?? 0);
|
|
1760
|
+
async function keepQuestion(ctx, base, turn, project, asked) {
|
|
1761
|
+
const kept = (await readCreateState(ctx, base)).question;
|
|
1762
|
+
const same = kept !== undefined && kept.project === (asked?.project ?? project);
|
|
1763
|
+
if (asked) {
|
|
1764
|
+
if (!same || askedIn(kept) <= turn)
|
|
1765
|
+
await saveCreateState(ctx, base, { question: asked });
|
|
1766
|
+
} else if (same && askedIn(kept) < turn)
|
|
1767
|
+
await saveCreateState(ctx, base, { question: undefined });
|
|
1768
|
+
}
|
|
1253
1769
|
async function follow(ctx, api, turn, path, from, mode, project) {
|
|
1254
1770
|
const controller = new AbortController;
|
|
1255
1771
|
ctx.untilStopped().then(() => controller.abort());
|
|
1256
|
-
const said = { held: null, engine: null, project, asked: false, next: [] };
|
|
1772
|
+
const said = { held: null, engine: null, project, asked: false, next: [], base: api.base, question: null, work: null };
|
|
1257
1773
|
let final;
|
|
1258
1774
|
try {
|
|
1259
|
-
final = await stream(api, path, { from, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
|
|
1775
|
+
final = await stream(api, path, { ...from !== undefined ? { start: { from } } : {}, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
|
|
1260
1776
|
if (mode === "events")
|
|
1261
1777
|
ctx.out(JSON.stringify(event2));
|
|
1262
1778
|
else if (mode === "text")
|
|
@@ -1273,6 +1789,7 @@ async function follow(ctx, api, turn, path, from, mode, project) {
|
|
|
1273
1789
|
} finally {
|
|
1274
1790
|
controller.abort();
|
|
1275
1791
|
release(ctx, said);
|
|
1792
|
+
await keepQuestion(ctx, api.base, turn, project, said.question);
|
|
1276
1793
|
}
|
|
1277
1794
|
if (final === null) {
|
|
1278
1795
|
const message = `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}`;
|
|
@@ -1302,7 +1819,6 @@ async function follow(ctx, api, turn, path, from, mode, project) {
|
|
|
1302
1819
|
}
|
|
1303
1820
|
return code;
|
|
1304
1821
|
}
|
|
1305
|
-
var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1306
1822
|
function capital(text) {
|
|
1307
1823
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1308
1824
|
}
|
|
@@ -1314,8 +1830,8 @@ function lasting(at, expires) {
|
|
|
1314
1830
|
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
1315
1831
|
return "";
|
|
1316
1832
|
if (minutes < 90)
|
|
1317
|
-
return
|
|
1318
|
-
return
|
|
1833
|
+
return plural(minutes, "minute");
|
|
1834
|
+
return plural(Math.round(minutes / 60), "hour");
|
|
1319
1835
|
}
|
|
1320
1836
|
function jobDone(ctx, label, figures, summary) {
|
|
1321
1837
|
if (figures)
|
|
@@ -1345,21 +1861,26 @@ function render(ctx, event, said) {
|
|
|
1345
1861
|
release(ctx, said);
|
|
1346
1862
|
switch (event.type) {
|
|
1347
1863
|
case "turn.started":
|
|
1348
|
-
if (event.routed
|
|
1349
|
-
ctx.err(`Started: Velven is choosing the work, ${
|
|
1864
|
+
if (event.routed)
|
|
1865
|
+
ctx.err(`Started: Velven is choosing the work, ${plural(event.credits_held, "credit")} held.`);
|
|
1350
1866
|
else
|
|
1351
|
-
ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${
|
|
1867
|
+
ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held.`);
|
|
1352
1868
|
return;
|
|
1353
1869
|
case "route":
|
|
1870
|
+
said.work = event.work;
|
|
1354
1871
|
ctx.out(event.said);
|
|
1355
|
-
ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${
|
|
1872
|
+
ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held: ${event.reason})`);
|
|
1356
1873
|
return;
|
|
1357
1874
|
case "ask": {
|
|
1358
1875
|
ctx.out(`${event.question} (question ${event.index} of ${event.of})`);
|
|
1359
1876
|
for (const [i, choice] of event.choices.entries())
|
|
1360
1877
|
ctx.out(` ${i + 1}. ${choice}`);
|
|
1361
1878
|
const send2 = sendCommand(said);
|
|
1362
|
-
|
|
1879
|
+
if (event.item_id && said.project) {
|
|
1880
|
+
said.question = { project: said.project, item: event.item_id, choices: event.choices };
|
|
1881
|
+
ctx.out(`Answer: ${send2} --answer 1 (a choice's number), or in your own words.`);
|
|
1882
|
+
} else
|
|
1883
|
+
ctx.out(`Answer: ${send2} "${event.choices[0]}", or in your own words.`);
|
|
1363
1884
|
if (event.picture || event.topic === "style")
|
|
1364
1885
|
ctx.out(`Or show the look: ${send2} --ref <file> "like this".`);
|
|
1365
1886
|
ctx.out(`Just build it: ${send2} --build "Just build it"`);
|
|
@@ -1371,7 +1892,8 @@ function render(ctx, event, said) {
|
|
|
1371
1892
|
return;
|
|
1372
1893
|
for (const line of planCard(event.plan))
|
|
1373
1894
|
ctx.out(line);
|
|
1374
|
-
|
|
1895
|
+
if (said.work === "draft")
|
|
1896
|
+
ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
|
|
1375
1897
|
return;
|
|
1376
1898
|
case "brief": {
|
|
1377
1899
|
if (!event.done)
|
|
@@ -1411,8 +1933,9 @@ function render(ctx, event, said) {
|
|
|
1411
1933
|
}
|
|
1412
1934
|
case "tool":
|
|
1413
1935
|
ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
|
|
1414
|
-
if (event.picture?.
|
|
1415
|
-
ctx.out(`Painted ${event.picture.id} (${event.picture.width} by ${event.picture.height}) ${dim(ctx, event.picture.
|
|
1936
|
+
if (event.picture?.media) {
|
|
1937
|
+
ctx.out(`Painted ${event.picture.id} (${event.picture.width} by ${event.picture.height}) ${dim(ctx, mediaLink(said.base, event.picture.media))}`);
|
|
1938
|
+
}
|
|
1416
1939
|
return;
|
|
1417
1940
|
case "text":
|
|
1418
1941
|
ctx.out(event.text);
|
|
@@ -1431,10 +1954,10 @@ function render(ctx, event, said) {
|
|
|
1431
1954
|
ctx.err("The live link is gone.");
|
|
1432
1955
|
return;
|
|
1433
1956
|
case "shot":
|
|
1434
|
-
ctx.err(` screenshot ${event.label}: ${event.
|
|
1957
|
+
ctx.err(` screenshot ${event.label}: ${mediaLink(said.base, event.media)}`);
|
|
1435
1958
|
return;
|
|
1436
1959
|
case "clip":
|
|
1437
|
-
ctx.out(`Clip: ${event.
|
|
1960
|
+
ctx.out(`Clip: ${mediaLink(said.base, event.media)}`);
|
|
1438
1961
|
return;
|
|
1439
1962
|
case "build":
|
|
1440
1963
|
if (event.ok)
|
|
@@ -1446,10 +1969,10 @@ function render(ctx, event, said) {
|
|
|
1446
1969
|
}
|
|
1447
1970
|
return;
|
|
1448
1971
|
case "playtest":
|
|
1449
|
-
ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" :
|
|
1972
|
+
ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" : plural(event.problems, "check problem")}.`);
|
|
1450
1973
|
return;
|
|
1451
1974
|
case "review":
|
|
1452
|
-
ctx.err(`Review: ${event.ok ? "no problems" :
|
|
1975
|
+
ctx.err(`Review: ${event.ok ? "no problems" : plural(event.problems, "problem")}.`);
|
|
1453
1976
|
return;
|
|
1454
1977
|
case "commit":
|
|
1455
1978
|
ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
|
|
@@ -1464,11 +1987,14 @@ function render(ctx, event, said) {
|
|
|
1464
1987
|
return;
|
|
1465
1988
|
}
|
|
1466
1989
|
case "cost":
|
|
1467
|
-
ctx.err(`${event.credits_used} of ${
|
|
1990
|
+
ctx.err(`${event.credits_used} of ${plural(event.credits_held, "credit")} used so far.`);
|
|
1468
1991
|
return;
|
|
1469
1992
|
case "next":
|
|
1470
1993
|
said.next = event.choices;
|
|
1471
1994
|
return;
|
|
1995
|
+
case "steer":
|
|
1996
|
+
ctx.out(event.state === "taken" ? "A message you sent while it ran was taken into account." : event.why === "paused" ? "A message you sent while it ran could not be taken this turn; it is queued, but the queue is paused. Start it: velven create resume" : "A message you sent while it ran could not be taken this turn; it is queued as the next message.");
|
|
1997
|
+
return;
|
|
1472
1998
|
default:
|
|
1473
1999
|
return;
|
|
1474
2000
|
}
|
|
@@ -1494,20 +2020,22 @@ function finish(ctx, turn, event, said) {
|
|
|
1494
2020
|
}
|
|
1495
2021
|
if (event.preview)
|
|
1496
2022
|
ctx.out(`Play it: ${event.preview.url}`);
|
|
1497
|
-
const back = event.refunded > 0 ? `; ${
|
|
2023
|
+
const back = event.refunded > 0 ? `; ${plural(event.refunded, "credit")} came back` : "";
|
|
1498
2024
|
if (event.status === "done") {
|
|
1499
|
-
ctx.out(`Turn ${turn} is done: ${
|
|
2025
|
+
ctx.out(`Turn ${turn} is done: ${plural(event.credits_charged, "credit")} used${back}.`);
|
|
1500
2026
|
if (event.stop_reason === "cap")
|
|
1501
2027
|
ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
|
|
1502
2028
|
if (event.stop_reason === "time")
|
|
1503
2029
|
ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
|
|
1504
2030
|
} else if (event.status === "cancelled") {
|
|
1505
|
-
ctx.err(`Turn ${turn} was cancelled: ${
|
|
2031
|
+
ctx.err(`Turn ${turn} was cancelled: ${plural(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
|
|
1506
2032
|
} else {
|
|
1507
|
-
ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
|
|
2033
|
+
ctx.err(`Turn ${turn} failed: ${event.error ?? (event.failure ? FAILURE_SENTENCES[event.failure.class] : "Velven could not finish it.")} A failed turn costs nothing.`);
|
|
1508
2034
|
}
|
|
1509
2035
|
if (reply?.includes(BUILD_AGAIN))
|
|
1510
2036
|
ctx.out(`Try again: ${sendCommand(said)} --build "Build it"`);
|
|
2037
|
+
else if (event.status === "failed" && event.failure?.retryable)
|
|
2038
|
+
ctx.out(`Try again: ${sendCommand(said)} with the same words.`);
|
|
1511
2039
|
if (event.next_turn) {
|
|
1512
2040
|
const elsewhere = event.next_project && said.project && event.next_project !== said.project ? ` on project ${event.next_project}` : "";
|
|
1513
2041
|
ctx.out(`Your next message${elsewhere} is running: velven create watch ${event.next_turn}`);
|
|
@@ -1517,6 +2045,9 @@ var BUILD_AGAIN = "Press Build it to try again.";
|
|
|
1517
2045
|
function sendCommand(said) {
|
|
1518
2046
|
return `velven create send${said.project ? ` --project ${said.project}` : ""}`;
|
|
1519
2047
|
}
|
|
2048
|
+
function mediaLink(base, media) {
|
|
2049
|
+
return `${base}${ROOT}/media/${media.id}`;
|
|
2050
|
+
}
|
|
1520
2051
|
function dim(ctx, text) {
|
|
1521
2052
|
return ctx.interactive && !ctx.env.NO_COLOR ? `\x1B[2m${text}\x1B[22m` : text;
|
|
1522
2053
|
}
|
|
@@ -1548,17 +2079,46 @@ function planCard(plan) {
|
|
|
1548
2079
|
lines.push(plan.score ? "It keeps a score." : "No score.");
|
|
1549
2080
|
return lines;
|
|
1550
2081
|
}
|
|
1551
|
-
function queuedLine(queued) {
|
|
1552
|
-
const ahead = queued.position !== null && queued.position > 1 ?
|
|
1553
|
-
|
|
2082
|
+
function queuedLine(queued, why) {
|
|
2083
|
+
const ahead = queued.position !== null && queued.position > 1 ? plural(queued.position - 1, "message") : null;
|
|
2084
|
+
const list = "velven create queue lists what waits.";
|
|
2085
|
+
switch (why) {
|
|
2086
|
+
case "paused":
|
|
2087
|
+
return `Queued, but the queue is paused after a failed or stopped turn. Start it: velven create resume`;
|
|
2088
|
+
case "wrapping_up":
|
|
2089
|
+
return `Queued: the turn is finishing, so it runs next${ahead ? ` after ${ahead} ahead of it` : ""}. ${list}`;
|
|
2090
|
+
case "limit":
|
|
2091
|
+
return `Queued: five messages already joined this turn, so it runs next${ahead ? ` after ${ahead} ahead of it` : ""}. ${list}`;
|
|
2092
|
+
case "phase":
|
|
2093
|
+
return `Queued: the turn is not writing code now, so it runs after the turn${ahead ? ` and ${ahead} ahead of it` : ""}. ${list}`;
|
|
2094
|
+
case "steer_off":
|
|
2095
|
+
return `Queued: it runs after the turn that is running${ahead ? ` and ${ahead} ahead of it` : ""}. ${list}`;
|
|
2096
|
+
default:
|
|
2097
|
+
return `Queued: it runs after ${ahead ? `${ahead} ahead of it` : "the message ahead of it"}. ${list}`;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
function pausedLine(paused) {
|
|
2101
|
+
return `Paused after a ${paused.why === "failed" ? "failed" : "stopped"} turn. Resume: velven create resume`;
|
|
1554
2102
|
}
|
|
1555
2103
|
async function cancel(ctx, api, arg) {
|
|
1556
2104
|
const turn = await turnId(ctx, api.base, arg, "cancel");
|
|
1557
2105
|
const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
|
|
1558
|
-
if (answer.status === "cancelled")
|
|
2106
|
+
if (answer.status === "cancelled" && !answer.started)
|
|
1559
2107
|
ctx.out(`Turn ${turn} is cancelled before it started. Its credits are back.`);
|
|
2108
|
+
else if (answer.status === "cancelled")
|
|
2109
|
+
ctx.out(`Turn ${turn} is stopped: ${plural(answer.credits_charged, "credit")} used. What it finished is saved.`);
|
|
1560
2110
|
else
|
|
1561
2111
|
ctx.out(`Turn ${turn} is stopping. What it finished is saved and unused credits come back. Follow it: velven create watch ${turn}`);
|
|
2112
|
+
try {
|
|
2113
|
+
const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
|
|
2114
|
+
const queue = await call(api, "GET", `${ROOT}/projects/${seg(view.project)}/queue`);
|
|
2115
|
+
if (queue.paused || answer.status === "cancelling" && queue.queued.some((q) => q.state === "waiting")) {
|
|
2116
|
+
ctx.out(`${answer.status === "cancelled" ? "Stopped" : "Stopping"}. The messages waiting on this project are paused: velven create resume starts them.`);
|
|
2117
|
+
}
|
|
2118
|
+
} catch (e) {
|
|
2119
|
+
if (!(e instanceof CliError))
|
|
2120
|
+
throw e;
|
|
2121
|
+
}
|
|
1562
2122
|
return EXIT.ok;
|
|
1563
2123
|
}
|
|
1564
2124
|
async function list(ctx, api, json) {
|
|
@@ -1590,11 +2150,14 @@ async function show(ctx, api, arg, json) {
|
|
|
1590
2150
|
}
|
|
1591
2151
|
ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
|
|
1592
2152
|
ctx.out(`Packs: ${packs(project)}`);
|
|
1593
|
-
|
|
1594
|
-
|
|
2153
|
+
if (project.turn && !project.turn.routed)
|
|
2154
|
+
ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
|
|
2155
|
+
const summary = project.summary;
|
|
2156
|
+
const version2 = summary?.head?.version;
|
|
2157
|
+
ctx.out(`Latest commit: ${project.head ? `${project.head.slice(0, 7)}${version2 !== null && version2 !== undefined ? ` (version ${version2})` : ""}` : "none yet"}`);
|
|
1595
2158
|
const t = project.turn;
|
|
1596
2159
|
if (t) {
|
|
1597
|
-
const charged = t.credits.charged !== null ? `, ${
|
|
2160
|
+
const charged = t.credits.charged !== null ? `, ${plural(t.credits.charged, "credit")} used` : `, ${plural(t.credits.held, "credit")} held`;
|
|
1598
2161
|
const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
|
|
1599
2162
|
ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
|
|
1600
2163
|
if (t.error)
|
|
@@ -1602,12 +2165,32 @@ async function show(ctx, api, arg, json) {
|
|
|
1602
2165
|
} else {
|
|
1603
2166
|
ctx.out("Newest turn: none yet");
|
|
1604
2167
|
}
|
|
2168
|
+
if (summary)
|
|
2169
|
+
for (const line of summaryLines(summary))
|
|
2170
|
+
ctx.out(line);
|
|
1605
2171
|
if (project.preview?.url)
|
|
1606
2172
|
ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
|
|
1607
2173
|
else
|
|
1608
2174
|
ctx.out("Preview: none yet");
|
|
1609
2175
|
return EXIT.ok;
|
|
1610
2176
|
}
|
|
2177
|
+
function summaryLines(summary) {
|
|
2178
|
+
const lines = [];
|
|
2179
|
+
if (summary.open_turn)
|
|
2180
|
+
lines.push(`Running: ${summary.open_turn.phase ?? "starting"} (turn ${summary.open_turn.id})`);
|
|
2181
|
+
if (summary.rebuilding) {
|
|
2182
|
+
const version2 = summary.head?.version;
|
|
2183
|
+
lines.push(`A preview of ${version2 !== null && version2 !== undefined ? `version ${version2}` : "the current version"} is being rebuilt.`);
|
|
2184
|
+
}
|
|
2185
|
+
if (summary.queue.paused)
|
|
2186
|
+
lines.push(`${plural(summary.queue.waiting, "message")} waiting. ${pausedLine(summary.queue.paused)}`);
|
|
2187
|
+
else if (summary.queue.waiting > 0)
|
|
2188
|
+
lines.push(`${plural(summary.queue.waiting, "message")} waiting.`);
|
|
2189
|
+
const failure = failureLine(summary);
|
|
2190
|
+
if (failure)
|
|
2191
|
+
lines.push(failure);
|
|
2192
|
+
return lines;
|
|
2193
|
+
}
|
|
1611
2194
|
async function showTimeline(ctx, api, arg, turnFlag, all, json) {
|
|
1612
2195
|
let id;
|
|
1613
2196
|
if (turnFlag !== undefined) {
|
|
@@ -1624,12 +2207,11 @@ async function showTimeline(ctx, api, arg, turnFlag, all, json) {
|
|
|
1624
2207
|
id = view.turn.id;
|
|
1625
2208
|
}
|
|
1626
2209
|
const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
|
|
1627
|
-
const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
|
|
1628
2210
|
if (json) {
|
|
1629
|
-
ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
|
|
2211
|
+
ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline: turn.timeline }));
|
|
1630
2212
|
return EXIT.ok;
|
|
1631
2213
|
}
|
|
1632
|
-
for (const line of renderTimeline(turn, timeline.spans, { all }))
|
|
2214
|
+
for (const line of renderTimeline(turn, turn.timeline.spans, { all }))
|
|
1633
2215
|
ctx.out(line);
|
|
1634
2216
|
return EXIT.ok;
|
|
1635
2217
|
}
|
|
@@ -1657,7 +2239,7 @@ async function stats(ctx, api, daysFlag, origin, json) {
|
|
|
1657
2239
|
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
1658
2240
|
return EXIT.ok;
|
|
1659
2241
|
}
|
|
1660
|
-
for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit
|
|
2242
|
+
for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit }))
|
|
1661
2243
|
ctx.out(line);
|
|
1662
2244
|
return EXIT.ok;
|
|
1663
2245
|
}
|
|
@@ -1689,7 +2271,7 @@ async function referencePictures(ctx, api, args, projectFlag, json) {
|
|
|
1689
2271
|
return EXIT.ok;
|
|
1690
2272
|
}
|
|
1691
2273
|
for (const r of references) {
|
|
1692
|
-
const carried = r.messages
|
|
2274
|
+
const carried = r.messages ? `on ${plural(r.messages, "message")}` : "not sent yet";
|
|
1693
2275
|
ctx.out(`${r.id.padEnd(7)} ${when(r.created_at).padEnd(20)} ${`${r.width} by ${r.height}`.padEnd(12)} ${carried}`);
|
|
1694
2276
|
}
|
|
1695
2277
|
ctx.out(`${references.length} of ${REFS_PER_PROJECT} kept. A picture no message carried goes a day after it was last uploaded.`);
|
|
@@ -1714,7 +2296,7 @@ async function usage(ctx, api, project, before, json) {
|
|
|
1714
2296
|
return EXIT.ok;
|
|
1715
2297
|
}
|
|
1716
2298
|
for (const t of answer.turns) {
|
|
1717
|
-
const used = t.credits.charged !== null ? `${
|
|
2299
|
+
const used = t.credits.charged !== null ? `${plural(t.credits.charged, "credit")} used` : `${plural(t.credits.held, "credit")} held`;
|
|
1718
2300
|
const kind = t.work ?? `${t.effort}${t.plan ? " plan" : ""}`;
|
|
1719
2301
|
ctx.out(`turn ${String(t.turn).padEnd(6)} ${when(t.created_at).padEnd(20)} ${(t.project.title ?? "Untitled").padEnd(24)} ${kind.padEnd(10)} ${t.status.padEnd(9)} ${used}`.trimEnd());
|
|
1720
2302
|
}
|
|
@@ -1742,42 +2324,164 @@ async function showPlan(ctx, api, args, projectFlag, json) {
|
|
|
1742
2324
|
ctx.out(`Build it: velven create send${projectFlag ? ` --project ${id}` : ""} --build "Build it"`);
|
|
1743
2325
|
return EXIT.ok;
|
|
1744
2326
|
}
|
|
1745
|
-
|
|
2327
|
+
function queuedId(id, usage2) {
|
|
2328
|
+
if (id === undefined || !/^\d+$/.test(id) || Number(id) === 0)
|
|
2329
|
+
throw new CliError(usage2, EXIT.usage, "usage");
|
|
2330
|
+
return Number(id);
|
|
2331
|
+
}
|
|
2332
|
+
async function queue(ctx, api, args, projectFlag, build, json) {
|
|
1746
2333
|
const [action, id, ...rest] = args;
|
|
2334
|
+
if (build && action !== "edit")
|
|
2335
|
+
throw new CliError("--build goes with velven create queue edit.", EXIT.usage, "usage");
|
|
1747
2336
|
if (action === "rm") {
|
|
1748
|
-
|
|
1749
|
-
|
|
2337
|
+
const n = queuedId(rest.length ? undefined : id, "Name one waiting message by its number, like velven create queue rm 12.");
|
|
2338
|
+
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
2339
|
+
await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/queue/${seg(n)}`);
|
|
2340
|
+
if (json)
|
|
2341
|
+
ctx.out(JSON.stringify({ ok: true, removed: n }));
|
|
2342
|
+
else
|
|
2343
|
+
ctx.out(`Removed message ${n}: it will not run.`);
|
|
2344
|
+
return EXIT.ok;
|
|
2345
|
+
}
|
|
2346
|
+
if (action === "edit") {
|
|
2347
|
+
const n = queuedId(id, 'Name the waiting message and its new words, like velven create queue edit 12 "add a pond".');
|
|
2348
|
+
const text = rest.join(" ").trim();
|
|
2349
|
+
if (!text && !build)
|
|
2350
|
+
throw new CliError(`Give the message's new words, like velven create queue edit 12 "add a pond", or --build.`, EXIT.usage, "usage");
|
|
2351
|
+
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
2352
|
+
const body = {};
|
|
2353
|
+
if (text)
|
|
2354
|
+
body.text = text;
|
|
2355
|
+
if (build)
|
|
2356
|
+
body.intent = "build";
|
|
2357
|
+
const { queued: queued2 } = await call(api, "PATCH", `${ROOT}/projects/${seg(project2)}/queue/${seg(n)}`, body);
|
|
2358
|
+
if (json)
|
|
2359
|
+
ctx.out(JSON.stringify({ ok: true, queued: queued2 }));
|
|
2360
|
+
else
|
|
2361
|
+
ctx.out(`Changed message ${n}${queued2.position !== null ? `, ${ordinal(queued2.position)} in line` : ""}.`);
|
|
2362
|
+
return EXIT.ok;
|
|
2363
|
+
}
|
|
2364
|
+
if (action === "move") {
|
|
2365
|
+
const n = queuedId(id, "Name the waiting message and its new place, like velven create queue move 12 1.");
|
|
2366
|
+
const [place, ...extra] = rest;
|
|
2367
|
+
if (place === undefined || !/^\d+$/.test(place) || Number(place) === 0 || extra.length) {
|
|
2368
|
+
throw new CliError(`The place is a number, 1 for the next to run, like velven create queue move ${n} 1.`, EXIT.usage, "usage");
|
|
1750
2369
|
}
|
|
1751
2370
|
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
1752
|
-
await
|
|
2371
|
+
const view = await moveQueued(api, project2, n, Number(place));
|
|
2372
|
+
const moved = view.queued.find((q) => q.id === n);
|
|
1753
2373
|
if (json)
|
|
1754
|
-
ctx.out(JSON.stringify({ ok: true,
|
|
2374
|
+
ctx.out(JSON.stringify({ ok: true, ...view }));
|
|
1755
2375
|
else
|
|
1756
|
-
ctx.out(`
|
|
2376
|
+
ctx.out(`Moved message ${n}${moved?.position ? ` to ${ordinal(moved.position)} in line` : ""}.`);
|
|
1757
2377
|
return EXIT.ok;
|
|
1758
2378
|
}
|
|
1759
2379
|
if (action !== undefined) {
|
|
1760
|
-
throw new CliError(`Unknown command "create queue ${action}". List what waits with velven create queue,
|
|
2380
|
+
throw new CliError(`Unknown command "create queue ${action}". List what waits with velven create queue; change one with queue edit, move one with queue move, remove one with queue rm.`, EXIT.usage, "usage");
|
|
1761
2381
|
}
|
|
1762
2382
|
const project = await currentProject(ctx, api.base, projectFlag);
|
|
1763
|
-
const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
|
|
2383
|
+
const { queued, paused } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
|
|
1764
2384
|
if (json) {
|
|
1765
|
-
ctx.out(JSON.stringify({ ok: true, queued }));
|
|
2385
|
+
ctx.out(JSON.stringify({ ok: true, queued, paused }));
|
|
1766
2386
|
return EXIT.ok;
|
|
1767
2387
|
}
|
|
1768
2388
|
if (!queued.length) {
|
|
1769
2389
|
ctx.out("Nothing is waiting on this project.");
|
|
1770
2390
|
return EXIT.ok;
|
|
1771
2391
|
}
|
|
2392
|
+
if (paused)
|
|
2393
|
+
ctx.out(pausedLine(paused));
|
|
1772
2394
|
for (const q of queued) {
|
|
1773
2395
|
const at = q.position !== null ? `${q.position}.` : q.state === "refused" ? "x" : "-";
|
|
1774
2396
|
const words = q.text.length > 60 ? `${q.text.slice(0, 59)}…` : q.text;
|
|
1775
2397
|
ctx.out(`${at.padStart(3)} ${String(q.id).padEnd(6)} ${when(q.created_at).padEnd(20)} ${q.intent === "build" ? "[build] " : ""}${words}`);
|
|
2398
|
+
if (q.from_steer)
|
|
2399
|
+
ctx.out(" (from a message sent while a turn ran)");
|
|
1776
2400
|
if (q.state === "refused" && q.refused)
|
|
1777
2401
|
ctx.out(` Not sent: ${q.refused}`);
|
|
1778
2402
|
}
|
|
1779
|
-
if (queued.some((q) => q.state === "waiting"))
|
|
1780
|
-
ctx.out(
|
|
2403
|
+
if (queued.some((q) => q.state === "waiting")) {
|
|
2404
|
+
ctx.out(`They run in this order ${paused ? "once you resume" : "when the running turn ends"}. Change one: velven create queue edit <number> <text>; move one: queue move <number> <place>; remove one: queue rm <number>`);
|
|
2405
|
+
}
|
|
2406
|
+
return EXIT.ok;
|
|
2407
|
+
}
|
|
2408
|
+
async function moveQueued(api, project, id, place) {
|
|
2409
|
+
for (let attempt = 0;; attempt++) {
|
|
2410
|
+
const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
|
|
2411
|
+
const waiting = queued.filter((q) => q.state === "waiting").sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
2412
|
+
if (!waiting.some((q) => q.id === id))
|
|
2413
|
+
throw new CliError(`Message ${id} is not waiting on this project: velven create queue lists what waits.`, EXIT.refused, "not_found");
|
|
2414
|
+
const order = waiting.map((q) => q.id).filter((q) => q !== id);
|
|
2415
|
+
order.splice(Math.min(place, waiting.length) - 1, 0, id);
|
|
2416
|
+
try {
|
|
2417
|
+
return await call(api, "PUT", `${ROOT}/projects/${seg(project)}/queue/order`, { order });
|
|
2418
|
+
} catch (e) {
|
|
2419
|
+
if (!(e instanceof CliError) || e.code !== "queue_mismatch" || attempt > 0)
|
|
2420
|
+
throw e;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
function ordinal(n) {
|
|
2425
|
+
const tens = n % 100;
|
|
2426
|
+
const suffix = tens >= 11 && tens <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";
|
|
2427
|
+
return `${n}${suffix}`;
|
|
2428
|
+
}
|
|
2429
|
+
async function resume(ctx, api, args, projectFlag, json) {
|
|
2430
|
+
if (args.length)
|
|
2431
|
+
throw new CliError("velven create resume takes no words: name another project with --project <id>.", EXIT.usage, "usage");
|
|
2432
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
2433
|
+
const answer = await call(api, "POST", `${ROOT}/projects/${seg(project)}/queue/resume`, {});
|
|
2434
|
+
if (answer.started !== null)
|
|
2435
|
+
await saveCreateState(ctx, api.base, { project, turn: answer.started });
|
|
2436
|
+
if (json) {
|
|
2437
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
2438
|
+
return EXIT.ok;
|
|
2439
|
+
}
|
|
2440
|
+
const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
|
|
2441
|
+
const waiting = queued.filter((q) => q.state === "waiting").length + (answer.started !== null ? 1 : 0);
|
|
2442
|
+
const first = answer.started !== null ? ` The first starts now: velven create watch ${answer.started}` : waiting > 0 ? " They start when the running turn ends." : "";
|
|
2443
|
+
ctx.out(answer.resumed ? `Resumed: ${waiting} waiting.${first}` : `Nothing was paused.${first}`);
|
|
2444
|
+
return EXIT.ok;
|
|
2445
|
+
}
|
|
2446
|
+
async function versions(ctx, api, args, projectFlag, json) {
|
|
2447
|
+
if (args.length)
|
|
2448
|
+
throw new CliError("velven create versions takes no words: name another project with --project <id>.", EXIT.usage, "usage");
|
|
2449
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
2450
|
+
const { versions: rows } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/versions`);
|
|
2451
|
+
if (json) {
|
|
2452
|
+
ctx.out(JSON.stringify({ ok: true, versions: rows }));
|
|
2453
|
+
return EXIT.ok;
|
|
2454
|
+
}
|
|
2455
|
+
if (!rows.length) {
|
|
2456
|
+
ctx.out('No versions yet: a turn that saves the game makes one. velven create send "a fox that hops over logs"');
|
|
2457
|
+
return EXIT.ok;
|
|
2458
|
+
}
|
|
2459
|
+
for (const v of rows) {
|
|
2460
|
+
const label = v.version === 0 ? "0 template" : String(v.version);
|
|
2461
|
+
const words = v.version === 0 ? "" : v.message.length > 60 ? `${v.message.slice(0, 59)}…` : v.message;
|
|
2462
|
+
const marks = [v.head ? "head" : null, v.rolled_back ? "rolled back" : null, v.preview?.status === "ready" ? "preview" : null].filter((m) => m !== null);
|
|
2463
|
+
ctx.out(`${label.padStart(10)} ${v.commit.slice(0, 7)} ${words.padEnd(60)} ${marks.join(", ")}`.trimEnd());
|
|
2464
|
+
}
|
|
2465
|
+
ctx.out("Go back or forward to one: velven create restore <version>");
|
|
2466
|
+
return EXIT.ok;
|
|
2467
|
+
}
|
|
2468
|
+
async function restore(ctx, api, args, projectFlag, json) {
|
|
2469
|
+
const [arg, ...rest] = args;
|
|
2470
|
+
if (arg === undefined || !/^\d+$/.test(arg) || rest.length) {
|
|
2471
|
+
throw new CliError("Name the version to go to by its number, like velven create restore 3 (velven create versions lists them).", EXIT.usage, "usage");
|
|
2472
|
+
}
|
|
2473
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
2474
|
+
const answer = await call(api, "POST", `${ROOT}/projects/${seg(project)}/restore`, { version: Number(arg) });
|
|
2475
|
+
if (json) {
|
|
2476
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
2477
|
+
return EXIT.ok;
|
|
2478
|
+
}
|
|
2479
|
+
if ("unchanged" in answer)
|
|
2480
|
+
ctx.out(`Already on version ${arg}.`);
|
|
2481
|
+
else
|
|
2482
|
+
ctx.out(restoreLine(answer.restored.version, answer.restored.commit, answer.restored.from_version));
|
|
2483
|
+
if (answer.rebuild)
|
|
2484
|
+
ctx.out("Its preview expired; Velven is rebuilding it (no credits). velven create show prints it when ready.");
|
|
1781
2485
|
return EXIT.ok;
|
|
1782
2486
|
}
|
|
1783
2487
|
async function credits(ctx, api, json) {
|
|
@@ -1786,7 +2490,7 @@ async function credits(ctx, api, json) {
|
|
|
1786
2490
|
ctx.out(JSON.stringify({ ok: true, credits: view }));
|
|
1787
2491
|
return EXIT.ok;
|
|
1788
2492
|
}
|
|
1789
|
-
ctx.out(`${
|
|
2493
|
+
ctx.out(`${plural(view.available, "credit")} available: ${view.daily_left} left of this week's allowance, ${view.balance} in your balance, ${view.held} held by open turns.`);
|
|
1790
2494
|
for (const row of view.ledger.slice(0, 10)) {
|
|
1791
2495
|
const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
|
|
1792
2496
|
ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
|
|
@@ -1797,12 +2501,6 @@ function packs(project) {
|
|
|
1797
2501
|
const engine = `${project.engine.id} ${project.engine.version}`;
|
|
1798
2502
|
return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
|
|
1799
2503
|
}
|
|
1800
|
-
function when(iso) {
|
|
1801
|
-
if (!iso)
|
|
1802
|
-
return "";
|
|
1803
|
-
const d = new Date(iso);
|
|
1804
|
-
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
1805
|
-
}
|
|
1806
2504
|
async function currentProject(ctx, base, given) {
|
|
1807
2505
|
if (given)
|
|
1808
2506
|
return given;
|
|
@@ -1825,7 +2523,7 @@ async function turnId(ctx, base, arg, verb) {
|
|
|
1825
2523
|
|
|
1826
2524
|
// src/dev.ts
|
|
1827
2525
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
1828
|
-
import {
|
|
2526
|
+
import { readdir as readdir3, readFile as readFile6, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
1829
2527
|
import { createServer } from "node:http";
|
|
1830
2528
|
import { extname, join as join5, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
1831
2529
|
import { createBrotliDecompress, createGunzip } from "node:zlib";
|
|
@@ -2702,11 +3400,11 @@ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
|
|
|
2702
3400
|
throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
|
|
2703
3401
|
}
|
|
2704
3402
|
}
|
|
2705
|
-
async function
|
|
3403
|
+
async function readStart(abs, length) {
|
|
2706
3404
|
const handle = await open(abs, "r");
|
|
2707
3405
|
try {
|
|
2708
|
-
const head = new Uint8Array(
|
|
2709
|
-
const { bytesRead } = await handle.read(head, 0,
|
|
3406
|
+
const head = new Uint8Array(length);
|
|
3407
|
+
const { bytesRead } = await handle.read(head, 0, length, 0);
|
|
2710
3408
|
return head.subarray(0, bytesRead);
|
|
2711
3409
|
} finally {
|
|
2712
3410
|
await handle.close();
|
|
@@ -2716,7 +3414,7 @@ var SCAN_WINDOW = 8 * MB;
|
|
|
2716
3414
|
var SCAN_OVERLAP = 64 * 1024;
|
|
2717
3415
|
async function fileFindings(file) {
|
|
2718
3416
|
if (!scansContent(file.path) || isCompressed(file.path))
|
|
2719
|
-
return refuseFindings(file.path, await
|
|
3417
|
+
return refuseFindings(file.path, await readStart(file.abs, HEAD_BYTES));
|
|
2720
3418
|
const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
|
|
2721
3419
|
const handle = await open(file.abs, "r");
|
|
2722
3420
|
try {
|
|
@@ -2761,7 +3459,7 @@ async function checkMedia(config, files) {
|
|
|
2761
3459
|
continue;
|
|
2762
3460
|
const path = mediaPath(raw);
|
|
2763
3461
|
const file = files.find((f) => f.path === path);
|
|
2764
|
-
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(file.abs)) : await
|
|
3462
|
+
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(file.abs)) : await readStart(file.abs, HEAD_BYTES);
|
|
2765
3463
|
const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
|
|
2766
3464
|
if (problem)
|
|
2767
3465
|
problems.push(problem);
|
|
@@ -2879,16 +3577,6 @@ function contentType(path, head) {
|
|
|
2879
3577
|
}
|
|
2880
3578
|
var WASM_HEAD_BYTES = 1024 * 1024;
|
|
2881
3579
|
var ISOLATION_HEADER = { "Document-Isolation-Policy": "isolate-and-credentialless" };
|
|
2882
|
-
async function readStart(abs, length) {
|
|
2883
|
-
const handle = await open2(abs, "r");
|
|
2884
|
-
try {
|
|
2885
|
-
const head = new Uint8Array(length);
|
|
2886
|
-
const { bytesRead } = await handle.read(head, 0, length, 0);
|
|
2887
|
-
return head.subarray(0, bytesRead);
|
|
2888
|
-
} finally {
|
|
2889
|
-
await handle.close();
|
|
2890
|
-
}
|
|
2891
|
-
}
|
|
2892
3580
|
function inflatedStart(abs, encoding) {
|
|
2893
3581
|
return new Promise((resolveHead) => {
|
|
2894
3582
|
const source = createReadStream2(abs);
|
|
@@ -3089,7 +3777,7 @@ import { stat as stat3 } from "node:fs/promises";
|
|
|
3089
3777
|
import { resolve as resolve3 } from "node:path";
|
|
3090
3778
|
|
|
3091
3779
|
// src/upload.ts
|
|
3092
|
-
import { open as
|
|
3780
|
+
import { open as open2 } from "node:fs/promises";
|
|
3093
3781
|
var PARALLEL = 6;
|
|
3094
3782
|
var ATTEMPTS = 4;
|
|
3095
3783
|
function slicesOf(files, pieceBytes = PIECE_BYTES) {
|
|
@@ -3108,7 +3796,7 @@ function slicesOf(files, pieceBytes = PIECE_BYTES) {
|
|
|
3108
3796
|
return slices;
|
|
3109
3797
|
}
|
|
3110
3798
|
async function readSlice({ file, offset, length }) {
|
|
3111
|
-
const handle = await
|
|
3799
|
+
const handle = await open2(file.abs, "r");
|
|
3112
3800
|
try {
|
|
3113
3801
|
const body = Buffer.alloc(length);
|
|
3114
3802
|
let read = 0;
|
|
@@ -3126,7 +3814,7 @@ async function readSlice({ file, offset, length }) {
|
|
|
3126
3814
|
function retryable(status) {
|
|
3127
3815
|
return status === null || status === 408 || status === 429 || status >= 500;
|
|
3128
3816
|
}
|
|
3129
|
-
async function uploadMissing(ctx, base, targets, files,
|
|
3817
|
+
async function uploadMissing(ctx, base, targets, files, pieceBytes = PIECE_BYTES) {
|
|
3130
3818
|
const slices = slicesOf(files, pieceBytes);
|
|
3131
3819
|
let failure = null;
|
|
3132
3820
|
await eachLimit(targets, PARALLEL, async (target) => {
|
|
@@ -3157,10 +3845,8 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
|
|
|
3157
3845
|
});
|
|
3158
3846
|
status = response.status;
|
|
3159
3847
|
detail = (await response.text()).slice(0, 200);
|
|
3160
|
-
if (response.ok)
|
|
3161
|
-
onProgress(slice.length);
|
|
3848
|
+
if (response.ok)
|
|
3162
3849
|
return;
|
|
3163
|
-
}
|
|
3164
3850
|
} catch (e) {
|
|
3165
3851
|
detail = e instanceof Error ? e.message : String(e);
|
|
3166
3852
|
}
|
|
@@ -3175,7 +3861,6 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
|
|
|
3175
3861
|
}
|
|
3176
3862
|
|
|
3177
3863
|
// src/publish.ts
|
|
3178
|
-
var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3179
3864
|
var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
|
|
3180
3865
|
var POLL_MS = 3000;
|
|
3181
3866
|
var WAIT_LIMIT_MS = 20 * 60 * 1000;
|
|
@@ -3268,7 +3953,7 @@ async function publish(ctx, options) {
|
|
|
3268
3953
|
if (guest)
|
|
3269
3954
|
say(`By publishing you agree to Velven's Terms: ${terms}`);
|
|
3270
3955
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
3271
|
-
say(`Publishing ${
|
|
3956
|
+
say(`Publishing ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
|
|
3272
3957
|
await hashAll(files);
|
|
3273
3958
|
const body = {
|
|
3274
3959
|
fields: listingFields(config),
|
|
@@ -3306,15 +3991,12 @@ async function publish(ctx, options) {
|
|
|
3306
3991
|
const slices = slicesOf(files);
|
|
3307
3992
|
const bytes = [...wanted].reduce((sum, sha) => sum + (slices.get(sha)?.length ?? 0), 0);
|
|
3308
3993
|
if (sending) {
|
|
3309
|
-
say(`Uploading ${sending} of ${
|
|
3994
|
+
say(`Uploading ${sending} of ${plural(distinct.length, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
|
|
3310
3995
|
} else
|
|
3311
3996
|
say("Every file is already on Velven; nothing to upload.");
|
|
3312
|
-
await uploadMissing(ctx, base, created.uploads, files
|
|
3997
|
+
await uploadMissing(ctx, base, created.uploads, files);
|
|
3313
3998
|
const versionPath = `/api/v1/hosting/versions/${seg(created.version.id)}`;
|
|
3314
|
-
const finished = await call(api, "POST", `${versionPath}/finish`, {
|
|
3315
|
-
preview: !options.prod,
|
|
3316
|
-
...guest && claim ? { claim } : {}
|
|
3317
|
-
}).catch(claimedMeanwhile(base, slug, created.version.id, "finish"));
|
|
3999
|
+
const finished = await call(api, "POST", `${versionPath}/finish`, { preview: !options.prod }).catch(claimedMeanwhile(base, slug, created.version.id, "finish"));
|
|
3318
4000
|
const result = {
|
|
3319
4001
|
ok: true,
|
|
3320
4002
|
space: created.space,
|
|
@@ -3446,26 +4128,17 @@ async function signedIn(ctx) {
|
|
|
3446
4128
|
return { base, creds: await requireToken(ctx, base) };
|
|
3447
4129
|
}
|
|
3448
4130
|
async function listVersions(api, slug) {
|
|
3449
|
-
|
|
3450
|
-
return Array.isArray(answer) ? answer : answer.versions;
|
|
3451
|
-
}
|
|
3452
|
-
function when2(iso) {
|
|
3453
|
-
if (!iso)
|
|
3454
|
-
return "";
|
|
3455
|
-
const d = new Date(iso);
|
|
3456
|
-
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
4131
|
+
return (await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`)).versions;
|
|
3457
4132
|
}
|
|
3458
4133
|
function printVersions(ctx, rows) {
|
|
3459
4134
|
for (const v of rows) {
|
|
3460
|
-
const
|
|
3461
|
-
const size2 = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
|
|
3462
|
-
const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when2(v.createdAt).padEnd(20), size2];
|
|
4135
|
+
const cols = [`${v.live ? "*" : " "} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when(v.createdAt).padEnd(20), `${v.files} files, ${formatSize(v.size)}`];
|
|
3463
4136
|
ctx.out(cols.join(" ").trimEnd());
|
|
3464
4137
|
if (v.reason && v.status !== "live")
|
|
3465
4138
|
ctx.out(` ${v.reason}`);
|
|
3466
4139
|
}
|
|
3467
4140
|
}
|
|
3468
|
-
async function
|
|
4141
|
+
async function versions2(ctx, spaceFlag, json) {
|
|
3469
4142
|
const api = await signedIn(ctx);
|
|
3470
4143
|
const slug = await spaceSlug(ctx, spaceFlag);
|
|
3471
4144
|
const rows = await listVersions(api, slug);
|
|
@@ -3506,7 +4179,7 @@ async function rollback(ctx, arg, spaceFlag) {
|
|
|
3506
4179
|
number = versionNumber(await ctx.ask("Which version should go live? "));
|
|
3507
4180
|
}
|
|
3508
4181
|
const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/rollback`, { version: number });
|
|
3509
|
-
ctx.out(`Version ${answer.version
|
|
4182
|
+
ctx.out(`Version ${answer.version.number} of ${slug} is live again: ${answer.pageUrl}`);
|
|
3510
4183
|
return EXIT.ok;
|
|
3511
4184
|
}
|
|
3512
4185
|
var RESET_CATEGORIES = ["scores", "saves", "achievements", "content", "rooms"];
|
|
@@ -3530,7 +4203,7 @@ async function reset(ctx, options) {
|
|
|
3530
4203
|
if (player)
|
|
3531
4204
|
body.player = player;
|
|
3532
4205
|
const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/reset`, body);
|
|
3533
|
-
const counts =
|
|
4206
|
+
const counts = Object.entries(answer.deleted).map(([k, n]) => `${n} ${k}`);
|
|
3534
4207
|
ctx.out(`Deleted ${what}${counts.length ? ` (${counts.join(", ")})` : ""}.`);
|
|
3535
4208
|
return EXIT.ok;
|
|
3536
4209
|
}
|
|
@@ -3603,7 +4276,7 @@ var COMMANDS = {
|
|
|
3603
4276
|
},
|
|
3604
4277
|
versions: {
|
|
3605
4278
|
flags: { json: "boolean", space: "string" },
|
|
3606
|
-
run: (ctx, p) =>
|
|
4279
|
+
run: (ctx, p) => versions2(ctx, stringFlag(p, "space"), p.flags.json === true)
|
|
3607
4280
|
},
|
|
3608
4281
|
rollback: { flags: { space: "string" }, run: (ctx, p) => rollback(ctx, p.positionals[0], stringFlag(p, "space")) },
|
|
3609
4282
|
reset: {
|