@saluzi/saluzi-edu 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -158,7 +158,7 @@ describe('Session Attach wait mode', () => {
158
158
  expect(res.status).toBe(403)
159
159
  const body = (await res.json()) as { error: { type: string } }
160
160
  expect(body.error.type).toBe('forbidden')
161
- // updatePollTime runs only AFTER the gate — a 403 must not keep a dead
161
+ // touchEnvironmentActivity runs only AFTER the gate — a 403 must not keep a dead
162
162
  // env looking alive.
163
163
  expect(storeGetEnvironment(env.id)?.lastPollAt?.getTime()).toBe(
164
164
  stale.getTime(),
@@ -399,6 +399,91 @@ describe('V1 Session Routes', () => {
399
399
  body: JSON.stringify({ events: [{ type: 'init', data: 'starting' }] }),
400
400
  })
401
401
  expect(sessRes.status).toBe(200)
402
+ const { id } = await sessRes.json()
403
+ // Legacy raw shape — published verbatim as before (type from the event,
404
+ // direction outbound).
405
+ const events = getEventBus(id).getEventsSince(0)
406
+ expect(events).toHaveLength(1)
407
+ expect(events[0]?.type).toBe('init')
408
+ expect(events[0]?.direction).toBe('outbound')
409
+ })
410
+
411
+ test('POST /v1/sessions with CCR-wrapped system/init seed — publishes a typed system event the Web UI scan can find', async () => {
412
+ // `slz rcs one`'s headless worker seeds its pre-created session with a
413
+ // system/init so the Web UI has the worker's command/skill tables and
414
+ // capabilities before the first message spawns a --print child. The
415
+ // create route must unwrap { type: 'event', data: SDKMessage } and run
416
+ // the inner message through the standard ingest normalization —
417
+ // publishing the raw wrapper would yield an opaque 'event'-typed entry
418
+ // no consumer (loadHistory worker-info scan, live SSE handler) matches.
419
+ const sessRes = await app.request('/v1/sessions', {
420
+ method: 'POST',
421
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
422
+ body: JSON.stringify({
423
+ events: [
424
+ {
425
+ type: 'event',
426
+ data: {
427
+ type: 'system',
428
+ subtype: 'init',
429
+ uuid: 'seed-init-uuid',
430
+ slash_commands: ['compact'],
431
+ slash_command_details: [
432
+ {
433
+ name: 'compact',
434
+ description: 'Shrink context',
435
+ type: 'local',
436
+ },
437
+ ],
438
+ skills: ['code-review'],
439
+ skill_details: [
440
+ {
441
+ name: 'code-review',
442
+ description: 'Review code',
443
+ type: 'prompt',
444
+ },
445
+ ],
446
+ remote_capabilities: {
447
+ file_search: true,
448
+ clear: true,
449
+ set_max_thinking_tokens: true,
450
+ get_context_usage: true,
451
+ stop_task: true,
452
+ set_model: true,
453
+ set_permission_mode: true,
454
+ interrupt: true,
455
+ end_session: true,
456
+ mcp_management: true,
457
+ remote_settings: true,
458
+ rewind: true,
459
+ set_effort: true,
460
+ side_question: true,
461
+ },
462
+ },
463
+ },
464
+ ],
465
+ }),
466
+ })
467
+ expect(sessRes.status).toBe(200)
468
+ const { id } = await sessRes.json()
469
+
470
+ const events = getEventBus(id).getEventsSince(0)
471
+ expect(events).toHaveLength(1)
472
+ expect(events[0]?.type).toBe('system')
473
+ expect(events[0]?.direction).toBe('inbound')
474
+ const payload = events[0]?.payload as Record<string, unknown>
475
+ expect(payload.subtype).toBe('init')
476
+ expect(payload.slash_command_details).toEqual([
477
+ { name: 'compact', description: 'Shrink context', type: 'local' },
478
+ ])
479
+ expect(payload.skill_details).toEqual([
480
+ { name: 'code-review', description: 'Review code', type: 'prompt' },
481
+ ])
482
+ expect(
483
+ (payload.remote_capabilities as Record<string, unknown>).set_model,
484
+ ).toBe(true)
485
+ // raw keeps the full SDK message for the adapter's payload.raw fallback.
486
+ expect((payload.raw as Record<string, unknown>).subtype).toBe('init')
402
487
  })
403
488
  })
404
489
 
@@ -4,7 +4,11 @@ import { mockConfigModule } from './helpers/mock-config'
4
4
  // Mock config before importing modules
5
5
  mock.module('../config', () => mockConfigModule())
6
6
 
7
- import { storeReset, storeCreateEnvironment } from '../store'
7
+ import {
8
+ storeReset,
9
+ storeCreateEnvironment,
10
+ storeUpdateEnvironment,
11
+ } from '../store'
8
12
  import {
9
13
  createSession,
10
14
  createCodeSession,
@@ -22,7 +26,7 @@ import {
22
26
  registerEnvironment,
23
27
  deregisterEnvironment,
24
28
  getEnvironment,
25
- updatePollTime,
29
+ touchEnvironmentActivity,
26
30
  listActiveEnvironments,
27
31
  listActiveEnvironmentsResponse,
28
32
  listActiveEnvironmentsByUsername,
@@ -230,14 +234,38 @@ describe('Environment Service', () => {
230
234
  })
231
235
  })
232
236
 
233
- describe('updatePollTime', () => {
234
- test('updates lastPollAt', () => {
237
+ describe('touchEnvironmentActivity', () => {
238
+ test('updates lastPollAt for an active env', () => {
235
239
  const result = registerEnvironment({})
236
- const before = getEnvironment(result.environment_id)?.lastPollAt
237
- // Small delay to ensure time difference
238
- updatePollTime(result.environment_id)
240
+ const stale = new Date(Date.now() - 60_000)
241
+ storeUpdateEnvironment(result.environment_id, { lastPollAt: stale })
242
+ touchEnvironmentActivity(result.environment_id)
239
243
  const after = getEnvironment(result.environment_id)?.lastPollAt
240
- expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime())
244
+ expect(after!.getTime()).toBeGreaterThan(stale.getTime())
245
+ })
246
+
247
+ test('reactivates a disconnected env', () => {
248
+ const result = registerEnvironment({})
249
+ storeUpdateEnvironment(result.environment_id, { status: 'disconnected' })
250
+ touchEnvironmentActivity(result.environment_id)
251
+ const env = getEnvironment(result.environment_id)
252
+ expect(env?.status).toBe('active')
253
+ expect(env?.lastPollAt).toBeTruthy()
254
+ })
255
+
256
+ test('leaves a deregistered env untouched (terminal state)', () => {
257
+ const result = registerEnvironment({})
258
+ const stale = new Date(Date.now() - 60_000)
259
+ storeUpdateEnvironment(result.environment_id, { lastPollAt: stale })
260
+ deregisterEnvironment(result.environment_id)
261
+ touchEnvironmentActivity(result.environment_id)
262
+ const env = getEnvironment(result.environment_id)
263
+ expect(env?.status).toBe('deregistered')
264
+ expect(env?.lastPollAt?.getTime()).toBe(stale.getTime())
265
+ })
266
+
267
+ test('no-ops for a non-existent env', () => {
268
+ expect(() => touchEnvironmentActivity('env_no')).not.toThrow()
241
269
  })
242
270
  })
243
271
 
@@ -10,6 +10,8 @@ import {
10
10
  storeCreateSession,
11
11
  storeGetWorkItem,
12
12
  storeGetPendingWorkItem,
13
+ storeGetEnvironment,
14
+ storeUpdateEnvironment,
13
15
  } from '../store'
14
16
  import {
15
17
  createWorkItem,
@@ -129,6 +131,42 @@ describe('Work Dispatch', () => {
129
131
  const result = heartbeatWork('work_no')
130
132
  expect(result.state).toBe('acked')
131
133
  })
134
+
135
+ test('refreshes env liveness — at-capacity workers poll for work only every 10 min', async () => {
136
+ const workId = await createWorkItem(envId, sessionId)
137
+ ackWork(workId)
138
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
139
+ storeUpdateEnvironment(envId, { lastPollAt: stale })
140
+ heartbeatWork(workId)
141
+ const lastPollAt = storeGetEnvironment(envId)?.lastPollAt
142
+ expect(lastPollAt!.getTime()).toBeGreaterThan(stale.getTime())
143
+ })
144
+
145
+ test('reactivates a monitor-flipped (disconnected) env', async () => {
146
+ const workId = await createWorkItem(envId, sessionId)
147
+ ackWork(workId)
148
+ storeUpdateEnvironment(envId, { status: 'disconnected' })
149
+ heartbeatWork(workId)
150
+ const env = storeGetEnvironment(envId)
151
+ expect(env?.status).toBe('active')
152
+ expect(env?.lastPollAt).toBeTruthy()
153
+ })
154
+
155
+ test('does not refresh env liveness for a stopped (completed) work item', async () => {
156
+ // 410 path: a zombie heartbeat after a Web-UI session deletion must
157
+ // not resurrect the env or refresh its liveness.
158
+ const workId = await createWorkItem(envId, sessionId)
159
+ stopWork(workId)
160
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
161
+ storeUpdateEnvironment(envId, {
162
+ status: 'disconnected',
163
+ lastPollAt: stale,
164
+ })
165
+ heartbeatWork(workId)
166
+ const env = storeGetEnvironment(envId)
167
+ expect(env?.status).toBe('disconnected')
168
+ expect(env?.lastPollAt?.getTime()).toBe(stale.getTime())
169
+ })
132
170
  })
133
171
 
134
172
  describe('reconnectWorkForEnvironment', () => {
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../services/session'
12
12
  import { createWorkItem } from '../../services/work-dispatch'
13
13
  import { publishSessionEvent } from '../../services/transport'
14
+ import { ingestBridgeMessage } from '../../transport/ws-handler'
14
15
  import { loadPersistedEvents } from '../../services/event-persistence'
15
16
  import { getEventBus } from '../../transport/event-bus'
16
17
  import { touchEnvironmentActivity } from '../../services/environment'
@@ -225,7 +226,26 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
225
226
  // Publish initial events if provided
226
227
  if (body.events && Array.isArray(body.events)) {
227
228
  for (const evt of body.events) {
228
- publishSessionEvent(session.id, evt.type || 'init', evt, 'outbound')
229
+ // CCR wire format { type: 'event', data: SDKMessage } — unwrap and run
230
+ // the inner SDK message through the standard ingest path (event-type
231
+ // derivation + payload normalization, e.g. system/init's
232
+ // slash_command_details / remote_capabilities preservation). Publishing
233
+ // the raw wrapper as-is yields an opaque 'event'-typed entry no
234
+ // consumer recognizes.
235
+ const data =
236
+ evt &&
237
+ typeof evt === 'object' &&
238
+ evt.type === 'event' &&
239
+ evt.data &&
240
+ typeof evt.data === 'object' &&
241
+ typeof evt.data.type === 'string'
242
+ ? evt.data
243
+ : null
244
+ if (data) {
245
+ ingestBridgeMessage(session.id, data)
246
+ } else {
247
+ publishSessionEvent(session.id, evt.type || 'init', evt, 'outbound')
248
+ }
229
249
  }
230
250
  }
231
251
 
@@ -17,7 +17,7 @@ import {
17
17
  } from '../../auth/middleware'
18
18
  import { getEventBus } from '../../transport/event-bus'
19
19
  import { storeGetSessionWorker, storeUpsertSessionWorker } from '../../store'
20
- import { updatePollTime } from '../../services/environment'
20
+ import { touchEnvironmentActivity } from '../../services/environment'
21
21
 
22
22
  const app = new Hono()
23
23
 
@@ -120,11 +120,12 @@ app.post(
120
120
  touchSession(sessionId)
121
121
  // V2 path doesn't go through /v1/environments/:id/work/poll, so
122
122
  // disconnect-monitor never sees lastPollAt updates. Refresh the parent
123
- // environment's poll time here — otherwise the env gets marked
123
+ // environment's liveness here — otherwise the env gets marked
124
124
  // disconnected and disappears from /web/environments even though the
125
- // worker is actively heartbeating.
125
+ // worker is actively heartbeating. touchEnvironmentActivity (not a raw
126
+ // lastPollAt write) so a monitor-flipped env is also reactivated.
126
127
  if (session.environment_id) {
127
- updatePollTime(session.environment_id)
128
+ touchEnvironmentActivity(session.environment_id)
128
129
  }
129
130
  return c.json({ status: 'ok', last_heartbeat_at: now.toISOString() }, 200)
130
131
  },
@@ -142,9 +143,9 @@ app.post('/:id/worker/register', acceptCliHeaders, apiKeyAuth, async c => {
142
143
  }
143
144
 
144
145
  const epoch = incrementEpoch(sessionId)
145
- // Same lastPollAt rationale as heartbeat — V2 path skips v1 poll.
146
+ // Same liveness rationale as heartbeat — V2 path skips v1 poll.
146
147
  if (session.environment_id) {
147
- updatePollTime(session.environment_id)
148
+ touchEnvironmentActivity(session.environment_id)
148
149
  }
149
150
  return c.json({ worker_epoch: epoch }, 200)
150
151
  })
@@ -28,8 +28,11 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
28
28
  continue
29
29
  }
30
30
  if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
31
+ // lastPollAt is written by touchEnvironmentActivity — any worker
32
+ // traffic counts (poll, assignment, session-create, work heartbeat),
33
+ // not just literal polls.
31
34
  log(
32
- `[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
35
+ `[RCS] Environment ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
33
36
  )
34
37
  storeUpdateEnvironment(env.id, { status: 'disconnected' })
35
38
  // 会话不自动归档:perpetual worker 重启时复用 envId 重新激活 env,
@@ -273,16 +273,13 @@ export function getEnvironment(envId: string) {
273
273
  return storeGetEnvironment(envId)
274
274
  }
275
275
 
276
- export function updatePollTime(envId: string) {
277
- storeUpdateEnvironment(envId, { lastPollAt: new Date() })
278
- }
279
-
280
276
  /**
281
277
  * Worker-traffic liveness signal: refresh lastPollAt and, when the disconnect
282
278
  * monitor had flipped the env to 'disconnected', reactivate it.
283
279
  *
284
280
  * 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)
281
+ * (work poll, assignment poll, session-creation retry during claim wait,
282
+ * work-item heartbeat while at capacity, v2 worker register/heartbeat)
286
283
  * proves it is alive. Without reactivation the CLI polls a zombie env
287
284
  * forever: the work-poll route never 404s, so the client's env-lost recovery
288
285
  * never fires, and any session created on the flipped env gets no work item
@@ -11,6 +11,7 @@ import {
11
11
  import { config } from '../config'
12
12
  import { getBaseUrl } from '../config'
13
13
  import { touchSession } from './session'
14
+ import { touchEnvironmentActivity } from './environment'
14
15
  import type { WorkResponse } from '../types/api'
15
16
 
16
17
  /** Encode work secret as base64 JSON (no JWT — just API key as token) */
@@ -140,6 +141,19 @@ export function heartbeatWork(workId: string): {
140
141
  if (item?.sessionId) {
141
142
  touchSession(item.sessionId)
142
143
  }
144
+ // Worker liveness for the parent env: an at-capacity worker (e.g. the
145
+ // `slz rcs one` repl worker, capacity=1) stops polling for work for the
146
+ // whole duration of a session (at-capacity poll interval is 10 min) and
147
+ // sends only this heartbeat (~60s). Without counting it as env activity,
148
+ // the disconnect monitor flips every busy env to 'disconnected' once
149
+ // RCS_DISCONNECT_TIMEOUT (default 300s) passes since the dispatch poll.
150
+ // Use the item's own environmentId — the route's :id param is not
151
+ // validated against it. Completed (stopped → 410) items must NOT refresh
152
+ // liveness: a zombie heartbeat after a Web-UI session deletion must not
153
+ // resurrect the env.
154
+ if (item && item.state !== 'completed') {
155
+ touchEnvironmentActivity(item.environmentId)
156
+ }
143
157
  const now = new Date()
144
158
  return {
145
159
  lease_extended: true,