@instafy/cli 0.2.3 → 0.3.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/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 +169 -0
- package/dist/bind/src/controller.mjs +244 -0
- package/dist/bind/src/detached-env.mjs +27 -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 +2261 -387
- package/package.json +3 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `instafy-bind check <dir> --expect "<item>,<item>"`: does the site show every item the request named?
|
|
2
|
+
// A builder that loses an item to a lookup that timed out, or trims a list to what fits, must not ship a page that
|
|
3
|
+
// quietly leaves it out. The check reads what the page can show without a browser: the text of its HTML pages and
|
|
4
|
+
// every string in its data files (data.json, data/**/*.json, records included). Items come from --expect and from
|
|
5
|
+
// an "expect" array in data.json. Text that only says an item is unavailable ("Not found yet: Tokyo", a data file's
|
|
6
|
+
// "missing" list) does not count as showing it: builders are told to say on the page what they could not add, and
|
|
7
|
+
// that line must not pass the check. Pure helpers are exported for the tests.
|
|
8
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const SKIP_DIRS = new Set([".bind", "node_modules", "handlers", ".git", ".uwcache", "test", "tests"]);
|
|
13
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const MAX_FILES = 400;
|
|
15
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
16
|
+
|
|
17
|
+
/** "Brooks Adrenaline GTS 24, Hoka Arahi 7" (commas, semicolons or new lines), a JSON array of names, or an array of
|
|
18
|
+
* names / { name } objects → unique names in order. */
|
|
19
|
+
export function parseExpect(raw) {
|
|
20
|
+
let list = raw;
|
|
21
|
+
if (raw == null || raw === "") return [];
|
|
22
|
+
if (typeof raw === "string") {
|
|
23
|
+
if (/^\s*\[/.test(raw)) {
|
|
24
|
+
try {
|
|
25
|
+
list = JSON.parse(raw);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(`--expect is not a JSON array (${error.message})`);
|
|
28
|
+
}
|
|
29
|
+
} else list = raw.split(/[,;\n]/);
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(list)) return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const entry of list) {
|
|
34
|
+
const name = String(entry && typeof entry === "object" ? entry.name ?? "" : entry ?? "").trim();
|
|
35
|
+
if (name && !out.some((x) => norm(x) === norm(name))) out.push(name);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const norm = (text) => String(text ?? "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
41
|
+
|
|
42
|
+
/** Does the text name the item, as whole words ("Gel-Kayano 31" in "ASICS Gel Kayano 31 review"; not "Arahi 7" in
|
|
43
|
+
* "Arahi 75")? Scripts written without spaces (日本語, 中文) match as a substring. */
|
|
44
|
+
export function names(text, item) {
|
|
45
|
+
const want = norm(item);
|
|
46
|
+
if (!want) return false;
|
|
47
|
+
const hay = ` ${norm(text)} `;
|
|
48
|
+
return CJK.test(want) ? hay.includes(want) : hay.includes(` ${want} `);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The visible text of an HTML page: no scripts, styles, comments or tags. */
|
|
52
|
+
export function htmlText(html) {
|
|
53
|
+
return String(html ?? "")
|
|
54
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
55
|
+
.replace(/<(script|style|template)\b[\s\S]*?<\/\1\s*>/gi, " ")
|
|
56
|
+
.replace(/<[^>]+>/g, " ")
|
|
57
|
+
.replace(/ /gi, " ")
|
|
58
|
+
.replace(/&/gi, "&")
|
|
59
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
60
|
+
.replace(/\s+/g, " ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A line or string that only says an item is unavailable (the eval grader reads pages the same way). */
|
|
64
|
+
export const UNAVAILABLE_RE = /^\s*(not found( yet)?|missing|not on the (map|page)( yet)?|could ?n[o']t (find|add|place)|unavailable|not available)\b/i;
|
|
65
|
+
/** Data keys that list what the page could not show ("missing": [...] in the map pack's data). */
|
|
66
|
+
const UNAVAILABLE_KEYS = new Set(["expect", "missing", "notfound", "not_found", "unavailable", "unplaced"]);
|
|
67
|
+
|
|
68
|
+
/** The visible text of an HTML page as lines (block ends and <br> break them), without lines that only say an item is
|
|
69
|
+
* unavailable. */
|
|
70
|
+
export function htmlLines(html) {
|
|
71
|
+
const blocks = String(html ?? "")
|
|
72
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
73
|
+
.replace(/<(script|style|template)\b[\s\S]*?<\/\1\s*>/gi, " ")
|
|
74
|
+
.replace(/<br\s*\/?>|<\/(p|div|li|dt|dd|tr|td|th|h[1-6]|section|article|header|footer|aside|nav|figcaption|caption|summary|details|blockquote|pre|ul|ol|dl|table|main)\s*>/gi, "\n");
|
|
75
|
+
return htmlText(blocks.replace(/\n/g, " \u0000 "))
|
|
76
|
+
.split("\u0000")
|
|
77
|
+
.map((line) => line.trim())
|
|
78
|
+
.filter((line) => line && !UNAVAILABLE_RE.test(line));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every string value in a JSON value (keys are not shown, values are), except the "expect" list, lists of what is
|
|
82
|
+
* unavailable, and strings that only say an item is. */
|
|
83
|
+
export function jsonStrings(value, out = []) {
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
if (!UNAVAILABLE_RE.test(value)) out.push(value);
|
|
86
|
+
} else if (typeof value === "number") out.push(String(value));
|
|
87
|
+
else if (Array.isArray(value)) for (const v of value) jsonStrings(v, out);
|
|
88
|
+
else if (value && typeof value === "object") {
|
|
89
|
+
for (const [k, v] of Object.entries(value)) if (!UNAVAILABLE_KEYS.has(k.toLowerCase())) jsonStrings(v, out);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function siteFiles(root) {
|
|
95
|
+
const files = [];
|
|
96
|
+
const walk = (dir, depth) => {
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
104
|
+
if (files.length >= MAX_FILES) return;
|
|
105
|
+
const full = path.join(dir, entry.name);
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".") && depth < 4) walk(full, depth + 1);
|
|
108
|
+
} else if (entry.isFile() && /\.(html?|json)$/i.test(entry.name) && entry.name !== "bound.json" && entry.name !== "package.json") {
|
|
109
|
+
files.push(full);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(root, 0);
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the site can show: [{ file, text }] for its HTML pages and data files, and the "expect" list of data.json. */
|
|
118
|
+
export function readSite(dir) {
|
|
119
|
+
const root = path.resolve(dir);
|
|
120
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`no site folder at ${dir}`);
|
|
121
|
+
const parts = [];
|
|
122
|
+
let declared = [];
|
|
123
|
+
for (const file of siteFiles(root)) {
|
|
124
|
+
let raw;
|
|
125
|
+
try {
|
|
126
|
+
if (fs.statSync(file).size > MAX_FILE_BYTES) continue;
|
|
127
|
+
raw = fs.readFileSync(file, "utf8");
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
132
|
+
if (/\.json$/i.test(file)) {
|
|
133
|
+
let value;
|
|
134
|
+
try {
|
|
135
|
+
value = JSON.parse(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (rel === "data.json" && Array.isArray(value?.expect)) declared = parseExpect(value.expect);
|
|
140
|
+
parts.push({ file: rel, text: jsonStrings(value).join("\n") });
|
|
141
|
+
} else parts.push({ file: rel, text: htmlLines(raw).join("\n") });
|
|
142
|
+
}
|
|
143
|
+
return { root, parts, declared };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Which expected items the site shows, and where; which it leaves out. */
|
|
147
|
+
export function checkSite(dir, expect = []) {
|
|
148
|
+
const site = readSite(dir);
|
|
149
|
+
const items = parseExpect([...site.declared, ...parseExpect(expect)]);
|
|
150
|
+
const shown = [];
|
|
151
|
+
const missing = [];
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
const where = site.parts.filter((p) => names(p.text, item)).map((p) => p.file);
|
|
154
|
+
if (where.length) shown.push({ item, files: where.slice(0, 3) });
|
|
155
|
+
else missing.push(item);
|
|
156
|
+
}
|
|
157
|
+
return { items, shown, missing, files: site.parts.map((p) => p.file) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The CLI's lines and exit code: 0 every item shown, 1 some missing, 2 nothing to check. */
|
|
161
|
+
export function checkReport(dir, expect, { json = false } = {}) {
|
|
162
|
+
const result = checkSite(dir, expect);
|
|
163
|
+
if (!result.items.length) {
|
|
164
|
+
const message = 'nothing to check: pass --expect "<item>,<item>" (each item the request named) or put "expect": [...] in data.json';
|
|
165
|
+
return { exit: 2, lines: [json ? JSON.stringify({ ok: false, error: message }) : message] };
|
|
166
|
+
}
|
|
167
|
+
if (json) return { exit: result.missing.length ? 1 : 0, lines: [JSON.stringify({ ok: !result.missing.length, ...result })] };
|
|
168
|
+
const lines = [];
|
|
169
|
+
if (result.missing.length) {
|
|
170
|
+
lines.push(`MISSING: the request named ${result.missing.length} item(s) the site does not show: ${result.missing.join(", ")}.`);
|
|
171
|
+
lines.push("Add each one to the page (its data file), or say plainly in the reply which are missing and why.");
|
|
172
|
+
}
|
|
173
|
+
lines.push(`${result.shown.length} of ${result.items.length} expected item(s) shown${result.shown.length ? `: ${result.shown.map((s) => s.item).join(", ")}` : ""}.`);
|
|
174
|
+
lines.push(`Read ${result.files.length} file(s): the HTML text and the data files (what a script renders from elsewhere is not seen).`);
|
|
175
|
+
return { exit: result.missing.length ? 1 : 0, lines };
|
|
176
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Controller URL, access token, conversation and space, resolved like the Instafy CLI (`instafy chat`).
|
|
2
|
+
// The token is kept on a non-enumerable property so logging or serialising the config never prints it.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { credentialFreeEnv } from "./detached-env.mjs";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONTROLLER_URL = "http://127.0.0.1:8788";
|
|
9
|
+
|
|
10
|
+
const clean = (value) => {
|
|
11
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
12
|
+
if (!trimmed || ["null", "undefined"].includes(trimmed.toLowerCase())) return null;
|
|
13
|
+
return trimmed;
|
|
14
|
+
};
|
|
15
|
+
const normalizeUrl = (value) => clean(value)?.replace(/\/$/, "") ?? null;
|
|
16
|
+
|
|
17
|
+
function originOf(raw, label) {
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(raw);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`${label} must be a valid URL`);
|
|
23
|
+
}
|
|
24
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
|
|
25
|
+
throw new Error(`${label} must use http(s) and must not contain credentials`);
|
|
26
|
+
}
|
|
27
|
+
return url.origin;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJson(file) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
33
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Nearest .instafy/space.json from cwd upwards (the CLI's project manifest). */
|
|
40
|
+
export function findSpaceManifest(cwd) {
|
|
41
|
+
let current = path.resolve(cwd);
|
|
42
|
+
for (;;) {
|
|
43
|
+
const file = path.join(current, ".instafy", "space.json");
|
|
44
|
+
if (fs.existsSync(file)) {
|
|
45
|
+
const parsed = readJson(file);
|
|
46
|
+
const spaceId = clean(parsed.spaceId) ?? clean(parsed.space_id);
|
|
47
|
+
if (spaceId) return { ...parsed, spaceId, file };
|
|
48
|
+
}
|
|
49
|
+
const parent = path.dirname(current);
|
|
50
|
+
if (parent === current) return null;
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the controller connection.
|
|
57
|
+
* Order (like the CLI, except for the runtime job credential):
|
|
58
|
+
* - URL: --server-url, the runtime job binding (CONTROLLER_BASE_URL), INSTAFY_SERVER_URL, the CLI config, the default.
|
|
59
|
+
* - Token: --access-token, INSTAFY_ACCESS_TOKEN, SUPABASE_ACCESS_TOKEN, then the CLI config's token when its saved
|
|
60
|
+
* origin matches. The runtime job credential (CONTROLLER_ACCESS_TOKEN) is never used: it stops working when the
|
|
61
|
+
* Octo turn that started `serve` ends, so a long-lived binder must not depend on it.
|
|
62
|
+
* - The owner's conversation (parent of the site's thread): --conversation, INSTAFY_CONVERSATION_ID, CONVERSATION_ID.
|
|
63
|
+
* - The agent conversation: --agent-conversation, INSTAFY_BIND_CONVERSATION_ID. Without it the binder creates the
|
|
64
|
+
* site's own thread in the space on the first agent intent.
|
|
65
|
+
* - Space: --space, SPACE_ID, INSTAFY_SPACE_ID, PROJECT_ID, INSTAFY_PROJECT_ID, .instafy/space.json.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveControllerConfig({
|
|
68
|
+
serverUrl = null,
|
|
69
|
+
accessToken = null,
|
|
70
|
+
conversation = null,
|
|
71
|
+
agentConversation = null,
|
|
72
|
+
space = null,
|
|
73
|
+
env = process.env,
|
|
74
|
+
cwd = process.cwd(),
|
|
75
|
+
home = os.homedir(),
|
|
76
|
+
} = {}) {
|
|
77
|
+
const manifest = findSpaceManifest(cwd);
|
|
78
|
+
const profile = clean(env.INSTAFY_PROFILE) ?? clean(manifest?.profile);
|
|
79
|
+
if (profile && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) throw new Error(`invalid Instafy profile "${profile}"`);
|
|
80
|
+
const configFile = profile ? path.join(home, ".instafy", "profiles", `${profile}.json`) : path.join(home, ".instafy", "config.json");
|
|
81
|
+
const stored = readJson(configFile);
|
|
82
|
+
const conversationId = clean(conversation) ?? clean(env.INSTAFY_CONVERSATION_ID) ?? clean(env.CONVERSATION_ID);
|
|
83
|
+
|
|
84
|
+
let runtime = null;
|
|
85
|
+
// The runtime's job binding. Its token is never used (see above), so a binder started with --detach does not even
|
|
86
|
+
// receive it (detachedBinderEnv); the binding is still recognised to say why agent intents are unavailable.
|
|
87
|
+
if (clean(env.RUNTIME_ID) && conversationId && clean(env.CONTROLLER_BASE_URL)) {
|
|
88
|
+
runtime = { controllerUrl: normalizeUrl(env.CONTROLLER_BASE_URL) };
|
|
89
|
+
originOf(runtime.controllerUrl, "CONTROLLER_BASE_URL");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const controllerUrl =
|
|
93
|
+
normalizeUrl(serverUrl) ?? runtime?.controllerUrl ?? normalizeUrl(env.INSTAFY_SERVER_URL) ?? normalizeUrl(stored.controllerUrl) ?? DEFAULT_CONTROLLER_URL;
|
|
94
|
+
const origin = originOf(controllerUrl, "controller URL");
|
|
95
|
+
|
|
96
|
+
let token = null;
|
|
97
|
+
let tokenSource = "none";
|
|
98
|
+
let note = null;
|
|
99
|
+
if (clean(accessToken)) {
|
|
100
|
+
token = clean(accessToken);
|
|
101
|
+
tokenSource = "explicit";
|
|
102
|
+
} else if (clean(env.INSTAFY_ACCESS_TOKEN)) {
|
|
103
|
+
token = clean(env.INSTAFY_ACCESS_TOKEN);
|
|
104
|
+
tokenSource = "env:INSTAFY_ACCESS_TOKEN";
|
|
105
|
+
} else if (clean(env.SUPABASE_ACCESS_TOKEN)) {
|
|
106
|
+
token = clean(env.SUPABASE_ACCESS_TOKEN);
|
|
107
|
+
tokenSource = "env:SUPABASE_ACCESS_TOKEN";
|
|
108
|
+
} else if (clean(stored.accessToken)) {
|
|
109
|
+
const savedOrigin = normalizeUrl(stored.controllerUrl) ? originOf(normalizeUrl(stored.controllerUrl), "saved controller URL") : null;
|
|
110
|
+
if (savedOrigin === origin) {
|
|
111
|
+
token = clean(stored.accessToken);
|
|
112
|
+
tokenSource = "config";
|
|
113
|
+
} else {
|
|
114
|
+
note = "the saved login belongs to a different controller; it is not used";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!token && runtime) {
|
|
119
|
+
tokenSource = "runtime-refused";
|
|
120
|
+
note = "only the runtime job token is available; it stops working when this Octo turn ends, so agent intents need `instafy login` or INSTAFY_ACCESS_TOKEN";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const spaceId =
|
|
124
|
+
clean(space) ?? clean(env.SPACE_ID) ?? clean(env.INSTAFY_SPACE_ID) ?? clean(env.PROJECT_ID) ?? clean(env.INSTAFY_PROJECT_ID) ?? manifest?.spaceId ?? null;
|
|
125
|
+
const agentConversationId = clean(agentConversation) ?? clean(env.INSTAFY_BIND_CONVERSATION_ID);
|
|
126
|
+
|
|
127
|
+
const config = { controllerUrl, tokenSource, hasToken: Boolean(token), conversationId, agentConversationId, spaceId, profile, configFile, note };
|
|
128
|
+
Object.defineProperty(config, "token", { value: token, enumerable: false });
|
|
129
|
+
return config;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Why the agent executor cannot dispatch, or null when it can. */
|
|
133
|
+
export function agentUnavailableReason(config) {
|
|
134
|
+
if (config.tokenSource === "runtime-refused") return "the runtime job token ends with this Octo turn; run `instafy login` or set INSTAFY_ACCESS_TOKEN";
|
|
135
|
+
if (!config.hasToken) return "no Instafy access token (run `instafy login`, or set INSTAFY_ACCESS_TOKEN)";
|
|
136
|
+
if (!config.agentConversationId && !config.spaceId) {
|
|
137
|
+
return "no space for the site's own Octo thread (pass --space, or --agent-conversation <id> to use an existing conversation)";
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when agent turns would go into the owner's own working conversation. */
|
|
143
|
+
export function agentUsesOwnerConversation(config) {
|
|
144
|
+
return Boolean(config.agentConversationId && config.conversationId && config.agentConversationId === config.conversationId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Credential names the binder itself reads as the owner's login (above). */
|
|
148
|
+
const BINDER_LOGIN_ENV = ["INSTAFY_ACCESS_TOKEN", "SUPABASE_ACCESS_TOKEN"];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Why --agent-executor is refused here, or null. It treats a click from this machine's loopback as the owner's own,
|
|
152
|
+
* which is only true on a single-user machine. An Instafy runtime (a job binding: RUNTIME_ID or CONTROLLER_BASE_URL)
|
|
153
|
+
* is shared: other members' Octo turns run there and could click on the owner's login, so it fails closed.
|
|
154
|
+
*/
|
|
155
|
+
export function agentExecutorRefusal(env = process.env) {
|
|
156
|
+
if (!clean(env.RUNTIME_ID) && !clean(env.CONTROLLER_BASE_URL)) return null;
|
|
157
|
+
return "--agent-executor runs Octo turns on your own login for clicks from this machine, so it needs a machine only you use; this is a shared Instafy runtime (RUNTIME_ID or CONTROLLER_BASE_URL is set). Ask Octo works without it: clicks in Studio are sent as the clicker's own message";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The environment of a binder started with `serve --detach`: `credentialFreeEnv` (no runtime job token or other
|
|
162
|
+
* CONTROLLER_* secret, no SUPABASE_* service values, no credential-like names such as space secrets), except names
|
|
163
|
+
* passed with --allow-env, and the owner's login only with `keepLogin` (--agent-executor, the one mode that
|
|
164
|
+
* dispatches Octo turns itself). Everything else (PATH, HOME, the space and conversation ids, plain settings a
|
|
165
|
+
* handler may declare in `env`) is kept.
|
|
166
|
+
*/
|
|
167
|
+
export function detachedBinderEnv(env = process.env, allowEnv = [], { keepLogin = false } = {}) {
|
|
168
|
+
return credentialFreeEnv(env, [...allowEnv, ...(keepLogin ? BINDER_LOGIN_ENV : [])]);
|
|
169
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Minimal controller API client: the calls `instafy chat --wait --json` makes (packages/instafy-cli/src/chat.ts),
|
|
2
|
+
// reimplemented without dependencies, plus creating the site's own thread and cancelling a job.
|
|
3
|
+
// Shapes follow packages/runtime-controller: messages come back newest first with createdAt; a finished run's
|
|
4
|
+
// status is "success" (or failed, canceled, merged, expired); dispatch answers {status, runId?, jobId?, conversationId}.
|
|
5
|
+
|
|
6
|
+
const TERMINAL = ["success", "succeeded", "completed", "complete", "merged", "ready", "failed", "error", "errored", "cancelled", "canceled", "expired"];
|
|
7
|
+
const SUCCESS = ["success", "succeeded", "completed", "complete", "merged", "ready"];
|
|
8
|
+
const NON_REPLY_TYPES = [
|
|
9
|
+
"token_usage", "command_execution", "mcp_tool_call", "plan_update", "learn_router",
|
|
10
|
+
"integration_request", "status", "activity", "thread_update",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const lower = (status) => String(status ?? "").trim().toLowerCase();
|
|
14
|
+
export const isTerminalStatus = (status) => TERMINAL.includes(lower(status));
|
|
15
|
+
export const isSuccessfulStatus = (status) => SUCCESS.includes(lower(status));
|
|
16
|
+
export const isWaitingForApproval = (status) => lower(status) === "awaiting_approval";
|
|
17
|
+
|
|
18
|
+
export function messageRunId(message) {
|
|
19
|
+
const raw = message?.runId ?? message?.run_id ?? null;
|
|
20
|
+
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function messageType(message) {
|
|
24
|
+
const metadata = message?.metadata;
|
|
25
|
+
if (!metadata || typeof metadata !== "object") return null;
|
|
26
|
+
const direct = metadata.messageType ?? metadata.message_type;
|
|
27
|
+
return typeof direct === "string" && direct.trim() ? direct.trim().toLowerCase() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Raw `{...}` or a single ```json fenced object. */
|
|
31
|
+
export function looksLikeJsonObjectReply(content) {
|
|
32
|
+
const trimmed = String(content ?? "").trim();
|
|
33
|
+
if (!trimmed) return false;
|
|
34
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return true;
|
|
35
|
+
const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
36
|
+
if (fence) {
|
|
37
|
+
const inner = fence[1].trim();
|
|
38
|
+
return inner.startsWith("{") && inner.endsWith("}");
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An assistant message that can be the run's reply (status messages only when they hold a JSON object). */
|
|
44
|
+
export function isReplyCandidate(message, runId) {
|
|
45
|
+
if (String(message?.role ?? "").toLowerCase() !== "assistant") return false;
|
|
46
|
+
if (runId && messageRunId(message) !== runId) return false;
|
|
47
|
+
if (typeof message.content !== "string" || !message.content.trim()) return false;
|
|
48
|
+
const type = messageType(message);
|
|
49
|
+
if (type === "status") return looksLikeJsonObjectReply(message.content);
|
|
50
|
+
return !(type && NON_REPLY_TYPES.includes(type));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const createdAt = (message) => Date.parse(message?.createdAt ?? message?.created_at ?? "");
|
|
54
|
+
|
|
55
|
+
/** Messages sorted newest first by createdAt. Messages without a time keep the server's order after timed ones. */
|
|
56
|
+
export function newestFirst(messages) {
|
|
57
|
+
return messages
|
|
58
|
+
.map((message, index) => ({ message, index, at: createdAt(message) }))
|
|
59
|
+
.sort((a, b) => {
|
|
60
|
+
const at = Number.isFinite(a.at), bt = Number.isFinite(b.at);
|
|
61
|
+
if (at && bt && a.at !== b.at) return b.at - a.at;
|
|
62
|
+
if (at !== bt) return at ? -1 : 1;
|
|
63
|
+
return a.index - b.index;
|
|
64
|
+
})
|
|
65
|
+
.map((entry) => entry.message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The run's reply: among its reply candidates, newest first, the first one `accept` takes (by default a bare
|
|
70
|
+
* JSON object), else the newest candidate. Skips ids in `exclude`.
|
|
71
|
+
*/
|
|
72
|
+
export function findLatestReply(messages, runId, { exclude = new Set(), accept = (m) => looksLikeJsonObjectReply(m.content) } = {}) {
|
|
73
|
+
let newest = null;
|
|
74
|
+
for (const message of newestFirst(messages)) {
|
|
75
|
+
if (!message || exclude.has(message.id) || !isReplyCandidate(message, runId)) continue;
|
|
76
|
+
if (accept(message)) return message;
|
|
77
|
+
newest ??= message;
|
|
78
|
+
}
|
|
79
|
+
return newest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ControllerError extends Error {
|
|
83
|
+
constructor(message, { status = 0, code = "upstream", viewerMessage = "Octo is unavailable right now" } = {}) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.code = code;
|
|
87
|
+
/** What viewers see; the detail above stays in the binder log. */
|
|
88
|
+
this.viewerMessage = viewerMessage;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One signal that aborts when either does (AbortSignal.any is Node 20+). */
|
|
93
|
+
function anySignal(signals) {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
for (const signal of signals.filter(Boolean)) {
|
|
96
|
+
if (signal.aborted) {
|
|
97
|
+
controller.abort(signal.reason);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
101
|
+
}
|
|
102
|
+
return controller.signal;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** metadata.purpose of the conversation a binder creates for a site's Octo turns. */
|
|
106
|
+
export const BIND_THREAD_PURPOSE = "bound-site-agent-thread";
|
|
107
|
+
|
|
108
|
+
export class ControllerClient {
|
|
109
|
+
/**
|
|
110
|
+
* @param {{controllerUrl: string, token: string, refreshToken?: () => Promise<string|null>|string|null, fetchImpl?: typeof fetch, timeoutMs?: number}} options
|
|
111
|
+
* refreshToken is asked once after a 401 (the CLI may have refreshed its saved login meanwhile).
|
|
112
|
+
*/
|
|
113
|
+
constructor({ controllerUrl, token, refreshToken = null, fetchImpl = globalThis.fetch, timeoutMs = 60_000 }) {
|
|
114
|
+
this.base = new URL(`${controllerUrl.replace(/\/$/, "")}/`);
|
|
115
|
+
Object.defineProperty(this, "token", { value: token, enumerable: false, writable: true });
|
|
116
|
+
this.refreshToken = refreshToken;
|
|
117
|
+
this.fetchImpl = fetchImpl;
|
|
118
|
+
this.timeoutMs = timeoutMs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async request(method, path, { query = {}, body, signal = null, timeoutMs = this.timeoutMs, retried = false } = {}) {
|
|
122
|
+
const url = new URL(path.replace(/^\//, ""), this.base);
|
|
123
|
+
if (url.origin !== this.base.origin) throw new ControllerError("refusing a cross-origin controller request", { code: "policy" });
|
|
124
|
+
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value));
|
|
125
|
+
const headers = { accept: "application/json" };
|
|
126
|
+
if (this.token) headers.authorization = `Bearer ${this.token}`;
|
|
127
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await this.fetchImpl(url, {
|
|
131
|
+
method,
|
|
132
|
+
headers,
|
|
133
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
134
|
+
redirect: "error",
|
|
135
|
+
signal: anySignal([AbortSignal.timeout(timeoutMs), signal]),
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (signal?.aborted) throw signal.reason;
|
|
139
|
+
throw new ControllerError(`controller unreachable: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
const text = await response.text().catch(() => "");
|
|
142
|
+
if (response.status === 401 && !retried && this.refreshToken) {
|
|
143
|
+
const fresh = await Promise.resolve(this.refreshToken()).catch(() => null);
|
|
144
|
+
if (fresh && fresh !== this.token) {
|
|
145
|
+
this.token = fresh;
|
|
146
|
+
return this.request(method, path, { query, body, signal, timeoutMs, retried: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (response.status === 401 || response.status === 403) {
|
|
150
|
+
throw new ControllerError(`controller rejected the access token (${response.status}); run \`instafy login\``, {
|
|
151
|
+
status: response.status,
|
|
152
|
+
code: "policy",
|
|
153
|
+
viewerMessage: "Octo is not available: the site owner's Instafy login was rejected",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new ControllerError(`controller ${method} ${url.pathname} failed (${response.status}): ${text.slice(0, 200)}`, { status: response.status });
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return text ? JSON.parse(text) : null;
|
|
161
|
+
} catch {
|
|
162
|
+
throw new ControllerError(`controller ${method} ${url.pathname} returned non-JSON`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** POST a prompt to a conversation. Returns {conversationId, runId, jobId, promptId, status}. */
|
|
167
|
+
async dispatch({ prompt, conversationId, intent = "feature", metadata = {}, signal = null }) {
|
|
168
|
+
if (!conversationId) throw new ControllerError("no conversation to dispatch to", { code: "policy" });
|
|
169
|
+
const payload = { promptText: prompt, intent, metadata };
|
|
170
|
+
const response = await this.request("POST", `/conversations/${encodeURIComponent(conversationId)}/messages`, { body: payload, signal });
|
|
171
|
+
const pick = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
|
|
172
|
+
return {
|
|
173
|
+
conversationId: pick(response?.conversationId) || conversationId,
|
|
174
|
+
runId: pick(response?.runId),
|
|
175
|
+
jobId: pick(response?.jobId),
|
|
176
|
+
promptId: pick(response?.promptId),
|
|
177
|
+
status: pick(response?.status),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create the conversation the site's agent turns go to. The controller (runtime-controller conversations.rs,
|
|
183
|
+
* resolve_thread_columns) accepts `threadKind` only together with `parentConversationId`, so:
|
|
184
|
+
* - with a parent: a private child thread `{parentConversationId, threadKind: "bind"}` under the owner's conversation;
|
|
185
|
+
* - without one: a root conversation in the space (no threadKind), marked as the binder's own work thread.
|
|
186
|
+
* When the parent is refused (400/403/404: gone, another space, private, internal), it falls back to a root
|
|
187
|
+
* conversation and reports why through `onFallback`. Returns {conversationId, parentConversationId}.
|
|
188
|
+
*
|
|
189
|
+
* The controller has no client-settable "hidden" or "internal" conversation (conversations.internal_purpose is
|
|
190
|
+
* set by controller code only, and strip_client_internal_metadata drops a client's internalPurpose), so the root
|
|
191
|
+
* conversation is made as hidden as the API allows: `visibility: "private"` keeps it out of every other member's
|
|
192
|
+
* conversation list, and `metadata.source` / `metadata.purpose` mark it as a tool's thread so Studio never binds
|
|
193
|
+
* it to a user's chat tab (frontend conversationAdoption.ts keys on `source`).
|
|
194
|
+
*/
|
|
195
|
+
async createThreadRecord({ spaceId, parentConversationId = null, title = null, signal = null, onFallback = null }) {
|
|
196
|
+
if (!spaceId) throw new ControllerError("no space to create the site's thread in", { code: "policy" });
|
|
197
|
+
const metadata = {
|
|
198
|
+
source: "instafy-bind",
|
|
199
|
+
purpose: BIND_THREAD_PURPOSE,
|
|
200
|
+
...(title ? { title: `Bound site: ${title}`.slice(0, 120) } : {}),
|
|
201
|
+
};
|
|
202
|
+
// Private in both cases: a viewer's click (their search, a rounded position, private_params) is posted here,
|
|
203
|
+
// and only the owner who serves the site should read it, not every member who can see the parent.
|
|
204
|
+
const create = async (parent) => {
|
|
205
|
+
const body = parent
|
|
206
|
+
? { metadata: { ...metadata, visibility: "private" }, parentConversationId: parent, threadKind: "bind" }
|
|
207
|
+
: { metadata: { ...metadata, visibility: "private" } };
|
|
208
|
+
const response = await this.request("POST", `/projects/${encodeURIComponent(spaceId)}/conversations/blank`, { body, signal });
|
|
209
|
+
const id = typeof response?.conversationId === "string" ? response.conversationId.trim() : "";
|
|
210
|
+
if (!id) throw new ControllerError("conversation creation response missing conversationId");
|
|
211
|
+
return { conversationId: id, parentConversationId: parent };
|
|
212
|
+
};
|
|
213
|
+
const parent = typeof parentConversationId === "string" && parentConversationId.trim() ? parentConversationId.trim() : null;
|
|
214
|
+
if (!parent) return create(null);
|
|
215
|
+
try {
|
|
216
|
+
return await create(parent);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (!(error instanceof ControllerError) || ![400, 403, 404].includes(error.status)) throw error;
|
|
219
|
+
onFallback?.(error);
|
|
220
|
+
return create(null);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** {@link createThreadRecord}, returning only the new conversation id. */
|
|
225
|
+
async createThread(options) {
|
|
226
|
+
return (await this.createThreadRecord(options)).conversationId;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Ask the controller to stop a dispatched job (best effort). */
|
|
230
|
+
async cancelJob(jobId, reason = "Cancelled from the bound site") {
|
|
231
|
+
return this.request("POST", `/jobs/${encodeURIComponent(jobId)}/cancel`, { body: { reason }, timeoutMs: 10_000 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async run(conversationId, runId, { signal = null } = {}) {
|
|
235
|
+
const runs = await this.request("GET", `/conversations/${encodeURIComponent(conversationId)}/runs`, { query: { limit: 20 }, signal });
|
|
236
|
+
return Array.isArray(runs) ? runs.find((run) => run?.id === runId) ?? null : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The newest messages of a conversation, newest first. */
|
|
240
|
+
async messages(conversationId, { limit = 50, signal = null } = {}) {
|
|
241
|
+
const page = await this.request("GET", `/conversations/${encodeURIComponent(conversationId)}/messages`, { query: { limit }, signal });
|
|
242
|
+
return newestFirst(Array.isArray(page?.messages) ? page.messages : []);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The environment of a process started to outlive the command (or Octo turn) that started it.
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// Shared by `instafy bind serve --detach` and `instafy preview open --start` (the CLI loads this file from its bundled
|
|
5
|
+
// copy of the binder), so both strip the same credentials.
|
|
6
|
+
|
|
7
|
+
const CREDENTIAL_ENV_RE = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|(^|_)KEY$|_KEY_|API_?KEY)/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `env` without credentials a long-lived process does not need. Such a process outlives the job that started it, and
|
|
11
|
+
* every process in a runtime can read its /proc/<pid>/environ, so it keeps no runtime job token or other CONTROLLER_*
|
|
12
|
+
* value (except CONTROLLER_BASE_URL, which is not secret), no SUPABASE_* value and no credential-like name (space
|
|
13
|
+
* secrets, logins). Names in `keep` are kept anyway. Everything else (PATH, HOME, the space and conversation ids,
|
|
14
|
+
* plain settings) is kept.
|
|
15
|
+
*/
|
|
16
|
+
export function credentialFreeEnv(env = process.env, keep = []) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [name, value] of Object.entries(env)) {
|
|
19
|
+
if (value === undefined) continue;
|
|
20
|
+
const kept =
|
|
21
|
+
keep.includes(name) ||
|
|
22
|
+
name === "CONTROLLER_BASE_URL" ||
|
|
23
|
+
(!/^(CONTROLLER|SUPABASE)_/.test(name) && !CREDENTIAL_ENV_RE.test(name));
|
|
24
|
+
if (kept) out[name] = value;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|