@instafy/cli 0.2.3 → 0.2.4
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/README.md +4 -0
- package/dist/bind/LICENSE +661 -0
- package/dist/bind/bin/instafy-bind.mjs +542 -0
- package/dist/bind/client/bind-client.js +1219 -0
- package/dist/bind/src/apply.mjs +293 -0
- package/dist/bind/src/bound.mjs +202 -0
- package/dist/bind/src/budget.mjs +58 -0
- package/dist/bind/src/check.mjs +176 -0
- package/dist/bind/src/config.mjs +180 -0
- package/dist/bind/src/controller.mjs +244 -0
- package/dist/bind/src/executors/agent.mjs +336 -0
- package/dist/bind/src/executors/script.mjs +163 -0
- package/dist/bind/src/handler-kit.mjs +150 -0
- package/dist/bind/src/init.mjs +238 -0
- package/dist/bind/src/jobs.mjs +316 -0
- package/dist/bind/src/net.mjs +281 -0
- package/dist/bind/src/page-fetch.mjs +43 -0
- package/dist/bind/src/preview-host.mjs +114 -0
- package/dist/bind/src/protocol.mjs +103 -0
- package/dist/bind/src/records-schema.mjs +580 -0
- package/dist/bind/src/records.mjs +161 -0
- package/dist/bind/src/save-owner.mjs +492 -0
- package/dist/bind/src/schema-lite.mjs +212 -0
- package/dist/bind/src/server.mjs +908 -0
- package/dist/bind/src/sse.mjs +119 -0
- package/dist/bind/src/starters/blank/app.js +17 -0
- package/dist/bind/src/starters/blank/index.html +22 -0
- package/dist/bind/src/starters/checklist/app.js +171 -0
- package/dist/bind/src/starters/checklist/bound.json +29 -0
- package/dist/bind/src/starters/checklist/data.json +26 -0
- package/dist/bind/src/starters/checklist/index.html +23 -0
- package/dist/bind/src/starters/compare/app.js +316 -0
- package/dist/bind/src/starters/compare/bound.json +26 -0
- package/dist/bind/src/starters/compare/data.json +170 -0
- package/dist/bind/src/starters/compare/index.html +23 -0
- package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
- package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
- package/dist/bind/src/starters/ranked/app.js +166 -0
- package/dist/bind/src/starters/ranked/bound.json +26 -0
- package/dist/bind/src/starters/ranked/data.json +142 -0
- package/dist/bind/src/starters/ranked/index.html +23 -0
- package/dist/bind/src/starters/site.js +888 -0
- package/dist/bind/src/starters/theme.css +492 -0
- package/dist/bind/src/starters/timeline/app.js +143 -0
- package/dist/bind/src/starters/timeline/data.json +41 -0
- package/dist/bind/src/starters/timeline/index.html +22 -0
- package/dist/bind/src/static.mjs +141 -0
- package/dist/bind/src/tokens.mjs +102 -0
- package/dist/bind/src/try.mjs +139 -0
- package/dist/bind/src/web.mjs +191 -0
- package/dist/cli.js +2117 -365
- package/package.json +3 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// `instafy-bind init <dir>`: a starter bound site (bound.json, index.html, handlers/hello.mjs).
|
|
2
|
+
// `instafy-bind init <dir> --starter compare|ranked|timeline|checklist|blank`: one of the themed starter pages in
|
|
3
|
+
// src/starters, plus the shared theme.css and site.js. They are ordinary files the site owns and edits.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
export const STARTERS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "starters");
|
|
9
|
+
/** Starter name -> {site file: source file under src/starters}. theme.css and site.js go with every one. */
|
|
10
|
+
export const STARTERS = Object.freeze({
|
|
11
|
+
compare: files("compare", ["index.html", "app.js", "data.json", "bound.json"], { "handlers/read_offer.mjs": "handlers/read_offer.mjs" }),
|
|
12
|
+
ranked: files("ranked", ["index.html", "app.js", "data.json", "bound.json"], { "handlers/read_offer.mjs": "handlers/read_offer.mjs" }),
|
|
13
|
+
timeline: files("timeline", ["index.html", "app.js", "data.json"]),
|
|
14
|
+
checklist: files("checklist", ["index.html", "app.js", "data.json", "bound.json"], { "handlers/set_check.mjs": "handlers/set_check.mjs" }),
|
|
15
|
+
blank: files("blank", ["index.html", "app.js"]),
|
|
16
|
+
});
|
|
17
|
+
export const STARTER_NAMES = Object.keys(STARTERS);
|
|
18
|
+
|
|
19
|
+
function files(name, own, shared = {}) {
|
|
20
|
+
const map = { "theme.css": "theme.css", "site.js": "site.js" };
|
|
21
|
+
for (const file of own) map[file] = `${name}/${file}`;
|
|
22
|
+
return Object.freeze({ ...map, ...shared });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NAME_PARAMS = {
|
|
26
|
+
type: "object",
|
|
27
|
+
required: ["name", "ref"],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
properties: {
|
|
30
|
+
// A pattern keeps free text out of the agent prompt: a name, not a sentence of instructions.
|
|
31
|
+
name: { type: "string", minLength: 1, maxLength: 40, pattern: "^[A-Za-z0-9À-ÿ .'-]{1,40}$" },
|
|
32
|
+
ref: { type: "string", maxLength: 60, pattern: "^greetings:[a-z0-9-]{1,40}$" },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const STARTER_BOUND = {
|
|
37
|
+
version: 1,
|
|
38
|
+
title: "Bound site starter",
|
|
39
|
+
intents: {
|
|
40
|
+
hello: {
|
|
41
|
+
label: "Say hello",
|
|
42
|
+
emoji: "👋",
|
|
43
|
+
describe: "A script handler that answers with a greeting claim",
|
|
44
|
+
params: NAME_PARAMS,
|
|
45
|
+
executor: "script",
|
|
46
|
+
handler: "handlers/hello.mjs",
|
|
47
|
+
lanes: ["local"],
|
|
48
|
+
target: { records: "data/records/hello.json" },
|
|
49
|
+
pending: "item",
|
|
50
|
+
cooldown_s: 2,
|
|
51
|
+
timeout_s: 20,
|
|
52
|
+
},
|
|
53
|
+
ask_octo: {
|
|
54
|
+
label: "Ask Octo",
|
|
55
|
+
emoji: "🐙",
|
|
56
|
+
describe: "An agent intent: one Octo turn writes the greeting instead of a script",
|
|
57
|
+
params: NAME_PARAMS,
|
|
58
|
+
executor: "agent",
|
|
59
|
+
agent: {
|
|
60
|
+
prompt: "Write a one-line, friendly greeting for {{name}} (at most 12 words). Return it as one claim with attr \"greeting\", label \"Greeting\", basis \"editorial\", prov.source \"octo\", ttl_s 3600. Do not browse.",
|
|
61
|
+
lane: "assist",
|
|
62
|
+
timeout_s: 180,
|
|
63
|
+
},
|
|
64
|
+
target: { records: "data/records/hello.json" },
|
|
65
|
+
pending: "item",
|
|
66
|
+
cooldown_s: 20,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
results: { files: ["data/records/hello.json"], poll_s: 5, sse: true },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const STARTER_HANDLER = `// Script handler for the "hello" intent. The binder runs it with node, cwd = the site folder.
|
|
73
|
+
// stdin: {intent, bound, site}. stdout: NDJSON progress / records / done / error. Never write site files.
|
|
74
|
+
// Fetching? Use kit.fetch(url): public http(s) only; it answers like fetch ({ok, status, text(), json()}).
|
|
75
|
+
const kit = await import(process.env.BIND_KIT);
|
|
76
|
+
const { intent } = await kit.readInput();
|
|
77
|
+
const { name, ref } = intent.params;
|
|
78
|
+
|
|
79
|
+
kit.progress(1, 1, "Writing a greeting");
|
|
80
|
+
kit.records([
|
|
81
|
+
{
|
|
82
|
+
type: "claim",
|
|
83
|
+
cid: kit.rid("clm", ref, "greeting", "local"),
|
|
84
|
+
ref,
|
|
85
|
+
attr: "greeting",
|
|
86
|
+
value: "Hello, " + name + "!",
|
|
87
|
+
label: "Greeting",
|
|
88
|
+
status: "ok",
|
|
89
|
+
basis: "editorial",
|
|
90
|
+
confidence: "high",
|
|
91
|
+
prov: { source: "hello-handler", lane: "local", observed_at: new Date().toISOString(), ttl_s: 3600 },
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
kit.done("Greeted " + name);
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
export const STARTER_HTML = `<!doctype html>
|
|
98
|
+
<html lang="en">
|
|
99
|
+
<head>
|
|
100
|
+
<meta charset="utf-8">
|
|
101
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
102
|
+
<title>Bound site starter</title>
|
|
103
|
+
<style>
|
|
104
|
+
/* Theme: Instafy preset (light + dark). Change the tokens to re-skin the page. */
|
|
105
|
+
:root { color-scheme: light dark; --bg: #fbfaf7; --fg: #0f172a; --muted: #5f5b54; --card: #fff; --line: #ebe7e2; --edge: #d4d0ca; --accent: #007acc; --accent-fill: #0066ac; --accent-fg: #fff; --skel: #f6f3ef; }
|
|
106
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #141414; --fg: #fbfaf7; --muted: #b5b0a9; --card: #1d1d1d; --line: #2e2e2e; --edge: #3d3d3d; --accent: #3794ff; --accent-fill: #1f74d8; --accent-fg: #fff; --skel: #262626; } }
|
|
107
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 Inter, ui-sans-serif, system-ui, sans-serif; }
|
|
108
|
+
main { max-width: 560px; margin: 0 auto; padding: 24px 16px; }
|
|
109
|
+
h1 { font-weight: 650; letter-spacing: -.02em; }
|
|
110
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
111
|
+
form { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
112
|
+
input { flex: 1 1 180px; min-height: 44px; padding: 0 14px; border: 1px solid var(--edge); border-radius: 16px; background: var(--card); color: inherit; font: inherit; }
|
|
113
|
+
button { min-height: 44px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--accent-fill); color: var(--accent-fg); font: inherit; font-weight: 600; cursor: pointer; }
|
|
114
|
+
button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--edge); min-height: 36px; padding: 0 12px; }
|
|
115
|
+
#status { color: var(--muted); min-height: 1.5em; }
|
|
116
|
+
#pending { display: flex; align-items: center; gap: 8px; color: var(--muted); }
|
|
117
|
+
#pending[hidden] { display: none; }
|
|
118
|
+
.skel { flex: 1; height: 14px; border-radius: 7px; background: linear-gradient(90deg, var(--skel), var(--line), var(--skel)); background-size: 200% 100%; animation: shimmer 1.2s infinite linear; }
|
|
119
|
+
@keyframes shimmer { to { background-position: -200% 0; } }
|
|
120
|
+
@media (prefers-reduced-motion: reduce) { .skel { animation: none; } }
|
|
121
|
+
ul { list-style: none; padding: 0; }
|
|
122
|
+
li { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; margin: 8px 0; border: 1px solid var(--line); border-radius: 16px; background: var(--card); }
|
|
123
|
+
li > div { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
124
|
+
li small { display: block; color: var(--muted); }
|
|
125
|
+
li.expired { opacity: .6; }
|
|
126
|
+
</style>
|
|
127
|
+
</head>
|
|
128
|
+
<body>
|
|
129
|
+
<main>
|
|
130
|
+
<h1>Bound site starter</h1>
|
|
131
|
+
<form id="f">
|
|
132
|
+
<input id="name" placeholder="Your name" maxlength="40" required>
|
|
133
|
+
<button>Say hello</button>
|
|
134
|
+
<button type="button" id="octo" hidden>Ask Octo</button>
|
|
135
|
+
</form>
|
|
136
|
+
<p id="status" role="status" aria-live="polite"></p>
|
|
137
|
+
<p id="pending" hidden><span class="skel"></span><button type="button" class="ghost" id="cancel">Cancel</button></p>
|
|
138
|
+
<ul id="records"></ul>
|
|
139
|
+
</main>
|
|
140
|
+
<script src="__bind/client.js"></script>
|
|
141
|
+
<script>
|
|
142
|
+
// Patterns worth keeping: render records with textContent only; show a placeholder while an intent runs
|
|
143
|
+
// (for any viewer: instafyBind.isPending); show each value's source and age; offer ↻ via instafyBind.retry.
|
|
144
|
+
const bind = window.instafyBind;
|
|
145
|
+
const $ = (id) => document.getElementById(id);
|
|
146
|
+
let mine = null;
|
|
147
|
+
|
|
148
|
+
const slug = (text) => text.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "someone";
|
|
149
|
+
|
|
150
|
+
function render() {
|
|
151
|
+
const records = bind.records().filter((r) => r.type === "claim").sort((a, b) => (b.prov.observed_at || "").localeCompare(a.prov.observed_at || ""));
|
|
152
|
+
$("records").replaceChildren(...records.map((r) => {
|
|
153
|
+
const li = document.createElement("li");
|
|
154
|
+
if (bind.expired(r)) li.classList.add("expired");
|
|
155
|
+
const body = document.createElement("div");
|
|
156
|
+
body.textContent = (r.label || r.attr) + ": " + r.value;
|
|
157
|
+
const meta = document.createElement("small");
|
|
158
|
+
meta.textContent = [r.prov.source, r.prov.lane === "assist" ? "Octo" : r.prov.lane, bind.ageText(r), bind.expired(r) ? "expired" : ""].filter(Boolean).join(" · ");
|
|
159
|
+
body.append(meta);
|
|
160
|
+
const again = document.createElement("button");
|
|
161
|
+
again.type = "button";
|
|
162
|
+
again.className = "ghost";
|
|
163
|
+
again.textContent = "↻";
|
|
164
|
+
again.title = "Check again";
|
|
165
|
+
again.setAttribute("aria-label", "Check again");
|
|
166
|
+
again.disabled = bind.isPending(r.ref);
|
|
167
|
+
again.addEventListener("click", () => bind.retry(r));
|
|
168
|
+
li.append(body, again);
|
|
169
|
+
return li;
|
|
170
|
+
}));
|
|
171
|
+
const running = bind.active();
|
|
172
|
+
$("pending").hidden = running.length === 0;
|
|
173
|
+
$("cancel").hidden = !mine;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function send(name) {
|
|
177
|
+
const text = $("name").value.trim();
|
|
178
|
+
if (!text) return;
|
|
179
|
+
const ref = "greetings:" + slug(text);
|
|
180
|
+
mine = bind.intent(name, { name: text, ref }, { ref, ui: "form" });
|
|
181
|
+
render();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const status = $("status");
|
|
185
|
+
bind.ready.then((info) => {
|
|
186
|
+
status.textContent = info.connected ? "Connected to the binder" : "No binder: showing saved results";
|
|
187
|
+
$("octo").hidden = !info.names.includes("ask_octo");
|
|
188
|
+
});
|
|
189
|
+
bind.on("records", render);
|
|
190
|
+
bind.on("ack", (m) => {
|
|
191
|
+
if (m.status === "rejected" && m.id === mine) { mine = null; status.textContent = "Not sent: " + m.reason; }
|
|
192
|
+
render();
|
|
193
|
+
});
|
|
194
|
+
bind.on("progress", (m) => { status.textContent = m.progress.message || "Working"; render(); });
|
|
195
|
+
bind.on("done", (m) => { if (m.id === mine) mine = null; status.textContent = m.summary || "Done"; render(); });
|
|
196
|
+
bind.on("error", (m) => { if (m.id === mine) mine = null; status.textContent = m.error.code === "cancelled" ? "Cancelled" : "Error: " + m.error.message; render(); });
|
|
197
|
+
bind.on("command", (m) => console.log("command from the binder", m.command));
|
|
198
|
+
setInterval(render, 30000);
|
|
199
|
+
|
|
200
|
+
$("f").addEventListener("submit", (event) => { event.preventDefault(); send("hello"); });
|
|
201
|
+
$("octo").addEventListener("click", () => send("ask_octo"));
|
|
202
|
+
$("cancel").addEventListener("click", () => { if (mine) bind.cancel(mine); });
|
|
203
|
+
</script>
|
|
204
|
+
</body>
|
|
205
|
+
</html>
|
|
206
|
+
`;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write the starter files: the hello starter, or with `starter` one of STARTER_NAMES. Existing files are kept unless
|
|
210
|
+
* `force`. Returns {written, skipped}. An unknown starter throws with the list of names.
|
|
211
|
+
*/
|
|
212
|
+
export function initSite(dir, { force = false, starter = null } = {}) {
|
|
213
|
+
let files;
|
|
214
|
+
if (starter == null || starter === "") {
|
|
215
|
+
files = {
|
|
216
|
+
"bound.json": `${JSON.stringify(STARTER_BOUND, null, 2)}\n`,
|
|
217
|
+
"index.html": STARTER_HTML,
|
|
218
|
+
"handlers/hello.mjs": STARTER_HANDLER,
|
|
219
|
+
};
|
|
220
|
+
} else {
|
|
221
|
+
const map = Object.hasOwn(STARTERS, starter) ? STARTERS[starter] : null;
|
|
222
|
+
if (!map) throw new Error(`unknown starter "${starter}" (use ${STARTER_NAMES.join(", ")})`);
|
|
223
|
+
files = Object.fromEntries(Object.entries(map).map(([rel, source]) => [rel, fs.readFileSync(path.join(STARTERS_DIR, source))]));
|
|
224
|
+
}
|
|
225
|
+
const written = [];
|
|
226
|
+
const skipped = [];
|
|
227
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
228
|
+
const file = path.join(dir, rel);
|
|
229
|
+
if (fs.existsSync(file) && !force) {
|
|
230
|
+
skipped.push(rel);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
234
|
+
fs.writeFileSync(file, content);
|
|
235
|
+
written.push(rel);
|
|
236
|
+
}
|
|
237
|
+
return { written, skipped };
|
|
238
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Job queue per site: 2 concurrent jobs, coalescing of identical (name, params) within 60 s,
|
|
2
|
+
// cooldown per (name, params.ref), a rate cap per intent, a cap on waiting jobs, timeouts, confirmation and cancel.
|
|
3
|
+
// Every state change is broadcast once per intent id, so each viewer whose click joined a job hears about it.
|
|
4
|
+
import { DEFAULT_AGENT_TIMEOUT_S, DEFAULT_SCRIPT_TIMEOUT_S, executorOf } from "./bound.mjs";
|
|
5
|
+
import { applyBatch, bumpVersion } from "./apply.mjs";
|
|
6
|
+
import { ERROR_CODES, clip, hostMessage, newJobId, stableStringify } from "./protocol.mjs";
|
|
7
|
+
import { randomToken, tokensEqual } from "./tokens.mjs";
|
|
8
|
+
|
|
9
|
+
export const MAX_CONCURRENT = 2;
|
|
10
|
+
export const COALESCE_MS = 60_000;
|
|
11
|
+
/** Jobs waiting (queued or needs_confirm) per site; more are refused with 429 "busy". */
|
|
12
|
+
export const MAX_WAITING = 20;
|
|
13
|
+
/** Accepted intents per intent name per minute, across all viewers. */
|
|
14
|
+
export const RATE_PER_MIN = 30;
|
|
15
|
+
/** Viewers that can share one coalesced job. */
|
|
16
|
+
export const MAX_IDS_PER_JOB = 10;
|
|
17
|
+
const KEEP_JOBS = 200;
|
|
18
|
+
const FINISHED = ["done", "error"];
|
|
19
|
+
|
|
20
|
+
export class JobManager {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {{dir: string, bound: object, name?: string}} options.site
|
|
24
|
+
* @param {{broadcast: (msg: object) => number}} options.hub
|
|
25
|
+
* @param {(job: object, ctx: object) => Promise<{summary?: string}|void>} options.runExecutor
|
|
26
|
+
* @param {(def: object, executor: string) => {http: number, body: object}|null} [options.admit] extra admission check (the Octo budget)
|
|
27
|
+
*/
|
|
28
|
+
constructor({
|
|
29
|
+
site, hub, runExecutor, admit = () => null, log = () => {}, maxConcurrent = MAX_CONCURRENT, coalesceMs = COALESCE_MS,
|
|
30
|
+
maxWaiting = MAX_WAITING, ratePerMin = RATE_PER_MIN, confirmAll = false, now = () => Date.now(),
|
|
31
|
+
}) {
|
|
32
|
+
this.site = site;
|
|
33
|
+
this.hub = hub;
|
|
34
|
+
this.runExecutor = runExecutor;
|
|
35
|
+
this.admit = admit;
|
|
36
|
+
this.log = log;
|
|
37
|
+
this.maxConcurrent = maxConcurrent;
|
|
38
|
+
this.coalesceMs = coalesceMs;
|
|
39
|
+
this.maxWaiting = maxWaiting;
|
|
40
|
+
this.ratePerMin = ratePerMin;
|
|
41
|
+
this.confirmAll = confirmAll;
|
|
42
|
+
this.now = now;
|
|
43
|
+
this.jobs = [];
|
|
44
|
+
this.byIntentId = new Map();
|
|
45
|
+
/** cooldown key -> {at, ms} */
|
|
46
|
+
this.lastAccepted = new Map();
|
|
47
|
+
/** intent name -> accepted timestamps in the last minute */
|
|
48
|
+
this.recent = new Map();
|
|
49
|
+
/** Slots in use. A slot is held until the executor itself settles (an Octo run is cancelled first). */
|
|
50
|
+
this.running = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One host message per intent id of the job. Returns the last event id. */
|
|
54
|
+
broadcast(type, job, fields = {}, ids = job.ids) {
|
|
55
|
+
let event = 0;
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
event = this.hub.broadcast(hostMessage(type, { id, ids: [...job.ids], job: job.id, name: job.name, ...(job.ref ? { ref: job.ref } : {}), ...fields }));
|
|
58
|
+
}
|
|
59
|
+
return event;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
markVersion(job, state, ids = job.ids) {
|
|
63
|
+
try {
|
|
64
|
+
bumpVersion(this.site.dir, { jobs: Object.fromEntries(ids.map((id) => [id, state])) });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.log("warn", `version.json: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pruneLimits(now) {
|
|
71
|
+
for (const [key, entry] of this.lastAccepted) if (now - entry.at >= entry.ms) this.lastAccepted.delete(key);
|
|
72
|
+
for (const [name, stamps] of this.recent) {
|
|
73
|
+
const fresh = stamps.filter((t) => now - t < 60_000);
|
|
74
|
+
if (fresh.length) this.recent.set(name, fresh);
|
|
75
|
+
else this.recent.delete(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Accept a validated intent. Returns {http, body}. The body carries a cancel_key for the submitting viewer only. */
|
|
80
|
+
submit(msg, def) {
|
|
81
|
+
const existing = this.byIntentId.get(msg.id);
|
|
82
|
+
if (existing) return { http: 409, body: { status: existing.state === "needs_confirm" ? "needs_confirm" : "queued", id: msg.id, job: existing.id } };
|
|
83
|
+
|
|
84
|
+
const now = this.now();
|
|
85
|
+
this.pruneLimits(now);
|
|
86
|
+
const key = `${msg.name}\u0000${stableStringify(msg.params)}`;
|
|
87
|
+
const twin = this.jobs.find((job) => job.key === key && ["needs_confirm", "queued", "running"].includes(job.state) && now - job.createdAt < this.coalesceMs);
|
|
88
|
+
if (twin && twin.ids.length < MAX_IDS_PER_JOB) {
|
|
89
|
+
const cancelKey = randomToken(18);
|
|
90
|
+
twin.ids.push(msg.id);
|
|
91
|
+
twin.cancelKeys.set(msg.id, cancelKey);
|
|
92
|
+
this.byIntentId.set(msg.id, twin);
|
|
93
|
+
const status = twin.state === "running" ? "running" : twin.state === "needs_confirm" ? "needs_confirm" : "queued";
|
|
94
|
+
this.broadcast("ack", twin, { status, coalesced: true }, [msg.id]);
|
|
95
|
+
return { http: 202, body: { status: status === "running" ? "queued" : status, id: msg.id, job: twin.id, coalesced: true, cancel_key: cancelKey } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Cooldown per (name, params.ref): params.ref is schema-checked; the envelope ref is a page hint and never a key.
|
|
99
|
+
const ref = typeof msg.params.ref === "string" ? msg.params.ref : null;
|
|
100
|
+
const cooldownKey = `${msg.name}\u0000${ref ?? ""}`;
|
|
101
|
+
const cooldownMs = (def.cooldown_s ?? 0) * 1000;
|
|
102
|
+
const last = this.lastAccepted.get(cooldownKey);
|
|
103
|
+
if (cooldownMs > 0 && last && now - last.at < cooldownMs) {
|
|
104
|
+
const retry = Math.ceil((cooldownMs - (now - last.at)) / 1000);
|
|
105
|
+
return { http: 429, body: { status: "rejected", id: msg.id, reason: "cooldown", retry_after_s: retry } };
|
|
106
|
+
}
|
|
107
|
+
const stamps = this.recent.get(msg.name) || [];
|
|
108
|
+
if (stamps.length >= this.ratePerMin) {
|
|
109
|
+
const retry = Math.max(1, Math.ceil((stamps[0] + 60_000 - now) / 1000));
|
|
110
|
+
return { http: 429, body: { status: "rejected", id: msg.id, reason: "rate", retry_after_s: retry } };
|
|
111
|
+
}
|
|
112
|
+
const waiting = this.jobs.filter((job) => job.state === "queued" || job.state === "needs_confirm").length;
|
|
113
|
+
if (waiting >= this.maxWaiting) {
|
|
114
|
+
return { http: 429, body: { status: "rejected", id: msg.id, reason: "busy", retry_after_s: 10 } };
|
|
115
|
+
}
|
|
116
|
+
const executor = executorOf(def);
|
|
117
|
+
const refused = this.admit(def, executor);
|
|
118
|
+
if (refused) return { http: refused.http, body: { status: "rejected", id: msg.id, ...refused.body } };
|
|
119
|
+
|
|
120
|
+
if (cooldownMs > 0) this.lastAccepted.set(cooldownKey, { at: now, ms: cooldownMs });
|
|
121
|
+
this.recent.set(msg.name, [...stamps, now]);
|
|
122
|
+
|
|
123
|
+
const timeoutS = def.timeout_s ?? (executor === "agent" ? def.agent?.timeout_s ?? DEFAULT_AGENT_TIMEOUT_S : DEFAULT_SCRIPT_TIMEOUT_S);
|
|
124
|
+
const cancelKey = randomToken(18);
|
|
125
|
+
const job = {
|
|
126
|
+
id: newJobId(now),
|
|
127
|
+
name: msg.name,
|
|
128
|
+
def,
|
|
129
|
+
executor,
|
|
130
|
+
intent: msg,
|
|
131
|
+
ids: [msg.id],
|
|
132
|
+
cancelKeys: new Map([[msg.id, cancelKey]]),
|
|
133
|
+
key,
|
|
134
|
+
ref: ref ?? msg.ref ?? null,
|
|
135
|
+
state: def.confirm || this.confirmAll ? "needs_confirm" : "queued",
|
|
136
|
+
createdAt: now,
|
|
137
|
+
startedAt: null,
|
|
138
|
+
endedAt: null,
|
|
139
|
+
timeoutMs: timeoutS * 1000,
|
|
140
|
+
records: 0,
|
|
141
|
+
rejected: 0,
|
|
142
|
+
summary: null,
|
|
143
|
+
error: null,
|
|
144
|
+
abort: new AbortController(),
|
|
145
|
+
};
|
|
146
|
+
this.jobs.push(job);
|
|
147
|
+
if (this.jobs.length > KEEP_JOBS) {
|
|
148
|
+
// Evict the oldest finished jobs only; waiting and running jobs always stay.
|
|
149
|
+
let excess = this.jobs.length - KEEP_JOBS;
|
|
150
|
+
this.jobs = this.jobs.filter((old) => {
|
|
151
|
+
if (excess > 0 && FINISHED.includes(old.state)) {
|
|
152
|
+
excess -= 1;
|
|
153
|
+
for (const id of old.ids) this.byIntentId.delete(id);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
this.byIntentId.set(msg.id, job);
|
|
160
|
+
this.broadcast("ack", job, { status: job.state });
|
|
161
|
+
this.log("info", `${job.id} ${msg.name} ${job.state}${job.ref ? ` ref=${job.ref}` : ""}`);
|
|
162
|
+
if (job.state === "queued") queueMicrotask(() => this.pump());
|
|
163
|
+
return { http: 202, body: { status: job.state, id: msg.id, job: job.id, cancel_key: cancelKey } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Confirm or decline a job waiting in needs_confirm. Returns the job or null. */
|
|
167
|
+
confirm(jobId, ok = true) {
|
|
168
|
+
const job = this.jobs.find((j) => j.id === jobId && j.state === "needs_confirm");
|
|
169
|
+
if (!job) return null;
|
|
170
|
+
if (!ok) {
|
|
171
|
+
this.finish(job, { code: "policy", message: "Declined by the site owner" });
|
|
172
|
+
return job;
|
|
173
|
+
}
|
|
174
|
+
job.state = "queued";
|
|
175
|
+
this.broadcast("ack", job, { status: "queued" });
|
|
176
|
+
this.pump();
|
|
177
|
+
return job;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pendingConfirmations() {
|
|
181
|
+
return this.jobs.filter((job) => job.state === "needs_confirm");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Cancel the caller's own intent. Needs the cancel_key the 202 returned to that viewer.
|
|
186
|
+
* A job shared by several viewers only drops the caller's id; the job stops when no id is left.
|
|
187
|
+
* Returns {job} | {denied: true} | null (nothing active).
|
|
188
|
+
*/
|
|
189
|
+
cancel(intentId, cancelKey) {
|
|
190
|
+
const job = this.byIntentId.get(intentId);
|
|
191
|
+
if (!job || FINISHED.includes(job.state)) return null;
|
|
192
|
+
if (!tokensEqual(String(cancelKey ?? ""), job.cancelKeys.get(intentId) ?? "")) return { denied: true };
|
|
193
|
+
if (job.ids.length > 1) {
|
|
194
|
+
job.ids = job.ids.filter((id) => id !== intentId);
|
|
195
|
+
job.cancelKeys.delete(intentId);
|
|
196
|
+
this.byIntentId.delete(intentId);
|
|
197
|
+
this.hub.broadcast(hostMessage("error", { id: intentId, ids: [intentId], job: job.id, name: job.name, error: { code: "cancelled", message: "Cancelled" } }));
|
|
198
|
+
this.markVersion(job, "error", [intentId]);
|
|
199
|
+
return { job };
|
|
200
|
+
}
|
|
201
|
+
if (job.state === "running") job.abort.abort({ code: "cancelled", message: "Cancelled" });
|
|
202
|
+
else this.finish(job, { code: "cancelled", message: "Cancelled" });
|
|
203
|
+
return { job };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
pump() {
|
|
207
|
+
while (this.running < this.maxConcurrent) {
|
|
208
|
+
const next = this.jobs.find((job) => job.state === "queued");
|
|
209
|
+
if (!next) return;
|
|
210
|
+
this.start(next);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
start(job) {
|
|
215
|
+
job.state = "running";
|
|
216
|
+
job.startedAt = this.now();
|
|
217
|
+
this.running += 1;
|
|
218
|
+
this.broadcast("ack", job, { status: "running" });
|
|
219
|
+
this.markVersion(job, "running");
|
|
220
|
+
const timer = setTimeout(() => job.abort.abort({ code: "timeout", message: `No result within ${Math.round(job.timeoutMs / 1000)} s` }), job.timeoutMs);
|
|
221
|
+
timer.unref?.();
|
|
222
|
+
const signal = job.abort.signal;
|
|
223
|
+
const live = () => job.state === "running";
|
|
224
|
+
const ctx = {
|
|
225
|
+
signal,
|
|
226
|
+
siteDir: this.site.dir,
|
|
227
|
+
bound: this.site.bound,
|
|
228
|
+
log: (level, text) => this.log(level, `${job.id} ${text}`),
|
|
229
|
+
progress: (progress) => {
|
|
230
|
+
if (!live()) return;
|
|
231
|
+
const clean = {};
|
|
232
|
+
if (Number.isInteger(progress.step) && progress.step >= 0) clean.step = progress.step;
|
|
233
|
+
if (Number.isInteger(progress.of) && progress.of >= 1) clean.of = progress.of;
|
|
234
|
+
if (progress.message) clean.message = clip(progress.message);
|
|
235
|
+
if (progress.source) clean.source = clip(progress.source, 80);
|
|
236
|
+
this.broadcast("progress", job, { progress: clean });
|
|
237
|
+
},
|
|
238
|
+
records: (records) => {
|
|
239
|
+
if (!live()) return { accepted: 0, rejected: 0 };
|
|
240
|
+
const result = applyBatch(this.site.dir, records, {
|
|
241
|
+
name: job.name, def: job.def, intent: job.intent, job: job.id, ids: job.ids, bound: this.site.bound,
|
|
242
|
+
});
|
|
243
|
+
job.records += result.applied.length;
|
|
244
|
+
job.rejected += result.rejected.length;
|
|
245
|
+
if (result.rejected.length && !job.firstRejection) job.firstRejection = result.rejected[0].reason;
|
|
246
|
+
for (const r of result.rejected) this.log("warn", `${job.id} rejected record #${r.index}: ${r.reason}`);
|
|
247
|
+
if (result.trimmed) this.log("warn", `${job.id} ${result.file.rel} passed ${Math.round(result.file.limit / 1024)} KB; trimmed ${result.trimmed} old record(s)`);
|
|
248
|
+
if (result.applied.length) {
|
|
249
|
+
this.broadcast("results", job, { records: result.applied, files: { [result.file.rel]: result.file.v } });
|
|
250
|
+
}
|
|
251
|
+
return { accepted: result.applied.length, rejected: result.rejected.length, reasons: result.rejected };
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
// The slot is released when the executor settles, not when the viewer is told: a cancelled Octo run
|
|
255
|
+
// keeps its slot until the controller has been asked to stop it.
|
|
256
|
+
const run = Promise.resolve().then(() => this.runExecutor(job, ctx));
|
|
257
|
+
run.catch(() => {}).finally(() => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
this.running -= 1;
|
|
260
|
+
this.pump();
|
|
261
|
+
});
|
|
262
|
+
const aborted = new Promise((resolve) => signal.addEventListener("abort", () => resolve({ aborted: signal.reason }), { once: true }));
|
|
263
|
+
Promise.race([run.then((value) => ({ value })), aborted])
|
|
264
|
+
.then((outcome) => {
|
|
265
|
+
if (outcome.aborted) this.finish(job, outcome.aborted);
|
|
266
|
+
else this.finish(job, null, outcome.value?.summary);
|
|
267
|
+
})
|
|
268
|
+
.catch((error) => {
|
|
269
|
+
if (signal.aborted) this.finish(job, signal.reason);
|
|
270
|
+
else this.finish(job, { code: ERROR_CODES.includes(error.code) ? error.code : "internal", message: error.viewerMessage || error.message });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
finish(job, error, summary = null) {
|
|
275
|
+
if (FINISHED.includes(job.state)) return;
|
|
276
|
+
job.endedAt = this.now();
|
|
277
|
+
// A handler whose every record was refused has told the viewer nothing: that is an error the page can show
|
|
278
|
+
// ("could not read …"), not a silent "done".
|
|
279
|
+
if (!error && job.records === 0 && job.rejected > 0) {
|
|
280
|
+
error = { code: "invalid", message: `the handler's ${job.rejected === 1 ? "record was" : `${job.rejected} records were`} rejected: ${job.firstRejection || "invalid record"}` };
|
|
281
|
+
}
|
|
282
|
+
if (error) {
|
|
283
|
+
job.state = "error";
|
|
284
|
+
job.error = { code: ERROR_CODES.includes(error.code) ? error.code : "internal", message: clip(error.viewerMessage || error.message || error.code) };
|
|
285
|
+
this.broadcast("error", job, { error: job.error, rejected: job.rejected });
|
|
286
|
+
this.markVersion(job, "error");
|
|
287
|
+
this.log("warn", `${job.id} ${job.name} error ${job.error.code}: ${error.message || job.error.message}`);
|
|
288
|
+
} else {
|
|
289
|
+
job.state = "done";
|
|
290
|
+
job.summary = summary ? clip(summary, 200) : job.records ? `${job.records} result${job.records === 1 ? "" : "s"}` : "Nothing new found";
|
|
291
|
+
this.broadcast("done", job, { summary: job.summary, count: job.records, rejected: job.rejected });
|
|
292
|
+
this.markVersion(job, "done");
|
|
293
|
+
this.log("info", `${job.id} ${job.name} done: ${job.summary}${job.rejected ? ` (${job.rejected} rejected)` : ""}`);
|
|
294
|
+
}
|
|
295
|
+
if (!job.abort.signal.aborted) job.abort.abort({ code: "internal", message: "finished" });
|
|
296
|
+
this.pump();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Debug view: the last 50 jobs. */
|
|
300
|
+
list() {
|
|
301
|
+
return this.jobs.slice(-50).reverse().map((job) => ({
|
|
302
|
+
job: job.id, name: job.name, executor: job.executor, ids: job.ids, ref: job.ref, state: job.state,
|
|
303
|
+
created_at: new Date(job.createdAt).toISOString(),
|
|
304
|
+
started_at: job.startedAt ? new Date(job.startedAt).toISOString() : null,
|
|
305
|
+
ended_at: job.endedAt ? new Date(job.endedAt).toISOString() : null,
|
|
306
|
+
ms: job.endedAt && job.startedAt ? job.endedAt - job.startedAt : null,
|
|
307
|
+
records: job.records, rejected: job.rejected, summary: job.summary, error: job.error,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
close() {
|
|
312
|
+
for (const job of this.jobs) {
|
|
313
|
+
if (job.state === "running") job.abort.abort({ code: "cancelled", message: "Binder stopped" });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|