@saluzi/saluzi-edu 0.2.65 → 0.2.66
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/cli.js +44 -44
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +54 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +37 -0
- package/packages/remote-control-server/src/__tests__/low-priority-fixes.test.ts +95 -0
- package/packages/remote-control-server/src/__tests__/routes.test.ts +69 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +17 -9
- package/packages/remote-control-server/src/routes/v1/environments.work.ts +2 -2
- package/packages/remote-control-server/src/routes/v1/sessions.ts +39 -9
- package/packages/remote-control-server/src/routes/web/sessions.ts +25 -22
- package/packages/remote-control-server/src/services/environment.ts +25 -0
package/package.json
CHANGED
|
@@ -181,6 +181,60 @@ describe('Session Attach wait mode', () => {
|
|
|
181
181
|
expect(await res.json()).toEqual({ assigned: false })
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
test('assignment: disconnected env + authorized caller → reactivated, not 404', async () => {
|
|
185
|
+
// A wait-mode worker whose env the disconnect monitor flipped (laptop
|
|
186
|
+
// sleep during the 24h wait, pre-fix claim-wait, etc.) must recover on
|
|
187
|
+
// its next poll instead of dead-waiting on swallowed 404s — the CLI
|
|
188
|
+
// ignores assignment errors until its deadline.
|
|
189
|
+
const env = storeCreateEnvironment({
|
|
190
|
+
secret: 'test-api-key',
|
|
191
|
+
machineName: 'mac1',
|
|
192
|
+
ownerUserId: owner.id,
|
|
193
|
+
sessionMode: 'attach',
|
|
194
|
+
})
|
|
195
|
+
storeUpdateEnvironment(env.id, { status: 'disconnected' })
|
|
196
|
+
|
|
197
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
198
|
+
headers: { ...ADMIN_KEY },
|
|
199
|
+
})
|
|
200
|
+
expect(res.status).toBe(200)
|
|
201
|
+
expect(await res.json()).toEqual({ assigned: false })
|
|
202
|
+
expect(storeGetEnvironment(env.id)?.status).toBe('active')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('assignment: disconnected env + stranger → 403, env NOT reactivated', async () => {
|
|
206
|
+
// Same invariant as the heartbeat test above: a 403 must not resurrect
|
|
207
|
+
// or keep alive an env the caller has no business touching.
|
|
208
|
+
const env = storeCreateEnvironment({
|
|
209
|
+
secret: 'test-api-key',
|
|
210
|
+
machineName: 'mac1',
|
|
211
|
+
ownerUserId: owner.id,
|
|
212
|
+
sessionMode: 'attach',
|
|
213
|
+
})
|
|
214
|
+
storeUpdateEnvironment(env.id, { status: 'disconnected' })
|
|
215
|
+
const stranger = createUser(db)
|
|
216
|
+
|
|
217
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
218
|
+
headers: { Authorization: rctAuth(stranger.id) },
|
|
219
|
+
})
|
|
220
|
+
expect(res.status).toBe(403)
|
|
221
|
+
expect(storeGetEnvironment(env.id)?.status).toBe('disconnected')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('assignment: deregistered env → 404 (terminal)', async () => {
|
|
225
|
+
const env = storeCreateEnvironment({
|
|
226
|
+
secret: 'test-api-key',
|
|
227
|
+
machineName: 'mac1',
|
|
228
|
+
ownerUserId: owner.id,
|
|
229
|
+
sessionMode: 'attach',
|
|
230
|
+
})
|
|
231
|
+
storeUpdateEnvironment(env.id, { status: 'deregistered' })
|
|
232
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
233
|
+
headers: { ...ADMIN_KEY },
|
|
234
|
+
})
|
|
235
|
+
expect(res.status).toBe(404)
|
|
236
|
+
})
|
|
237
|
+
|
|
184
238
|
test('assignment: session bound to the env (web attach) → assigned with session id', async () => {
|
|
185
239
|
const env = storeCreateEnvironment({
|
|
186
240
|
secret: 'test-api-key',
|
|
@@ -313,6 +313,43 @@ describe('POST /v1/sessions/:id/attach', () => {
|
|
|
313
313
|
expect(body.error.type).toBe('environment_unowned')
|
|
314
314
|
})
|
|
315
315
|
|
|
316
|
+
test('bare admin API key attaches to an unowned env (documented worker setup)', async () => {
|
|
317
|
+
// Self-hosted workers connect with SALUZI_BRIDGE_OAUTH_TOKEN=<RCS_API_KEYS>
|
|
318
|
+
// and no SALUZI_BRIDGE_USERNAME — the env they register is unowned until
|
|
319
|
+
// claimed, and the direct-attach path never prints a claim URL. A hard
|
|
320
|
+
// 409 here made `/rc --attach <id>` fail out of the box. Bare admin keys
|
|
321
|
+
// are trusted credentials (ensureAttachAccess already audited their
|
|
322
|
+
// session-manage bypass) — the bind is allowed.
|
|
323
|
+
const owner = createUser(db)
|
|
324
|
+
const env = storeCreateEnvironment({
|
|
325
|
+
secret: 'test-api-key',
|
|
326
|
+
machineName: 'cli-worker',
|
|
327
|
+
})
|
|
328
|
+
const session = createSession({
|
|
329
|
+
environment_id: null,
|
|
330
|
+
title: 'Web session',
|
|
331
|
+
source: 'web',
|
|
332
|
+
})
|
|
333
|
+
storeBindSession(session.id, owner.id, 'user')
|
|
334
|
+
|
|
335
|
+
const res = await app.request(`/v1/sessions/${session.id}/attach`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: { ...JSON_HDRS, ...ADMIN_KEY },
|
|
338
|
+
body: JSON.stringify({ environment_id: env.id }),
|
|
339
|
+
})
|
|
340
|
+
expect(res.status).toBe(200)
|
|
341
|
+
const body = (await res.json()) as {
|
|
342
|
+
status: string
|
|
343
|
+
session_id: string
|
|
344
|
+
work_item_id: string
|
|
345
|
+
rebind: boolean
|
|
346
|
+
}
|
|
347
|
+
expect(body.status).toBe('attached')
|
|
348
|
+
expect(body.session_id).toBe(session.id)
|
|
349
|
+
expect(body.work_item_id).toMatch(/^work_/)
|
|
350
|
+
expect(body.rebind).toBe(true)
|
|
351
|
+
})
|
|
352
|
+
|
|
316
353
|
test('archived session → 409 session_closed', async () => {
|
|
317
354
|
const world = seedWorld(db)
|
|
318
355
|
const { archiveSession } = await import('../services/session')
|
|
@@ -10,8 +10,11 @@ import { Hono } from 'hono'
|
|
|
10
10
|
import {
|
|
11
11
|
storeReset,
|
|
12
12
|
storeCreateSession,
|
|
13
|
+
storeCreateEnvironment,
|
|
13
14
|
storeBindSession,
|
|
14
15
|
storeListSessionsByOwnerUuid,
|
|
16
|
+
storeListSessionsByEnvironment,
|
|
17
|
+
storeGetActiveWorkItemBySession,
|
|
15
18
|
} from '../store'
|
|
16
19
|
import { initDatabase, resetDbSingleton } from '../db/sqlite'
|
|
17
20
|
import { issueToken } from '../auth/token'
|
|
@@ -176,3 +179,95 @@ describe('M2: resolveOwnedWebSessionId removes redundant fallback', () => {
|
|
|
176
179
|
expect(source).not.toMatch(/import\s*\{[^}]*storeIsSessionOwner[^}]*\}/)
|
|
177
180
|
})
|
|
178
181
|
})
|
|
182
|
+
|
|
183
|
+
// =============================================================================
|
|
184
|
+
// worker_at_capacity pre-validation: the repl-worker capacity check must run
|
|
185
|
+
// BEFORE createSession. It used to run after, where the just-created session
|
|
186
|
+
// (already bound to body.environment_id) always tripped it — every web session
|
|
187
|
+
// creation on a repl worker env 409'd AND left a stray bound-but-never-
|
|
188
|
+
// dispatched session behind on each retry.
|
|
189
|
+
// =============================================================================
|
|
190
|
+
describe('worker_at_capacity pre-validation (repl workers)', () => {
|
|
191
|
+
let app: Hono
|
|
192
|
+
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
freshDb()
|
|
195
|
+
storeReset()
|
|
196
|
+
app = createApp()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('idle repl worker env → 200, session created AND work item dispatched', async () => {
|
|
200
|
+
const env = storeCreateEnvironment({
|
|
201
|
+
secret: 'test-api-key',
|
|
202
|
+
machineName: 'cli-worker',
|
|
203
|
+
workerType: 'repl',
|
|
204
|
+
})
|
|
205
|
+
const res = await app.request('/web/sessions', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {
|
|
208
|
+
Authorization: userAuth('webuser'),
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({ title: 'On Worker', environment_id: env.id }),
|
|
212
|
+
})
|
|
213
|
+
expect(res.status).toBe(200)
|
|
214
|
+
const body = (await res.json()) as { id: string; environment_id: string }
|
|
215
|
+
expect(body.environment_id).toBe(env.id)
|
|
216
|
+
// Pre-fix the 409 returned before createWorkItem — the CLI would poll a
|
|
217
|
+
// session that never received work.
|
|
218
|
+
const work = storeGetActiveWorkItemBySession(body.id)
|
|
219
|
+
expect(work).not.toBeNull()
|
|
220
|
+
expect(work?.environmentId).toBe(env.id)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('repl env with an open session → 409 and NO stray session left behind', async () => {
|
|
224
|
+
const env = storeCreateEnvironment({
|
|
225
|
+
secret: 'test-api-key',
|
|
226
|
+
machineName: 'cli-worker',
|
|
227
|
+
workerType: 'repl',
|
|
228
|
+
})
|
|
229
|
+
storeCreateSession({
|
|
230
|
+
environmentId: env.id,
|
|
231
|
+
title: 'Existing',
|
|
232
|
+
source: 'web',
|
|
233
|
+
})
|
|
234
|
+
const before = storeListSessionsByEnvironment(env.id).length
|
|
235
|
+
|
|
236
|
+
const res = await app.request('/web/sessions', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: userAuth('webuser'),
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify({ title: 'Overflow', environment_id: env.id }),
|
|
243
|
+
})
|
|
244
|
+
expect(res.status).toBe(409)
|
|
245
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
246
|
+
expect(body.error.type).toBe('worker_at_capacity')
|
|
247
|
+
// Pre-fix the session record was created (and bound) before the 409.
|
|
248
|
+
expect(storeListSessionsByEnvironment(env.id).length).toBe(before)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('non-repl (spawn) env skips the capacity check', async () => {
|
|
252
|
+
const env = storeCreateEnvironment({
|
|
253
|
+
secret: 'test-api-key',
|
|
254
|
+
machineName: 'spawn-worker',
|
|
255
|
+
workerType: 'claude_code',
|
|
256
|
+
maxSessions: 4,
|
|
257
|
+
})
|
|
258
|
+
storeCreateSession({
|
|
259
|
+
environmentId: env.id,
|
|
260
|
+
title: 'Existing',
|
|
261
|
+
source: 'web',
|
|
262
|
+
})
|
|
263
|
+
const res = await app.request('/web/sessions', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: userAuth('webuser'),
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify({ title: 'Second', environment_id: env.id }),
|
|
270
|
+
})
|
|
271
|
+
expect(res.status).toBe(200)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
storeCreateSession,
|
|
11
11
|
storeCreateEnvironment,
|
|
12
12
|
storeBindSession,
|
|
13
|
+
storeUpdateEnvironment,
|
|
14
|
+
storeGetEnvironment,
|
|
15
|
+
storeGetActiveWorkItemBySession,
|
|
13
16
|
} from '../store'
|
|
14
17
|
import {
|
|
15
18
|
removeEventBus,
|
|
@@ -323,6 +326,72 @@ describe('V1 Session Routes', () => {
|
|
|
323
326
|
expect(body.id).toMatch(/^session_/)
|
|
324
327
|
})
|
|
325
328
|
|
|
329
|
+
test('POST /v1/sessions with environment_id refreshes env liveness (claim-wait retry loop)', async () => {
|
|
330
|
+
// An unowned env's claim-wait loop retries POST /v1/sessions every 5s
|
|
331
|
+
// (409 environment_unowned) for up to 24h — that retry IS the worker's
|
|
332
|
+
// only traffic. Without touching lastPollAt here the disconnect monitor
|
|
333
|
+
// flips the env after RCS_DISCONNECT_TIMEOUT, and when the claim finally
|
|
334
|
+
// lands the work item dispatch rejects the non-active env: a session
|
|
335
|
+
// that exists yet never receives work (the CLI polls 204s forever).
|
|
336
|
+
ensureUser('testuser')
|
|
337
|
+
const envRes = await app.request('/v1/environments/bridge', {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: {
|
|
340
|
+
...AUTH_HEADERS,
|
|
341
|
+
'Content-Type': 'application/json',
|
|
342
|
+
'X-User-Id': 'testuser',
|
|
343
|
+
},
|
|
344
|
+
body: JSON.stringify({ machine_name: 'test' }),
|
|
345
|
+
})
|
|
346
|
+
const { environment_id } = await envRes.json()
|
|
347
|
+
// Age the heartbeat past the disconnect timeout, then simulate one
|
|
348
|
+
// claim-wait retry.
|
|
349
|
+
const stale = new Date(Date.now() - 10 * 60 * 1000)
|
|
350
|
+
storeUpdateEnvironment(environment_id, { lastPollAt: stale })
|
|
351
|
+
const before = storeGetEnvironment(environment_id)?.lastPollAt?.getTime()
|
|
352
|
+
|
|
353
|
+
const sessRes = await app.request('/v1/sessions', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
356
|
+
body: JSON.stringify({ environment_id }),
|
|
357
|
+
})
|
|
358
|
+
expect(sessRes.status).toBe(200)
|
|
359
|
+
const after = storeGetEnvironment(environment_id)?.lastPollAt?.getTime()
|
|
360
|
+
expect(after).not.toBe(before)
|
|
361
|
+
expect(after ?? 0).toBeGreaterThan(stale.getTime())
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('POST /v1/sessions reactivates a monitor-flipped env so the work item is created', async () => {
|
|
365
|
+
// Recovery path for envs already flipped to 'disconnected' (CLI waited
|
|
366
|
+
// out the timeout before these fixes, laptop sleep, etc.): the retry
|
|
367
|
+
// reactivates the env, so the session gets its work item instead of
|
|
368
|
+
// becoming a zombie the CLI polls forever.
|
|
369
|
+
ensureUser('testuser')
|
|
370
|
+
const envRes = await app.request('/v1/environments/bridge', {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: {
|
|
373
|
+
...AUTH_HEADERS,
|
|
374
|
+
'Content-Type': 'application/json',
|
|
375
|
+
'X-User-Id': 'testuser',
|
|
376
|
+
},
|
|
377
|
+
body: JSON.stringify({ machine_name: 'test' }),
|
|
378
|
+
})
|
|
379
|
+
const { environment_id } = await envRes.json()
|
|
380
|
+
storeUpdateEnvironment(environment_id, { status: 'disconnected' })
|
|
381
|
+
|
|
382
|
+
const sessRes = await app.request('/v1/sessions', {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
385
|
+
body: JSON.stringify({ environment_id }),
|
|
386
|
+
})
|
|
387
|
+
expect(sessRes.status).toBe(200)
|
|
388
|
+
const body = await sessRes.json()
|
|
389
|
+
expect(storeGetEnvironment(environment_id)?.status).toBe('active')
|
|
390
|
+
const work = storeGetActiveWorkItemBySession(body.id)
|
|
391
|
+
expect(work).not.toBeNull()
|
|
392
|
+
expect(work?.environmentId).toBe(environment_id)
|
|
393
|
+
})
|
|
394
|
+
|
|
326
395
|
test('POST /v1/sessions with events — publishes initial events', async () => {
|
|
327
396
|
const sessRes = await app.request('/v1/sessions', {
|
|
328
397
|
method: 'POST',
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
registerEnvironment,
|
|
5
5
|
deregisterEnvironment,
|
|
6
6
|
reconnectEnvironment,
|
|
7
|
-
|
|
7
|
+
touchEnvironmentActivity,
|
|
8
8
|
} from '../../services/environment'
|
|
9
9
|
import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
|
|
10
10
|
import {
|
|
@@ -291,7 +291,12 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
|
|
|
291
291
|
app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
|
|
292
292
|
const envId = c.req.param('id')!
|
|
293
293
|
const env = storeGetEnvironment(envId)
|
|
294
|
-
|
|
294
|
+
// 'disconnected' is NOT a 404 here: the ownership gate below authorizes the
|
|
295
|
+
// caller first, then touchEnvironmentActivity reactivates the env — a live
|
|
296
|
+
// wait-mode worker polling a monitor-flipped env recovers instead of
|
|
297
|
+
// dead-waiting on swallowed 404s for its 24h deadline. Deregistered is
|
|
298
|
+
// terminal.
|
|
299
|
+
if (!env || env.status === 'deregistered') {
|
|
295
300
|
return c.json(
|
|
296
301
|
{ error: { type: 'not_found', message: 'Environment not found' } },
|
|
297
302
|
404,
|
|
@@ -299,10 +304,11 @@ app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
|
|
|
299
304
|
}
|
|
300
305
|
|
|
301
306
|
// Ownership gate: assignment discloses the env's bound session AND doubles
|
|
302
|
-
// as its liveness signal (
|
|
303
|
-
// the env owner (the wait-mode worker polls under the same
|
|
304
|
-
// registered with) and admins. Unowned envs (claim pending)
|
|
305
|
-
// nothing is bound yet, so there is nothing to disclose or keep
|
|
307
|
+
// as its liveness signal (touchEnvironmentActivity below) — both are
|
|
308
|
+
// restricted to the env owner (the wait-mode worker polls under the same
|
|
309
|
+
// identity it registered with) and admins. Unowned envs (claim pending)
|
|
310
|
+
// stay open: nothing is bound yet, so there is nothing to disclose or keep
|
|
311
|
+
// alive.
|
|
306
312
|
const caller = resolveAttachCaller(c)
|
|
307
313
|
if (caller.dbUnavailable) {
|
|
308
314
|
return c.json(
|
|
@@ -345,9 +351,11 @@ app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
|
|
|
345
351
|
// The attach-wait worker has no work item in flight, so the work-poll
|
|
346
352
|
// route never refreshes its heartbeat. This poll IS its liveness signal:
|
|
347
353
|
// without touching lastPollAt the disconnect monitor flips the env to
|
|
348
|
-
// disconnected after the idle timeout
|
|
349
|
-
//
|
|
350
|
-
|
|
354
|
+
// disconnected after the idle timeout and the CLI silently spins until its
|
|
355
|
+
// 24h deadline (P0-2). touchEnvironmentActivity also reactivates an env the
|
|
356
|
+
// monitor already flipped (runs only after the gate — a 403 must not keep
|
|
357
|
+
// a dead env looking alive).
|
|
358
|
+
touchEnvironmentActivity(envId)
|
|
351
359
|
const assigned = storeListSessionsByEnvironment(envId)
|
|
352
360
|
.filter(s => !isSessionClosedStatus(s.status))
|
|
353
361
|
.filter(s => s.source !== 'acp')
|
|
@@ -6,14 +6,14 @@ import {
|
|
|
6
6
|
heartbeatWork,
|
|
7
7
|
} from '../../services/work-dispatch'
|
|
8
8
|
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
|
|
9
|
-
import {
|
|
9
|
+
import { touchEnvironmentActivity } from '../../services/environment'
|
|
10
10
|
|
|
11
11
|
const app = new Hono()
|
|
12
12
|
|
|
13
13
|
/** GET /v1/environments/:id/work/poll — Long-poll for work */
|
|
14
14
|
app.get('/:id/work/poll', acceptCliHeaders, apiKeyAuth, async c => {
|
|
15
15
|
const envId = c.req.param('id')!
|
|
16
|
-
|
|
16
|
+
touchEnvironmentActivity(envId)
|
|
17
17
|
const result = await pollWork(envId)
|
|
18
18
|
if (!result) {
|
|
19
19
|
// Return 204 No Content so the client's axios parses it as null
|
|
@@ -13,6 +13,7 @@ import { createWorkItem } from '../../services/work-dispatch'
|
|
|
13
13
|
import { publishSessionEvent } from '../../services/transport'
|
|
14
14
|
import { loadPersistedEvents } from '../../services/event-persistence'
|
|
15
15
|
import { getEventBus } from '../../transport/event-bus'
|
|
16
|
+
import { touchEnvironmentActivity } from '../../services/environment'
|
|
16
17
|
import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
|
|
17
18
|
import {
|
|
18
19
|
bridgeAuth,
|
|
@@ -157,6 +158,19 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
|
|
|
157
158
|
const body = await c.req.json()
|
|
158
159
|
const username = c.get('userId')
|
|
159
160
|
|
|
161
|
+
// Worker liveness: this route is the claim-wait loop's only traffic (an
|
|
162
|
+
// unowned env retries POST /v1/sessions every 5s until the claim URL is
|
|
163
|
+
// opened, up to 24h). Without touching the env here the disconnect monitor
|
|
164
|
+
// flips it to 'disconnected' after RCS_DISCONNECT_TIMEOUT, and when the
|
|
165
|
+
// claim finally lands the session is created but createWorkItem rejects
|
|
166
|
+
// the non-active env — a session that exists yet never receives work, so
|
|
167
|
+
// the CLI polls 204s forever and never connects. Touch BEFORE the unowned
|
|
168
|
+
// 409 so the retry loop itself keeps the env alive; touchEnvironmentActivity
|
|
169
|
+
// also reactivates an env the monitor already flipped.
|
|
170
|
+
if (typeof body.environment_id === 'string' && body.environment_id) {
|
|
171
|
+
touchEnvironmentActivity(body.environment_id)
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
// Resolve env ownership to bind session_owners.
|
|
161
175
|
let envOwnerUserId: string | null = null
|
|
162
176
|
let envTeamId: string | null = null
|
|
@@ -439,18 +453,34 @@ async function performAttach(
|
|
|
439
453
|
// owns (the env it just registered). Admin may attach on behalf.
|
|
440
454
|
const ownership = storeGetEnvironmentOwnership(envId)
|
|
441
455
|
if (!ownership?.ownerUserId) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
456
|
+
// The documented self-hosted worker setup authenticates with a bare
|
|
457
|
+
// RCS_API_KEYS admin key (SALUZI_BRIDGE_OAUTH_TOKEN, no
|
|
458
|
+
// SALUZI_BRIDGE_USERNAME) — its env is unowned until claimed, so a hard
|
|
459
|
+
// 409 here made `/rc --attach <id>` fail out of the box with a claim URL
|
|
460
|
+
// the direct-attach path never prints. Bare admin keys are trusted
|
|
461
|
+
// credentials (ensureAttachAccess already audited their session-manage
|
|
462
|
+
// bypass) — allow the bind. Identity callers still get the claim-flow
|
|
463
|
+
// guidance (the wait-mode CLI prints the URL).
|
|
464
|
+
if (!caller.isApiAdmin || caller.hasIdentity) {
|
|
465
|
+
return c.json(
|
|
466
|
+
{
|
|
467
|
+
error: {
|
|
468
|
+
type: 'environment_unowned',
|
|
469
|
+
message:
|
|
470
|
+
'Environment has no owner yet; claim it via the printed URL before attaching sessions',
|
|
471
|
+
},
|
|
448
472
|
},
|
|
449
|
-
|
|
450
|
-
|
|
473
|
+
409,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
log(
|
|
477
|
+
`[audit] Bare admin API key attached session ${sessionId} to unowned env ${envId}`,
|
|
451
478
|
)
|
|
452
479
|
}
|
|
453
|
-
|
|
480
|
+
// Optional chain: the bare-admin fallthrough above leaves ownership
|
|
481
|
+
// possibly-null/unowned here — but isApiAdmin is true on that path, so the
|
|
482
|
+
// short-circuit keeps the semantics identical.
|
|
483
|
+
if (!caller.isApiAdmin && ownership?.ownerUserId !== caller.uuid) {
|
|
454
484
|
return c.json(
|
|
455
485
|
{
|
|
456
486
|
error: {
|
|
@@ -96,6 +96,31 @@ app.post('/sessions', sessionAuth, async c => {
|
|
|
96
96
|
const session = getSession(sessionId)
|
|
97
97
|
return c.json(toWebSessionResponse(session!), 200)
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
// In-process (replBridge) workers register with workerType='repl' and
|
|
101
|
+
// maxSessions=1 — they can only serve one session at a time. Reject
|
|
102
|
+
// multi-session creation with a helpful error guiding the user to
|
|
103
|
+
// `slz rc` (spawn mode). MUST run before createSession: the new session
|
|
104
|
+
// is created WITH body.environment_id, so checking after would always
|
|
105
|
+
// see the just-created session and 409 every request (leaving a stray
|
|
106
|
+
// bound-but-never-dispatched session behind on each retry).
|
|
107
|
+
if (env?.workerType === 'repl') {
|
|
108
|
+
const existing = storeListSessionsByEnvironment(env.id).filter(
|
|
109
|
+
s => !isSessionClosedStatus(s.status),
|
|
110
|
+
)
|
|
111
|
+
if (existing.length > 0) {
|
|
112
|
+
return c.json(
|
|
113
|
+
{
|
|
114
|
+
error: {
|
|
115
|
+
type: 'worker_at_capacity',
|
|
116
|
+
message:
|
|
117
|
+
'该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
409,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
const teamId = typeof body.team_id === 'string' ? body.team_id : null
|
|
@@ -120,28 +145,6 @@ app.post('/sessions', sessionAuth, async c => {
|
|
|
120
145
|
|
|
121
146
|
// Dispatch work to environment if specified
|
|
122
147
|
if (body.environment_id) {
|
|
123
|
-
// In-process (replBridge) workers register with workerType='repl' and
|
|
124
|
-
// maxSessions=1 — they can only serve one session at a time. Reject
|
|
125
|
-
// multi-session creation with a helpful error guiding the user to
|
|
126
|
-
// `slz rc` (spawn mode).
|
|
127
|
-
const env = storeGetEnvironment(body.environment_id)
|
|
128
|
-
if (env?.workerType === 'repl') {
|
|
129
|
-
const existing = storeListSessionsByEnvironment(env.id).filter(
|
|
130
|
-
s => !isSessionClosedStatus(s.status),
|
|
131
|
-
)
|
|
132
|
-
if (existing.length > 0) {
|
|
133
|
-
return c.json(
|
|
134
|
-
{
|
|
135
|
-
error: {
|
|
136
|
-
type: 'worker_at_capacity',
|
|
137
|
-
message:
|
|
138
|
-
'该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。',
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
409,
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
148
|
try {
|
|
146
149
|
const spawnMode =
|
|
147
150
|
body.spawn_mode === 'worktree' || body.spawn_mode === 'same-dir'
|
|
@@ -277,6 +277,31 @@ export function updatePollTime(envId: string) {
|
|
|
277
277
|
storeUpdateEnvironment(envId, { lastPollAt: new Date() })
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Worker-traffic liveness signal: refresh lastPollAt and, when the disconnect
|
|
282
|
+
* monitor had flipped the env to 'disconnected', reactivate it.
|
|
283
|
+
*
|
|
284
|
+
* The flip is based on a stale heartbeat — a worker actively hitting the env
|
|
285
|
+
* (work poll, assignment poll, session-creation retry during claim wait)
|
|
286
|
+
* proves it is alive. Without reactivation the CLI polls a zombie env
|
|
287
|
+
* forever: the work-poll route never 404s, so the client's env-lost recovery
|
|
288
|
+
* never fires, and any session created on the flipped env gets no work item
|
|
289
|
+
* (createWorkItem rejects non-active envs) — a session that exists but can
|
|
290
|
+
* never connect. 'deregistered' is terminal and stays dead.
|
|
291
|
+
*/
|
|
292
|
+
export function touchEnvironmentActivity(envId: string) {
|
|
293
|
+
const env = storeGetEnvironment(envId)
|
|
294
|
+
if (!env) return
|
|
295
|
+
if (env.status === 'disconnected') {
|
|
296
|
+
storeUpdateEnvironment(envId, { status: 'active', lastPollAt: new Date() })
|
|
297
|
+
log(
|
|
298
|
+
`[RCS] Environment ${envId} reactivated by worker traffic (was disconnected)`,
|
|
299
|
+
)
|
|
300
|
+
} else if (env.status === 'active') {
|
|
301
|
+
storeUpdateEnvironment(envId, { lastPollAt: new Date() })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
export function listActiveEnvironments() {
|
|
281
306
|
return storeListActiveEnvironments()
|
|
282
307
|
}
|