@velven/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -4
- package/README.md +122 -22
- package/dist/velven.js +1254 -184
- 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.0";
|
|
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,34 +74,61 @@ 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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
81
|
+
async function putBytes(url, bytes, type, tooLarge) {
|
|
82
82
|
let response;
|
|
83
83
|
try {
|
|
84
|
-
response = await fetch(
|
|
85
|
-
method,
|
|
86
|
-
headers,
|
|
87
|
-
body: body === undefined ? undefined : JSON.stringify(body)
|
|
88
|
-
});
|
|
84
|
+
response = await fetch(url, { method: "PUT", headers: { "content-type": type, "user-agent": USER_AGENT }, body: bytes });
|
|
89
85
|
} catch (e) {
|
|
90
|
-
throw new CliError(`Could not
|
|
86
|
+
throw new CliError(`Could not upload the picture (${causeOf(e)}).`, EXIT.failed, "network");
|
|
91
87
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
await response.text().catch(() => "");
|
|
89
|
+
if (response.status === 413)
|
|
90
|
+
throw new CliError(tooLarge, EXIT.refused, "too_large");
|
|
91
|
+
if (!response.ok)
|
|
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
|
+
}
|
|
94
|
+
async function answerOf(api, path, body, method, retry) {
|
|
95
|
+
const headers = requestHeaders(api, "application/json");
|
|
96
|
+
if (body !== undefined)
|
|
97
|
+
headers["content-type"] = body.type;
|
|
98
|
+
for (let attempt = 0;; attempt++) {
|
|
99
|
+
const again = retry !== undefined && attempt === 0;
|
|
100
|
+
let response;
|
|
101
|
+
let text;
|
|
95
102
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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");
|
|
99
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;
|
|
127
|
+
}
|
|
128
|
+
if (parsed === null)
|
|
129
|
+
throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
|
|
130
|
+
return parsed;
|
|
100
131
|
}
|
|
101
|
-
if (!response.ok)
|
|
102
|
-
throw errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
|
|
103
|
-
if (parsed === null)
|
|
104
|
-
throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
|
|
105
|
-
return parsed;
|
|
106
132
|
}
|
|
107
133
|
function seg(value) {
|
|
108
134
|
return encodeURIComponent(String(value));
|
|
@@ -120,7 +146,8 @@ async function stream(api, path, options, onEvent) {
|
|
|
120
146
|
async function connect() {
|
|
121
147
|
let got = false;
|
|
122
148
|
const connectionKeys = new Set;
|
|
123
|
-
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;
|
|
124
151
|
const headers = requestHeaders(api, "text/event-stream");
|
|
125
152
|
if (lastId !== null)
|
|
126
153
|
headers["last-event-id"] = lastId;
|
|
@@ -323,10 +350,9 @@ async function login(ctx) {
|
|
|
323
350
|
ctx.out("Opened it in your browser. Waiting for you to approve...");
|
|
324
351
|
else
|
|
325
352
|
ctx.out("Open that link in a browser. Waiting for you to approve...");
|
|
326
|
-
|
|
327
|
-
const deadline = Date.now() + (started.expires_in || 900) * 1000;
|
|
353
|
+
const deadline = Date.now() + started.expires_in * 1000;
|
|
328
354
|
while (Date.now() < deadline) {
|
|
329
|
-
await ctx.sleep(interval * 1000);
|
|
355
|
+
await ctx.sleep(started.interval * 1000);
|
|
330
356
|
try {
|
|
331
357
|
const token = await call(api, "POST", "/api/agent/token", { device_code: started.device_code });
|
|
332
358
|
await saveToken(ctx, base, { token: token.access_token, handle: token.handle });
|
|
@@ -339,10 +365,6 @@ async function login(ctx) {
|
|
|
339
365
|
throw e;
|
|
340
366
|
if (e.code === "authorization_pending")
|
|
341
367
|
continue;
|
|
342
|
-
if (e.code === "slow_down") {
|
|
343
|
-
interval += 5;
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
368
|
if (e.code === "access_denied")
|
|
347
369
|
throw new CliError("The sign-in was denied. Nothing was saved.", EXIT.auth, "access_denied");
|
|
348
370
|
if (e.code === "expired_token" || e.code === "invalid_grant")
|
|
@@ -408,7 +430,11 @@ function parseArgs(argv, spec) {
|
|
|
408
430
|
if (value === undefined || eq === -1 && value.startsWith("--")) {
|
|
409
431
|
throw new CliError(`--${name} needs a value.`, EXIT.usage, "usage");
|
|
410
432
|
}
|
|
411
|
-
|
|
433
|
+
if (kind === "strings") {
|
|
434
|
+
const given = flags[name];
|
|
435
|
+
flags[name] = [...Array.isArray(given) ? given : [], value];
|
|
436
|
+
} else
|
|
437
|
+
flags[name] = value;
|
|
412
438
|
}
|
|
413
439
|
return { positionals, flags };
|
|
414
440
|
}
|
|
@@ -416,6 +442,10 @@ function stringFlag(parsed, name) {
|
|
|
416
442
|
const value = parsed.flags[name];
|
|
417
443
|
return typeof value === "string" ? value : undefined;
|
|
418
444
|
}
|
|
445
|
+
function stringsFlag(parsed, name) {
|
|
446
|
+
const value = parsed.flags[name];
|
|
447
|
+
return Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
|
|
448
|
+
}
|
|
419
449
|
|
|
420
450
|
// src/context.ts
|
|
421
451
|
import { spawn } from "node:child_process";
|
|
@@ -467,9 +497,21 @@ function nodeContext() {
|
|
|
467
497
|
};
|
|
468
498
|
}
|
|
469
499
|
|
|
500
|
+
// src/create.ts
|
|
501
|
+
import { randomUUID } from "node:crypto";
|
|
502
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
503
|
+
import { resolve } from "node:path";
|
|
504
|
+
|
|
470
505
|
// src/create-state.ts
|
|
471
506
|
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
472
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
|
+
}
|
|
473
515
|
function createStatePath(ctx) {
|
|
474
516
|
return join2(ctx.home, ".config", "velven", "create.json");
|
|
475
517
|
}
|
|
@@ -489,9 +531,11 @@ async function readCreateFile(ctx) {
|
|
|
489
531
|
}
|
|
490
532
|
async function readCreateState(ctx, api) {
|
|
491
533
|
const state = (await readCreateFile(ctx)).apis[api] ?? {};
|
|
534
|
+
const question = questionOf(state.question);
|
|
492
535
|
return {
|
|
493
536
|
...typeof state.project === "string" && state.project ? { project: state.project } : {},
|
|
494
|
-
...Number.isInteger(state.turn) ? { turn: state.turn } : {}
|
|
537
|
+
...Number.isInteger(state.turn) ? { turn: state.turn } : {},
|
|
538
|
+
...question ? { question } : {}
|
|
495
539
|
};
|
|
496
540
|
}
|
|
497
541
|
async function saveCreateState(ctx, api, patch) {
|
|
@@ -499,6 +543,8 @@ async function saveCreateState(ctx, api, patch) {
|
|
|
499
543
|
const next = { ...file.apis[api], ...patch };
|
|
500
544
|
if (next.turn === undefined)
|
|
501
545
|
delete next.turn;
|
|
546
|
+
if (next.question === undefined)
|
|
547
|
+
delete next.question;
|
|
502
548
|
file.apis[api] = next;
|
|
503
549
|
const path = createStatePath(ctx);
|
|
504
550
|
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
@@ -507,13 +553,21 @@ async function saveCreateState(ctx, api, patch) {
|
|
|
507
553
|
await chmod2(path, 384);
|
|
508
554
|
}
|
|
509
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
|
+
|
|
510
565
|
// src/create-timeline.ts
|
|
511
566
|
var BAR_WIDTH = 60;
|
|
512
567
|
var FOLD_OVER = 20;
|
|
513
568
|
var LABEL_MAX = 44;
|
|
514
569
|
var CHAIN_SLACK_MS = 50;
|
|
515
|
-
var PHASE_ORDER = ["workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
|
|
516
|
-
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
570
|
+
var PHASE_ORDER = ["routing", "workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
|
|
517
571
|
function seconds(ms) {
|
|
518
572
|
return `${(ms / 1000).toFixed(1)} s`;
|
|
519
573
|
}
|
|
@@ -569,7 +623,11 @@ function spanFacts(span) {
|
|
|
569
623
|
fps_median: (v) => `${v} fps`,
|
|
570
624
|
memory_mb: (v) => `${v} MB memory`,
|
|
571
625
|
clocks: (v) => Number(v) > 1 ? "across two clocks" : null,
|
|
572
|
-
reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null
|
|
626
|
+
reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null,
|
|
627
|
+
route: (v) => `routed ${v}`,
|
|
628
|
+
route_by: (v) => v === "fallback" ? "the router did not answer" : null,
|
|
629
|
+
downgraded: (v) => v === true ? "full was over the hold" : null,
|
|
630
|
+
route_reason: (v) => `"${v}"`
|
|
573
631
|
};
|
|
574
632
|
if (span.kind === "model") {
|
|
575
633
|
const input = (num(a.input) ?? 0) + (num(a.cached) ?? 0);
|
|
@@ -707,13 +765,13 @@ function bar(row, total) {
|
|
|
707
765
|
return `|${" ".repeat(a)}${"#".repeat(b - a)}${" ".repeat(BAR_WIDTH - b)}|`;
|
|
708
766
|
}
|
|
709
767
|
function renderTimeline(turn, spans, options = {}) {
|
|
710
|
-
const kind =
|
|
768
|
+
const kind = turnKind(turn, spans);
|
|
711
769
|
if (spans.length === 0) {
|
|
712
770
|
const why = turn.status === "queued" || turn.status === "running" ? " A turn's timeline is written when it ends." : "";
|
|
713
771
|
return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
|
|
714
772
|
}
|
|
715
773
|
const spanEnd = Math.max(...spans.map(endOf));
|
|
716
|
-
const total = Math.max(spanEnd, turn.timing
|
|
774
|
+
const total = Math.max(spanEnd, turn.timing.total_ms ?? 0);
|
|
717
775
|
const critical = criticalPath(spans);
|
|
718
776
|
const { roots, children } = treeOf(spans);
|
|
719
777
|
const rows = [];
|
|
@@ -756,7 +814,7 @@ function renderTimeline(turn, spans, options = {}) {
|
|
|
756
814
|
visit(span, 0);
|
|
757
815
|
const cut = (label) => label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 3)}...` : label;
|
|
758
816
|
const width = Math.max(...rows.map((r) => cut(r.label).length), 4);
|
|
759
|
-
const turnTotal = turn.timing
|
|
817
|
+
const turnTotal = turn.timing.total_ms ?? spanEnd;
|
|
760
818
|
const lines = [
|
|
761
819
|
`Turn ${turn.id}, a ${kind}, ${turn.status}: ${duration(turnTotal)}, ${plural(spans.length, "span")}${firsts(turn.timing)}. * marks the critical path.`,
|
|
762
820
|
`${"span".padEnd(width)} ${"0 s".padEnd(BAR_WIDTH / 2 + 1)}${seconds(total).padStart(BAR_WIDTH / 2 + 1)} ${"start".padStart(8)} ${"time".padStart(8)}`
|
|
@@ -768,12 +826,26 @@ function renderTimeline(turn, spans, options = {}) {
|
|
|
768
826
|
return lines;
|
|
769
827
|
}
|
|
770
828
|
function isFirstBuild(spans) {
|
|
771
|
-
return spans.some((s) => s.name === "beginTurn" && s.attrs?.first_build === true);
|
|
829
|
+
return spans.some((s) => (s.name === "beginTurn" || s.name === "askRouter") && s.attrs?.first_build === true);
|
|
830
|
+
}
|
|
831
|
+
function workOf(spans) {
|
|
832
|
+
const interview = spans.find((s) => s.name === "askInterviewer" && typeof s.attrs?.interview === "string")?.attrs?.interview;
|
|
833
|
+
if (interview === "ask")
|
|
834
|
+
return "interview";
|
|
835
|
+
if (interview === "draft")
|
|
836
|
+
return "draft";
|
|
837
|
+
const route = spans.find((s) => s.name === "askRouter" && typeof s.attrs?.route === "string")?.attrs?.route;
|
|
838
|
+
return typeof route === "string" ? route : null;
|
|
839
|
+
}
|
|
840
|
+
function turnKind(turn, spans) {
|
|
841
|
+
const work = workOf(spans);
|
|
842
|
+
if (work === "interview" || work === "draft")
|
|
843
|
+
return `${turn.effort} ${work} turn`;
|
|
844
|
+
const what = turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn";
|
|
845
|
+
return `${turn.effort} ${work === "fast" || work === "full" ? `${work} ` : ""}${what}`;
|
|
772
846
|
}
|
|
773
847
|
function firsts(timing) {
|
|
774
|
-
|
|
775
|
-
return "";
|
|
776
|
-
const say = (ms) => typeof ms === "number" ? duration(ms) : "-";
|
|
848
|
+
const say = (ms) => ms === null ? "-" : duration(ms);
|
|
777
849
|
return `; live link ${say(timing.first_dev_ms)}, first change ${say(timing.first_write_ms)}, first playable ${say(timing.first_preview_ms)}`;
|
|
778
850
|
}
|
|
779
851
|
var isEngineRun = (s) => s.kind === "step" && s.name === "engine_programmer";
|
|
@@ -806,11 +878,7 @@ function engineSummary(spans) {
|
|
|
806
878
|
if (!run)
|
|
807
879
|
return null;
|
|
808
880
|
const a = run.attrs ?? {};
|
|
809
|
-
const
|
|
810
|
-
const steps = num(a.steps) ?? kids.filter((s) => s.kind === "model").length;
|
|
811
|
-
const tools = num(a.tools) ?? kids.filter((s) => s.kind === "tool").length;
|
|
812
|
-
const images = num(a.images) ?? kids.filter((s) => s.kind === "tool" && s.name === "make_image").length;
|
|
813
|
-
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")];
|
|
814
882
|
if (a.cut === true)
|
|
815
883
|
figures.push("cut short");
|
|
816
884
|
return `in ${duration(run.ms)}: ${figures.join(", ")}`;
|
|
@@ -824,23 +892,29 @@ function table(rows, indent) {
|
|
|
824
892
|
var sec = (ms) => ms === null ? "-" : seconds(ms);
|
|
825
893
|
var pair = (p, f) => `${f(p.p50)} / ${f(p.p90)}`;
|
|
826
894
|
function groupTitle(g) {
|
|
895
|
+
if (g.work === "interview" || g.work === "draft")
|
|
896
|
+
return `${g.effort} ${g.work} turns, ${g.engine}`;
|
|
827
897
|
const what = g.plan ? "plan turns" : g.first_build === true ? "first builds" : g.first_build === false ? "later builds" : "build turns";
|
|
828
|
-
|
|
898
|
+
const work = g.work === "plan" ? "routed " : g.work ? `${g.work} ` : "";
|
|
899
|
+
return `${g.effort} ${work}${what}, ${g.engine}`;
|
|
829
900
|
}
|
|
830
901
|
function renderStats(groups, options) {
|
|
831
902
|
const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
|
|
832
903
|
if (groups.length === 0)
|
|
833
904
|
return [`No turns finished in ${over}.`];
|
|
834
|
-
const per = options.microsPerCredit
|
|
835
|
-
const credits = (micros) => micros === null ||
|
|
905
|
+
const per = options.microsPerCredit;
|
|
906
|
+
const credits = (micros) => micros === null || per === null ? "-" : String(Math.ceil(micros / per));
|
|
836
907
|
const cents = (micros) => micros === null ? "-" : (micros / 1e4).toFixed(1);
|
|
837
908
|
const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
|
|
838
909
|
for (const g of groups) {
|
|
839
910
|
lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
|
|
911
|
+
const reacted = REACTION_WORDS.filter(([key]) => (g.reactions[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
|
|
912
|
+
if (reacted.length)
|
|
913
|
+
lines.push(` the next message: ${reacted.join(", ")}`);
|
|
840
914
|
const figures = [
|
|
841
915
|
["figure", "p50", "p90", "turns"],
|
|
842
916
|
["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
|
|
843
|
-
["first change", sec(g.first_write_ms
|
|
917
|
+
["first change", sec(g.first_write_ms.p50), sec(g.first_write_ms.p90), ""],
|
|
844
918
|
["first playable", sec(g.first_preview_ms.p50), sec(g.first_preview_ms.p90), ""],
|
|
845
919
|
["whole turn", sec(g.total_ms.p50), sec(g.total_ms.p90), ""]
|
|
846
920
|
];
|
|
@@ -865,6 +939,12 @@ function renderStats(groups, options) {
|
|
|
865
939
|
}
|
|
866
940
|
return lines;
|
|
867
941
|
}
|
|
942
|
+
var REACTION_WORDS = [
|
|
943
|
+
["moved_on", "moved on"],
|
|
944
|
+
["corrected", "corrected it"],
|
|
945
|
+
["asked_checks", "asked for checks"],
|
|
946
|
+
["none", "no reaction"]
|
|
947
|
+
];
|
|
868
948
|
function rank(phase) {
|
|
869
949
|
const i = PHASE_ORDER.indexOf(phase);
|
|
870
950
|
return i === -1 ? PHASE_ORDER.length : i;
|
|
@@ -875,38 +955,82 @@ var CREATE_HELP = `velven create: describe a game, and Velven's agents build it.
|
|
|
875
955
|
|
|
876
956
|
Usage:
|
|
877
957
|
velven create new [title...] Start a project; it becomes the current one
|
|
878
|
-
--engine <id> The engine pack (default
|
|
879
|
-
--genre <id> The genre pack (default
|
|
880
|
-
|
|
958
|
+
--engine <id> The engine pack (default: Velven picks, 3D unless your idea is clearly 2D)
|
|
959
|
+
--genre <id> The genre pack (default none: an open game, Velven follows your idea;
|
|
960
|
+
--genre arcade holds it to arcade's rules)
|
|
961
|
+
velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn.
|
|
962
|
+
Velven picks how much to do (a quick change, a full build with a playtest,
|
|
963
|
+
a plan; on a new game a question or two, then a short plan to shape) unless
|
|
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)
|
|
881
967
|
--project <id> A project other than the current one
|
|
882
|
-
--effort quick|standard|deep How much the turn may do
|
|
883
|
-
|
|
884
|
-
|
|
968
|
+
--effort quick|standard|deep How much the turn may do, instead of Velven's pick. The project keeps it
|
|
969
|
+
for a later message that passes only a plan flag; a message with neither
|
|
970
|
+
flag is Velven's pick again
|
|
971
|
+
--plan, --no-plan Plan mode, instead of Velven's pick: the turn updates the design document
|
|
972
|
+
and changes no game files. Kept as --effort is (with --effort alone, on for
|
|
973
|
+
a new standard or deep, off for quick)
|
|
974
|
+
--build Build it: no more questions, Velven fills in what the plan leaves open
|
|
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
|
|
979
|
+
--ref <file|ref-n> A reference picture (PNG, JPEG or WebP, up to 10 MB) sent with the message, or
|
|
980
|
+
one the project keeps by its id (ref-3); up to 4
|
|
885
981
|
--yes, -y Do not ask before sending
|
|
886
982
|
--detach Send it and return at once; follow it later with watch
|
|
887
983
|
velven create watch [turn] Follow a turn to its end (default: the last one)
|
|
888
984
|
--from <n> Start from this event
|
|
889
|
-
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
|
|
890
999
|
velven create list Your projects (* is the current one)
|
|
891
|
-
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)
|
|
892
1002
|
--timeline The newest turn's timeline instead: a waterfall of every step, model and
|
|
893
1003
|
tool call, the critical path marked (* at the line's end)
|
|
894
1004
|
--turn <id> With --timeline: this turn instead of the project's newest
|
|
895
1005
|
--all With --timeline: list every tool call and screenshot (past 20 under one
|
|
896
1006
|
step they fold into one line)
|
|
1007
|
+
velven create refs The project's reference pictures: each id, when it was added and how many
|
|
1008
|
+
messages carried it (a project keeps up to 16)
|
|
1009
|
+
--project <id> A project other than the current one
|
|
1010
|
+
velven create refs rm <ref-n> Delete a picture, sent or not; the messages that carried it keep its number
|
|
1011
|
+
velven create plan The project's short plan: what the game is, how it plays, its look
|
|
1012
|
+
--project <id> A project other than the current one
|
|
1013
|
+
velven create queue The messages waiting on the project, in the order they run
|
|
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)
|
|
1017
|
+
velven create queue rm <id> Remove a waiting message
|
|
897
1018
|
velven create credits Your credits and the latest entries
|
|
1019
|
+
velven create usage What each of your turns held and used, newest first
|
|
1020
|
+
--project <id> Only this project's turns
|
|
1021
|
+
--before <turn> The page before this turn (the line at the end names it)
|
|
898
1022
|
velven create stats Admins: p50 and p90 over recent turns, by effort, plan mode and engine
|
|
899
1023
|
--days <n> Turns created in the last n days, 1 to 90 (default 7)
|
|
900
1024
|
--origin <origin> Only turns sent from web, api, cli, mcp or benchmark
|
|
901
1025
|
|
|
902
|
-
--json: new, send, list, show,
|
|
903
|
-
show --timeline the timeline); watch prints one event per line.
|
|
904
|
-
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).
|
|
905
1029
|
|
|
906
1030
|
Exit codes:
|
|
907
1031
|
0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
|
|
908
|
-
2 bad command 3 needs sign-in 4 refused (no credits, closed,
|
|
909
|
-
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`;
|
|
910
1034
|
var CREATE_FLAGS = {
|
|
911
1035
|
engine: "string",
|
|
912
1036
|
genre: "string",
|
|
@@ -914,29 +1038,52 @@ var CREATE_FLAGS = {
|
|
|
914
1038
|
effort: "string",
|
|
915
1039
|
plan: "boolean",
|
|
916
1040
|
"no-plan": "boolean",
|
|
1041
|
+
build: "boolean",
|
|
1042
|
+
answer: "string",
|
|
1043
|
+
queue: "boolean",
|
|
1044
|
+
ref: "strings",
|
|
917
1045
|
yes: "boolean",
|
|
918
1046
|
detach: "boolean",
|
|
919
1047
|
from: "string",
|
|
1048
|
+
"until-idle": "boolean",
|
|
920
1049
|
json: "boolean",
|
|
921
1050
|
timeline: "boolean",
|
|
922
1051
|
turn: "string",
|
|
923
1052
|
all: "boolean",
|
|
924
1053
|
days: "string",
|
|
925
|
-
origin: "string"
|
|
1054
|
+
origin: "string",
|
|
1055
|
+
before: "string"
|
|
926
1056
|
};
|
|
927
1057
|
var SUBCOMMANDS = {
|
|
928
1058
|
new: ["engine", "genre", "json"],
|
|
929
|
-
send: ["project", "effort", "plan", "no-plan", "yes", "detach", "json"],
|
|
930
|
-
watch: ["from", "json"],
|
|
1059
|
+
send: ["project", "effort", "plan", "no-plan", "build", "answer", "queue", "ref", "yes", "detach", "json"],
|
|
1060
|
+
watch: ["from", "until-idle", "json"],
|
|
931
1061
|
cancel: [],
|
|
1062
|
+
resume: ["project", "json"],
|
|
1063
|
+
versions: ["project", "json"],
|
|
1064
|
+
restore: ["project", "json"],
|
|
932
1065
|
list: ["json"],
|
|
933
1066
|
show: ["json", "timeline", "turn", "all"],
|
|
1067
|
+
refs: ["project", "json"],
|
|
1068
|
+
plan: ["project", "json"],
|
|
1069
|
+
queue: ["project", "build", "json"],
|
|
934
1070
|
credits: ["json"],
|
|
1071
|
+
usage: ["project", "before", "json"],
|
|
935
1072
|
stats: ["days", "origin", "json"]
|
|
936
1073
|
};
|
|
937
1074
|
var EFFORTS = ["quick", "standard", "deep"];
|
|
1075
|
+
var REFS_PER_MESSAGE = 4;
|
|
1076
|
+
var REFS_PER_PROJECT = 16;
|
|
938
1077
|
var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
|
|
939
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
|
+
};
|
|
940
1087
|
async function create(ctx, parsed) {
|
|
941
1088
|
const [sub, ...args] = parsed.positionals;
|
|
942
1089
|
if (!sub) {
|
|
@@ -960,9 +1107,15 @@ async function create(ctx, parsed) {
|
|
|
960
1107
|
case "send":
|
|
961
1108
|
return send(ctx, api, args, parsed, json);
|
|
962
1109
|
case "watch":
|
|
963
|
-
return watch(ctx, api, args
|
|
1110
|
+
return watch(ctx, api, args, stringFlag(parsed, "from"), parsed.flags["until-idle"] === true, json);
|
|
964
1111
|
case "cancel":
|
|
965
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);
|
|
966
1119
|
case "list":
|
|
967
1120
|
return list(ctx, api, json);
|
|
968
1121
|
case "show":
|
|
@@ -974,6 +1127,14 @@ async function create(ctx, parsed) {
|
|
|
974
1127
|
return show(ctx, api, args[0], json);
|
|
975
1128
|
case "stats":
|
|
976
1129
|
return stats(ctx, api, stringFlag(parsed, "days"), stringFlag(parsed, "origin"), json);
|
|
1130
|
+
case "refs":
|
|
1131
|
+
return referencePictures(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1132
|
+
case "usage":
|
|
1133
|
+
return usage(ctx, api, stringFlag(parsed, "project"), stringFlag(parsed, "before"), json);
|
|
1134
|
+
case "plan":
|
|
1135
|
+
return showPlan(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1136
|
+
case "queue":
|
|
1137
|
+
return queue(ctx, api, args, stringFlag(parsed, "project"), parsed.flags.build === true, json);
|
|
977
1138
|
default:
|
|
978
1139
|
return credits(ctx, api, json);
|
|
979
1140
|
}
|
|
@@ -993,12 +1154,17 @@ async function newProject(ctx, api, title, engine, genre, json) {
|
|
|
993
1154
|
return EXIT.ok;
|
|
994
1155
|
}
|
|
995
1156
|
ctx.out(`Started ${project.title ?? "a project"} (${project.id}): ${packs(project)}. It is now the current project.`);
|
|
996
|
-
|
|
1157
|
+
if (!project.genre)
|
|
1158
|
+
ctx.out("An open game: Velven follows your idea. --genre arcade holds a project to arcade's rules.");
|
|
1159
|
+
if (!engine)
|
|
1160
|
+
ctx.out("Velven picks the engine from your idea: 3D unless it is clearly 2D.");
|
|
1161
|
+
ctx.out('Tell it what to build: velven create send "a cozy game where you grow a garden on a floating island"');
|
|
997
1162
|
return EXIT.ok;
|
|
998
1163
|
}
|
|
999
1164
|
async function send(ctx, api, words, parsed, json) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1165
|
+
let text = words.join(" ").trim();
|
|
1166
|
+
const answerFlag = stringFlag(parsed, "answer");
|
|
1167
|
+
if (!text && answerFlag === undefined)
|
|
1002
1168
|
throw new CliError('Say what to build or change, like velven create send "make the fox jump higher".', EXIT.usage, "usage");
|
|
1003
1169
|
const effort = stringFlag(parsed, "effort");
|
|
1004
1170
|
if (effort !== undefined && !EFFORTS.includes(effort)) {
|
|
@@ -1008,60 +1174,292 @@ async function send(ctx, api, words, parsed, json) {
|
|
|
1008
1174
|
throw new CliError("Pass --plan or --no-plan, not both.", EXIT.usage, "usage");
|
|
1009
1175
|
}
|
|
1010
1176
|
const plan = parsed.flags.plan === true ? true : parsed.flags["no-plan"] === true ? false : undefined;
|
|
1177
|
+
const routed = effort === undefined && plan === undefined;
|
|
1178
|
+
const refs = stringsFlag(parsed, "ref");
|
|
1179
|
+
if (refs.length > REFS_PER_MESSAGE)
|
|
1180
|
+
throw new CliError(`A message takes up to ${REFS_PER_MESSAGE} pictures; ${refs.length} were given with --ref.`, EXIT.usage, "usage");
|
|
1011
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)));
|
|
1012
1197
|
const say = json ? () => {} : (line) => ctx.out(line);
|
|
1013
1198
|
const query = new URLSearchParams;
|
|
1199
|
+
if (routed)
|
|
1200
|
+
query.set("routed", "true");
|
|
1201
|
+
if (routed && parsed.flags.build === true)
|
|
1202
|
+
query.set("intent", "build");
|
|
1014
1203
|
if (effort !== undefined)
|
|
1015
1204
|
query.set("effort", effort);
|
|
1016
1205
|
if (plan !== undefined)
|
|
1017
1206
|
query.set("plan", String(plan));
|
|
1018
1207
|
const search = query.toString();
|
|
1019
1208
|
const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
|
|
1020
|
-
|
|
1021
|
-
|
|
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";
|
|
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.`);
|
|
1212
|
+
} else {
|
|
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`;
|
|
1214
|
+
say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
|
|
1215
|
+
}
|
|
1022
1216
|
if (!estimate.enough) {
|
|
1023
1217
|
throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
|
|
1024
1218
|
}
|
|
1025
1219
|
if (ctx.interactive && !json && parsed.flags.yes !== true) {
|
|
1026
|
-
const
|
|
1027
|
-
if (
|
|
1220
|
+
const reply = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
|
|
1221
|
+
if (reply === "n" || reply === "no") {
|
|
1028
1222
|
ctx.out("Nothing was sent.");
|
|
1029
1223
|
return EXIT.failed;
|
|
1030
1224
|
}
|
|
1031
1225
|
}
|
|
1032
|
-
const body = { text };
|
|
1226
|
+
const body = { text, client_id: randomUUID() };
|
|
1227
|
+
if (parsed.flags.queue === true)
|
|
1228
|
+
body.delivery = "queue";
|
|
1033
1229
|
if (effort !== undefined)
|
|
1034
1230
|
body.effort = effort;
|
|
1035
1231
|
if (plan !== undefined)
|
|
1036
1232
|
body.plan = plan;
|
|
1037
|
-
|
|
1038
|
-
|
|
1233
|
+
if (parsed.flags.build === true)
|
|
1234
|
+
body.intent = "build";
|
|
1235
|
+
if (answer)
|
|
1236
|
+
body.answer = answer;
|
|
1237
|
+
if (pictures.length) {
|
|
1238
|
+
const ids = [];
|
|
1239
|
+
for (const picture of pictures)
|
|
1240
|
+
ids.push(typeof picture === "string" ? picture : await uploadPicture(api, project, picture));
|
|
1241
|
+
body.references = ids;
|
|
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);
|
|
1250
|
+
let turn;
|
|
1251
|
+
let stream2;
|
|
1252
|
+
if ("queued" in sent) {
|
|
1253
|
+
const queued = sent.queued;
|
|
1254
|
+
if (queued.state === "refused")
|
|
1255
|
+
throw new CliError(queued.refused ?? "Velven could not start this message.", EXIT.refused, "refused");
|
|
1256
|
+
if (queued.state === "waiting" || queued.turn === null) {
|
|
1257
|
+
if (json)
|
|
1258
|
+
ctx.out(JSON.stringify({ ok: true, queued, why: sent.why, ...sent.again ? { again: true } : {} }));
|
|
1259
|
+
else
|
|
1260
|
+
say(`${already}${queuedLine(queued, sent.why)}`);
|
|
1261
|
+
return EXIT.ok;
|
|
1262
|
+
}
|
|
1263
|
+
turn = { id: queued.turn, ...queued.capped === true ? { queued: true } : {} };
|
|
1264
|
+
} else {
|
|
1265
|
+
turn = sent.turn;
|
|
1266
|
+
stream2 = sent.stream;
|
|
1267
|
+
}
|
|
1039
1268
|
await saveCreateState(ctx, api.base, { project, turn: turn.id });
|
|
1040
1269
|
if (turn.queued)
|
|
1041
|
-
say(
|
|
1270
|
+
say(`${already}Turn ${turn.id} waits for room on Velven and starts on its own.`);
|
|
1042
1271
|
else
|
|
1043
|
-
say(
|
|
1272
|
+
say(`${already}Turn ${turn.id} is on its way.`);
|
|
1044
1273
|
if (parsed.flags.detach === true) {
|
|
1045
1274
|
if (json)
|
|
1046
|
-
ctx.out(JSON.stringify({ ok: true, turn }));
|
|
1275
|
+
ctx.out(JSON.stringify({ ok: true, turn, ...sent.again ? { again: true } : {} }));
|
|
1047
1276
|
else
|
|
1048
1277
|
say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
|
|
1049
1278
|
return EXIT.ok;
|
|
1050
1279
|
}
|
|
1051
|
-
const path =
|
|
1052
|
-
return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text");
|
|
1280
|
+
const path = stream2 ?? `${ROOT}/turns/${seg(turn.id)}/stream`;
|
|
1281
|
+
return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text", project);
|
|
1053
1282
|
}
|
|
1054
|
-
async function
|
|
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
|
+
}
|
|
1407
|
+
var REF_ID = /^ref-[1-9][0-9]{0,2}$/;
|
|
1408
|
+
async function uploadPicture(api, project, picture) {
|
|
1409
|
+
const { upload } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references/uploads`);
|
|
1410
|
+
const tooLarge = `The picture ${picture.file} is larger than ${Math.floor(upload.max_bytes / (1024 * 1024))} MB. Send a smaller one.`;
|
|
1411
|
+
if (picture.bytes.length > upload.max_bytes)
|
|
1412
|
+
throw new CliError(tooLarge, EXIT.refused, "too_large");
|
|
1413
|
+
await putBytes(upload.url, picture.bytes, picture.type, tooLarge);
|
|
1414
|
+
try {
|
|
1415
|
+
const { reference } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references`, { upload: upload.id });
|
|
1416
|
+
return reference.id;
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
if (e instanceof CliError && e.code === "too_many_references") {
|
|
1419
|
+
const named = `velven create refs --project ${project}`;
|
|
1420
|
+
throw new CliError(`${e.message} See them: ${named}; remove one: velven create refs rm <ref-n> --project ${project}.`, e.exitCode, e.code);
|
|
1421
|
+
}
|
|
1422
|
+
throw e;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
var PICTURE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
|
|
1426
|
+
async function readPicture(ctx, file) {
|
|
1427
|
+
let bytes;
|
|
1428
|
+
try {
|
|
1429
|
+
bytes = new Uint8Array(await readFile3(resolve(ctx.cwd, file)));
|
|
1430
|
+
} catch {
|
|
1431
|
+
throw new CliError(`Could not read the picture ${file}.`, EXIT.usage, "usage");
|
|
1432
|
+
}
|
|
1433
|
+
const ext = file.toLowerCase().split(".").pop() ?? "";
|
|
1434
|
+
return { file, bytes, type: PICTURE_TYPES[ext] ?? "application/octet-stream" };
|
|
1435
|
+
}
|
|
1436
|
+
async function turnProject(ctx, api, turn) {
|
|
1437
|
+
try {
|
|
1438
|
+
const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
|
|
1439
|
+
return view.project;
|
|
1440
|
+
} catch {
|
|
1441
|
+
return (await readCreateState(ctx, api.base)).project ?? null;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
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");
|
|
1055
1448
|
let from;
|
|
1056
1449
|
if (fromFlag !== undefined) {
|
|
1057
1450
|
if (!/^\d+$/.test(fromFlag))
|
|
1058
1451
|
throw new CliError(`--from is an event number, like 12, not "${fromFlag}".`, EXIT.usage, "usage");
|
|
1059
1452
|
from = Number(fromFlag);
|
|
1060
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");
|
|
1061
1458
|
const turn = await turnId(ctx, api.base, arg, "watch");
|
|
1459
|
+
const named = json ? null : await turnProject(ctx, api, turn);
|
|
1062
1460
|
let code;
|
|
1063
1461
|
try {
|
|
1064
|
-
code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text");
|
|
1462
|
+
code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text", named);
|
|
1065
1463
|
} catch (e) {
|
|
1066
1464
|
if (e instanceof CliError && e.code === "stream_lost")
|
|
1067
1465
|
await saveCreateState(ctx, api.base, { turn });
|
|
@@ -1070,13 +1468,311 @@ async function watch(ctx, api, arg, fromFlag, json) {
|
|
|
1070
1468
|
await saveCreateState(ctx, api.base, { turn });
|
|
1071
1469
|
return code;
|
|
1072
1470
|
}
|
|
1073
|
-
async function
|
|
1471
|
+
async function watchProject(ctx, api, project, after, untilIdle, json) {
|
|
1074
1472
|
const controller = new AbortController;
|
|
1075
1473
|
ctx.untilStopped().then(() => controller.abort());
|
|
1076
|
-
const
|
|
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;
|
|
1077
1479
|
let final;
|
|
1078
1480
|
try {
|
|
1079
|
-
final = await stream(api,
|
|
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
|
+
}
|
|
1769
|
+
async function follow(ctx, api, turn, path, from, mode, project) {
|
|
1770
|
+
const controller = new AbortController;
|
|
1771
|
+
ctx.untilStopped().then(() => controller.abort());
|
|
1772
|
+
const said = { held: null, engine: null, project, asked: false, next: [], base: api.base, question: null, work: null };
|
|
1773
|
+
let final;
|
|
1774
|
+
try {
|
|
1775
|
+
final = await stream(api, path, { ...from !== undefined ? { start: { from } } : {}, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
|
|
1080
1776
|
if (mode === "events")
|
|
1081
1777
|
ctx.out(JSON.stringify(event2));
|
|
1082
1778
|
else if (mode === "text")
|
|
@@ -1093,6 +1789,7 @@ async function follow(ctx, api, turn, path, from, mode) {
|
|
|
1093
1789
|
} finally {
|
|
1094
1790
|
controller.abort();
|
|
1095
1791
|
release(ctx, said);
|
|
1792
|
+
await keepQuestion(ctx, api.base, turn, project, said.question);
|
|
1096
1793
|
}
|
|
1097
1794
|
if (final === null) {
|
|
1098
1795
|
const message = `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}`;
|
|
@@ -1118,11 +1815,10 @@ async function follow(ctx, api, turn, path, from, mode) {
|
|
|
1118
1815
|
}
|
|
1119
1816
|
ctx.out(JSON.stringify({ ok: code === EXIT.ok, ...error, turn: view }));
|
|
1120
1817
|
} else if (mode === "text") {
|
|
1121
|
-
finish(ctx, turn, event);
|
|
1818
|
+
finish(ctx, turn, event, said);
|
|
1122
1819
|
}
|
|
1123
1820
|
return code;
|
|
1124
1821
|
}
|
|
1125
|
-
var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1126
1822
|
function capital(text) {
|
|
1127
1823
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1128
1824
|
}
|
|
@@ -1134,8 +1830,8 @@ function lasting(at, expires) {
|
|
|
1134
1830
|
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
1135
1831
|
return "";
|
|
1136
1832
|
if (minutes < 90)
|
|
1137
|
-
return
|
|
1138
|
-
return
|
|
1833
|
+
return plural(minutes, "minute");
|
|
1834
|
+
return plural(Math.round(minutes / 60), "hour");
|
|
1139
1835
|
}
|
|
1140
1836
|
function jobDone(ctx, label, figures, summary) {
|
|
1141
1837
|
if (figures)
|
|
@@ -1165,12 +1861,57 @@ function render(ctx, event, said) {
|
|
|
1165
1861
|
release(ctx, said);
|
|
1166
1862
|
switch (event.type) {
|
|
1167
1863
|
case "turn.started":
|
|
1168
|
-
|
|
1864
|
+
if (event.routed)
|
|
1865
|
+
ctx.err(`Started: Velven is choosing the work, ${plural(event.credits_held, "credit")} held.`);
|
|
1866
|
+
else
|
|
1867
|
+
ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held.`);
|
|
1868
|
+
return;
|
|
1869
|
+
case "route":
|
|
1870
|
+
said.work = event.work;
|
|
1871
|
+
ctx.out(event.said);
|
|
1872
|
+
ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held: ${event.reason})`);
|
|
1873
|
+
return;
|
|
1874
|
+
case "ask": {
|
|
1875
|
+
ctx.out(`${event.question} (question ${event.index} of ${event.of})`);
|
|
1876
|
+
for (const [i, choice] of event.choices.entries())
|
|
1877
|
+
ctx.out(` ${i + 1}. ${choice}`);
|
|
1878
|
+
const send2 = sendCommand(said);
|
|
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.`);
|
|
1884
|
+
if (event.picture || event.topic === "style")
|
|
1885
|
+
ctx.out(`Or show the look: ${send2} --ref <file> "like this".`);
|
|
1886
|
+
ctx.out(`Just build it: ${send2} --build "Just build it"`);
|
|
1887
|
+
said.asked = true;
|
|
1169
1888
|
return;
|
|
1889
|
+
}
|
|
1890
|
+
case "plan":
|
|
1891
|
+
if (!event.done)
|
|
1892
|
+
return;
|
|
1893
|
+
for (const line of planCard(event.plan))
|
|
1894
|
+
ctx.out(line);
|
|
1895
|
+
if (said.work === "draft")
|
|
1896
|
+
ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
|
|
1897
|
+
return;
|
|
1898
|
+
case "brief": {
|
|
1899
|
+
if (!event.done)
|
|
1900
|
+
return;
|
|
1901
|
+
if (event.title || event.pitch)
|
|
1902
|
+
ctx.out(`Designing ${event.title ?? "your game"}${event.pitch ? `: ${event.pitch}` : ""}`);
|
|
1903
|
+
if (event.style)
|
|
1904
|
+
ctx.out(` Style: ${event.style}`);
|
|
1905
|
+
if (event.palette.length)
|
|
1906
|
+
ctx.out(` Palette: ${event.palette.join(", ")}`);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1170
1909
|
case "turn.queued":
|
|
1171
1910
|
ctx.err("Still waiting for room on Velven.");
|
|
1172
1911
|
return;
|
|
1173
1912
|
case "phase":
|
|
1913
|
+
if (event.progress)
|
|
1914
|
+
ctx.out(event.progress.label);
|
|
1174
1915
|
ctx.err(`${capital(event.phase)}${event.detail ? `: ${event.detail}` : ""}`);
|
|
1175
1916
|
return;
|
|
1176
1917
|
case "job": {
|
|
@@ -1192,6 +1933,9 @@ function render(ctx, event, said) {
|
|
|
1192
1933
|
}
|
|
1193
1934
|
case "tool":
|
|
1194
1935
|
ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
|
|
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
|
+
}
|
|
1195
1939
|
return;
|
|
1196
1940
|
case "text":
|
|
1197
1941
|
ctx.out(event.text);
|
|
@@ -1210,10 +1954,10 @@ function render(ctx, event, said) {
|
|
|
1210
1954
|
ctx.err("The live link is gone.");
|
|
1211
1955
|
return;
|
|
1212
1956
|
case "shot":
|
|
1213
|
-
ctx.err(` screenshot ${event.label}: ${event.
|
|
1957
|
+
ctx.err(` screenshot ${event.label}: ${mediaLink(said.base, event.media)}`);
|
|
1214
1958
|
return;
|
|
1215
1959
|
case "clip":
|
|
1216
|
-
ctx.out(`Clip: ${event.
|
|
1960
|
+
ctx.out(`Clip: ${mediaLink(said.base, event.media)}`);
|
|
1217
1961
|
return;
|
|
1218
1962
|
case "build":
|
|
1219
1963
|
if (event.ok)
|
|
@@ -1225,10 +1969,10 @@ function render(ctx, event, said) {
|
|
|
1225
1969
|
}
|
|
1226
1970
|
return;
|
|
1227
1971
|
case "playtest":
|
|
1228
|
-
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")}.`);
|
|
1229
1973
|
return;
|
|
1230
1974
|
case "review":
|
|
1231
|
-
ctx.err(`Review: ${event.ok ? "no problems" :
|
|
1975
|
+
ctx.err(`Review: ${event.ok ? "no problems" : plural(event.problems, "problem")}.`);
|
|
1232
1976
|
return;
|
|
1233
1977
|
case "commit":
|
|
1234
1978
|
ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
|
|
@@ -1243,44 +1987,138 @@ function render(ctx, event, said) {
|
|
|
1243
1987
|
return;
|
|
1244
1988
|
}
|
|
1245
1989
|
case "cost":
|
|
1246
|
-
ctx.err(`${event.credits_used} of ${
|
|
1990
|
+
ctx.err(`${event.credits_used} of ${plural(event.credits_held, "credit")} used so far.`);
|
|
1991
|
+
return;
|
|
1992
|
+
case "next":
|
|
1993
|
+
said.next = event.choices;
|
|
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.");
|
|
1247
1997
|
return;
|
|
1248
1998
|
default:
|
|
1249
1999
|
return;
|
|
1250
2000
|
}
|
|
1251
2001
|
}
|
|
1252
|
-
function finish(ctx, turn, event) {
|
|
2002
|
+
function finish(ctx, turn, event, said) {
|
|
1253
2003
|
if (event.type === "error") {
|
|
1254
2004
|
const tail = event.code === "turn_stopped" || event.code === "not_found" ? " Check it again later: velven create show" : ` The turn may go on. Watch it: velven create watch ${turn}; check it: velven create show`;
|
|
1255
2005
|
ctx.err(`${event.message || "The turn's stream answered with an error."}${tail}`);
|
|
1256
2006
|
return;
|
|
1257
2007
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
2008
|
+
const reply = event.reply && said.asked ? event.reply.split(`
|
|
2009
|
+
|
|
2010
|
+
`).slice(1).join(`
|
|
2011
|
+
|
|
2012
|
+
`) : event.reply;
|
|
2013
|
+
if (reply)
|
|
2014
|
+
ctx.out(reply);
|
|
2015
|
+
if (said.next.length) {
|
|
2016
|
+
ctx.out("Where it could go next:");
|
|
2017
|
+
for (const [i, choice] of said.next.entries())
|
|
2018
|
+
ctx.out(` ${i + 1}. ${choice}`);
|
|
2019
|
+
ctx.out(`Send one: velven create send${said.project ? ` --project ${said.project}` : ""} "${said.next[0]}"`);
|
|
2020
|
+
}
|
|
1260
2021
|
if (event.preview)
|
|
1261
2022
|
ctx.out(`Play it: ${event.preview.url}`);
|
|
1262
|
-
const back = event.refunded > 0 ? `; ${
|
|
2023
|
+
const back = event.refunded > 0 ? `; ${plural(event.refunded, "credit")} came back` : "";
|
|
1263
2024
|
if (event.status === "done") {
|
|
1264
|
-
ctx.out(`Turn ${turn} is done: ${
|
|
2025
|
+
ctx.out(`Turn ${turn} is done: ${plural(event.credits_charged, "credit")} used${back}.`);
|
|
1265
2026
|
if (event.stop_reason === "cap")
|
|
1266
2027
|
ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
|
|
1267
2028
|
if (event.stop_reason === "time")
|
|
1268
2029
|
ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
|
|
1269
|
-
|
|
2030
|
+
} else if (event.status === "cancelled") {
|
|
2031
|
+
ctx.err(`Turn ${turn} was cancelled: ${plural(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
|
|
2032
|
+
} else {
|
|
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.`);
|
|
1270
2034
|
}
|
|
1271
|
-
if (
|
|
1272
|
-
ctx.
|
|
1273
|
-
|
|
2035
|
+
if (reply?.includes(BUILD_AGAIN))
|
|
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.`);
|
|
2039
|
+
if (event.next_turn) {
|
|
2040
|
+
const elsewhere = event.next_project && said.project && event.next_project !== said.project ? ` on project ${event.next_project}` : "";
|
|
2041
|
+
ctx.out(`Your next message${elsewhere} is running: velven create watch ${event.next_turn}`);
|
|
1274
2042
|
}
|
|
1275
|
-
|
|
2043
|
+
}
|
|
2044
|
+
var BUILD_AGAIN = "Press Build it to try again.";
|
|
2045
|
+
function sendCommand(said) {
|
|
2046
|
+
return `velven create send${said.project ? ` --project ${said.project}` : ""}`;
|
|
2047
|
+
}
|
|
2048
|
+
function mediaLink(base, media) {
|
|
2049
|
+
return `${base}${ROOT}/media/${media.id}`;
|
|
2050
|
+
}
|
|
2051
|
+
function dim(ctx, text) {
|
|
2052
|
+
return ctx.interactive && !ctx.env.NO_COLOR ? `\x1B[2m${text}\x1B[22m` : text;
|
|
2053
|
+
}
|
|
2054
|
+
function planCard(plan) {
|
|
2055
|
+
const lines = [];
|
|
2056
|
+
if (plan.title)
|
|
2057
|
+
lines.push(plan.title);
|
|
2058
|
+
if (plan.pitch)
|
|
2059
|
+
lines.push(plan.pitch);
|
|
2060
|
+
if (plan.dimension)
|
|
2061
|
+
lines.push(`${plan.dimension === "3d" ? "3D" : "2D"}${plan.camera ? `, ${plan.camera}` : ""}`);
|
|
2062
|
+
if (plan.play?.length) {
|
|
2063
|
+
lines.push("How it plays:");
|
|
2064
|
+
for (const [i, beat] of plan.play.entries())
|
|
2065
|
+
lines.push(` ${i + 1}. ${beat}`);
|
|
2066
|
+
}
|
|
2067
|
+
if (plan.look)
|
|
2068
|
+
lines.push(`Look: ${plan.look}`);
|
|
2069
|
+
if (plan.palette?.length)
|
|
2070
|
+
lines.push(`Palette: ${plan.palette.join(", ")}`);
|
|
2071
|
+
if (plan.contents?.length) {
|
|
2072
|
+
lines.push("What is in it:");
|
|
2073
|
+
for (const thing of plan.contents)
|
|
2074
|
+
lines.push(` - ${thing}`);
|
|
2075
|
+
}
|
|
2076
|
+
if (plan.ending)
|
|
2077
|
+
lines.push(`How it ends: ${plan.ending}`);
|
|
2078
|
+
if (plan.score !== undefined)
|
|
2079
|
+
lines.push(plan.score ? "It keeps a score." : "No score.");
|
|
2080
|
+
return lines;
|
|
2081
|
+
}
|
|
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`;
|
|
1276
2102
|
}
|
|
1277
2103
|
async function cancel(ctx, api, arg) {
|
|
1278
2104
|
const turn = await turnId(ctx, api.base, arg, "cancel");
|
|
1279
2105
|
const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
|
|
1280
|
-
if (answer.status === "cancelled")
|
|
2106
|
+
if (answer.status === "cancelled" && !answer.started)
|
|
1281
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.`);
|
|
1282
2110
|
else
|
|
1283
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
|
+
}
|
|
1284
2122
|
return EXIT.ok;
|
|
1285
2123
|
}
|
|
1286
2124
|
async function list(ctx, api, json) {
|
|
@@ -1312,11 +2150,14 @@ async function show(ctx, api, arg, json) {
|
|
|
1312
2150
|
}
|
|
1313
2151
|
ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
|
|
1314
2152
|
ctx.out(`Packs: ${packs(project)}`);
|
|
1315
|
-
|
|
1316
|
-
|
|
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"}`);
|
|
1317
2158
|
const t = project.turn;
|
|
1318
2159
|
if (t) {
|
|
1319
|
-
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`;
|
|
1320
2161
|
const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
|
|
1321
2162
|
ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
|
|
1322
2163
|
if (t.error)
|
|
@@ -1324,12 +2165,32 @@ async function show(ctx, api, arg, json) {
|
|
|
1324
2165
|
} else {
|
|
1325
2166
|
ctx.out("Newest turn: none yet");
|
|
1326
2167
|
}
|
|
2168
|
+
if (summary)
|
|
2169
|
+
for (const line of summaryLines(summary))
|
|
2170
|
+
ctx.out(line);
|
|
1327
2171
|
if (project.preview?.url)
|
|
1328
2172
|
ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
|
|
1329
2173
|
else
|
|
1330
2174
|
ctx.out("Preview: none yet");
|
|
1331
2175
|
return EXIT.ok;
|
|
1332
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
|
+
}
|
|
1333
2194
|
async function showTimeline(ctx, api, arg, turnFlag, all, json) {
|
|
1334
2195
|
let id;
|
|
1335
2196
|
if (turnFlag !== undefined) {
|
|
@@ -1346,12 +2207,11 @@ async function showTimeline(ctx, api, arg, turnFlag, all, json) {
|
|
|
1346
2207
|
id = view.turn.id;
|
|
1347
2208
|
}
|
|
1348
2209
|
const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
|
|
1349
|
-
const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
|
|
1350
2210
|
if (json) {
|
|
1351
|
-
ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
|
|
2211
|
+
ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline: turn.timeline }));
|
|
1352
2212
|
return EXIT.ok;
|
|
1353
2213
|
}
|
|
1354
|
-
for (const line of renderTimeline(turn, timeline.spans, { all }))
|
|
2214
|
+
for (const line of renderTimeline(turn, turn.timeline.spans, { all }))
|
|
1355
2215
|
ctx.out(line);
|
|
1356
2216
|
return EXIT.ok;
|
|
1357
2217
|
}
|
|
@@ -1379,8 +2239,249 @@ async function stats(ctx, api, daysFlag, origin, json) {
|
|
|
1379
2239
|
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
1380
2240
|
return EXIT.ok;
|
|
1381
2241
|
}
|
|
1382
|
-
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 }))
|
|
2243
|
+
ctx.out(line);
|
|
2244
|
+
return EXIT.ok;
|
|
2245
|
+
}
|
|
2246
|
+
async function referencePictures(ctx, api, args, projectFlag, json) {
|
|
2247
|
+
const [action, id, ...rest] = args;
|
|
2248
|
+
if (action === "rm") {
|
|
2249
|
+
if (id === undefined || !REF_ID.test(id) || rest.length) {
|
|
2250
|
+
throw new CliError("Name one picture to remove by its id, like velven create refs rm ref-3.", EXIT.usage, "usage");
|
|
2251
|
+
}
|
|
2252
|
+
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
2253
|
+
await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/references/${seg(id)}`);
|
|
2254
|
+
if (json)
|
|
2255
|
+
ctx.out(JSON.stringify({ ok: true, deleted: id }));
|
|
2256
|
+
else
|
|
2257
|
+
ctx.out(`Removed ${id}. The messages that carried it keep its number, shown as removed.`);
|
|
2258
|
+
return EXIT.ok;
|
|
2259
|
+
}
|
|
2260
|
+
if (action !== undefined) {
|
|
2261
|
+
throw new CliError(`Unknown command "create refs ${action}". List the pictures with velven create refs, remove one with velven create refs rm ref-3.`, EXIT.usage, "usage");
|
|
2262
|
+
}
|
|
2263
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
2264
|
+
const { references } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/references`);
|
|
2265
|
+
if (json) {
|
|
2266
|
+
ctx.out(JSON.stringify({ ok: true, references }));
|
|
2267
|
+
return EXIT.ok;
|
|
2268
|
+
}
|
|
2269
|
+
if (!references.length) {
|
|
2270
|
+
ctx.out('No reference pictures yet. Attach one: velven create send --ref <picture> "like this".');
|
|
2271
|
+
return EXIT.ok;
|
|
2272
|
+
}
|
|
2273
|
+
for (const r of references) {
|
|
2274
|
+
const carried = r.messages ? `on ${plural(r.messages, "message")}` : "not sent yet";
|
|
2275
|
+
ctx.out(`${r.id.padEnd(7)} ${when(r.created_at).padEnd(20)} ${`${r.width} by ${r.height}`.padEnd(12)} ${carried}`);
|
|
2276
|
+
}
|
|
2277
|
+
ctx.out(`${references.length} of ${REFS_PER_PROJECT} kept. A picture no message carried goes a day after it was last uploaded.`);
|
|
2278
|
+
return EXIT.ok;
|
|
2279
|
+
}
|
|
2280
|
+
async function usage(ctx, api, project, before, json) {
|
|
2281
|
+
if (before !== undefined && (!/^\d+$/.test(before) || Number(before) === 0))
|
|
2282
|
+
throw new CliError(`--before is a turn number, like 12, not "${before}".`, EXIT.usage, "usage");
|
|
2283
|
+
const query = new URLSearchParams;
|
|
2284
|
+
if (project)
|
|
2285
|
+
query.set("project", project);
|
|
2286
|
+
if (before)
|
|
2287
|
+
query.set("before", before);
|
|
2288
|
+
const search = query.toString();
|
|
2289
|
+
const answer = await call(api, "GET", `${ROOT}/usage${search ? `?${search}` : ""}`);
|
|
2290
|
+
if (json) {
|
|
2291
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
2292
|
+
return EXIT.ok;
|
|
2293
|
+
}
|
|
2294
|
+
if (!answer.turns.length) {
|
|
2295
|
+
ctx.out(before ? "No earlier turns." : "No turns yet.");
|
|
2296
|
+
return EXIT.ok;
|
|
2297
|
+
}
|
|
2298
|
+
for (const t of answer.turns) {
|
|
2299
|
+
const used = t.credits.charged !== null ? `${plural(t.credits.charged, "credit")} used` : `${plural(t.credits.held, "credit")} held`;
|
|
2300
|
+
const kind = t.work ?? `${t.effort}${t.plan ? " plan" : ""}`;
|
|
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());
|
|
2302
|
+
}
|
|
2303
|
+
if (answer.next !== null)
|
|
2304
|
+
ctx.out(`More: velven create usage${project ? ` --project ${project}` : ""} --before ${answer.next}`);
|
|
2305
|
+
return EXIT.ok;
|
|
2306
|
+
}
|
|
2307
|
+
async function showPlan(ctx, api, args, projectFlag, json) {
|
|
2308
|
+
if (args.length)
|
|
2309
|
+
throw new CliError("velven create plan takes no words: name another project with --project <id>.", EXIT.usage, "usage");
|
|
2310
|
+
const id = await currentProject(ctx, api.base, projectFlag);
|
|
2311
|
+
const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
|
|
2312
|
+
const plan = project.game_plan ?? null;
|
|
2313
|
+
if (json) {
|
|
2314
|
+
ctx.out(JSON.stringify({ ok: true, plan }));
|
|
2315
|
+
return EXIT.ok;
|
|
2316
|
+
}
|
|
2317
|
+
if (!plan) {
|
|
2318
|
+
ctx.out('This project has no plan yet. Send your idea and Velven sketches one with you: velven create send "a cozy farming game".');
|
|
2319
|
+
return EXIT.ok;
|
|
2320
|
+
}
|
|
2321
|
+
for (const line of planCard(plan))
|
|
1383
2322
|
ctx.out(line);
|
|
2323
|
+
if (!project.head)
|
|
2324
|
+
ctx.out(`Build it: velven create send${projectFlag ? ` --project ${id}` : ""} --build "Build it"`);
|
|
2325
|
+
return EXIT.ok;
|
|
2326
|
+
}
|
|
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) {
|
|
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");
|
|
2336
|
+
if (action === "rm") {
|
|
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");
|
|
2369
|
+
}
|
|
2370
|
+
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
2371
|
+
const view = await moveQueued(api, project2, n, Number(place));
|
|
2372
|
+
const moved = view.queued.find((q) => q.id === n);
|
|
2373
|
+
if (json)
|
|
2374
|
+
ctx.out(JSON.stringify({ ok: true, ...view }));
|
|
2375
|
+
else
|
|
2376
|
+
ctx.out(`Moved message ${n}${moved?.position ? ` to ${ordinal(moved.position)} in line` : ""}.`);
|
|
2377
|
+
return EXIT.ok;
|
|
2378
|
+
}
|
|
2379
|
+
if (action !== undefined) {
|
|
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");
|
|
2381
|
+
}
|
|
2382
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
2383
|
+
const { queued, paused } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
|
|
2384
|
+
if (json) {
|
|
2385
|
+
ctx.out(JSON.stringify({ ok: true, queued, paused }));
|
|
2386
|
+
return EXIT.ok;
|
|
2387
|
+
}
|
|
2388
|
+
if (!queued.length) {
|
|
2389
|
+
ctx.out("Nothing is waiting on this project.");
|
|
2390
|
+
return EXIT.ok;
|
|
2391
|
+
}
|
|
2392
|
+
if (paused)
|
|
2393
|
+
ctx.out(pausedLine(paused));
|
|
2394
|
+
for (const q of queued) {
|
|
2395
|
+
const at = q.position !== null ? `${q.position}.` : q.state === "refused" ? "x" : "-";
|
|
2396
|
+
const words = q.text.length > 60 ? `${q.text.slice(0, 59)}…` : q.text;
|
|
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)");
|
|
2400
|
+
if (q.state === "refused" && q.refused)
|
|
2401
|
+
ctx.out(` Not sent: ${q.refused}`);
|
|
2402
|
+
}
|
|
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.");
|
|
1384
2485
|
return EXIT.ok;
|
|
1385
2486
|
}
|
|
1386
2487
|
async function credits(ctx, api, json) {
|
|
@@ -1389,7 +2490,7 @@ async function credits(ctx, api, json) {
|
|
|
1389
2490
|
ctx.out(JSON.stringify({ ok: true, credits: view }));
|
|
1390
2491
|
return EXIT.ok;
|
|
1391
2492
|
}
|
|
1392
|
-
ctx.out(`${
|
|
2493
|
+
ctx.out(`${plural(view.available, "credit")} available: ${view.daily_left} left of today's allowance, ${view.balance} in your balance, ${view.held} held by open turns.`);
|
|
1393
2494
|
for (const row of view.ledger.slice(0, 10)) {
|
|
1394
2495
|
const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
|
|
1395
2496
|
ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
|
|
@@ -1400,12 +2501,6 @@ function packs(project) {
|
|
|
1400
2501
|
const engine = `${project.engine.id} ${project.engine.version}`;
|
|
1401
2502
|
return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
|
|
1402
2503
|
}
|
|
1403
|
-
function when(iso) {
|
|
1404
|
-
if (!iso)
|
|
1405
|
-
return "";
|
|
1406
|
-
const d = new Date(iso);
|
|
1407
|
-
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
1408
|
-
}
|
|
1409
2504
|
async function currentProject(ctx, base, given) {
|
|
1410
2505
|
if (given)
|
|
1411
2506
|
return given;
|
|
@@ -1428,9 +2523,9 @@ async function turnId(ctx, base, arg, verb) {
|
|
|
1428
2523
|
|
|
1429
2524
|
// src/dev.ts
|
|
1430
2525
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
1431
|
-
import {
|
|
2526
|
+
import { readdir as readdir3, readFile as readFile6, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
1432
2527
|
import { createServer } from "node:http";
|
|
1433
|
-
import { extname, join as join5, relative as relative2, resolve, sep } from "node:path";
|
|
2528
|
+
import { extname, join as join5, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
1434
2529
|
import { createBrotliDecompress, createGunzip } from "node:zlib";
|
|
1435
2530
|
|
|
1436
2531
|
// ../scan-rules/src/each-limit.ts
|
|
@@ -1753,7 +2848,7 @@ function refusalSentence(findings) {
|
|
|
1753
2848
|
// src/files.ts
|
|
1754
2849
|
import { createHash } from "node:crypto";
|
|
1755
2850
|
import { createReadStream } from "node:fs";
|
|
1756
|
-
import { open, readdir as readdir2, readFile as
|
|
2851
|
+
import { open, readdir as readdir2, readFile as readFile5, realpath, stat } from "node:fs/promises";
|
|
1757
2852
|
import { join as join4, relative } from "node:path";
|
|
1758
2853
|
|
|
1759
2854
|
// src/ignore.ts
|
|
@@ -2058,7 +3153,7 @@ function mediaProblem(kind, path, size2, bytes, missing = "is not among the file
|
|
|
2058
3153
|
}
|
|
2059
3154
|
|
|
2060
3155
|
// src/project.ts
|
|
2061
|
-
import { readdir, readFile as
|
|
3156
|
+
import { readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
2062
3157
|
import { join as join3, win32 } from "node:path";
|
|
2063
3158
|
var SPACE_TYPES = ["game", "world", "tool", "wonder"];
|
|
2064
3159
|
var DEVICES = ["desktop", "mobile", "vr"];
|
|
@@ -2078,7 +3173,7 @@ async function readProject(dir) {
|
|
|
2078
3173
|
const path = join3(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
|
|
2079
3174
|
let text;
|
|
2080
3175
|
try {
|
|
2081
|
-
text = await
|
|
3176
|
+
text = await readFile4(path, "utf8");
|
|
2082
3177
|
} catch {
|
|
2083
3178
|
return { path, exists: false, data: {} };
|
|
2084
3179
|
}
|
|
@@ -2243,7 +3338,7 @@ async function walk(root, skipped = () => {}) {
|
|
|
2243
3338
|
const realRoot = await realpath(root);
|
|
2244
3339
|
let rules = [];
|
|
2245
3340
|
try {
|
|
2246
|
-
rules = parseIgnore(await
|
|
3341
|
+
rules = parseIgnore(await readFile5(join4(root, ".velvenignore"), "utf8"));
|
|
2247
3342
|
} catch {
|
|
2248
3343
|
rules = [];
|
|
2249
3344
|
}
|
|
@@ -2305,11 +3400,11 @@ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
|
|
|
2305
3400
|
throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
|
|
2306
3401
|
}
|
|
2307
3402
|
}
|
|
2308
|
-
async function
|
|
3403
|
+
async function readStart(abs, length) {
|
|
2309
3404
|
const handle = await open(abs, "r");
|
|
2310
3405
|
try {
|
|
2311
|
-
const head = new Uint8Array(
|
|
2312
|
-
const { bytesRead } = await handle.read(head, 0,
|
|
3406
|
+
const head = new Uint8Array(length);
|
|
3407
|
+
const { bytesRead } = await handle.read(head, 0, length, 0);
|
|
2313
3408
|
return head.subarray(0, bytesRead);
|
|
2314
3409
|
} finally {
|
|
2315
3410
|
await handle.close();
|
|
@@ -2319,7 +3414,7 @@ var SCAN_WINDOW = 8 * MB;
|
|
|
2319
3414
|
var SCAN_OVERLAP = 64 * 1024;
|
|
2320
3415
|
async function fileFindings(file) {
|
|
2321
3416
|
if (!scansContent(file.path) || isCompressed(file.path))
|
|
2322
|
-
return refuseFindings(file.path, await
|
|
3417
|
+
return refuseFindings(file.path, await readStart(file.abs, HEAD_BYTES));
|
|
2323
3418
|
const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
|
|
2324
3419
|
const handle = await open(file.abs, "r");
|
|
2325
3420
|
try {
|
|
@@ -2364,7 +3459,7 @@ async function checkMedia(config, files) {
|
|
|
2364
3459
|
continue;
|
|
2365
3460
|
const path = mediaPath(raw);
|
|
2366
3461
|
const file = files.find((f) => f.path === path);
|
|
2367
|
-
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await
|
|
3462
|
+
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(file.abs)) : await readStart(file.abs, HEAD_BYTES);
|
|
2368
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");
|
|
2369
3464
|
if (problem)
|
|
2370
3465
|
problems.push(problem);
|
|
@@ -2376,9 +3471,9 @@ async function checkMedia(config, files) {
|
|
|
2376
3471
|
return out;
|
|
2377
3472
|
}
|
|
2378
3473
|
function hashFile(abs) {
|
|
2379
|
-
return new Promise((
|
|
3474
|
+
return new Promise((resolve2, reject) => {
|
|
2380
3475
|
const hash = createHash("sha256");
|
|
2381
|
-
createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () =>
|
|
3476
|
+
createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
|
|
2382
3477
|
});
|
|
2383
3478
|
}
|
|
2384
3479
|
var HASH_CHUNK = 4 * MB;
|
|
@@ -2482,16 +3577,6 @@ function contentType(path, head) {
|
|
|
2482
3577
|
}
|
|
2483
3578
|
var WASM_HEAD_BYTES = 1024 * 1024;
|
|
2484
3579
|
var ISOLATION_HEADER = { "Document-Isolation-Policy": "isolate-and-credentialless" };
|
|
2485
|
-
async function readStart(abs, length) {
|
|
2486
|
-
const handle = await open2(abs, "r");
|
|
2487
|
-
try {
|
|
2488
|
-
const head = new Uint8Array(length);
|
|
2489
|
-
const { bytesRead } = await handle.read(head, 0, length, 0);
|
|
2490
|
-
return head.subarray(0, bytesRead);
|
|
2491
|
-
} finally {
|
|
2492
|
-
await handle.close();
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
3580
|
function inflatedStart(abs, encoding) {
|
|
2496
3581
|
return new Promise((resolveHead) => {
|
|
2497
3582
|
const source = createReadStream2(abs);
|
|
@@ -2551,7 +3636,7 @@ function injectSdk(html, src) {
|
|
|
2551
3636
|
return tag + html;
|
|
2552
3637
|
}
|
|
2553
3638
|
async function ignoreRules(root) {
|
|
2554
|
-
return parseIgnore(await
|
|
3639
|
+
return parseIgnore(await readFile6(join5(root, ".velvenignore"), "utf8").catch(() => ""));
|
|
2555
3640
|
}
|
|
2556
3641
|
async function namedAsOnDisk(base, rel) {
|
|
2557
3642
|
const parts = rel.split("/").filter(Boolean);
|
|
@@ -2567,7 +3652,7 @@ async function statExact(base, rel) {
|
|
|
2567
3652
|
return info && await namedAsOnDisk(base, rel) ? info : null;
|
|
2568
3653
|
}
|
|
2569
3654
|
async function startDevServer(root, options) {
|
|
2570
|
-
const base =
|
|
3655
|
+
const base = resolve2(root);
|
|
2571
3656
|
const realBase = await realpath2(base);
|
|
2572
3657
|
const server = createServer(async (req, res) => {
|
|
2573
3658
|
const send2 = (status, body2, headers2 = {}) => {
|
|
@@ -2586,7 +3671,7 @@ async function startDevServer(root, options) {
|
|
|
2586
3671
|
if (!parts.every(servableSegment))
|
|
2587
3672
|
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
2588
3673
|
let rel = parts.join("/");
|
|
2589
|
-
let file =
|
|
3674
|
+
let file = resolve2(base, rel);
|
|
2590
3675
|
if (file !== base && !file.startsWith(base + sep))
|
|
2591
3676
|
return send2(404, "Not found");
|
|
2592
3677
|
let info = await statExact(base, rel);
|
|
@@ -2615,7 +3700,7 @@ async function startDevServer(root, options) {
|
|
|
2615
3700
|
if (!real || !servableRelative(relative2(realBase, real))) {
|
|
2616
3701
|
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
2617
3702
|
}
|
|
2618
|
-
let body = await
|
|
3703
|
+
let body = await readFile6(file);
|
|
2619
3704
|
const { type, encoding } = contentType(file, body);
|
|
2620
3705
|
if (rel === options.entry && options.sdkSrc && !encoding)
|
|
2621
3706
|
body = injectSdk(body.toString("utf8"), options.sdkSrc);
|
|
@@ -2638,7 +3723,7 @@ async function startDevServer(root, options) {
|
|
|
2638
3723
|
};
|
|
2639
3724
|
}
|
|
2640
3725
|
async function dev(ctx, dirArg, portFlag) {
|
|
2641
|
-
const dir =
|
|
3726
|
+
const dir = resolve2(ctx.cwd, dirArg ?? ".");
|
|
2642
3727
|
const info = await stat2(dir).catch(() => null);
|
|
2643
3728
|
if (!info?.isDirectory())
|
|
2644
3729
|
throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
|
|
@@ -2689,10 +3774,10 @@ async function dev(ctx, dirArg, portFlag) {
|
|
|
2689
3774
|
|
|
2690
3775
|
// src/publish.ts
|
|
2691
3776
|
import { stat as stat3 } from "node:fs/promises";
|
|
2692
|
-
import { resolve as
|
|
3777
|
+
import { resolve as resolve3 } from "node:path";
|
|
2693
3778
|
|
|
2694
3779
|
// src/upload.ts
|
|
2695
|
-
import { open as
|
|
3780
|
+
import { open as open2 } from "node:fs/promises";
|
|
2696
3781
|
var PARALLEL = 6;
|
|
2697
3782
|
var ATTEMPTS = 4;
|
|
2698
3783
|
function slicesOf(files, pieceBytes = PIECE_BYTES) {
|
|
@@ -2711,7 +3796,7 @@ function slicesOf(files, pieceBytes = PIECE_BYTES) {
|
|
|
2711
3796
|
return slices;
|
|
2712
3797
|
}
|
|
2713
3798
|
async function readSlice({ file, offset, length }) {
|
|
2714
|
-
const handle = await
|
|
3799
|
+
const handle = await open2(file.abs, "r");
|
|
2715
3800
|
try {
|
|
2716
3801
|
const body = Buffer.alloc(length);
|
|
2717
3802
|
let read = 0;
|
|
@@ -2729,7 +3814,7 @@ async function readSlice({ file, offset, length }) {
|
|
|
2729
3814
|
function retryable(status) {
|
|
2730
3815
|
return status === null || status === 408 || status === 429 || status >= 500;
|
|
2731
3816
|
}
|
|
2732
|
-
async function uploadMissing(ctx, base, targets, files,
|
|
3817
|
+
async function uploadMissing(ctx, base, targets, files, pieceBytes = PIECE_BYTES) {
|
|
2733
3818
|
const slices = slicesOf(files, pieceBytes);
|
|
2734
3819
|
let failure = null;
|
|
2735
3820
|
await eachLimit(targets, PARALLEL, async (target) => {
|
|
@@ -2760,10 +3845,8 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
|
|
|
2760
3845
|
});
|
|
2761
3846
|
status = response.status;
|
|
2762
3847
|
detail = (await response.text()).slice(0, 200);
|
|
2763
|
-
if (response.ok)
|
|
2764
|
-
onProgress(slice.length);
|
|
3848
|
+
if (response.ok)
|
|
2765
3849
|
return;
|
|
2766
|
-
}
|
|
2767
3850
|
} catch (e) {
|
|
2768
3851
|
detail = e instanceof Error ? e.message : String(e);
|
|
2769
3852
|
}
|
|
@@ -2778,7 +3861,6 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
|
|
|
2778
3861
|
}
|
|
2779
3862
|
|
|
2780
3863
|
// src/publish.ts
|
|
2781
|
-
var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
2782
3864
|
var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
|
|
2783
3865
|
var POLL_MS = 3000;
|
|
2784
3866
|
var WAIT_LIMIT_MS = 20 * 60 * 1000;
|
|
@@ -2796,7 +3878,7 @@ function fileEntry(f) {
|
|
|
2796
3878
|
}
|
|
2797
3879
|
var hashesOf = (f) => f.pieces ?? (f.sha256 ? [f.sha256] : []);
|
|
2798
3880
|
async function folderOf(ctx, arg) {
|
|
2799
|
-
const dir =
|
|
3881
|
+
const dir = resolve3(ctx.cwd, arg ?? ".");
|
|
2800
3882
|
const info = await stat3(dir).catch(() => null);
|
|
2801
3883
|
if (!info)
|
|
2802
3884
|
throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
|
|
@@ -2871,7 +3953,7 @@ async function publish(ctx, options) {
|
|
|
2871
3953
|
if (guest)
|
|
2872
3954
|
say(`By publishing you agree to Velven's Terms: ${terms}`);
|
|
2873
3955
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
2874
|
-
say(`Publishing ${
|
|
3956
|
+
say(`Publishing ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
|
|
2875
3957
|
await hashAll(files);
|
|
2876
3958
|
const body = {
|
|
2877
3959
|
fields: listingFields(config),
|
|
@@ -2909,15 +3991,12 @@ async function publish(ctx, options) {
|
|
|
2909
3991
|
const slices = slicesOf(files);
|
|
2910
3992
|
const bytes = [...wanted].reduce((sum, sha) => sum + (slices.get(sha)?.length ?? 0), 0);
|
|
2911
3993
|
if (sending) {
|
|
2912
|
-
say(`Uploading ${sending} of ${
|
|
3994
|
+
say(`Uploading ${sending} of ${plural(distinct.length, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
|
|
2913
3995
|
} else
|
|
2914
3996
|
say("Every file is already on Velven; nothing to upload.");
|
|
2915
|
-
await uploadMissing(ctx, base, created.uploads, files
|
|
3997
|
+
await uploadMissing(ctx, base, created.uploads, files);
|
|
2916
3998
|
const versionPath = `/api/v1/hosting/versions/${seg(created.version.id)}`;
|
|
2917
|
-
const finished = await call(api, "POST", `${versionPath}/finish`, {
|
|
2918
|
-
preview: !options.prod,
|
|
2919
|
-
...guest && claim ? { claim } : {}
|
|
2920
|
-
}).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"));
|
|
2921
4000
|
const result = {
|
|
2922
4001
|
ok: true,
|
|
2923
4002
|
space: created.space,
|
|
@@ -3049,26 +4128,17 @@ async function signedIn(ctx) {
|
|
|
3049
4128
|
return { base, creds: await requireToken(ctx, base) };
|
|
3050
4129
|
}
|
|
3051
4130
|
async function listVersions(api, slug) {
|
|
3052
|
-
|
|
3053
|
-
return Array.isArray(answer) ? answer : answer.versions;
|
|
3054
|
-
}
|
|
3055
|
-
function when2(iso) {
|
|
3056
|
-
if (!iso)
|
|
3057
|
-
return "";
|
|
3058
|
-
const d = new Date(iso);
|
|
3059
|
-
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;
|
|
3060
4132
|
}
|
|
3061
4133
|
function printVersions(ctx, rows) {
|
|
3062
4134
|
for (const v of rows) {
|
|
3063
|
-
const
|
|
3064
|
-
const size2 = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
|
|
3065
|
-
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)}`];
|
|
3066
4136
|
ctx.out(cols.join(" ").trimEnd());
|
|
3067
4137
|
if (v.reason && v.status !== "live")
|
|
3068
4138
|
ctx.out(` ${v.reason}`);
|
|
3069
4139
|
}
|
|
3070
4140
|
}
|
|
3071
|
-
async function
|
|
4141
|
+
async function versions2(ctx, spaceFlag, json) {
|
|
3072
4142
|
const api = await signedIn(ctx);
|
|
3073
4143
|
const slug = await spaceSlug(ctx, spaceFlag);
|
|
3074
4144
|
const rows = await listVersions(api, slug);
|
|
@@ -3109,7 +4179,7 @@ async function rollback(ctx, arg, spaceFlag) {
|
|
|
3109
4179
|
number = versionNumber(await ctx.ask("Which version should go live? "));
|
|
3110
4180
|
}
|
|
3111
4181
|
const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/rollback`, { version: number });
|
|
3112
|
-
ctx.out(`Version ${answer.version
|
|
4182
|
+
ctx.out(`Version ${answer.version.number} of ${slug} is live again: ${answer.pageUrl}`);
|
|
3113
4183
|
return EXIT.ok;
|
|
3114
4184
|
}
|
|
3115
4185
|
var RESET_CATEGORIES = ["scores", "saves", "achievements", "content", "rooms"];
|
|
@@ -3133,7 +4203,7 @@ async function reset(ctx, options) {
|
|
|
3133
4203
|
if (player)
|
|
3134
4204
|
body.player = player;
|
|
3135
4205
|
const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/reset`, body);
|
|
3136
|
-
const counts =
|
|
4206
|
+
const counts = Object.entries(answer.deleted).map(([k, n]) => `${n} ${k}`);
|
|
3137
4207
|
ctx.out(`Deleted ${what}${counts.length ? ` (${counts.join(", ")})` : ""}.`);
|
|
3138
4208
|
return EXIT.ok;
|
|
3139
4209
|
}
|
|
@@ -3206,7 +4276,7 @@ var COMMANDS = {
|
|
|
3206
4276
|
},
|
|
3207
4277
|
versions: {
|
|
3208
4278
|
flags: { json: "boolean", space: "string" },
|
|
3209
|
-
run: (ctx, p) =>
|
|
4279
|
+
run: (ctx, p) => versions2(ctx, stringFlag(p, "space"), p.flags.json === true)
|
|
3210
4280
|
},
|
|
3211
4281
|
rollback: { flags: { space: "string" }, run: (ctx, p) => rollback(ctx, p.positionals[0], stringFlag(p, "space")) },
|
|
3212
4282
|
reset: {
|