@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.2.65",
3
+ "version": "0.2.66",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- updatePollTime,
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
- if (!env || env.status !== 'active') {
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 (updatePollTime below) — both are restricted to
303
- // the env owner (the wait-mode worker polls under the same identity it
304
- // registered with) and admins. Unowned envs (claim pending) stay open:
305
- // nothing is bound yet, so there is nothing to disclose or keep alive.
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, assignment starts 404ing, and the
349
- // CLI silently spins until its 24h deadline (P0-2).
350
- updatePollTime(envId)
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 { updatePollTime } from '../../services/environment'
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
- updatePollTime(envId)
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
- return c.json(
443
- {
444
- error: {
445
- type: 'environment_unowned',
446
- message:
447
- 'Environment has no owner yet; claim it via the printed URL before attaching sessions',
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
- 409,
473
+ 409,
474
+ )
475
+ }
476
+ log(
477
+ `[audit] Bare admin API key attached session ${sessionId} to unowned env ${envId}`,
451
478
  )
452
479
  }
453
- if (!caller.isApiAdmin && ownership.ownerUserId !== caller.uuid) {
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
  }