specrails-desktop 2.53.0 → 2.54.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/package.json
CHANGED
|
@@ -4,6 +4,7 @@ exports.MISSION_ACCESS_REASON = exports.MOBILE_PROTOCOL_VERSION = void 0;
|
|
|
4
4
|
exports.mobileCapabilities = mobileCapabilities;
|
|
5
5
|
exports.missionSnapshot = missionSnapshot;
|
|
6
6
|
exports.createMobileMissionsRouter = createMobileMissionsRouter;
|
|
7
|
+
const feature_flags_1 = require("../feature-flags");
|
|
7
8
|
const node_string_decoder_1 = require("node:string_decoder");
|
|
8
9
|
const express_1 = require("express");
|
|
9
10
|
const agent_store_1 = require("../agent-store");
|
|
@@ -17,7 +18,7 @@ exports.MOBILE_PROTOCOL_VERSION = 2;
|
|
|
17
18
|
exports.MISSION_ACCESS_REASON = 'Mission agents can access more than their pinned project. Missions require an all-projects device grant; your permitted Specs and Rails remain available.';
|
|
18
19
|
function mobileCapabilities(db, deviceId) {
|
|
19
20
|
const missions = process.env.SPECRAILS_AGENT_CHAT !== 'false' && (0, mobile_devices_1.getAllowedProjects)(db, deviceId) === null;
|
|
20
|
-
return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true },
|
|
21
|
+
return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true, missionRailCards: missions && (0, feature_flags_1.isMissionRailCardsEnabled)() },
|
|
21
22
|
...(!missions ? { missionsUnavailableReason: process.env.SPECRAILS_AGENT_CHAT === 'false' ? 'Missions are disabled on Desktop.' : exports.MISSION_ACCESS_REASON } : {}) };
|
|
22
23
|
}
|
|
23
24
|
function conversationView(value) {
|
|
@@ -56,7 +57,7 @@ function missionSnapshot(value) {
|
|
|
56
57
|
try {
|
|
57
58
|
const parsed = record(JSON.parse(raw));
|
|
58
59
|
if (parsed.kind === 'pr_decision')
|
|
59
|
-
card = { ...pick(parsed, ['kind', 'railIndex', 'ticketIds', 'decision', 'implementationOutcome', 'deliveryOutcome', 'prNumber']), repositoryDeliveries: (Array.isArray(parsed.repositoryDeliveries) ? parsed.repositoryDeliveries : []).slice(0, 30).map(value => pick(value, ['repositoryId', 'repositoryName', 'decision'])) };
|
|
60
|
+
card = { ...pick(parsed, ['kind', 'railIndex', 'ticketIds', 'decision', 'implementationOutcome', 'deliveryOutcome', 'prNumber', 'railName', 'phase', 'statusDetail', 'baseBranch']), runIds: (Array.isArray(parsed.runIds) ? parsed.runIds : []).filter(id => typeof id === 'string' && ID.test(id)).slice(0, 30), runtime: pick(parsed.runtime, ['status', 'currentStep', 'pendingApproval']), repositoryDeliveries: (Array.isArray(parsed.repositoryDeliveries) ? parsed.repositoryDeliveries : []).slice(0, 30).map(value => pick(value, ['repositoryId', 'repositoryName', 'decision'])) };
|
|
60
61
|
}
|
|
61
62
|
catch { /* Non-card system rows do not expose internal provider payloads. */ }
|
|
62
63
|
}
|
|
@@ -65,7 +66,7 @@ function missionSnapshot(value) {
|
|
|
65
66
|
break;
|
|
66
67
|
}
|
|
67
68
|
const content = record(message).role === 'system' ? '' : take(raw, 40_000);
|
|
68
|
-
bounded.unshift({ ...pick(message, ['id', 'role', 'created_at', 'delivery_status', 'delivery_receipt']), content, ...(card ? { card } : {}) });
|
|
69
|
+
bounded.unshift({ ...pick(message, ['id', 'role', 'created_at', 'delivery_status', 'delivery_receipt']), content, intents: (Array.isArray(record(message).intents) ? record(message).intents : []).slice(0, 30).map(value => pick(value, ['kind', 'proposalIndex', 'status', 'railIndex', 'runIds'])), ...(card ? { card } : {}) });
|
|
69
70
|
}
|
|
70
71
|
return { conversation: conversationView(data.conversation), messages: bounded, truncated,
|
|
71
72
|
pendingMessages, pendingTruncated: pending.length > 50,
|
|
@@ -90,7 +91,12 @@ function createMobileMissionsRouter({ db, upstream }) {
|
|
|
90
91
|
res.status(400).json({ error: 'A valid provider is required' });
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
const model = segment(req.query.model);
|
|
95
|
+
if (model.length > 200) {
|
|
96
|
+
res.status(400).json({ error: 'Invalid model' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
void send(res, 'GET', `/api/agent/models?provider=${encodeURIComponent(provider)}${model ? `&model=${encodeURIComponent(model)}` : ''}`);
|
|
94
100
|
});
|
|
95
101
|
router.get('/projects/:pid/repositories', (req, res) => {
|
|
96
102
|
const pid = segment(req.params.pid);
|
|
@@ -175,6 +181,22 @@ function createMobileMissionsRouter({ db, upstream }) {
|
|
|
175
181
|
}
|
|
176
182
|
void send(res, 'POST', `${id.base}/send`, { text: b.text, queueId: b.queueId, deliveryMode: b.deliveryMode ?? 'queue' }, data => pick(data, ['accepted', 'queued', 'duplicate', 'removed', 'deliveryMode']));
|
|
177
183
|
});
|
|
184
|
+
router.patch('/projects/:pid/missions/:cid/messages/:mid/intent', (req, res) => {
|
|
185
|
+
const id = identity(req, res);
|
|
186
|
+
if (!id)
|
|
187
|
+
return;
|
|
188
|
+
const mid = segment(req.params.mid), b = record(req.body);
|
|
189
|
+
if (!ID.test(mid) || b.kind !== 'rail-launch' || !Number.isSafeInteger(b.proposalIndex) || Number(b.proposalIndex) < 0 || Number(b.proposalIndex) > 100) {
|
|
190
|
+
res.status(400).json({ error: 'Invalid proposal' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const message = db.prepare('SELECT conversation_id FROM agent_messages WHERE id = ?').get(mid);
|
|
194
|
+
if (!message || message.conversation_id !== segment(req.params.cid)) {
|
|
195
|
+
res.status(404).json({ error: 'Message not found in this mission' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
void send(res, 'PATCH', `${id.base}/messages/${encodeURIComponent(mid)}/intent`, pick(b, ['kind', 'proposalIndex', 'status', 'railIndex', 'runIds', 'prDeliveryId']), data => ({ message: pick(record(data).message, ['id', 'intents']) }));
|
|
199
|
+
});
|
|
178
200
|
router.post('/projects/:pid/missions/:cid/abort', (req, res) => { const id = identity(req, res); if (id)
|
|
179
201
|
void send(res, 'POST', `${id.base}/abort`, {}); });
|
|
180
202
|
for (const action of ['edit', 'steer', 'remove']) {
|
|
@@ -187,6 +187,23 @@ function createMobileRouter(deps) {
|
|
|
187
187
|
return;
|
|
188
188
|
void forward(res, 'DELETE', `/api/projects/${encodeURIComponent(pid)}/tickets/${encodeURIComponent(tid)}`, '');
|
|
189
189
|
});
|
|
190
|
+
v1.post('/projects/:pid/rails', (req, res) => {
|
|
191
|
+
const pid = seg(req.params.pid);
|
|
192
|
+
if (!validate(res, [pid, PID_RE]))
|
|
193
|
+
return;
|
|
194
|
+
const name = req.body?.name;
|
|
195
|
+
if (name != null && (typeof name !== 'string' || name.length > 60)) {
|
|
196
|
+
res.status(400).json({ error: 'Invalid rail name' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
void forward(res, 'POST', `/api/projects/${encodeURIComponent(pid)}/rails`, '', { name: name ?? null });
|
|
200
|
+
});
|
|
201
|
+
v1.get('/projects/:pid/profiles', (req, res) => {
|
|
202
|
+
const pid = seg(req.params.pid), provider = seg(req.query.provider);
|
|
203
|
+
if (!validate(res, [pid, PID_RE], [provider, PID_RE]))
|
|
204
|
+
return;
|
|
205
|
+
void forward(res, 'GET', `/api/projects/${encodeURIComponent(pid)}/profiles`, `?provider=${encodeURIComponent(provider)}`);
|
|
206
|
+
});
|
|
190
207
|
v1.put('/projects/:pid/rails/:i/tickets', (req, res) => {
|
|
191
208
|
const pid = seg(req.params.pid), i = seg(req.params.i);
|
|
192
209
|
if (!validate(res, [pid, PID_RE], [i, NUM_RE]))
|
|
@@ -219,6 +236,33 @@ function createMobileRouter(deps) {
|
|
|
219
236
|
// provider. This gateway only narrows to the cross-provider enum.
|
|
220
237
|
narrowed.reasoning_effort = b.reasoning_effort;
|
|
221
238
|
}
|
|
239
|
+
if (b.targetPrNumber !== undefined) {
|
|
240
|
+
if (!Number.isSafeInteger(b.targetPrNumber) || Number(b.targetPrNumber) < 1) {
|
|
241
|
+
res.status(400).json({ error: 'Invalid PR number' });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
narrowed.targetPrNumber = b.targetPrNumber;
|
|
245
|
+
}
|
|
246
|
+
if (b.baseBranch !== undefined) {
|
|
247
|
+
if (typeof b.baseBranch !== 'string' || !b.baseBranch.trim() || b.baseBranch.length > 200) {
|
|
248
|
+
res.status(400).json({ error: 'Invalid base branch' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
narrowed.baseBranch = b.baseBranch;
|
|
252
|
+
}
|
|
253
|
+
if (b.originConversationId !== undefined) {
|
|
254
|
+
if (typeof b.originConversationId !== 'string' || !PID_RE.test(b.originConversationId)) {
|
|
255
|
+
res.status(400).json({ error: 'Invalid mission' });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const conversation = deps.db.prepare('SELECT pinned_project_id FROM agent_conversations WHERE id = ?').get(b.originConversationId);
|
|
259
|
+
if (!conversation || conversation.pinned_project_id !== pid || (0, mobile_devices_1.getAllowedProjects)(deps.db, req.mobileDevice.id) !== null) {
|
|
260
|
+
res.status(403).json({ error: 'Mission is not accessible' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
narrowed.originConversationId = b.originConversationId;
|
|
264
|
+
narrowed.originSurface = 'agent-chat';
|
|
265
|
+
}
|
|
222
266
|
if (typeof b.interactive === 'boolean')
|
|
223
267
|
narrowed.interactive = b.interactive;
|
|
224
268
|
if (b.repositoryIds !== undefined) {
|
|
@@ -423,6 +467,7 @@ function createMobileRouter(deps) {
|
|
|
423
467
|
exports.MOBILE_ALLOWLIST = [
|
|
424
468
|
{ method: 'GET', path: '/v1/capabilities' },
|
|
425
469
|
{ method: 'GET', path: '/v1/mission-models' },
|
|
470
|
+
{ method: 'PATCH', path: '/v1/projects/:pid/missions/:cid/messages/:mid/intent' },
|
|
426
471
|
{ method: 'GET', path: '/v1/projects/:pid/repositories' },
|
|
427
472
|
{ method: 'GET', path: '/v1/projects/:pid/missions' },
|
|
428
473
|
{ method: 'POST', path: '/v1/projects/:pid/missions' },
|
|
@@ -445,6 +490,9 @@ exports.MOBILE_ALLOWLIST = [
|
|
|
445
490
|
{ method: 'GET', path: '/v1/projects/:pid/loop-runs/:id' },
|
|
446
491
|
{ method: 'GET', path: '/v1/projects/:pid/queue' },
|
|
447
492
|
{ method: 'GET', path: '/v1/projects/:pid/rails' },
|
|
493
|
+
{ method: 'POST', path: '/v1/projects/:pid/rails' },
|
|
494
|
+
{ method: 'GET', path: '/v1/projects/:pid/profiles' },
|
|
495
|
+
{ method: 'PUT', path: '/v1/projects/:pid/rails/:i/name' },
|
|
448
496
|
{ method: 'GET', path: '/v1/projects/:pid/activity' },
|
|
449
497
|
{ method: 'GET', path: '/v1/projects/:pid/stats' },
|
|
450
498
|
{ method: 'GET', path: '/v1/projects/:pid/state' },
|
|
@@ -22,6 +22,7 @@ class MobileSignalReconnect {
|
|
|
22
22
|
_deps;
|
|
23
23
|
_timeoutMs;
|
|
24
24
|
_timer = null;
|
|
25
|
+
_attempts = new Map();
|
|
25
26
|
_busy = false;
|
|
26
27
|
_epoch = 0;
|
|
27
28
|
_requests = new Set();
|
|
@@ -34,9 +35,11 @@ class MobileSignalReconnect {
|
|
|
34
35
|
return;
|
|
35
36
|
this._timer = setInterval(() => void this.poll(), intervalMs);
|
|
36
37
|
this._timer.unref?.();
|
|
38
|
+
void this.poll();
|
|
37
39
|
}
|
|
38
40
|
stop() {
|
|
39
41
|
this._epoch++;
|
|
42
|
+
this._attempts.clear();
|
|
40
43
|
for (const controller of this._requests)
|
|
41
44
|
controller.abort();
|
|
42
45
|
this._requests.clear();
|
|
@@ -61,6 +64,14 @@ class MobileSignalReconnect {
|
|
|
61
64
|
if (epoch !== this._epoch)
|
|
62
65
|
return;
|
|
63
66
|
if (req !== null) {
|
|
67
|
+
let requestId = null;
|
|
68
|
+
try {
|
|
69
|
+
const value = JSON.parse(req);
|
|
70
|
+
if (typeof value?.requestId === 'string' && /^[a-zA-Z0-9_-]{8,80}$/.test(value.requestId))
|
|
71
|
+
requestId = value.requestId;
|
|
72
|
+
}
|
|
73
|
+
catch { /* legacy request */ }
|
|
74
|
+
this._attempts.set(room, requestId);
|
|
64
75
|
const offer = await this._deps.makeOffer(room);
|
|
65
76
|
if (epoch !== this._epoch)
|
|
66
77
|
return;
|
|
@@ -68,7 +79,7 @@ class MobileSignalReconnect {
|
|
|
68
79
|
// BUG-AUTH-01: never publish `offer.secret` to the public mailbox.
|
|
69
80
|
// The reconnecting companion authenticates with its existing device
|
|
70
81
|
// token; the secret would only help an eavesdropper.
|
|
71
|
-
await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, hub: offer.hubInstanceId, name: offer.hubName }));
|
|
82
|
+
await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, hub: offer.hubInstanceId, name: offer.hubName, ...(requestId ? { requestId } : {}) }));
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
// A phone's answer waiting? → complete the connection.
|
|
@@ -80,8 +91,11 @@ class MobileSignalReconnect {
|
|
|
80
91
|
if (ans) {
|
|
81
92
|
try {
|
|
82
93
|
const parsed = JSON.parse(ans);
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
const expected = this._attempts.get(room);
|
|
95
|
+
if (expected && parsed.requestId !== expected)
|
|
96
|
+
continue;
|
|
97
|
+
if (typeof parsed.sdp === 'string' && await this._deps.acceptAnswer(room, parsed.sdp))
|
|
98
|
+
this._attempts.delete(room);
|
|
85
99
|
}
|
|
86
100
|
catch {
|
|
87
101
|
/* malformed answer — ignore */
|