@saluzi/saluzi-edu 0.4.0 → 0.4.1
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 +45 -45
- 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__/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/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(),
|
|
@@ -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', () => {
|
|
@@ -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,
|