@gotcos/glasses-server 6.58.2 → 6.60.0
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/CHANGELOG.md +57 -0
- package/README.md +79 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +16 -6
- package/managed-runtime-contract.json +2 -0
- package/package.json +5 -2
- package/server/index.ts +35 -3
- package/server/lib/claude-hooks-installer.ts +120 -17
- package/server/lib/client-liveness.ts +92 -4
- package/server/lib/permission-broker.ts +560 -31
- package/server/lib/query-job-types.ts +11 -2
- package/server/lib/session-hooks-runtime.ts +23 -0
- package/server/lib/work-activity.ts +377 -39
- package/server/lib/work-board-reader.ts +63 -0
- package/server/lib/work-handoff-requests.ts +426 -0
- package/server/routes/permission-broker.ts +37 -3
- package/server/routes/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express'
|
|
2
|
+
import { listBoard, TaskBridgeError, TaskRunError, type TaskBoardRow } from '../lib/task-store.js'
|
|
3
|
+
import { WorkHandoffRequestError, WorkHandoffRequestStore, handoffFullView, handoffStatusView, normalizeRequestId,
|
|
4
|
+
parseHandoffRequest, parseHandoffResult, type HandoffRequestInput, type HandoffRequestState } from '../lib/work-handoff-requests.js'
|
|
5
|
+
import { controlSnapshotRevision, readWorkJournal, sameSession, type WorkJournalRead } from '../lib/work-activity.js'
|
|
6
|
+
import { createWorkBoardReader, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
|
+
import { DEFAULT_MODEL, normalizeModelPreference } from '../../shared/model-preference.js'
|
|
8
|
+
|
|
9
|
+
export interface WorkHandoffRequestsDependencies {
|
|
10
|
+
store: WorkHandoffRequestStore | null
|
|
11
|
+
list: typeof listBoard
|
|
12
|
+
/** The bounded, shared board read (5 s). Defaults to one over `list`. */
|
|
13
|
+
readBoard?: WorkBoardReader<TaskBoardRow>
|
|
14
|
+
/** COS Control's handoff journal, read only to check a reply's receipt. */
|
|
15
|
+
journal: () => WorkJournalRead
|
|
16
|
+
/** The model a New session request without one takes: COS_G2_DEFAULT_MODEL, else the server default. */
|
|
17
|
+
defaultModel: () => string
|
|
18
|
+
/** Whether the caller is this Mac itself. Control's routes answer only loopback callers. */
|
|
19
|
+
isLocal: (req: Request) => boolean
|
|
20
|
+
}
|
|
21
|
+
export const defaultHandoffModel = (): string => normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
|
|
22
|
+
/** How old a board read may be when a request is checked against it. */
|
|
23
|
+
export const HANDOFF_BOARD_AGE_MS = 2_000
|
|
24
|
+
const STATES: readonly string[] = ['pending', 'claimed', 'sent', 'refused', 'unresolved', 'unconfirmed', 'expired']
|
|
25
|
+
|
|
26
|
+
/** The Mac itself, as the maintenance routes decide it (routes/maintenance.ts). Phone and tailnet traffic is not. */
|
|
27
|
+
export function isLoopbackRequest(req: Pick<Request, 'socket'>): boolean {
|
|
28
|
+
const address = req.socket.remoteAddress ?? ''
|
|
29
|
+
return address === '::1' || address === '127.0.0.1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 6.59.0: the handoff request inbox. The glasses ask for Work to start (POST, GET by id, over the network); COS Control
|
|
34
|
+
* on this Mac lists, claims and reports (loopback only). Auth is supplied by the parent /api mount. Writes take the
|
|
35
|
+
* global api_mutation maintenance lease like every other short /api mutation, so they are refused while a drain is
|
|
36
|
+
* open. Nothing here runs a provider or Jev.
|
|
37
|
+
*/
|
|
38
|
+
export function createWorkHandoffRequestsRouter(overrides: Partial<WorkHandoffRequestsDependencies> & Pick<WorkHandoffRequestsDependencies, 'store'>): Router {
|
|
39
|
+
const deps: WorkHandoffRequestsDependencies = { list: listBoard, journal: () => readWorkJournal(), defaultModel: defaultHandoffModel,
|
|
40
|
+
isLocal: isLoopbackRequest, ...overrides }
|
|
41
|
+
const board = deps.readBoard ?? createWorkBoardReader(() => deps.list())
|
|
42
|
+
const router = Router()
|
|
43
|
+
router.use('/work-board/handoff-requests', (_req, res, next) => {
|
|
44
|
+
res.set('Cache-Control', 'private, no-store')
|
|
45
|
+
if (!deps.store) return res.status(503).json({ error: { code: 'handoff_requests_unavailable', message: 'Work handoff requests are unavailable on this server.' } })
|
|
46
|
+
next()
|
|
47
|
+
})
|
|
48
|
+
/** Refusals are answers. Control's refusals and every 5xx are logged with their code; a note never is. */
|
|
49
|
+
const fail = (res: Response, e: unknown, where: string, id?: string) => {
|
|
50
|
+
if (e instanceof WorkHandoffRequestError) {
|
|
51
|
+
if (e.status >= 500 || where === 'claim' || where === 'result') console.warn(`[work-handoff-requests] ${where}${id ? ' ' + id : ''}: ${e.status} ${e.code}${e.extra.cause ? ` (${e.extra.cause})` : ''}`)
|
|
52
|
+
if (e.extra.resetsAt) res.set('Retry-After', String(Math.max(1, Math.ceil((Date.parse(e.extra.resetsAt) - Date.now()) / 1000))))
|
|
53
|
+
return res.status(e.status).json({ error: { code: e.code, message: e.message, ...e.extra } })
|
|
54
|
+
}
|
|
55
|
+
console.error(`[work-handoff-requests] ${where} failed:`, e instanceof Error ? e.message : e)
|
|
56
|
+
return res.status(503).json({ error: { code: 'handoff_requests_unavailable', message: 'Work handoff requests are unavailable. Try again shortly.' } })
|
|
57
|
+
}
|
|
58
|
+
const requestId = (raw: unknown): string => {
|
|
59
|
+
const id = normalizeRequestId(raw)
|
|
60
|
+
if (!id) throw new WorkHandoffRequestError('invalid_handoff_request', 400, 'The request id must be a UUID v4.')
|
|
61
|
+
return id
|
|
62
|
+
}
|
|
63
|
+
const local = (req: Request) => {
|
|
64
|
+
if (!deps.isLocal(req)) throw new WorkHandoffRequestError('local_only', 403, 'Only COS Control on this Mac can do this.')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A reply names the item's newest receipt, on the session it continues. */
|
|
68
|
+
const checkReplyTarget = (input: HandoffRequestInput) => {
|
|
69
|
+
const journal = deps.journal()
|
|
70
|
+
if (!journal.available) throw new WorkHandoffRequestError('work_history_unavailable', 503, 'COS Control’s Work history cannot be read here.')
|
|
71
|
+
const workId = `task:${input.domain}:${input.workIdentity}`
|
|
72
|
+
const own = journal.receipts.filter(r => r.workID === workId)
|
|
73
|
+
const target = own.find(r => r.id === input.replyTo)
|
|
74
|
+
if (!target) throw new WorkHandoffRequestError('reply_target_invalid', 409, 'That handoff is not this task’s.')
|
|
75
|
+
const newest = own.reduce((a, b) => (b.createdAt as number) > (a.createdAt as number)
|
|
76
|
+
|| (b.createdAt === a.createdAt && (b.id as string) > (a.id as string)) ? b : a)
|
|
77
|
+
if (newest !== target) throw new WorkHandoffRequestError('reply_target_invalid', 409, 'This task has a newer handoff. Refresh before replying.')
|
|
78
|
+
const session = typeof target.sessionID === 'string' && !(target.mode === 'fork' && target.sessionID === target.sourceSessionID) ? target.sessionID : null
|
|
79
|
+
if (!session || !sameSession(session, input.sessionId!)) throw new WorkHandoffRequestError('reply_target_invalid', 409, 'A reply continues the session that handoff went to.')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Glasses: ask for Work to start, reply, or say it is not done yet.
|
|
83
|
+
router.post('/work-board/handoff-requests', async (req, res) => {
|
|
84
|
+
let id: string | undefined
|
|
85
|
+
try {
|
|
86
|
+
const { input, fingerprint } = parseHandoffRequest(req.body, deps.defaultModel())
|
|
87
|
+
id = input.clientRequestId
|
|
88
|
+
// A retry is answered from the store, even when the board has since changed or is unreachable.
|
|
89
|
+
const replay = deps.store!.replay(input.clientRequestId, fingerprint)
|
|
90
|
+
if (replay) return res.status(200).json({ request: handoffFullView(replay) })
|
|
91
|
+
let rows: TaskBoardRow[]
|
|
92
|
+
try { rows = await board.read(HANDOFF_BOARD_AGE_MS) }
|
|
93
|
+
catch (e) {
|
|
94
|
+
const cause = e instanceof WorkBoardTimeoutError ? 'work_board_timeout' : e instanceof TaskRunError || e instanceof TaskBridgeError ? e.code : 'work_board_unavailable'
|
|
95
|
+
throw new WorkHandoffRequestError('work_board_unavailable', 503, 'Work board is unavailable. Nothing was sent; try again shortly.', { cause })
|
|
96
|
+
}
|
|
97
|
+
const row = rows.find(r => r.domain === input.domain && (r.workIdentity || r.id) === input.workIdentity)
|
|
98
|
+
if (!row) throw new WorkHandoffRequestError('task_not_found', 404, 'This task is not on the board any more.')
|
|
99
|
+
// COS Control refuses a glasses request for a checked task and for one at stage Complete (0.5.252).
|
|
100
|
+
if (row.checked || row.workStage === 'complete') throw new WorkHandoffRequestError('task_complete', 409, 'This task is complete.')
|
|
101
|
+
if (input.replyTo) checkReplyTarget(input)
|
|
102
|
+
const { request, created } = deps.store!.create(input, fingerprint, controlSnapshotRevision(row))
|
|
103
|
+
return res.status(created ? 201 : 200).json({ request: handoffFullView(request) })
|
|
104
|
+
} catch (e) { return fail(res, e, 'create', id) }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Control: the requests to act on (with note, mode, session and model), oldest first. Listing pending ones is what
|
|
108
|
+
// tells the glasses COS Control is taking requests.
|
|
109
|
+
router.get('/work-board/handoff-requests', (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
local(req)
|
|
112
|
+
const { state } = req.query
|
|
113
|
+
if (Object.keys(req.query).some(key => key !== 'state') || (state !== undefined && (typeof state !== 'string' || !STATES.includes(state)))) {
|
|
114
|
+
throw new WorkHandoffRequestError('invalid_handoff_request', 400, 'state must be one of the request states.')
|
|
115
|
+
}
|
|
116
|
+
if (state === 'pending') deps.store!.noteConsumer()
|
|
117
|
+
return res.json({ version: 1, requests: deps.store!.list(state as HandoffRequestState | undefined).map(handoffFullView),
|
|
118
|
+
quarantined: deps.store!.quarantineCount() })
|
|
119
|
+
} catch (e) { return fail(res, e, 'list') }
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Glasses: where one request is. State and times only, never the note.
|
|
123
|
+
router.get('/work-board/handoff-requests/:clientRequestId', (req, res) => {
|
|
124
|
+
try {
|
|
125
|
+
const request = deps.store!.get(requestId(req.params.clientRequestId))
|
|
126
|
+
if (!request) throw new WorkHandoffRequestError('request_not_found', 404, 'No such handoff request.')
|
|
127
|
+
return res.json({ request: handoffStatusView(request) })
|
|
128
|
+
} catch (e) { return fail(res, e, 'status') }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Control: take one pending request. 200 claimed with its claimToken (the same token again is 200), 409 already
|
|
132
|
+
// claimed, 410 expired.
|
|
133
|
+
router.post('/work-board/handoff-requests/:clientRequestId/claim', (req, res) => {
|
|
134
|
+
let id: string | undefined
|
|
135
|
+
try {
|
|
136
|
+
local(req)
|
|
137
|
+
id = requestId(req.params.clientRequestId)
|
|
138
|
+
const body = req.body
|
|
139
|
+
const token = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>).claimToken : undefined
|
|
140
|
+
if (!(body === undefined || (body && typeof body === 'object' && !Array.isArray(body)
|
|
141
|
+
&& Object.keys(body).every(key => key === 'claimToken') && (token === undefined || typeof token === 'string')))) {
|
|
142
|
+
throw new WorkHandoffRequestError('invalid_handoff_request', 400, 'A claim takes no body, or { claimToken } to claim again.')
|
|
143
|
+
}
|
|
144
|
+
const request = deps.store!.claim(id, token as string | undefined)
|
|
145
|
+
return res.json({ request: handoffFullView(request), claimToken: request.claimToken })
|
|
146
|
+
} catch (e) { return fail(res, e, 'claim', id) }
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Control: what happened to a claimed request, under its claim token.
|
|
150
|
+
router.post('/work-board/handoff-requests/:clientRequestId/result', (req, res) => {
|
|
151
|
+
let id: string | undefined
|
|
152
|
+
try {
|
|
153
|
+
local(req)
|
|
154
|
+
id = requestId(req.params.clientRequestId)
|
|
155
|
+
return res.json({ request: handoffFullView(deps.store!.report(id, parseHandoffResult(req.body))) })
|
|
156
|
+
} catch (e) { return fail(res, e, 'result', id) }
|
|
157
|
+
})
|
|
158
|
+
return router
|
|
159
|
+
}
|