conductor-remote 1.15.1 → 1.15.2
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/dist-node/src/server.js +46 -21
- package/package.json +1 -1
package/dist-node/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import http from 'node:http';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import zlib from 'node:zlib';
|
|
5
6
|
import { startAutoUpdate, updateStatus } from "./autoupdate.js";
|
|
6
7
|
import { loadConfig } from "./config.js";
|
|
7
8
|
import { ConductorDb } from "./db.js";
|
|
@@ -22,10 +23,34 @@ const MIME = {
|
|
|
22
23
|
'.svg': 'image/svg+xml',
|
|
23
24
|
'.png': 'image/png'
|
|
24
25
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Successful GETs are conditional + compressed to keep the phone's polling cheap.
|
|
28
|
+
* `no-cache` (not `no-store`) means the browser must revalidate on every tick —
|
|
29
|
+
* the relay still runs the handler and auth each time, so data is never stale;
|
|
30
|
+
* a matching ETag just elides the redundant body (304), and changed bodies over
|
|
31
|
+
* ~1 KB go out gzipped. Errors and non-GETs stay unconditional `no-store`.
|
|
32
|
+
*/
|
|
33
|
+
function json(req, res, status, body) {
|
|
34
|
+
const payload = Buffer.from(JSON.stringify(body));
|
|
35
|
+
if (status !== 200 || req.method !== 'GET') {
|
|
36
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
37
|
+
return void res.end(payload);
|
|
38
|
+
}
|
|
39
|
+
// Weak: the same entity may be delivered gzipped or plain.
|
|
40
|
+
const etag = `W/"${crypto.createHash('sha1').update(payload).digest('base64url')}"`;
|
|
41
|
+
const headers = {
|
|
42
|
+
'content-type': 'application/json; charset=utf-8',
|
|
43
|
+
'cache-control': 'no-cache',
|
|
44
|
+
etag,
|
|
45
|
+
vary: 'accept-encoding'
|
|
46
|
+
};
|
|
47
|
+
if (req.headers['if-none-match'] === etag)
|
|
48
|
+
return void res.writeHead(304, headers).end();
|
|
49
|
+
if (payload.length > 1024 && /\bgzip\b/.test(String(req.headers['accept-encoding'] ?? ''))) {
|
|
50
|
+
headers['content-encoding'] = 'gzip';
|
|
51
|
+
return void res.writeHead(200, headers).end(zlib.gzipSync(payload));
|
|
52
|
+
}
|
|
53
|
+
res.writeHead(200, headers).end(payload);
|
|
29
54
|
}
|
|
30
55
|
/** Constant-time string compare — the token is the sole internet-facing gate when exposed via Funnel. */
|
|
31
56
|
function tokenEq(candidate) {
|
|
@@ -85,14 +110,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
85
110
|
return serveStatic(req, res, pathname);
|
|
86
111
|
// Everything under /api requires the shared secret.
|
|
87
112
|
if (!authed(req))
|
|
88
|
-
return json(res, 401, { error: 'unauthorized' });
|
|
113
|
+
return json(req, res, 401, { error: 'unauthorized' });
|
|
89
114
|
try {
|
|
90
115
|
// GET /api/state — workspace list with active-session status
|
|
91
116
|
if (req.method === 'GET' && pathname === '/api/state') {
|
|
92
117
|
const update = updateStatus();
|
|
93
118
|
const workspaces = reads.listWorkspaces();
|
|
94
119
|
attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
|
|
95
|
-
return json(res, 200, {
|
|
120
|
+
return json(req, res, 200, {
|
|
96
121
|
workspaces,
|
|
97
122
|
actuator: await describeActuator(actuator),
|
|
98
123
|
version: update.current,
|
|
@@ -104,10 +129,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
104
129
|
if (req.method === 'GET' && m) {
|
|
105
130
|
const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]));
|
|
106
131
|
if (!icon)
|
|
107
|
-
return json(res, 404, { error: 'no icon' });
|
|
132
|
+
return json(req, res, 404, { error: 'no icon' });
|
|
108
133
|
return void fs.readFile(icon.path, (err, data) => {
|
|
109
134
|
if (err)
|
|
110
|
-
return void json(res, 404, { error: 'no icon' });
|
|
135
|
+
return void json(req, res, 404, { error: 'no icon' });
|
|
111
136
|
// Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
|
|
112
137
|
res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
|
|
113
138
|
res.end(data);
|
|
@@ -116,42 +141,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
116
141
|
// GET /api/workspaces/:id/sessions
|
|
117
142
|
m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
|
|
118
143
|
if (req.method === 'GET' && m) {
|
|
119
|
-
return json(res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
|
|
144
|
+
return json(req, res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
|
|
120
145
|
}
|
|
121
146
|
// POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
|
|
122
147
|
if (req.method === 'POST' && m) {
|
|
123
148
|
const workspaceId = decodeURIComponent(m[1]);
|
|
124
149
|
const ws = reads.getWorkspace(workspaceId);
|
|
125
150
|
if (!ws)
|
|
126
|
-
return json(res, 404, { error: 'workspace not found' });
|
|
151
|
+
return json(req, res, 404, { error: 'workspace not found' });
|
|
127
152
|
const before = new Set(reads.listSessions(workspaceId).map(s => s.id));
|
|
128
153
|
const result = await newChat(ws);
|
|
129
154
|
if (!result.ok)
|
|
130
|
-
return json(res, 502, result);
|
|
155
|
+
return json(req, res, 502, result);
|
|
131
156
|
// The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
|
|
132
157
|
let sessionId = null;
|
|
133
158
|
for (let i = 0; i < 12 && !sessionId; i++) {
|
|
134
159
|
await new Promise(r => setTimeout(r, 500));
|
|
135
160
|
sessionId = reads.listSessions(workspaceId).find(s => !before.has(s.id))?.id ?? null;
|
|
136
161
|
}
|
|
137
|
-
return json(res, 200, { ok: true, sessionId });
|
|
162
|
+
return json(req, res, 200, { ok: true, sessionId });
|
|
138
163
|
}
|
|
139
164
|
// GET /api/workspaces/:id/diff
|
|
140
165
|
m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/);
|
|
141
166
|
if (req.method === 'GET' && m) {
|
|
142
167
|
const ws = reads.getWorkspace(decodeURIComponent(m[1]));
|
|
143
168
|
if (!ws)
|
|
144
|
-
return json(res, 404, { error: 'workspace not found' });
|
|
169
|
+
return json(req, res, 404, { error: 'workspace not found' });
|
|
145
170
|
if (!ws.worktree)
|
|
146
|
-
return json(res, 409, { error: 'worktree path unresolved' });
|
|
171
|
+
return json(req, res, 409, { error: 'worktree path unresolved' });
|
|
147
172
|
const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
|
|
148
|
-
return json(res, 200, diff);
|
|
173
|
+
return json(req, res, 200, diff);
|
|
149
174
|
}
|
|
150
175
|
// GET /api/sessions/:id/messages?after=<rowid>
|
|
151
176
|
m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
|
|
152
177
|
if (req.method === 'GET' && m) {
|
|
153
178
|
const after = Number(url.searchParams.get('after') ?? 0);
|
|
154
|
-
return json(res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
|
|
179
|
+
return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
|
|
155
180
|
}
|
|
156
181
|
// POST /api/sessions/:id/prompt { text }
|
|
157
182
|
m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
|
|
@@ -160,19 +185,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
160
185
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
161
186
|
const text = (body.text ?? '').trim();
|
|
162
187
|
if (!text)
|
|
163
|
-
return json(res, 400, { error: 'empty prompt' });
|
|
188
|
+
return json(req, res, 400, { error: 'empty prompt' });
|
|
164
189
|
const ws = body.workspaceId
|
|
165
190
|
? reads.getWorkspace(body.workspaceId)
|
|
166
191
|
: (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
|
|
167
192
|
if (!ws)
|
|
168
|
-
return json(res, 404, { error: 'workspace for session not found' });
|
|
193
|
+
return json(req, res, 404, { error: 'workspace for session not found' });
|
|
169
194
|
const result = await actuator.send({ workspace: ws, sessionId }, text);
|
|
170
|
-
return json(res, result.ok ? 200 : 502, result);
|
|
195
|
+
return json(req, res, result.ok ? 200 : 502, result);
|
|
171
196
|
}
|
|
172
|
-
return json(res, 404, { error: 'no route', pathname });
|
|
197
|
+
return json(req, res, 404, { error: 'no route', pathname });
|
|
173
198
|
}
|
|
174
199
|
catch (err) {
|
|
175
|
-
return json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
200
|
+
return json(req, res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
176
201
|
}
|
|
177
202
|
});
|
|
178
203
|
server.listen(cfg.port, cfg.host, () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.15.
|
|
3
|
+
"version": "1.15.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|