@crievo/omni 0.1.7 → 0.1.9
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/omnivideo.config.example.json +2 -0
- package/package.json +1 -1
- package/reference/cloud.md +15 -1
- package/reference/learnings.md +7 -0
- package/reference/scenes-contract.md +3 -2
- package/reference/studio.md +3 -2
- package/studio/api/app.ts +4 -0
- package/studio/api/routes/cost.ts +13 -0
- package/studio/api/routes/me.ts +8 -0
- package/studio/api/services/agent-persona.ts +1 -0
- package/studio/api/services/agent-tools.ts +2 -2
- package/studio/api/services/cost.ts +22 -0
- package/studio/api/services/me.ts +67 -0
- package/studio/api/services/sessions.ts +8 -2
- package/studio/api/services/sound.ts +17 -6
- package/studio/runner/runner.ts +57 -10
- package/studio/shared/omni-screens.ts +1 -1
- package/studio/shared/schemas/cost.ts +10 -0
- package/studio/shared/types/api.ts +77 -1
- package/studio/studio.ts +27 -2
- package/studio/web/components/AppShell.tsx +11 -3
- package/studio/web/components/FirstConnection.tsx +222 -0
- package/studio/web/components/OfflineNotice.tsx +59 -0
- package/studio/web/components/SetupSteps.tsx +63 -0
- package/studio/web/components/Sidebar.tsx +10 -25
- package/studio/web/components/StudioOffline.tsx +48 -50
- package/studio/web/components/UserCard.tsx +79 -0
- package/studio/web/components/icons.tsx +2 -0
- package/studio/web/hooks/useComputers.ts +55 -0
- package/studio/web/hooks/useCost.ts +11 -0
- package/studio/web/hooks/useMe.ts +32 -0
- package/studio/web/hooks/useSetupProgress.ts +54 -0
- package/studio/web/lib/me.ts +17 -0
- package/studio/web/lib/query-client.ts +6 -0
- package/studio/web/lib/settings-categories.ts +2 -2
- package/studio/web/lib/setup-retry.ts +29 -0
- package/studio/web/lib/sound-timeline.ts +33 -0
- package/studio/web/lib/studio-offline.ts +105 -59
- package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
- package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
- package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
- package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
- package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
- package/studio/web/pages/SettingsPage/index.tsx +15 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/setup-steps.css +163 -0
- package/studio/web/styles/components/user-card.css +121 -0
- package/studio/web/styles/index.css +2 -0
- package/studio/web/styles/pages/settings/shell.css +79 -0
- package/studio/web/styles/pages/settings/usage.css +47 -0
- package/studio/web/styles/pages/studio-offline.css +224 -38
- package/tools/cost.mjs +114 -0
- package/tools/lib/agents.mjs +3 -2
- package/tools/lib/channel.mjs +1 -1
- package/tools/lib/cloud-user.mjs +58 -0
- package/tools/lib/cost.mjs +357 -0
- package/tools/lib/deps.mjs +27 -12
- package/tools/lib/oauth-client.mjs +28 -14
- package/tools/lib/pages-style.mjs +67 -0
- package/tools/lib/pairing.mjs +1 -1
- package/tools/lib/schedule/schedule.mjs +1 -1
- package/tools/lib/self-update.mjs +122 -0
- package/tools/lib/setup-progress.mjs +315 -0
- package/tools/lib/up.mjs +176 -61
- package/tools/metrics.mjs +6 -1
- package/tools/omni.mjs +41 -16
- package/tools/omnichat.mjs +45 -9
- package/tools/platforms.mjs +75 -14
- package/tools/runner.mjs +6 -6
- package/tools/setup.mjs +18 -5
- package/tools/sound.mjs +198 -94
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* The studio service updating itself. When the hosted studio and this machine speak different protocols the hub
|
|
2
|
+
closes the runner's socket with UPGRADE (tools/lib/channel.mjs), and which side is old is not in a form the runner
|
|
3
|
+
can read. It doesn't need to: it asks the npm registry whether a version of the CLI newer than the one it runs
|
|
4
|
+
exists. If so, this machine is the one behind, and `npx -y @crievo/omni@<latest> up` does what a session's hook
|
|
5
|
+
would: installs the new copy under ~/.omni/app, swaps the studio service onto it and restarts it
|
|
6
|
+
(tools/lib/up.mjs). If not, the hosted studio is the old side (or npm hasn't published yet) and there is nothing
|
|
7
|
+
to do here but wait: the runner looks again in a minute.
|
|
8
|
+
|
|
9
|
+
Never on a development checkout (`.git` in the code root: `omni up` never rewires one), never without the
|
|
10
|
+
plugin's login (a legacy key machine has no `up` to run), never while the setup is running, and at most once an
|
|
11
|
+
hour. Everything the outside world does (the registry, the process) is injected for the tests. Messages in
|
|
12
|
+
Portuguese: they reach the log as they are. */
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { PACKAGE } from "./cli.mjs";
|
|
18
|
+
import { OMNI_DIR, depsEnv } from "./deps.mjs";
|
|
19
|
+
import { CODE_ROOT, DATA_ROOT } from "./paths.mjs";
|
|
20
|
+
|
|
21
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
22
|
+
export const UPDATE_EVERY_MS = 60 * 60_000;
|
|
23
|
+
|
|
24
|
+
/** the version a code root runs, or null (a checkout has no package.json of the CLI) @param {string} codeRoot */
|
|
25
|
+
export function versionOf(codeRoot) {
|
|
26
|
+
try {
|
|
27
|
+
return String(JSON.parse(readFileSync(join(codeRoot, "package.json"), "utf8")).version ?? "") || null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** whether `a` is a later release than `b` (major.minor.patch; a pre-release tag counts as the release it precedes)
|
|
34
|
+
* @param {string} a @param {string} b */
|
|
35
|
+
export function isNewer(a, b) {
|
|
36
|
+
const n = (/** @type {string} */ v) =>
|
|
37
|
+
v
|
|
38
|
+
.split("-")[0]
|
|
39
|
+
.split(".")
|
|
40
|
+
.map(x => Number.parseInt(x, 10) || 0);
|
|
41
|
+
const [x, y] = [n(a), n(b)];
|
|
42
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
|
|
43
|
+
/* same numbers: "1.2.3" is later than "1.2.3-beta" */
|
|
44
|
+
return !a.includes("-") && b.includes("-");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** the newest published version of the CLI @param {{ fetch?: typeof fetch }} [o] */
|
|
48
|
+
export async function latestVersion({ fetch: doFetch = fetch } = {}) {
|
|
49
|
+
const r = await doFetch(`${REGISTRY}/${PACKAGE.replace("/", "%2F")}/latest`, {
|
|
50
|
+
headers: { accept: "application/json" },
|
|
51
|
+
signal: AbortSignal.timeout(8_000),
|
|
52
|
+
});
|
|
53
|
+
if (!r.ok) throw new Error(`o npm respondeu ${r.status}`);
|
|
54
|
+
const v = String(/** @type {any} */ (await r.json()).version ?? "");
|
|
55
|
+
if (!/^\d+\.\d+\.\d+/.test(v)) throw new Error("o npm não disse a versão");
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** runs `npx -y <spec> up` in a process of its own, so the service swapping itself doesn't take it down with it;
|
|
60
|
+
* its output goes to ~/.omni/update.log @param {{ spec: string, dataRoot: string }} o */
|
|
61
|
+
export function startUpdate({ spec, dataRoot }) {
|
|
62
|
+
mkdirSync(OMNI_DIR, { recursive: true });
|
|
63
|
+
const out = openSync(join(OMNI_DIR, "update.log"), "a");
|
|
64
|
+
const win = process.platform === "win32";
|
|
65
|
+
spawn(win ? "npx.cmd" : "npx", ["-y", spec, "up"], {
|
|
66
|
+
detached: true,
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
/* npx is a .cmd on Windows, which Node only spawns through a shell */
|
|
69
|
+
shell: win,
|
|
70
|
+
stdio: ["ignore", out, out],
|
|
71
|
+
env: { ...process.env, ...depsEnv(), OMNI_HOME: dataRoot },
|
|
72
|
+
}).unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {{
|
|
77
|
+
* codeRoot?: string,
|
|
78
|
+
* dataRoot?: string,
|
|
79
|
+
* fetch?: typeof fetch,
|
|
80
|
+
* run?: (o: { spec: string, dataRoot: string }) => void,
|
|
81
|
+
* log?: (line: string) => void,
|
|
82
|
+
* now?: () => number,
|
|
83
|
+
* everyMs?: number,
|
|
84
|
+
* busy?: () => boolean,
|
|
85
|
+
* loggedIn?: () => boolean,
|
|
86
|
+
* }} [o]
|
|
87
|
+
* @returns {() => Promise<"dev" | "login" | "busy" | "wait" | "offline" | "current" | "started">} one look: what it did
|
|
88
|
+
*/
|
|
89
|
+
export function createSelfUpdater({
|
|
90
|
+
codeRoot = CODE_ROOT,
|
|
91
|
+
dataRoot = DATA_ROOT,
|
|
92
|
+
fetch: doFetch = fetch,
|
|
93
|
+
run = startUpdate,
|
|
94
|
+
log = () => {},
|
|
95
|
+
now = Date.now,
|
|
96
|
+
everyMs = UPDATE_EVERY_MS,
|
|
97
|
+
busy = () => false,
|
|
98
|
+
loggedIn = () => true,
|
|
99
|
+
} = {}) {
|
|
100
|
+
let lastStart = 0;
|
|
101
|
+
return async function update() {
|
|
102
|
+
if (existsSync(join(codeRoot, ".git"))) return "dev";
|
|
103
|
+
if (!loggedIn()) return "login";
|
|
104
|
+
if (busy()) return "busy";
|
|
105
|
+
if (lastStart && now() - lastStart < everyMs) return "wait";
|
|
106
|
+
const current = versionOf(codeRoot);
|
|
107
|
+
let latest;
|
|
108
|
+
try {
|
|
109
|
+
latest = await latestVersion({ fetch: doFetch });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
log(`aviso: não consegui ver se há versão nova do Omni (${/** @type {Error} */ (e).message})`);
|
|
112
|
+
return "offline";
|
|
113
|
+
}
|
|
114
|
+
/* no newer version: this machine is already the latest, so the hosted studio is the old side, or npm is yet to
|
|
115
|
+
publish what it was deployed with */
|
|
116
|
+
if (!current || !isNewer(latest, current)) return "current";
|
|
117
|
+
lastStart = now();
|
|
118
|
+
log(`aviso: atualizando o Omni ${current} → ${latest}: esta máquina está atrás do studio hospedado`);
|
|
119
|
+
run({ spec: `${PACKAGE}@${latest}`, dataRoot });
|
|
120
|
+
return "started";
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/* The first-time setup, as the user watches it. After the plugin's login the CLI prepares the computer (the
|
|
2
|
+
workspace, a stable copy of the app, the programs the pipeline runs, the studio service), and the hosted
|
|
3
|
+
page shows each piece as a vertical stepper with a loading circle that turns into a check
|
|
4
|
+
(studio/web/components/SetupSteps.tsx). The page can't ask this computer while the studio isn't connected
|
|
5
|
+
to it, so the CLI writes the progress to the user's own cloud, which the page reads
|
|
6
|
+
(`setup` in the config of GET /api/cloud/config; cloud/studio/src/cloud.mjs). The same record is kept in
|
|
7
|
+
~/.omni/setup.json.
|
|
8
|
+
|
|
9
|
+
The steps are named for what they are for, never for the program that does it: the user reads "Configuração
|
|
10
|
+
para extrair o estilo visual", not "yt-dlp". Their ids are the ones tools/lib/deps.mjs reports (video,
|
|
11
|
+
render, voice, style). Labels reach the user as they are: Portuguese.
|
|
12
|
+
|
|
13
|
+
The reporter never throws and never waits on the network for long: a failed update is dropped, the next
|
|
14
|
+
one carries the whole record anyway. The clock, fetch, the token and the file are injected for the tests. */
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { OMNI_DIR } from "./deps.mjs";
|
|
19
|
+
|
|
20
|
+
export const SETUP_FILE = join(OMNI_DIR, "setup.json");
|
|
21
|
+
const LOCK_FILE = join(OMNI_DIR, "setup.lock");
|
|
22
|
+
/* a setup that holds the lock for longer than this is a dead one */
|
|
23
|
+
const LOCK_STALE_MS = 60 * 60_000;
|
|
24
|
+
const PUT_TIMEOUT_MS = 8_000;
|
|
25
|
+
|
|
26
|
+
/** @typedef {"pending" | "running" | "done" | "error"} StepState */
|
|
27
|
+
/** @typedef {{ id: string, label: string, detail: string, state: StepState, error?: string }} SetupStep */
|
|
28
|
+
/** @typedef {{ v: 1, state: "running" | "done" | "error", device: string, started_at: number, updated_at: number, steps: SetupStep[], watching_at?: number, handled?: string }} SetupRecord
|
|
29
|
+
* `watching_at`: the last time the process that is waiting for a retry looked (the page offers "Tentar de novo" only
|
|
30
|
+
* while it is recent); `handled`: the id of the last retry request it dealt with */
|
|
31
|
+
|
|
32
|
+
/** every piece of the setup, in the order it runs; what each is for, in the user's words */
|
|
33
|
+
export const SETUP_STEPS = [
|
|
34
|
+
{
|
|
35
|
+
id: "workspace",
|
|
36
|
+
label: "Criando a sua pasta de trabalho",
|
|
37
|
+
detail: "Onde ficam os seus vídeos e as suas configurações, no seu computador.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "app",
|
|
41
|
+
label: "Instalando o Omni neste computador",
|
|
42
|
+
detail: "Uma cópia que se atualiza sozinha e fica pronta para o Claude usar.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "video",
|
|
46
|
+
label: "Configuração para montar e exportar o vídeo",
|
|
47
|
+
detail: "Junta as cenas, a voz e as legendas no arquivo final.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "render",
|
|
51
|
+
label: "Configuração para renderizar as animações",
|
|
52
|
+
detail: "Transforma o motion de cada cena em vídeo.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "voice",
|
|
56
|
+
label: "Configuração para a voz, o tempo e as legendas",
|
|
57
|
+
detail: "Mede a fala e deixa cada cena e cada legenda no tempo certo.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "style",
|
|
61
|
+
label: "Configuração para extrair o estilo visual",
|
|
62
|
+
detail: "Lê um vídeo ou um link de referência e aprende o estilo dele.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "studio",
|
|
66
|
+
label: "Conectando este computador ao studio",
|
|
67
|
+
detail: "Para o site conversar com o Claude e com os seus arquivos daqui.",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** a fresh record: every step waiting @param {{ device: string, now?: () => number }} o @returns {SetupRecord} */
|
|
72
|
+
export function newSetup({ device, now = Date.now }) {
|
|
73
|
+
const t = now();
|
|
74
|
+
return {
|
|
75
|
+
v: 1,
|
|
76
|
+
state: "running",
|
|
77
|
+
device,
|
|
78
|
+
started_at: t,
|
|
79
|
+
updated_at: t,
|
|
80
|
+
steps: SETUP_STEPS.map(s => ({ ...s, state: "pending" })),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The record after a step changed (pure). The whole setup is done when every step is, and it fails when one
|
|
85
|
+
* did and none is left running. @param {SetupRecord} rec @param {string} id @param {StepState} state
|
|
86
|
+
* @param {string} [error] @param {() => number} [now] @returns {SetupRecord} */
|
|
87
|
+
export function withStep(rec, id, state, error, now = Date.now) {
|
|
88
|
+
const steps = rec.steps.map(s => {
|
|
89
|
+
if (s.id !== id) return s;
|
|
90
|
+
const { error: _old, ...rest } = s;
|
|
91
|
+
return state === "error" ? { ...rest, state, error: String(error ?? "").slice(0, 300) } : { ...rest, state };
|
|
92
|
+
});
|
|
93
|
+
const all = steps.every(s => s.state === "done");
|
|
94
|
+
const failed = steps.some(s => s.state === "error") && !steps.some(s => s.state === "running");
|
|
95
|
+
return { ...rec, steps, state: all ? "done" : failed ? "error" : "running", updated_at: now() };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Keeps a record and tells the cloud (and the file) every time it changes.
|
|
100
|
+
* @param {{
|
|
101
|
+
* studioUrl: string,
|
|
102
|
+
* token: () => Promise<string | null>,
|
|
103
|
+
* device: string,
|
|
104
|
+
* fetch?: typeof fetch,
|
|
105
|
+
* now?: () => number,
|
|
106
|
+
* file?: string,
|
|
107
|
+
* }} o
|
|
108
|
+
*/
|
|
109
|
+
export function createReporter({
|
|
110
|
+
studioUrl,
|
|
111
|
+
token,
|
|
112
|
+
device,
|
|
113
|
+
fetch: doFetch = fetch,
|
|
114
|
+
now = Date.now,
|
|
115
|
+
file = SETUP_FILE,
|
|
116
|
+
}) {
|
|
117
|
+
let record = newSetup({ device, now });
|
|
118
|
+
/* updates go one after the other, so the last one written is the last one the cloud keeps */
|
|
119
|
+
let chain = Promise.resolve();
|
|
120
|
+
|
|
121
|
+
const push = () => {
|
|
122
|
+
const body = JSON.stringify({ setup: record });
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
125
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
126
|
+
} catch {
|
|
127
|
+
/* the cloud copy is the one the page reads */
|
|
128
|
+
}
|
|
129
|
+
chain = chain.then(async () => {
|
|
130
|
+
try {
|
|
131
|
+
const t = await token();
|
|
132
|
+
if (!t) return;
|
|
133
|
+
await doFetch(`${studioUrl}/api/cloud/config`, {
|
|
134
|
+
method: "PUT",
|
|
135
|
+
headers: { authorization: `Bearer ${t}`, "content-type": "application/json" },
|
|
136
|
+
body,
|
|
137
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
/* offline or the studio is down: the next update carries everything */
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
/** the record as it is now */
|
|
147
|
+
get record() {
|
|
148
|
+
return record;
|
|
149
|
+
},
|
|
150
|
+
/** begins again with every step waiting (also tells the cloud) */
|
|
151
|
+
start() {
|
|
152
|
+
record = newSetup({ device, now });
|
|
153
|
+
push();
|
|
154
|
+
},
|
|
155
|
+
/** @param {string} id @param {StepState} state @param {string} [error] */
|
|
156
|
+
step(id, state, error) {
|
|
157
|
+
record = withStep(record, id, state, error, now);
|
|
158
|
+
push();
|
|
159
|
+
},
|
|
160
|
+
/** the process is waiting for a retry request: says so, so the page knows someone is there */
|
|
161
|
+
beat() {
|
|
162
|
+
record = { ...record, watching_at: now(), updated_at: now() };
|
|
163
|
+
push();
|
|
164
|
+
},
|
|
165
|
+
/** the retry request with this id has been dealt with @param {string} id */
|
|
166
|
+
handled(id) {
|
|
167
|
+
record = { ...record, handled: id, updated_at: now() };
|
|
168
|
+
push();
|
|
169
|
+
},
|
|
170
|
+
/** nobody is waiting any more */
|
|
171
|
+
stopWatching() {
|
|
172
|
+
const { watching_at: _gone, ...rest } = record;
|
|
173
|
+
record = { ...rest, updated_at: now() };
|
|
174
|
+
push();
|
|
175
|
+
},
|
|
176
|
+
/** waits for the last update to leave */
|
|
177
|
+
async flush() {
|
|
178
|
+
await chain;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @typedef {ReturnType<typeof createReporter>} Reporter */
|
|
184
|
+
|
|
185
|
+
/** a reporter that tells no one (the hook, the tests) @type {Reporter} */
|
|
186
|
+
export const silentReporter = {
|
|
187
|
+
get record() {
|
|
188
|
+
return /** @type {SetupRecord} */ (newSetup({ device: "" }));
|
|
189
|
+
},
|
|
190
|
+
start() {},
|
|
191
|
+
step() {},
|
|
192
|
+
beat() {},
|
|
193
|
+
handled() {},
|
|
194
|
+
stopWatching() {},
|
|
195
|
+
async flush() {},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/* --- retrying a step from the page ------------------------------------------------------------- */
|
|
199
|
+
|
|
200
|
+
/* the page asks for a retry by writing this key of the user's cloud config (the browser's session may write
|
|
201
|
+
there); the process that ran the setup, still alive after a failure, reads it */
|
|
202
|
+
export const RETRY_KEY = "setup_retry";
|
|
203
|
+
const RETRY_STEPS = new Set([...SETUP_STEPS.map(s => s.id), "all"]);
|
|
204
|
+
|
|
205
|
+
/** A retry request, if it is well formed: an id and a step this setup knows (or "all"). Nothing else the page
|
|
206
|
+
* could write is ever run. @param {unknown} v @returns {{ id: string, step: string } | null} */
|
|
207
|
+
export function parseRetry(v) {
|
|
208
|
+
if (!v || typeof v !== "object") return null;
|
|
209
|
+
const { id, step } = /** @type {Record<string, unknown>} */ (v);
|
|
210
|
+
return typeof id === "string" && id.length > 0 && id.length <= 64 && typeof step === "string" && RETRY_STEPS.has(step)
|
|
211
|
+
? { id, step }
|
|
212
|
+
: null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* After a failed setup, stays alive and does what the page asks: "Tentar de novo" on a step (or on all of them).
|
|
217
|
+
* It looks every few seconds, tells the page it is there (`watching_at`), and gives up after a quiet half hour or
|
|
218
|
+
* once the setup is done. Never throws: a studio that doesn't answer is just a look that found nothing.
|
|
219
|
+
* @param {{
|
|
220
|
+
* studioUrl: string,
|
|
221
|
+
* token: () => Promise<string | null>,
|
|
222
|
+
* reporter: ReturnType<typeof createReporter>,
|
|
223
|
+
* run: (step: string) => Promise<void>,
|
|
224
|
+
* fetch?: typeof fetch,
|
|
225
|
+
* now?: () => number,
|
|
226
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
227
|
+
* pollMs?: number, beatMs?: number, idleMs?: number,
|
|
228
|
+
* }} o
|
|
229
|
+
*/
|
|
230
|
+
export async function watchRetries({
|
|
231
|
+
studioUrl,
|
|
232
|
+
token,
|
|
233
|
+
reporter,
|
|
234
|
+
run,
|
|
235
|
+
fetch: doFetch = fetch,
|
|
236
|
+
now = Date.now,
|
|
237
|
+
sleep = ms => new Promise(r => setTimeout(r, ms)),
|
|
238
|
+
pollMs = 5_000,
|
|
239
|
+
beatMs = 15_000,
|
|
240
|
+
idleMs = 30 * 60_000,
|
|
241
|
+
}) {
|
|
242
|
+
const look = async () => {
|
|
243
|
+
try {
|
|
244
|
+
const t = await token();
|
|
245
|
+
if (!t) return null;
|
|
246
|
+
const r = await doFetch(`${studioUrl}/api/cloud/config`, {
|
|
247
|
+
headers: { authorization: `Bearer ${t}` },
|
|
248
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
249
|
+
});
|
|
250
|
+
return r.ok ? parseRetry(/** @type {any} */ (await r.json()).config?.[RETRY_KEY]) : null;
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
let seen = reporter.record.handled ?? "";
|
|
256
|
+
let lastBeat = now();
|
|
257
|
+
let lastWork = now();
|
|
258
|
+
reporter.beat();
|
|
259
|
+
while (reporter.record.state === "error" && now() - lastWork < idleMs) {
|
|
260
|
+
await sleep(pollMs);
|
|
261
|
+
const req = await look();
|
|
262
|
+
if (req && req.id !== seen) {
|
|
263
|
+
seen = req.id;
|
|
264
|
+
try {
|
|
265
|
+
await run(req.step);
|
|
266
|
+
} catch {
|
|
267
|
+
/* the step reports its own failure */
|
|
268
|
+
}
|
|
269
|
+
reporter.handled(req.id);
|
|
270
|
+
lastWork = lastBeat = now();
|
|
271
|
+
} else if (now() - lastBeat >= beatMs) {
|
|
272
|
+
reporter.beat();
|
|
273
|
+
lastBeat = now();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
reporter.stopWatching();
|
|
277
|
+
await reporter.flush();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* --- the lock: one setup at a time --------------------------------------------------------- */
|
|
281
|
+
|
|
282
|
+
const alive = (/** @type {number} */ pid) => {
|
|
283
|
+
try {
|
|
284
|
+
process.kill(pid, 0);
|
|
285
|
+
return true;
|
|
286
|
+
} catch (e) {
|
|
287
|
+
/* EPERM: it exists and isn't ours */
|
|
288
|
+
return /** @type {NodeJS.ErrnoException} */ (e).code === "EPERM";
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** whether a setup is running now: the lock names a live process and is not too old @param {string} [lock] */
|
|
293
|
+
export function setupRunning(lock = LOCK_FILE) {
|
|
294
|
+
try {
|
|
295
|
+
const pid = Number(readFileSync(lock, "utf8"));
|
|
296
|
+
return Number.isInteger(pid) && pid > 0 && alive(pid) && Date.now() - statSync(lock).mtimeMs < LOCK_STALE_MS;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Takes the lock for this process, or returns null when another setup holds it.
|
|
303
|
+
* @param {string} [lock] @returns {(() => void) | null} the release */
|
|
304
|
+
export function acquireSetupLock(lock = LOCK_FILE) {
|
|
305
|
+
if (setupRunning(lock)) return null;
|
|
306
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
307
|
+
writeFileSync(lock, String(process.pid));
|
|
308
|
+
return () => {
|
|
309
|
+
try {
|
|
310
|
+
if (Number(readFileSync(lock, "utf8")) === process.pid) rmSync(lock, { force: true });
|
|
311
|
+
} catch {
|
|
312
|
+
/* already gone */
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|