@celestea/studio 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/app.d.ts +111 -0
- package/dist/app.js +219 -0
- package/dist/auth/api-token.d.ts +76 -0
- package/dist/auth/api-token.js +172 -0
- package/dist/auth/htpasswd.d.ts +22 -0
- package/dist/auth/htpasswd.js +61 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.js +14 -0
- package/dist/auth/page.d.ts +19 -0
- package/dist/auth/page.js +73 -0
- package/dist/auth/rate-limit.d.ts +27 -0
- package/dist/auth/rate-limit.js +47 -0
- package/dist/auth/token.d.ts +49 -0
- package/dist/auth/token.js +112 -0
- package/dist/config.d.ts +78 -0
- package/dist/config.js +72 -0
- package/dist/deployment.d.ts +92 -0
- package/dist/deployment.js +154 -0
- package/dist/fake-runtime-adapter.d.ts +37 -0
- package/dist/fake-runtime-adapter.js +276 -0
- package/dist/handlers/auth.d.ts +24 -0
- package/dist/handlers/auth.js +155 -0
- package/dist/handlers/common.d.ts +76 -0
- package/dist/handlers/common.js +127 -0
- package/dist/handlers/config-shape.d.ts +76 -0
- package/dist/handlers/config-shape.js +227 -0
- package/dist/handlers/config.d.ts +26 -0
- package/dist/handlers/config.js +161 -0
- package/dist/handlers/context-shape.d.ts +33 -0
- package/dist/handlers/context-shape.js +59 -0
- package/dist/handlers/dialog.d.ts +22 -0
- package/dist/handlers/dialog.js +292 -0
- package/dist/handlers/exec.d.ts +22 -0
- package/dist/handlers/exec.js +131 -0
- package/dist/handlers/fs-read.d.ts +63 -0
- package/dist/handlers/fs-read.js +159 -0
- package/dist/handlers/fs.d.ts +57 -0
- package/dist/handlers/fs.js +153 -0
- package/dist/handlers/grants-shape.d.ts +45 -0
- package/dist/handlers/grants-shape.js +130 -0
- package/dist/handlers/grants.d.ts +21 -0
- package/dist/handlers/grants.js +267 -0
- package/dist/handlers/health.d.ts +37 -0
- package/dist/handlers/health.js +139 -0
- package/dist/handlers/index.d.ts +40 -0
- package/dist/handlers/index.js +85 -0
- package/dist/handlers/permissions.d.ts +9 -0
- package/dist/handlers/permissions.js +183 -0
- package/dist/handlers/plugins.d.ts +18 -0
- package/dist/handlers/plugins.js +23 -0
- package/dist/handlers/prompts.d.ts +12 -0
- package/dist/handlers/prompts.js +120 -0
- package/dist/handlers/providers.d.ts +12 -0
- package/dist/handlers/providers.js +178 -0
- package/dist/handlers/questions.d.ts +23 -0
- package/dist/handlers/questions.js +115 -0
- package/dist/handlers/session-model.d.ts +38 -0
- package/dist/handlers/session-model.js +105 -0
- package/dist/handlers/session-move.d.ts +22 -0
- package/dist/handlers/session-move.js +188 -0
- package/dist/handlers/session-tools.d.ts +19 -0
- package/dist/handlers/session-tools.js +75 -0
- package/dist/handlers/sessions.d.ts +32 -0
- package/dist/handlers/sessions.js +242 -0
- package/dist/handlers/usage.d.ts +26 -0
- package/dist/handlers/usage.js +77 -0
- package/dist/handlers/worker.d.ts +12 -0
- package/dist/handlers/worker.js +82 -0
- package/dist/handlers/workspaces.d.ts +12 -0
- package/dist/handlers/workspaces.js +88 -0
- package/dist/harness.test-util.d.ts +101 -0
- package/dist/harness.test-util.js +176 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/main.d.ts +13 -0
- package/dist/main.js +35 -0
- package/dist/plugins.d.ts +105 -0
- package/dist/plugins.js +97 -0
- package/dist/question-registry.d.ts +124 -0
- package/dist/question-registry.js +212 -0
- package/dist/question-rows.d.ts +32 -0
- package/dist/question-rows.js +43 -0
- package/dist/replay/compare.d.ts +47 -0
- package/dist/replay/compare.js +89 -0
- package/dist/replay/e2e-replay.d.ts +46 -0
- package/dist/replay/e2e-replay.js +91 -0
- package/dist/replay/expect-compact.d.ts +31 -0
- package/dist/replay/expect-compact.js +92 -0
- package/dist/replay/fixtures.d.ts +52 -0
- package/dist/replay/fixtures.js +53 -0
- package/dist/replay/host.d.ts +32 -0
- package/dist/replay/host.js +62 -0
- package/dist/replay/index.d.ts +16 -0
- package/dist/replay/index.js +16 -0
- package/dist/replay/probes.d.ts +55 -0
- package/dist/replay/probes.js +225 -0
- package/dist/replay/report.d.ts +13 -0
- package/dist/replay/report.js +103 -0
- package/dist/replay/session-e2e.d.ts +44 -0
- package/dist/replay/session-e2e.js +91 -0
- package/dist/routes.d.ts +47 -0
- package/dist/routes.js +59 -0
- package/dist/runtime/attachments-llm.d.ts +13 -0
- package/dist/runtime/attachments-llm.js +37 -0
- package/dist/runtime/boot-recovery.d.ts +32 -0
- package/dist/runtime/boot-recovery.js +71 -0
- package/dist/runtime/context-snapshot.d.ts +59 -0
- package/dist/runtime/context-snapshot.js +113 -0
- package/dist/runtime/engine-grants.d.ts +121 -0
- package/dist/runtime/engine-grants.js +338 -0
- package/dist/runtime/engine-permissions.d.ts +29 -0
- package/dist/runtime/engine-permissions.js +92 -0
- package/dist/runtime/engine-plugins.d.ts +189 -0
- package/dist/runtime/engine-plugins.js +292 -0
- package/dist/runtime/engine-profile.d.ts +28 -0
- package/dist/runtime/engine-profile.js +88 -0
- package/dist/runtime/engine-session.d.ts +9 -0
- package/dist/runtime/engine-session.js +9 -0
- package/dist/runtime/fallback-contract.d.ts +32 -0
- package/dist/runtime/fallback-contract.js +10 -0
- package/dist/runtime/fallback-host.d.ts +134 -0
- package/dist/runtime/fallback-host.js +299 -0
- package/dist/runtime/host-autowake.d.ts +79 -0
- package/dist/runtime/host-autowake.js +86 -0
- package/dist/runtime/image-downgrade.d.ts +49 -0
- package/dist/runtime/image-downgrade.js +90 -0
- package/dist/runtime/inbox-message.d.ts +11 -0
- package/dist/runtime/inbox-message.js +20 -0
- package/dist/runtime/index.d.ts +17 -0
- package/dist/runtime/index.js +17 -0
- package/dist/runtime/ledger-view.d.ts +40 -0
- package/dist/runtime/ledger-view.js +56 -0
- package/dist/runtime/llm-assembly.d.ts +66 -0
- package/dist/runtime/llm-assembly.js +114 -0
- package/dist/runtime/offline-llm.d.ts +71 -0
- package/dist/runtime/offline-llm.js +132 -0
- package/dist/runtime/provider-target.d.ts +10 -0
- package/dist/runtime/provider-target.js +10 -0
- package/dist/runtime/question-host.d.ts +33 -0
- package/dist/runtime/question-host.js +41 -0
- package/dist/runtime/question-view.d.ts +48 -0
- package/dist/runtime/question-view.js +76 -0
- package/dist/runtime/real-runtime-adapter.d.ts +95 -0
- package/dist/runtime/real-runtime-adapter.js +553 -0
- package/dist/runtime/recovery-audit.d.ts +81 -0
- package/dist/runtime/recovery-audit.js +95 -0
- package/dist/runtime/recovery-view.d.ts +36 -0
- package/dist/runtime/recovery-view.js +63 -0
- package/dist/runtime/session-compose.d.ts +235 -0
- package/dist/runtime/session-compose.js +350 -0
- package/dist/runtime/session-grants.d.ts +36 -0
- package/dist/runtime/session-grants.js +69 -0
- package/dist/runtime/session-lifecycle.d.ts +40 -0
- package/dist/runtime/session-lifecycle.js +86 -0
- package/dist/runtime/session-publisher.d.ts +40 -0
- package/dist/runtime/session-publisher.js +48 -0
- package/dist/runtime/session-release.d.ts +53 -0
- package/dist/runtime/session-release.js +80 -0
- package/dist/runtime/test-util.d.ts +78 -0
- package/dist/runtime/test-util.js +162 -0
- package/dist/runtime/watchdog-view.d.ts +33 -0
- package/dist/runtime/watchdog-view.js +45 -0
- package/dist/runtime/worker-bridge.d.ts +76 -0
- package/dist/runtime/worker-bridge.js +156 -0
- package/dist/runtime/worker-live.d.ts +22 -0
- package/dist/runtime/worker-live.js +25 -0
- package/dist/runtime/worker-recovery.d.ts +35 -0
- package/dist/runtime/worker-recovery.js +64 -0
- package/dist/runtime/worker-table.d.ts +58 -0
- package/dist/runtime/worker-table.js +70 -0
- package/dist/runtime-adapter.d.ts +421 -0
- package/dist/runtime-adapter.js +76 -0
- package/dist/serial-queue.d.ts +16 -0
- package/dist/serial-queue.js +22 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +119 -0
- package/dist/settings.d.ts +23 -0
- package/dist/settings.js +31 -0
- package/dist/sse.d.ts +67 -0
- package/dist/sse.js +186 -0
- package/dist/static.d.ts +20 -0
- package/dist/static.js +120 -0
- package/dist/store/builtin-sections.d.ts +52 -0
- package/dist/store/builtin-sections.js +118 -0
- package/dist/store/celestea-home.d.ts +12 -0
- package/dist/store/celestea-home.js +11 -0
- package/dist/store/fs-json.d.ts +51 -0
- package/dist/store/fs-json.js +119 -0
- package/dist/store/grants-audit.d.ts +84 -0
- package/dist/store/grants-audit.js +109 -0
- package/dist/store/grants-service.d.ts +44 -0
- package/dist/store/grants-service.js +41 -0
- package/dist/store/grants-tokens.d.ts +62 -0
- package/dist/store/grants-tokens.js +105 -0
- package/dist/store/grants.d.ts +113 -0
- package/dist/store/grants.js +238 -0
- package/dist/store/index.d.ts +36 -0
- package/dist/store/index.js +36 -0
- package/dist/store/mode.d.ts +35 -0
- package/dist/store/mode.js +41 -0
- package/dist/store/permissions.d.ts +61 -0
- package/dist/store/permissions.js +139 -0
- package/dist/store/prompts-compose.d.ts +56 -0
- package/dist/store/prompts-compose.js +62 -0
- package/dist/store/prompts-template.d.ts +26 -0
- package/dist/store/prompts-template.js +99 -0
- package/dist/store/prompts.d.ts +142 -0
- package/dist/store/prompts.js +273 -0
- package/dist/store/provider-probe.d.ts +81 -0
- package/dist/store/provider-probe.js +154 -0
- package/dist/store/providers.d.ts +97 -0
- package/dist/store/providers.js +219 -0
- package/dist/store/result.d.ts +29 -0
- package/dist/store/result.js +30 -0
- package/dist/store/session-id.d.ts +119 -0
- package/dist/store/session-id.js +220 -0
- package/dist/store/session-meta.d.ts +41 -0
- package/dist/store/session-meta.js +66 -0
- package/dist/store/session-ops.d.ts +109 -0
- package/dist/store/session-ops.js +264 -0
- package/dist/store/session-tools.d.ts +42 -0
- package/dist/store/session-tools.js +69 -0
- package/dist/store/sessions.d.ts +162 -0
- package/dist/store/sessions.js +307 -0
- package/dist/store/validate.d.ts +13 -0
- package/dist/store/validate.js +38 -0
- package/dist/store/workspaces.d.ts +67 -0
- package/dist/store/workspaces.js +228 -0
- package/dist/user-questions.d.ts +58 -0
- package/dist/user-questions.js +157 -0
- package/dist/version.d.ts +14 -0
- package/dist/version.js +52 -0
- package/package.json +38 -0
- package/webdist/assets/index-Bngs7gUm.js +98 -0
- package/webdist/assets/index-Bu6ci_rN.css +1 -0
- package/webdist/assets/katex-CCbS2qAY.js +257 -0
- package/webdist/assets/mhchem-CnUN8HwY.js +1 -0
- package/webdist/build-meta.json +7 -0
- package/webdist/index.html +261 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `workspaces.json` (v2) registry store — `contracts/data-files/workspaces.schema.json`,
|
|
3
|
+
* `src/workspaces.rs:122-140,794-970`.
|
|
4
|
+
*
|
|
5
|
+
* Frozen format facts honored here:
|
|
6
|
+
* - NO version field; the workspace KEY is the registered path's folder
|
|
7
|
+
* basename and is never stored;
|
|
8
|
+
* - v1 `{"name":…}` rows are tolerated and ignored; `active_session` is
|
|
9
|
+
* rewritten to the basename on load (idempotent);
|
|
10
|
+
* - unknown fields are tolerated, a malformed file is a HARD error (the
|
|
11
|
+
* unreadable registry is never overwritten with an empty one);
|
|
12
|
+
* - write = pretty JSON -> `<file>.json.tmp` -> rename (atomic, no fsync).
|
|
13
|
+
*/
|
|
14
|
+
import { renameSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { writeJsonAtomic, isDirectory, isFile, listEntries, readJsonIfExists } from "./fs-json.js";
|
|
17
|
+
import { badRequest, conflict, errText, fail, notFound, ok, serverError } from "./result.js";
|
|
18
|
+
import { sessionRoots, workspaceBasename } from "./session-id.js";
|
|
19
|
+
export const SESSION_FILE = "cli-main.jsonl";
|
|
20
|
+
function parseEntry(row) {
|
|
21
|
+
if (typeof row !== "object" || row === null)
|
|
22
|
+
return null;
|
|
23
|
+
const path = row["path"];
|
|
24
|
+
return typeof path === "string" && path !== "" ? { path } : null;
|
|
25
|
+
}
|
|
26
|
+
function parseRegistry(raw) {
|
|
27
|
+
const rec = (typeof raw === "object" && raw !== null ? raw : {});
|
|
28
|
+
const rows = Array.isArray(rec["workspaces"]) ? rec["workspaces"] : [];
|
|
29
|
+
const workspaces = [];
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const entry = parseEntry(row);
|
|
32
|
+
if (entry !== null)
|
|
33
|
+
workspaces.push(entry);
|
|
34
|
+
}
|
|
35
|
+
const active = rec["active_session"];
|
|
36
|
+
return { workspaces, active_session: typeof active === "string" && active !== "" ? active : null };
|
|
37
|
+
}
|
|
38
|
+
/** Duplicate folder names make the workspace key ambiguous: hard error. */
|
|
39
|
+
function assertUniqueBasenames(workspaces) {
|
|
40
|
+
const seen = new Map();
|
|
41
|
+
for (const w of workspaces) {
|
|
42
|
+
const base = workspaceBasename(w.path) ?? w.path;
|
|
43
|
+
const other = seen.get(base);
|
|
44
|
+
if (other !== undefined && other !== w.path) {
|
|
45
|
+
throw new Error(`two workspaces resolve to the same folder name '${base}' (workspace keys must be unique basenames; rename one folder)`);
|
|
46
|
+
}
|
|
47
|
+
seen.set(base, w.path);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function loadRegistry(file) {
|
|
51
|
+
const out = readJsonIfExists(file);
|
|
52
|
+
if (!out.exists)
|
|
53
|
+
return { workspaces: [], active_session: null };
|
|
54
|
+
if (out.error !== undefined)
|
|
55
|
+
throw new Error(`workspaces.json '${file}' is malformed: ${out.error}`);
|
|
56
|
+
const data = parseRegistry(out.value);
|
|
57
|
+
assertUniqueBasenames(data.workspaces);
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* W815-9: a registered path is stored normalized (absolute, no trailing slash).
|
|
62
|
+
* `register` used to keep whatever the client sent, so `/tmp/foo/` made the
|
|
63
|
+
* rename target `/tmp/foo/bar` — a child of the source folder (EINVAL).
|
|
64
|
+
*/
|
|
65
|
+
function normalizeWorkspacePath(path) {
|
|
66
|
+
return resolve(path);
|
|
67
|
+
}
|
|
68
|
+
export class WorkspacesStore {
|
|
69
|
+
file;
|
|
70
|
+
data;
|
|
71
|
+
constructor(file) {
|
|
72
|
+
this.file = file;
|
|
73
|
+
this.data = loadRegistry(file);
|
|
74
|
+
}
|
|
75
|
+
/** Snapshot of the registry (callers must not mutate it). */
|
|
76
|
+
registry() {
|
|
77
|
+
return { workspaces: this.data.workspaces.map((w) => ({ ...w })), active_session: this.data.active_session };
|
|
78
|
+
}
|
|
79
|
+
activeSession() {
|
|
80
|
+
return this.data.active_session;
|
|
81
|
+
}
|
|
82
|
+
workspacePath(name) {
|
|
83
|
+
return this.data.workspaces.find((w) => workspaceBasename(w.path) === name)?.path;
|
|
84
|
+
}
|
|
85
|
+
persist() {
|
|
86
|
+
try {
|
|
87
|
+
writeJsonAtomic(this.file, { workspaces: this.data.workspaces, active_session: this.data.active_session });
|
|
88
|
+
return ok(undefined);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return serverError(errText(e));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Persist `active_session`; the caller owns the 500 wording. */
|
|
95
|
+
setActiveSession(id) {
|
|
96
|
+
this.data.active_session = id;
|
|
97
|
+
return this.persist();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Count LIVE session dirs of a workspace across EVERY layer (W880): the
|
|
101
|
+
* canonical `<CELESTEA_HOME>/.../sessions`, the slice-A
|
|
102
|
+
* `<ws>/.celestea/sessions` and the oldest workspace root. A name present in
|
|
103
|
+
* more than one layer is counted ONCE (the canonical copy wins), matching
|
|
104
|
+
* `SessionsStore.list()`; missing a layer is how the GUI's workspace session
|
|
105
|
+
* count silently drops to 0.
|
|
106
|
+
*/
|
|
107
|
+
countSessions(path) {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
for (const root of sessionRoots(path)) {
|
|
110
|
+
for (const e of listEntries(root)) {
|
|
111
|
+
if (!e.isDir || e.name.startsWith(".") || seen.has(e.name))
|
|
112
|
+
continue;
|
|
113
|
+
if (isFile(`${root}/${e.name}/${SESSION_FILE}`))
|
|
114
|
+
seen.add(e.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return seen.size;
|
|
118
|
+
}
|
|
119
|
+
view() {
|
|
120
|
+
return {
|
|
121
|
+
workspaces: this.data.workspaces.map((w) => {
|
|
122
|
+
const name = workspaceBasename(w.path) ?? w.path;
|
|
123
|
+
return { name, path: w.path, sessions: this.countSessions(w.path) };
|
|
124
|
+
}),
|
|
125
|
+
active_session: this.data.active_session,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** POST /api/workspaces — register only; the folder is never touched. */
|
|
129
|
+
register(rawPath) {
|
|
130
|
+
const asked = rawPath.trim();
|
|
131
|
+
if (asked === "")
|
|
132
|
+
return badRequest("path must not be empty");
|
|
133
|
+
if (!asked.startsWith("/"))
|
|
134
|
+
return badRequest(`path '${asked}' must be absolute`);
|
|
135
|
+
// W815-9: canonicalize before storing (see `normalizeWorkspacePath`).
|
|
136
|
+
const path = normalizeWorkspacePath(asked);
|
|
137
|
+
if (!isDirectory(path))
|
|
138
|
+
return badRequest(`path '${path}' is not an existing directory`);
|
|
139
|
+
const base = workspaceBasename(path);
|
|
140
|
+
if (base === null)
|
|
141
|
+
return badRequest(`path '${path}' has no folder name`);
|
|
142
|
+
const existing = this.data.workspaces.find((w) => w.path === path);
|
|
143
|
+
if (existing !== undefined)
|
|
144
|
+
return conflict(`path '${path}' is already registered as workspace '${base}'`);
|
|
145
|
+
const clash = this.data.workspaces.find((w) => workspaceBasename(w.path) === base);
|
|
146
|
+
if (clash !== undefined) {
|
|
147
|
+
return conflict(`workspace '${base}' already exists (folder '${path}' and '${clash.path}' share the same folder name; rename one folder first)`);
|
|
148
|
+
}
|
|
149
|
+
this.data.workspaces.push({ path });
|
|
150
|
+
const saved = this.persist();
|
|
151
|
+
if (!saved.ok)
|
|
152
|
+
return saved;
|
|
153
|
+
return ok(base);
|
|
154
|
+
}
|
|
155
|
+
/** POST /api/workspaces/{name}/delete — deregister only. */
|
|
156
|
+
deregister(name) {
|
|
157
|
+
const idx = this.data.workspaces.findIndex((w) => workspaceBasename(w.path) === name);
|
|
158
|
+
if (idx < 0)
|
|
159
|
+
return notFound(`unknown workspace '${name}'`);
|
|
160
|
+
const [removed] = this.data.workspaces.splice(idx, 1);
|
|
161
|
+
if (this.data.active_session !== null && this.data.active_session.split("/")[0] === name) {
|
|
162
|
+
this.data.active_session = null;
|
|
163
|
+
}
|
|
164
|
+
void removed;
|
|
165
|
+
return this.persist();
|
|
166
|
+
}
|
|
167
|
+
batchDelete(names) {
|
|
168
|
+
let deleted = 0;
|
|
169
|
+
const failed = [];
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
const res = this.deregister(name);
|
|
172
|
+
if (res.ok)
|
|
173
|
+
deleted += 1;
|
|
174
|
+
else
|
|
175
|
+
failed.push({ name, error: res.error });
|
|
176
|
+
}
|
|
177
|
+
return { deleted, failed };
|
|
178
|
+
}
|
|
179
|
+
/** POST /api/workspaces/{name}/rename — really renames the FOLDER. */
|
|
180
|
+
renameWorkspace(name, newName) {
|
|
181
|
+
const idx = this.data.workspaces.findIndex((w) => workspaceBasename(w.path) === name);
|
|
182
|
+
if (idx < 0)
|
|
183
|
+
return notFound(`unknown workspace '${name}'`);
|
|
184
|
+
const row = this.data.workspaces[idx];
|
|
185
|
+
if (row === undefined)
|
|
186
|
+
return notFound(`unknown workspace '${name}'`);
|
|
187
|
+
if (this.data.workspaces.some((w) => workspaceBasename(w.path) === newName)) {
|
|
188
|
+
return conflict(`workspace '${newName}' already exists`);
|
|
189
|
+
}
|
|
190
|
+
// W815-9: normalize a legacy row's path before deriving the sibling target.
|
|
191
|
+
const from = normalizeWorkspacePath(row.path);
|
|
192
|
+
row.path = from;
|
|
193
|
+
const parent = from.slice(0, from.lastIndexOf("/")) || "/";
|
|
194
|
+
const target = `${parent === "/" ? "" : parent}/${newName}`;
|
|
195
|
+
if (target !== row.path && isDirectory(target)) {
|
|
196
|
+
return conflict(`target '${target}' already exists; rename the folder first`);
|
|
197
|
+
}
|
|
198
|
+
const previousActive = this.data.active_session;
|
|
199
|
+
try {
|
|
200
|
+
renameSync(from, target);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
return fail(500, `move failed: ${errText(e)}`);
|
|
204
|
+
}
|
|
205
|
+
row.path = target;
|
|
206
|
+
if (this.data.active_session !== null) {
|
|
207
|
+
const [ws, sess] = this.data.active_session.split("/");
|
|
208
|
+
if (ws === name && sess !== undefined)
|
|
209
|
+
this.data.active_session = `${newName}/${sess}`;
|
|
210
|
+
}
|
|
211
|
+
const saved = this.persist();
|
|
212
|
+
if (!saved.ok) {
|
|
213
|
+
// W815-10: the registry is the source of truth and it did NOT accept the
|
|
214
|
+
// move — undo the folder rename and the in-memory row so disk and memory
|
|
215
|
+
// cannot diverge (the retired backend's rollback, restored).
|
|
216
|
+
row.path = from;
|
|
217
|
+
this.data.active_session = previousActive;
|
|
218
|
+
try {
|
|
219
|
+
renameSync(target, from);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return fail(500, `${saved.error}; rollback failed: ${errText(e)}`);
|
|
223
|
+
}
|
|
224
|
+
return saved;
|
|
225
|
+
}
|
|
226
|
+
return saved;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's user-question service (W783 §4.1, §5, §6).
|
|
3
|
+
*
|
|
4
|
+
* One instance per composed session generation, all sharing the process-wide
|
|
5
|
+
* [QuestionRegistry]: the service validates the request, parks a
|
|
6
|
+
* [PendingQuestion], publishes the `question` SSE frame and then awaits the
|
|
7
|
+
* ASYNC answerer waterfall. The parked promise is what the tool call is waiting
|
|
8
|
+
* on, so an answer resolves it directly (§4.2) instead of being injected as a
|
|
9
|
+
* message — the injection path is exactly the deadlock §2.2 documents.
|
|
10
|
+
*
|
|
11
|
+
* The three increments over the official implementation:
|
|
12
|
+
* - **maximum wait** (§6): an absolute `expires_at` judged at read time plus a
|
|
13
|
+
* `setTimeout` that actively settles the parked promise. Neither half alone
|
|
14
|
+
* is enough: the deadline survives a restart and answers "how long is left",
|
|
15
|
+
* while the timer is what stops a tool from hanging forever.
|
|
16
|
+
* - **timeout decides nothing** (§6.3): `{answers: [], timed_out: true}`. No
|
|
17
|
+
* default option is selected and the turn is not ended — the tool
|
|
18
|
+
* description tells the model to carry on with its own judgement.
|
|
19
|
+
* - **sub-agent guard** (§5.3): a worker-driven turn throws `DELEGATED_CALLER`
|
|
20
|
+
* instead of parking forever with no human on the other end.
|
|
21
|
+
*/
|
|
22
|
+
import { type AskUserQuestionAnswerItem, type AskUserQuestionOutcome, type AskUserQuestionRequest, type EventBus } from "@celestea/core";
|
|
23
|
+
import { PendingQuestion, type QuestionRegistry } from "./question-registry.js";
|
|
24
|
+
/** What the service needs from the host (all injected — no Context lookups). */
|
|
25
|
+
export interface UserQuestionServiceOptions {
|
|
26
|
+
registry: QuestionRegistry;
|
|
27
|
+
/** The session's event bus (`runWaterfallAsync` dispatches to answerers). */
|
|
28
|
+
bus: EventBus;
|
|
29
|
+
/** The session asking; `null` = the detached generation. */
|
|
30
|
+
sessionId: string | null;
|
|
31
|
+
/** Is this session's runtime instance still live? (§5.3 `CALLER_NOT_LIVE`) */
|
|
32
|
+
isLive?: () => boolean;
|
|
33
|
+
/** Publish one request to the UI (the `question` SSE frame, §9 item 9). */
|
|
34
|
+
publish?: (question: PendingQuestion) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Record the request in the session log (§7 `user_question`). Absent = no
|
|
37
|
+
* persistence (the request then simply cannot be replayed after a restart).
|
|
38
|
+
*/
|
|
39
|
+
record?: (question: PendingQuestion) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Record how it ENDED (§7 `user_answer`). Called for BOTH paths — a real
|
|
42
|
+
* answer and a timeout — right here, because this is the single place that
|
|
43
|
+
* observes every settlement, whatever woke the question up.
|
|
44
|
+
*/
|
|
45
|
+
recordAnswer?: (requestId: string, answers: AskUserQuestionAnswerItem[], timedOut: boolean) => void;
|
|
46
|
+
/** Clock override (tests pin the deadline arithmetic). */
|
|
47
|
+
now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
/** The service the `ask_user_question` tool consumes. */
|
|
50
|
+
export interface HostUserQuestionService {
|
|
51
|
+
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionOutcome>;
|
|
52
|
+
/** The unanswered questions of THIS session (§7 recovery). */
|
|
53
|
+
pending(): PendingQuestion[];
|
|
54
|
+
/** One pending question by id, or undefined. */
|
|
55
|
+
find(requestId: string): PendingQuestion | undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Build the service of ONE composed session generation. */
|
|
58
|
+
export declare function createUserQuestionService(opts: UserQuestionServiceOptions): HostUserQuestionService;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's user-question service (W783 §4.1, §5, §6).
|
|
3
|
+
*
|
|
4
|
+
* One instance per composed session generation, all sharing the process-wide
|
|
5
|
+
* [QuestionRegistry]: the service validates the request, parks a
|
|
6
|
+
* [PendingQuestion], publishes the `question` SSE frame and then awaits the
|
|
7
|
+
* ASYNC answerer waterfall. The parked promise is what the tool call is waiting
|
|
8
|
+
* on, so an answer resolves it directly (§4.2) instead of being injected as a
|
|
9
|
+
* message — the injection path is exactly the deadlock §2.2 documents.
|
|
10
|
+
*
|
|
11
|
+
* The three increments over the official implementation:
|
|
12
|
+
* - **maximum wait** (§6): an absolute `expires_at` judged at read time plus a
|
|
13
|
+
* `setTimeout` that actively settles the parked promise. Neither half alone
|
|
14
|
+
* is enough: the deadline survives a restart and answers "how long is left",
|
|
15
|
+
* while the timer is what stops a tool from hanging forever.
|
|
16
|
+
* - **timeout decides nothing** (§6.3): `{answers: [], timed_out: true}`. No
|
|
17
|
+
* default option is selected and the turn is not ended — the tool
|
|
18
|
+
* description tells the model to carry on with its own judgement.
|
|
19
|
+
* - **sub-agent guard** (§5.3): a worker-driven turn throws `DELEGATED_CALLER`
|
|
20
|
+
* instead of parking forever with no human on the other end.
|
|
21
|
+
*/
|
|
22
|
+
import { askTimeoutMs, currentAskUserCaller, USER_QUESTION_REQUEST_EVENT, UserQuestionError, } from "@celestea/core";
|
|
23
|
+
import { PendingQuestion } from "./question-registry.js";
|
|
24
|
+
/** The rejection an empty question list produces (§5.3 `EMPTY_QUESTIONS`). */
|
|
25
|
+
function emptyQuestions() {
|
|
26
|
+
return new UserQuestionError("ask_user_question requires at least one question", "EMPTY_QUESTIONS");
|
|
27
|
+
}
|
|
28
|
+
/** The rejection a non-root (owned) caller produces (§5.3 `DELEGATED_CALLER`). */
|
|
29
|
+
function delegatedCaller(owner) {
|
|
30
|
+
const who = owner === null ? "another live agent" : `agent '${owner}'`;
|
|
31
|
+
return new UserQuestionError(`human interaction is unavailable while the calling agent is owned by ${who}; `
|
|
32
|
+
+ "include the unresolved question or decision in the child agent's final result", "DELEGATED_CALLER");
|
|
33
|
+
}
|
|
34
|
+
/** The rejection a dead asking generation produces (§5.3 `CALLER_NOT_LIVE`). */
|
|
35
|
+
function callerNotLive() {
|
|
36
|
+
return new UserQuestionError("human interaction requires the exact live calling generation", "CALLER_NOT_LIVE");
|
|
37
|
+
}
|
|
38
|
+
/** The rejection an unanswered waterfall produces (§5.3 `NO_PROVIDER`). */
|
|
39
|
+
function noAnswerer() {
|
|
40
|
+
return new UserQuestionError("no user-questions answerer accepted the request", "NO_PROVIDER");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `intent` asserts two things the types cannot (§3.3): the named `approve` label
|
|
44
|
+
* is one of THIS question's options, and a plan-review carries the plan it
|
|
45
|
+
* reviews. Either gap would show the user a choice the asker never offered — or
|
|
46
|
+
* an approval of something invisible — so it is caught here, at the asker.
|
|
47
|
+
*/
|
|
48
|
+
function validateIntent(question) {
|
|
49
|
+
const intent = question.intent;
|
|
50
|
+
if (intent === undefined)
|
|
51
|
+
return;
|
|
52
|
+
if (!(question.options ?? []).some((option) => option.label === intent.approve)) {
|
|
53
|
+
throw new UserQuestionError(`question ${question.id} declares intent ${intent.kind} whose approve label ${JSON.stringify(intent.approve)} names none of its options`, "BAD_INTENT");
|
|
54
|
+
}
|
|
55
|
+
if (question.detail === undefined) {
|
|
56
|
+
throw new UserQuestionError(`question ${question.id} declares intent ${intent.kind} without the detail it reviews`, "BAD_INTENT");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Run every request-shape check that must fail BEFORE a question is parked. */
|
|
60
|
+
function validateRequest(request) {
|
|
61
|
+
if (request.questions.length === 0)
|
|
62
|
+
throw emptyQuestions();
|
|
63
|
+
for (const question of request.questions)
|
|
64
|
+
validateIntent(question);
|
|
65
|
+
}
|
|
66
|
+
/** The service body: validate, park, dispatch, settle (§5.1/§5.2/§6). */
|
|
67
|
+
class HostUserQuestions {
|
|
68
|
+
opts;
|
|
69
|
+
now;
|
|
70
|
+
constructor(opts) {
|
|
71
|
+
this.opts = opts;
|
|
72
|
+
this.now = opts.now ?? Date.now;
|
|
73
|
+
}
|
|
74
|
+
pending() {
|
|
75
|
+
return this.opts.registry.ofSession(this.opts.sessionId);
|
|
76
|
+
}
|
|
77
|
+
find(requestId) {
|
|
78
|
+
return this.opts.registry.get(requestId);
|
|
79
|
+
}
|
|
80
|
+
async ask(request) {
|
|
81
|
+
if (request.signal?.aborted === true)
|
|
82
|
+
throw new UserQuestionError("ask_user_question was aborted before the user answered", "ASK_ABORTED");
|
|
83
|
+
validateRequest(request);
|
|
84
|
+
this.assertCaller();
|
|
85
|
+
const timeoutMs = askTimeoutMs(request.timeoutMs);
|
|
86
|
+
const question = this.park(request, timeoutMs);
|
|
87
|
+
try {
|
|
88
|
+
const outcome = await this.dispatch(question);
|
|
89
|
+
// §7: the log records how it ended BEFORE the tool resumes, so a replay
|
|
90
|
+
// can tell a real answer from the §6.3 expiry without any timer.
|
|
91
|
+
this.opts.recordAnswer?.(question.requestId, outcome.answers, outcome.timed_out);
|
|
92
|
+
return outcome;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
// Every exit path unregisters: a settled question is never listed again.
|
|
96
|
+
this.opts.registry.remove(question.requestId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** §5.3: only an unowned root turn has a human to answer it. */
|
|
100
|
+
assertCaller() {
|
|
101
|
+
const caller = currentAskUserCaller();
|
|
102
|
+
if (caller.kind === "delegated")
|
|
103
|
+
throw delegatedCaller(caller.owner);
|
|
104
|
+
if (this.opts.isLive !== undefined && !this.opts.isLive())
|
|
105
|
+
throw callerNotLive();
|
|
106
|
+
}
|
|
107
|
+
/** Create the pending entry, arm both timeout tracks and announce it. */
|
|
108
|
+
park(request, timeoutMs) {
|
|
109
|
+
const question = new PendingQuestion({
|
|
110
|
+
requestId: this.opts.registry.nextRequestId(),
|
|
111
|
+
sessionId: this.opts.sessionId,
|
|
112
|
+
questions: request.questions,
|
|
113
|
+
expiresAt: this.now() + timeoutMs,
|
|
114
|
+
timeoutMs,
|
|
115
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
116
|
+
});
|
|
117
|
+
this.opts.registry.add(question);
|
|
118
|
+
// The ACTIVE half of the double track (§6.1) — without it a tool whose
|
|
119
|
+
// answer never arrives would hang for the life of the process.
|
|
120
|
+
question.armTimer(this.now);
|
|
121
|
+
this.opts.publish?.(question);
|
|
122
|
+
this.opts.record?.(question);
|
|
123
|
+
return question;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Hand the parked question to the answerer waterfall.
|
|
127
|
+
*
|
|
128
|
+
* The BOTTOM of the chain awaits the pending question, because in this host
|
|
129
|
+
* the human is out of process: the browser answers over
|
|
130
|
+
* `POST /api/questions/{id}/answer` and that resolves this very promise. A
|
|
131
|
+
* composed layer (a plan-review presenter, a test harness) may still CLAIM the
|
|
132
|
+
* request by returning an outcome of its own without ever calling `next()`.
|
|
133
|
+
*
|
|
134
|
+
* Either way the value that comes back is the human's answer, which is what
|
|
135
|
+
* lets a question be answered while the turn slot is occupied (§4.2).
|
|
136
|
+
*/
|
|
137
|
+
async dispatch(question) {
|
|
138
|
+
const event = {
|
|
139
|
+
questions: [...question.questions],
|
|
140
|
+
timeoutMs: Math.max(0, question.expiresAt - this.now()),
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
return await this.opts.bus.runWaterfallAsync(USER_QUESTION_REQUEST_EVENT, event, () => question.result);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// A layer that delegated the SERVICE's own question left nobody to answer
|
|
147
|
+
// it: report the seam as unclaimed rather than leaking the sentinel.
|
|
148
|
+
if (question.isDelegation(error))
|
|
149
|
+
throw noAnswerer();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Build the service of ONE composed session generation. */
|
|
155
|
+
export function createUserQuestionService(opts) {
|
|
156
|
+
return new HostUserQuestions(opts);
|
|
157
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W887 + H: the studio's version string, resolvable BOTH in the source checkout
|
|
3
|
+
* and from a globally installed package.
|
|
4
|
+
*
|
|
5
|
+
* The single source of truth is `scripts/version.mjs` (git tag). That file only
|
|
6
|
+
* exists in the checkout, so it is imported DYNAMICALLY: a source run gets the
|
|
7
|
+
* git-derived version, an installed package (no checkout, no git) falls back to
|
|
8
|
+
* its own `package.json` version. A missing toolchain degrades instead of
|
|
9
|
+
* failing module load, and the answer is cached after the first resolution.
|
|
10
|
+
*/
|
|
11
|
+
/** The git-derived version in a checkout, else the package version. */
|
|
12
|
+
export declare function resolveStudioVersion(): Promise<string>;
|
|
13
|
+
/** Test seam: forget the memoized answer. */
|
|
14
|
+
export declare function resetStudioVersionCache(): void;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W887 + H: the studio's version string, resolvable BOTH in the source checkout
|
|
3
|
+
* and from a globally installed package.
|
|
4
|
+
*
|
|
5
|
+
* The single source of truth is `scripts/version.mjs` (git tag). That file only
|
|
6
|
+
* exists in the checkout, so it is imported DYNAMICALLY: a source run gets the
|
|
7
|
+
* git-derived version, an installed package (no checkout, no git) falls back to
|
|
8
|
+
* its own `package.json` version. A missing toolchain degrades instead of
|
|
9
|
+
* failing module load, and the answer is cached after the first resolution.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
let cached = null;
|
|
15
|
+
/** The installed package's own version — the release truth. */
|
|
16
|
+
function ownPackageVersion() {
|
|
17
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
for (let i = 0; i < 6; i++) {
|
|
19
|
+
try {
|
|
20
|
+
const doc = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
21
|
+
if (typeof doc.version === "string" && doc.version !== "")
|
|
22
|
+
return doc.version;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// No package.json here; keep walking toward the root.
|
|
26
|
+
}
|
|
27
|
+
const parent = dirname(dir);
|
|
28
|
+
if (parent === dir)
|
|
29
|
+
break;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
return "dev";
|
|
33
|
+
}
|
|
34
|
+
/** The git-derived version in a checkout, else the package version. */
|
|
35
|
+
export async function resolveStudioVersion() {
|
|
36
|
+
if (cached !== null)
|
|
37
|
+
return cached;
|
|
38
|
+
try {
|
|
39
|
+
// W887: the checkout-only toolchain seam, imported dynamically so an
|
|
40
|
+
// installed package (no scripts/) degrades to its package.json version.
|
|
41
|
+
const mod = (await import("../../../scripts/version.mjs"));
|
|
42
|
+
cached = mod.computeVersion().version;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
cached = ownPackageVersion();
|
|
46
|
+
}
|
|
47
|
+
return cached;
|
|
48
|
+
}
|
|
49
|
+
/** Test seam: forget the memoized answer. */
|
|
50
|
+
export function resetStudioVersionCache() {
|
|
51
|
+
cached = null;
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@celestea/studio",
|
|
3
|
+
"version": "2.7.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@hono/node-server": "^2.1.1",
|
|
15
|
+
"hono": "^4.13.8",
|
|
16
|
+
"@celestea/agent-loop": "2.7.1",
|
|
17
|
+
"@celestea/core": "2.7.1",
|
|
18
|
+
"@celestea/runtime": "2.7.1",
|
|
19
|
+
"@celestea/session": "2.7.1",
|
|
20
|
+
"@celestea/llm": "2.7.1",
|
|
21
|
+
"@celestea/tools": "2.7.1",
|
|
22
|
+
"@celestea/workers": "2.7.1"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"webdist"
|
|
27
|
+
],
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
35
|
+
"start": "tsx src/main.ts",
|
|
36
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/build-webdist.mjs"
|
|
37
|
+
}
|
|
38
|
+
}
|