@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/dist/cli.js +46 -46
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +1 -1
- package/packages/remote-control-server/src/__tests__/routes.test.ts +85 -0
- package/packages/remote-control-server/src/__tests__/services.test.ts +36 -8
- package/packages/remote-control-server/src/__tests__/work-dispatch.test.ts +38 -0
- package/packages/remote-control-server/src/routes/v1/sessions.ts +21 -1
- package/packages/remote-control-server/src/routes/v2/worker.ts +7 -6
- package/packages/remote-control-server/src/services/disconnect-monitor.ts +4 -1
- package/packages/remote-control-server/src/services/environment.ts +2 -5
- package/packages/remote-control-server/src/services/work-dispatch.ts +14 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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('
|
|
234
|
-
test('updates lastPollAt', () => {
|
|
237
|
+
describe('touchEnvironmentActivity', () => {
|
|
238
|
+
test('updates lastPollAt for an active env', () => {
|
|
235
239
|
const result = registerEnvironment({})
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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()).
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
146
|
+
// Same liveness rationale as heartbeat — V2 path skips v1 poll.
|
|
146
147
|
if (session.environment_id) {
|
|
147
|
-
|
|
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
|
|
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,
|