@gotcos/glasses-server 6.99.16 → 6.99.25
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 +137 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +23 -1
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/drop-store.ts +16 -2
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +40 -1
- package/server/lib/ping-sources.ts +6 -2
- package/server/lib/prep-runtime.ts +915 -0
- package/server/lib/prep-store.ts +226 -0
- package/server/lib/stt-resolver.ts +26 -2
- package/server/lib/transcript-cleanup.ts +11 -2
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/lib/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/health.ts +8 -0
- package/server/routes/prep.ts +81 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/web-control/app.css +24 -5
- package/server/web-control/app.js +646 -108
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +27 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +26 -0
- package/server/web-control/panes/cos-chat.js +641 -6
- package/server/web-control/panes/meetings.css +45 -2
- package/server/web-control/panes/meetings.js +230 -7
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.js +59 -21
- package/server/web-control/panes/settings.js +102 -4
- package/server/web-control/panes/today-actions.css +109 -0
- package/server/web-control/panes/today-actions.js +972 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Mounted under authenticated /api. Plan-only: never records a round, sends a handoff or writes a task. */
|
|
2
|
+
import { Router } from 'express'
|
|
3
|
+
import { ASK_WORK_PLAN_PROTOCOL, ASK_WORK_PLAN_BODY_BYTES, ASK_WORK_PLAN_DEADLINE_MS, ASK_WORK_PLAN_RULES,
|
|
4
|
+
AskWorkPlanError, parseAskWorkPlanRequest, resolveAskWorkTask, askWorkMeetingIDs, prepareAskWorkPlan, askWorkProposalID } from '../lib/ask-work-plan.js'
|
|
5
|
+
import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
|
|
6
|
+
import { createWorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
|
+
import { orchestratorInputFor, type OrchestratorParts } from '../lib/work-orchestrator/runtime.js'
|
|
8
|
+
import { runMeetingAskModel, AskProcessError } from '../lib/meeting-ask-runner.js'
|
|
9
|
+
import { maintenanceAdmissionsOpen, acquireMaintenanceWork } from '../lib/maintenance-lifecycle.js'
|
|
10
|
+
import { parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS,
|
|
11
|
+
WORK_CHAT_DEFAULT_EFFORT, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS,
|
|
12
|
+
WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS } from '../lib/work-chat.js'
|
|
13
|
+
|
|
14
|
+
export interface AskWorkPlanDeps {
|
|
15
|
+
board: () => Promise<TaskBoardRow[]>
|
|
16
|
+
input: typeof orchestratorInputFor
|
|
17
|
+
run: typeof runMeetingAskModel
|
|
18
|
+
admissions: () => boolean
|
|
19
|
+
lease: () => { release(): void }
|
|
20
|
+
now: () => number
|
|
21
|
+
}
|
|
22
|
+
/** Ask never launches usage probes; stale or unknown cached headroom disables targets in prepareAskWorkPlan. */
|
|
23
|
+
export function readAskWorkPlanInput(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts) {
|
|
24
|
+
return orchestratorInputFor(workID, meetingWorkIDs, parts, { usageMode: 'cached' })
|
|
25
|
+
}
|
|
26
|
+
const defaults: AskWorkPlanDeps = {
|
|
27
|
+
// A new reader guarantees the post-plan revision check starts a new bounded board read.
|
|
28
|
+
board: () => createWorkBoardReader(() => listBoard()).read(0), input: readAskWorkPlanInput, run: runMeetingAskModel,
|
|
29
|
+
admissions: maintenanceAdmissionsOpen, lease: () => acquireMaintenanceWork('work_chat'), now: Date.now,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createAskWorkPlanRouter(given: Partial<AskWorkPlanDeps> = {}) {
|
|
33
|
+
const deps = { ...defaults, ...given }, router = Router()
|
|
34
|
+
let running = false
|
|
35
|
+
router.use('/ask/work-plan', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
36
|
+
router.get('/ask/work-plan', (_req, res) => res.json({ protocolVersion: ASK_WORK_PLAN_PROTOCOL, planOnly: true,
|
|
37
|
+
requiresExistingWork: true, dispatch: false, controlSnapshot: 1, planner: 'sonnet', planners: WORK_CHAT_PLANNERS,
|
|
38
|
+
effort: WORK_CHAT_DEFAULT_EFFORT, efforts: WORK_CHAT_EFFORTS, maxAssignments: WORK_CHAT_MAX_ASSIGNMENTS,
|
|
39
|
+
maxQuestionChars: 2000, maxBodyBytes: ASK_WORK_PLAN_BODY_BYTES }))
|
|
40
|
+
router.post('/ask/work-plan', async (req, res) => {
|
|
41
|
+
const fail = (error: unknown) => {
|
|
42
|
+
const known = error instanceof AskWorkPlanError
|
|
43
|
+
if (!res.destroyed) res.status(known ? error.status : 503).json({ error: known ? error.code : 'ask_work_plan_unavailable',
|
|
44
|
+
message: known && error.code === 'question_contains_sensitive_text'
|
|
45
|
+
? 'Remove credentials from the question and try again.'
|
|
46
|
+
: 'No assignment was dispatched. Refresh the task and retry the plan.' })
|
|
47
|
+
}
|
|
48
|
+
let request
|
|
49
|
+
try { request = parseAskWorkPlanRequest(req.body) } catch (error) { fail(error); return }
|
|
50
|
+
if (!deps.admissions()) { fail(new AskWorkPlanError('maintenance', 503)); return }
|
|
51
|
+
if (running) { fail(new AskWorkPlanError('ask_work_plan_busy', 409)); return }
|
|
52
|
+
running = true
|
|
53
|
+
const controller = new AbortController(), abort = () => controller.abort()
|
|
54
|
+
const timer = setTimeout(abort, ASK_WORK_PLAN_DEADLINE_MS)
|
|
55
|
+
res.on('close', abort)
|
|
56
|
+
let lease: ReturnType<AskWorkPlanDeps['lease']> | undefined
|
|
57
|
+
let release = true
|
|
58
|
+
try {
|
|
59
|
+
lease = deps.lease()
|
|
60
|
+
let rows: TaskBoardRow[]
|
|
61
|
+
try { rows = await deps.board() }
|
|
62
|
+
catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
|
|
63
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
64
|
+
const task = resolveAskWorkTask(request, rows)
|
|
65
|
+
let evidence
|
|
66
|
+
// The default reads cached usage only. Still await the bounded evidence dependency before releasing admission.
|
|
67
|
+
try { evidence = await deps.input(request.workID, askWorkMeetingIDs(task)) }
|
|
68
|
+
catch { throw new AskWorkPlanError('orchestrator_input_unavailable', 503) }
|
|
69
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
70
|
+
const prepared = prepareAskWorkPlan(request, task, evidence, deps.now())
|
|
71
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
72
|
+
if (!deps.admissions()) throw new AskWorkPlanError('maintenance', 503)
|
|
73
|
+
const { planner = 'sonnet', effort = WORK_CHAT_DEFAULT_EFFORT, ...prompt } = prepared.input
|
|
74
|
+
// The runner settles only after termination proof (or explicit unconfirmed exit). Racing its
|
|
75
|
+
// promise with abort would release the lease and admit another provider during the kill wait.
|
|
76
|
+
const output = await deps.run({ engine: 'claude', model: planner }, JSON.stringify(prompt),
|
|
77
|
+
WORK_CHAT_SYSTEM + '\n\n' + WORK_CHAT_ORCHESTRATOR_RULES + '\n\n' + ASK_WORK_PLAN_RULES,
|
|
78
|
+
controller.signal, undefined, { caller: 'ask_work_plan' }, { effort, maxOutputTokens: WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS,
|
|
79
|
+
maxThinkingTokens: WORK_CHAT_THINKING_TOKENS[effort], deadlineMs: WORK_CHAT_DEADLINE_MS })
|
|
80
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
81
|
+
let answer
|
|
82
|
+
try { answer = parseWorkChatAnswer(output.text, prepared.input) }
|
|
83
|
+
catch { throw new AskWorkPlanError('invalid_plan_answer', 503) }
|
|
84
|
+
// A model may take minutes; never return an apparently current proposal for a changed/finished task.
|
|
85
|
+
try { rows = await deps.board() }
|
|
86
|
+
catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
|
|
87
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
88
|
+
const currentTask = resolveAskWorkTask(request, rows)
|
|
89
|
+
try { evidence = await deps.input(request.workID, askWorkMeetingIDs(currentTask)) }
|
|
90
|
+
catch { throw new AskWorkPlanError('orchestrator_input_unavailable', 503) }
|
|
91
|
+
if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
|
|
92
|
+
const current = prepareAskWorkPlan(request, currentTask, evidence, deps.now())
|
|
93
|
+
// Revalidate every proposed target against current discovery, live state and plan headroom.
|
|
94
|
+
try { answer = parseWorkChatAnswer(output.text, current.input) }
|
|
95
|
+
catch { throw new AskWorkPlanError('plan_targets_changed', 409) }
|
|
96
|
+
if (!res.destroyed) res.json({ protocolVersion: ASK_WORK_PLAN_PROTOCOL, planOnly: true, workID: request.workID,
|
|
97
|
+
revision: request.revision, title: prepared.title, ...answer, evidence: prepared.evidence, revalidatedEvidence: current.evidence,
|
|
98
|
+
proposalID: askWorkProposalID(prepared.input, answer), question: prepared.input.question, createdAt: new Date(deps.now()).toISOString(),
|
|
99
|
+
model: output.model ?? `claude:${planner} (requested)`, effort })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof AskProcessError && error.terminationUnconfirmed) {
|
|
102
|
+
// Match Meeting Ask's orphan policy: no second process or maintenance drain until recovery.
|
|
103
|
+
release = false
|
|
104
|
+
console.warn('[ask-work-plan] provider termination unconfirmed; admission and maintenance lease retained')
|
|
105
|
+
fail(new AskWorkPlanError('provider_termination_unconfirmed', 503))
|
|
106
|
+
} else fail(error)
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer); res.off('close', abort)
|
|
110
|
+
if (release) { lease?.release(); running = false }
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
return router
|
|
114
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dropItCapability } from '../lib/drop-settings.js'
|
|
2
|
+
import { PREP_CAPABILITY } from '../../shared/prep.js'
|
|
2
3
|
import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
|
|
3
4
|
import { macLiveVoicesSnapshot } from '../lib/mac-window-replay.js'
|
|
4
5
|
import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
@@ -465,6 +466,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
465
466
|
providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) }),
|
|
466
467
|
),
|
|
467
468
|
server_instance_id: getServerInstanceId(),
|
|
469
|
+
// 6.99.20: the same id in camelCase, the name COS Control 0.5.376's Prep discovery reads from /api/health
|
|
470
|
+
// (it refused Prep with "did not answer Prep discovery" when only server_instance_id was here). Additive.
|
|
471
|
+
serverInstanceId: getServerInstanceId(),
|
|
468
472
|
boot_id: serverMetrics.bootId,
|
|
469
473
|
generation_id: getServerGenerationId(),
|
|
470
474
|
mediaLimits,
|
|
@@ -538,6 +542,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
538
542
|
titleSteer: TITLE_STEER_CAPABILITY,
|
|
539
543
|
// Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
|
|
540
544
|
dropIt: dropItCapability(),
|
|
545
|
+
// 6.99.19 Prep: notes and files before a call, kept in the meeting by an explicit bind (routes/prep.ts). Absent on an older server.
|
|
546
|
+
prep: { ...PREP_CAPABILITY, serverInstanceId: getServerInstanceId() },
|
|
541
547
|
// 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
|
|
542
548
|
transcriptCleanup: transcriptCleanupAdvertised(),
|
|
543
549
|
richMedia: {
|
|
@@ -752,6 +758,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
752
758
|
titleSteer: TITLE_STEER_CAPABILITY,
|
|
753
759
|
// Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
|
|
754
760
|
dropIt: dropItCapability(),
|
|
761
|
+
// 6.99.19 Prep: notes and files before a call, kept in the meeting by an explicit bind (routes/prep.ts). Absent on an older server.
|
|
762
|
+
prep: { ...PREP_CAPABILITY, serverInstanceId: getServerInstanceId() },
|
|
755
763
|
richMedia: {
|
|
756
764
|
video: richMedia.video,
|
|
757
765
|
maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prep v1.1 routes (6.99.19). Mounted under /api, so the X-Cos-Token check in index.ts runs first. Every answer is
|
|
3
|
+
* Cache-Control: no-store and carries the top-level `serverInstanceId` (errors included; the original-bytes route
|
|
4
|
+
* sends it as the X-COS-Server-Instance header). Errors are `{ error: '<code>' }` like Meeting Ask; a revision conflict
|
|
5
|
+
* also carries `draft`, run_in_progress carries `run`, and file_context_limit carries needBytes and haveBytes.
|
|
6
|
+
* Every mutation requires X-COS-Expected-Server-Instance (v1.1 #19), checked before the runtime is called. The
|
|
7
|
+
* behaviour lives in lib/prep-runtime.ts; shared/prep.ts lists the routes and DTOs.
|
|
8
|
+
*/
|
|
9
|
+
import { Router, type Request, type Response } from 'express'
|
|
10
|
+
import { PrepError } from '../lib/prep-store.js'
|
|
11
|
+
import { wellFormed, type PrepRuntime } from '../lib/prep-runtime.js'
|
|
12
|
+
|
|
13
|
+
export const EXPECTED_SERVER_HEADER = 'x-cos-expected-server-instance'
|
|
14
|
+
|
|
15
|
+
/** An ASCII fallback name plus the RFC 5987 UTF-8 name: no quotes, slashes or control characters reach the header. */
|
|
16
|
+
export function attachmentDisposition(name: string): string {
|
|
17
|
+
// By code point, without lone surrogates: encodeURIComponent throws URIError on half an emoji.
|
|
18
|
+
const whole = Array.from(wellFormed(name)).slice(0, 240).join('')
|
|
19
|
+
const ascii = whole.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[ .]+/, '').slice(0, 120) || 'file'
|
|
20
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(whole)}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createPrepRouter(runtime: PrepRuntime): Router {
|
|
24
|
+
const router = Router()
|
|
25
|
+
router.use('/prep', (_q, res, next) => { res.set('Cache-Control', 'no-store'); next() })
|
|
26
|
+
|
|
27
|
+
const serverInstanceId = () => { try { return runtime.serverInstanceId() } catch { return null } }
|
|
28
|
+
const send = (res: Response, status: number, body: Record<string, unknown>) => res.status(status).json({ ...body, serverInstanceId: serverInstanceId() })
|
|
29
|
+
const failure = (res: Response, error: unknown) => {
|
|
30
|
+
if (error instanceof PrepError) {
|
|
31
|
+
send(res, error.status, { error: error.code, ...error.extra, ...(error.draft ? { draft: error.draft } : {}) })
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
console.warn('[prep] request failed:', error instanceof Error ? error.message : 'unknown')
|
|
35
|
+
send(res, 503, { error: 'prep_unavailable' })
|
|
36
|
+
}
|
|
37
|
+
/** A mutation: the expected server is checked before the runtime runs anything. */
|
|
38
|
+
const write = (status: number, fn: (req: Request) => Promise<Record<string, unknown>>) => async (req: Request, res: Response) => {
|
|
39
|
+
try {
|
|
40
|
+
runtime.authorize(req.get(EXPECTED_SERVER_HEADER))
|
|
41
|
+
send(res, status, await fn(req))
|
|
42
|
+
} catch (e) { failure(res, e) }
|
|
43
|
+
}
|
|
44
|
+
const read = (fn: (req: Request) => Promise<Record<string, unknown>> | Record<string, unknown>) => async (req: Request, res: Response) => {
|
|
45
|
+
try { send(res, 200, await fn(req)) } catch (e) { failure(res, e) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
router.post('/prep/drafts', async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
runtime.authorize(req.get(EXPECTED_SERVER_HEADER))
|
|
51
|
+
const { draft, created } = await runtime.create(req.body)
|
|
52
|
+
send(res, created ? 201 : 200, { draft })
|
|
53
|
+
} catch (e) { failure(res, e) }
|
|
54
|
+
})
|
|
55
|
+
router.get('/prep/drafts', read(req => ({ drafts: runtime.list({ occurrence: req.query.occurrence, state: req.query.state, limit: req.query.limit }) })))
|
|
56
|
+
router.get('/prep/drafts/:id', read(async req => ({ draft: await runtime.get(String(req.params.id)) })))
|
|
57
|
+
router.delete('/prep/drafts/:id', write(200, async req => ({ draft: await runtime.archive(String(req.params.id)) })))
|
|
58
|
+
router.patch('/prep/drafts/:id', write(200, async req => ({ draft: await runtime.patch(String(req.params.id), req.body) })))
|
|
59
|
+
router.post('/prep/drafts/:id/files', write(200, async req => ({ draft: await runtime.addFiles(String(req.params.id), req.body) })))
|
|
60
|
+
router.delete('/prep/drafts/:id/files/:fileId', write(200, async req => ({ draft: await runtime.removeFile(String(req.params.id), String(req.params.fileId), req.query.revision, req.query.serverInstanceId) })))
|
|
61
|
+
router.get('/prep/drafts/:id/files/:fileId', (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const file = runtime.readOriginal(String(req.params.id), String(req.params.fileId))
|
|
64
|
+
res.set({ 'Content-Type': file.mime, 'Content-Disposition': attachmentDisposition(file.name), 'X-Content-Type-Options': 'nosniff', 'X-COS-Server-Instance': serverInstanceId() ?? '' })
|
|
65
|
+
res.send(file.bytes)
|
|
66
|
+
} catch (e) { failure(res, e) }
|
|
67
|
+
})
|
|
68
|
+
router.get('/prep/drafts/:id/files/:fileId/text', read(req => ({ text: runtime.readText(String(req.params.id), String(req.params.fileId)) })))
|
|
69
|
+
router.post('/prep/drafts/:id/run', write(202, async req => ({ ...await runtime.run(String(req.params.id), req.body) })))
|
|
70
|
+
router.get('/prep/drafts/:id/runs/:clientRequestId', async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const { status, run, job } = await runtime.getRun(String(req.params.id), String(req.params.clientRequestId))
|
|
73
|
+
send(res, status, status === 202 ? { run } : { run, job })
|
|
74
|
+
} catch (e) { failure(res, e) }
|
|
75
|
+
})
|
|
76
|
+
router.post('/prep/drafts/:id/bind', write(200, async req => ({ ...await runtime.bind(String(req.params.id), req.body) })))
|
|
77
|
+
router.delete('/prep/drafts/:id/bind/:sessionId', write(200, async req => ({ draft: await runtime.unbind(String(req.params.id), String(req.params.sessionId), req.query.serverInstanceId) })))
|
|
78
|
+
router.get('/prep/by-session/:sessionId', read(req => ({ snapshots: runtime.bySession(String(req.params.sessionId)) })))
|
|
79
|
+
router.get('/prep/suggest', read(req => ({ drafts: runtime.suggest({ sessionId: req.query.sessionId, startedAt: req.query.startedAt }) })))
|
|
80
|
+
return router
|
|
81
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import express, { Router, type Request, type Response } from 'express'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
2
5
|
import { hostname, networkInterfaces } from 'node:os'
|
|
3
6
|
import {
|
|
4
7
|
hasProxyForwardedHeaders,
|
|
@@ -39,11 +42,20 @@ import {
|
|
|
39
42
|
} from '../lib/web-memories-bridge.js'
|
|
40
43
|
import { listLiveTranscriptSessions, readLiveTranscriptSession } from '../lib/web-live-chunks.js'
|
|
41
44
|
import { getWebUsageSummary } from '../lib/web-usage-bridge.js'
|
|
45
|
+
import { WEB_PET_POSES, controlSupportDir, petChunk, petPreview, planPose, readPetSources, webPetManifest, type WebPetPose } from '../lib/web-pet.js'
|
|
46
|
+
import { controlResourcesDir, galleryPets, galleryThumb, parsePetChoice, petChoiceKey, resolvePet, shippedPets, type PetChoice, type PetFetcher } from '../lib/web-pet-choice.js'
|
|
47
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
42
48
|
|
|
43
49
|
export { isMemoryWebOpReadOnly, MUTATING_MEMORY_WEB_OPS, READ_ONLY_MEMORY_WEB_OPS }
|
|
44
50
|
|
|
45
51
|
export interface WebControlRouterDeps {
|
|
46
52
|
apiToken: () => string
|
|
53
|
+
/** Tests: where the pet's state map and strips live, the chunk cache, and a stand-in for sips. */
|
|
54
|
+
petSupportDir?: () => string
|
|
55
|
+
petCacheDir?: () => string
|
|
56
|
+
petRun?: (cmd: string, args: string[]) => Promise<void>
|
|
57
|
+
petResourcesDir?: () => string | null
|
|
58
|
+
petFetch?: PetFetcher
|
|
47
59
|
port: () => number
|
|
48
60
|
publicPort?: () => number
|
|
49
61
|
httpsPort?: () => number | null
|
|
@@ -166,17 +178,15 @@ export function webControlEnabled(env: NodeJS.ProcessEnv = process.env): boolean
|
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
/**
|
|
169
|
-
* CSP for `/control/*`.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* `onclick` handlers, so only `/control/memories/*` keeps `'unsafe-inline'`.
|
|
181
|
+
* CSP for `/control/*`. No page has inline script, so every path gets `script-src 'self'`: an injected
|
|
182
|
+
* `<img onerror>` in rendered session or meeting text cannot run. 6.99.25: Memories is a native pane
|
|
183
|
+
* (panes/memories.js); the hosted page and its `/control/memories/*` 'unsafe-inline' exception are gone.
|
|
173
184
|
* `frame-ancestors 'self'` stops another site framing the signed-in page.
|
|
174
185
|
*/
|
|
175
|
-
export function webControlCsp(
|
|
176
|
-
const inlineScripts = requestPath.startsWith('/memories/')
|
|
186
|
+
export function webControlCsp(_requestPath: string): string {
|
|
177
187
|
return [
|
|
178
188
|
"default-src 'self'",
|
|
179
|
-
|
|
189
|
+
"script-src 'self'",
|
|
180
190
|
"style-src 'self' 'unsafe-inline'",
|
|
181
191
|
"font-src 'self' data:",
|
|
182
192
|
"img-src 'self' data: blob:",
|
|
@@ -550,6 +560,99 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
|
550
560
|
res.json(summary)
|
|
551
561
|
})
|
|
552
562
|
|
|
563
|
+
// The session pet on Home (lib/web-pet.ts) and the pet picker (lib/web-pet-choice.ts). `?pet=` is the browser's
|
|
564
|
+
// choice: mac (default), shipped:<id> or gallery:<id>. Everything here reads COS Control's files; nothing writes them.
|
|
565
|
+
const petDir = (): string => deps.petSupportDir?.() ?? controlSupportDir()
|
|
566
|
+
const petResources = (): string | null => (deps.petResourcesDir ? deps.petResourcesDir() : controlResourcesDir())
|
|
567
|
+
const petCacheRoot = (): string => deps.petCacheDir?.() ?? dataPath('web-pet')
|
|
568
|
+
const petCacheFor = (name: string): string => (name ? path.join(petCacheRoot(), name) : petCacheRoot())
|
|
569
|
+
const petChoiceOf = (req: Request): PetChoice | null => parsePetChoice(req.query.pet ?? 'mac')
|
|
570
|
+
const resolveFor = (choice: PetChoice) => resolvePet(choice, { supportDir: petDir(), resources: petResources() })
|
|
571
|
+
const sendImage = async (res: Response, file: string, type: string, immutable: boolean): Promise<void> => {
|
|
572
|
+
res.set('Cache-Control', immutable ? 'private, max-age=31536000, immutable' : 'private, no-cache')
|
|
573
|
+
// Not sendFile: it refuses any path with a dot folder, and the cache lives under ~/.cos-glasses.
|
|
574
|
+
res.type(type).send(await fs.promises.readFile(file))
|
|
575
|
+
}
|
|
576
|
+
const fileTag = (file: string): string => { try { const st = fs.statSync(file); return `${st.size}-${Math.round(st.mtimeMs)}` } catch { return '0' } }
|
|
577
|
+
|
|
578
|
+
router.get('/web-pet', (req: Request, res: Response) => {
|
|
579
|
+
res.set('Cache-Control', 'private, no-store')
|
|
580
|
+
const choice = petChoiceOf(req)
|
|
581
|
+
if (!choice) return res.status(400).json({ error: 'pet_choice_invalid' })
|
|
582
|
+
const key = petChoiceKey(choice)
|
|
583
|
+
const found = resolveFor(choice)
|
|
584
|
+
if (!found) return res.json({ available: false, pet: key, poses: {} })
|
|
585
|
+
if (found.kind === 'strips') return res.json({ ...webPetManifest(found.dir, key), pet: key, kind: 'strips' })
|
|
586
|
+
const tag = found.kind === 'still' ? fileTag(found.file) : found.preview
|
|
587
|
+
return res.json({ available: true, pet: key, kind: 'still', poses: {}, still: `/api/web-pet/still?pet=${encodeURIComponent(key)}&v=${encodeURIComponent(crypto.createHash('sha256').update(tag).digest('hex').slice(0, 16))}` })
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
router.get('/web-pet/sheet/:pose/:chunk', async (req: Request, res: Response) => {
|
|
591
|
+
const choice = petChoiceOf(req)
|
|
592
|
+
const found = choice ? resolveFor(choice) : null
|
|
593
|
+
if (!found || found.kind !== 'strips') return res.status(404).json({ error: 'pet_unavailable' })
|
|
594
|
+
const pose = String(req.params.pose) as WebPetPose
|
|
595
|
+
const src = (WEB_PET_POSES as readonly string[]).includes(pose) ? readPetSources(found.dir)[pose] : undefined
|
|
596
|
+
if (!src) return res.status(404).json({ error: 'pet_pose_unavailable' })
|
|
597
|
+
const plan = planPose(src)
|
|
598
|
+
const index = Number(req.params.chunk)
|
|
599
|
+
if (!Number.isInteger(index) || index < 0 || index >= plan.chunks) return res.status(404).json({ error: 'pet_chunk_unavailable' })
|
|
600
|
+
try {
|
|
601
|
+
const file = await petChunk(plan, index, { cacheDir: petCacheFor(found.cacheName), run: deps.petRun })
|
|
602
|
+
await sendImage(res, file, 'png', req.query.v === plan.key)
|
|
603
|
+
} catch (err) {
|
|
604
|
+
log(`[web-pet] could not build ${pose} chunk ${index}: ${err instanceof Error ? err.message : String(err)}`)
|
|
605
|
+
res.status(503).json({ error: 'pet_build_failed' })
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// A still pet: COS Control's single sprite, Cloud Puff, or a gallery picture (downloaded once, as the Mac does).
|
|
610
|
+
router.get('/web-pet/still', async (req: Request, res: Response) => {
|
|
611
|
+
const choice = petChoiceOf(req)
|
|
612
|
+
const found = choice ? resolveFor(choice) : null
|
|
613
|
+
if (!found || found.kind === 'strips') return res.status(404).json({ error: 'pet_unavailable' })
|
|
614
|
+
try {
|
|
615
|
+
const art = found.kind === 'still' ? found : await galleryThumb(found, petCacheFor('gallery'), deps.petFetch, petDir())
|
|
616
|
+
await sendImage(res, art.file, art.type, typeof req.query.v === 'string' && req.query.v.length > 0)
|
|
617
|
+
} catch (err) {
|
|
618
|
+
log(`[web-pet] gallery picture failed for ${found.kind === 'gallery' ? found.id : 'still'}: ${err instanceof Error ? err.message : String(err)}`)
|
|
619
|
+
res.status(502).json({ error: 'pet_picture_unavailable' })
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
// The picker's small picture: an animated pet's first frame, or the still itself.
|
|
624
|
+
router.get('/web-pet/preview', async (req: Request, res: Response) => {
|
|
625
|
+
const choice = petChoiceOf(req)
|
|
626
|
+
const found = choice ? resolveFor(choice) : null
|
|
627
|
+
if (!found) return res.status(404).json({ error: 'pet_unavailable' })
|
|
628
|
+
try {
|
|
629
|
+
if (found.kind === 'strips') return await sendImage(res, await petPreview(found.dir, petCacheFor(found.cacheName || 'mac-preview'), { run: deps.petRun }), 'png', typeof req.query.v === 'string')
|
|
630
|
+
const art = found.kind === 'still' ? found : await galleryThumb(found, petCacheFor('gallery'), deps.petFetch, petDir())
|
|
631
|
+
await sendImage(res, art.file, art.type, typeof req.query.v === 'string')
|
|
632
|
+
} catch (err) {
|
|
633
|
+
log(`[web-pet] preview failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
634
|
+
res.status(502).json({ error: 'pet_picture_unavailable' })
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
// What the picker lists: the Mac's pet, the characters COS Control ships, and its OpenPets gallery.
|
|
639
|
+
router.get('/web-pet/catalog', (_req: Request, res: Response) => {
|
|
640
|
+
res.set('Cache-Control', 'private, no-store')
|
|
641
|
+
const preview = (key: string, tag: string) => `/api/web-pet/preview?pet=${encodeURIComponent(key)}&v=${encodeURIComponent(crypto.createHash('sha256').update(tag).digest('hex').slice(0, 16))}`
|
|
642
|
+
const mac = resolveFor({ kind: 'mac' })
|
|
643
|
+
const macTag = mac ? (mac.kind === 'still' ? fileTag(mac.file) : fileTag(path.join(petDir(), 'session-pet-states.json'))) : ''
|
|
644
|
+
res.json({
|
|
645
|
+
mac: { available: !!mac, preview: mac ? preview('mac', 'mac:' + macTag) : null },
|
|
646
|
+
// The tag follows the art on disk, so a COS Control update with new art gets a new preview URL.
|
|
647
|
+
shipped: shippedPets(petResources()).map((p) => {
|
|
648
|
+
const r = resolveFor({ kind: 'shipped', id: p.id })
|
|
649
|
+
const tag = r?.kind === 'strips' ? fileTag(path.join(r.dir, 'session-pet-states.json')) : r?.kind === 'still' ? fileTag(r.file) : ''
|
|
650
|
+
return { ...p, pet: `shipped:${p.id}`, preview: preview(`shipped:${p.id}`, `shipped:${p.id}:${tag}`) }
|
|
651
|
+
}),
|
|
652
|
+
gallery: galleryPets(petDir()).map((p) => ({ id: p.id, name: p.name, description: p.description, category: p.category, pet: `gallery:${p.id}`, preview: preview(`gallery:${p.id}`, p.preview) })),
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
|
|
553
656
|
// Today / Work "Your Move" compatibility route
|
|
554
657
|
router.get('/work-board/your-move', async (_req: Request, res: Response) => {
|
|
555
658
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express'
|
|
6
6
|
import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
|
|
7
|
+
import { parsePrepOccurrence } from '../lib/prep-store.js'
|
|
8
|
+
import type { PrepOccurrence } from '../../shared/prep.js'
|
|
7
9
|
|
|
8
10
|
export const welcomeContextRouter = Router()
|
|
9
11
|
|
|
@@ -14,7 +16,29 @@ const CALENDAR_TTL_MS = 2 * 60_000
|
|
|
14
16
|
const COORD_MOVE_DEG = 0.05 // ~5km
|
|
15
17
|
|
|
16
18
|
type WeatherPayload = { temp: string; desc: string; location: string }
|
|
17
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 6.99.19: `occurrence` (shared/prep.ts PrepOccurrence) is present ONLY when the bridge answers the event with a
|
|
21
|
+
* provider, calendar id, event id or external id, and start. The bridge sends no event ids today, so it is absent.
|
|
22
|
+
* It lets a client find a Prep draft for this exact event; it never binds one, and nothing is inferred from the title.
|
|
23
|
+
*/
|
|
24
|
+
type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence }
|
|
25
|
+
|
|
26
|
+
/** The bridge event's provider identity, or undefined. snake_case (calendar_context.py) or camelCase is accepted. */
|
|
27
|
+
export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined): PrepOccurrence | undefined {
|
|
28
|
+
if (!evt || typeof evt !== 'object') return undefined
|
|
29
|
+
const pick = (...keys: string[]) => keys.map(k => evt[k]).find(v => v !== undefined && v !== null)
|
|
30
|
+
try {
|
|
31
|
+
return parsePrepOccurrence({
|
|
32
|
+
provider: pick('provider'),
|
|
33
|
+
account: pick('account', 'account_id', 'accountId') ?? null,
|
|
34
|
+
calendarId: pick('calendar_id', 'calendarId'),
|
|
35
|
+
eventId: pick('event_id', 'eventId') ?? null,
|
|
36
|
+
externalId: pick('external_id', 'externalId') ?? null,
|
|
37
|
+
startsAt: pick('start_time', 'start', 'startsAt'),
|
|
38
|
+
endsAt: pick('end_time', 'end', 'endsAt') ?? null,
|
|
39
|
+
}) ?? undefined
|
|
40
|
+
} catch { return undefined }
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
let cachedWeather: WeatherPayload | null = null
|
|
20
44
|
let weatherFetchedAt = 0
|
|
@@ -327,16 +351,20 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
|
|
|
327
351
|
const minsUntil = Number.isFinite(eventTime)
|
|
328
352
|
? Math.max(0, Math.round((eventTime - now) / 60_000))
|
|
329
353
|
: undefined
|
|
354
|
+
const occurrence = bridgeOccurrence(meaningful as Record<string, unknown>)
|
|
330
355
|
cachedNextEvent = {
|
|
331
356
|
title: meaningful.title || 'Meeting',
|
|
332
357
|
time: meaningful.start_time || meaningful.start || '',
|
|
333
358
|
...(minsUntil != null ? { inMinutes: minsUntil } : {}),
|
|
359
|
+
...(occurrence ? { occurrence } : {}),
|
|
334
360
|
}
|
|
335
361
|
} else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
|
|
362
|
+
const occurrence = bridgeOccurrence(cal.next_event as Record<string, unknown>)
|
|
336
363
|
cachedNextEvent = {
|
|
337
364
|
title: cal.next_event.title || 'Meeting',
|
|
338
365
|
time: cal.next_event.start_time || '',
|
|
339
366
|
...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
|
|
367
|
+
...(occurrence ? { occurrence } : {}),
|
|
340
368
|
}
|
|
341
369
|
} else {
|
|
342
370
|
cachedNextEvent = null
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
|
|
3
3
|
*
|
|
4
|
-
* Lens (over the network, token
|
|
5
|
-
* POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId
|
|
4
|
+
* Lens, and COS Control Web (over the network, token or browser sign-in from the parent /api mount):
|
|
5
|
+
* POST /api/work/chat/lens-rounds record a plan the lens or the web made (idempotent on clientRoundId;
|
|
6
|
+
* source "lens" with a lens- id, or "web" with a web- id)
|
|
6
7
|
* GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
|
|
7
8
|
* POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
|
|
8
9
|
* POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
|
|
10
|
+
* COS Control Web:
|
|
11
|
+
* GET /api/work/chat/web-plans { protocolVersion: 1, webPlans: 1, sources }: this server records web plans
|
|
12
|
+
* (a web round is refused 409 round_id_conflict when its id is reused
|
|
13
|
+
* for a different plan; GET /api/work/chat is unchanged)
|
|
9
14
|
* COS Control (this Mac only):
|
|
10
15
|
* GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
|
|
11
16
|
* POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
|
|
@@ -17,8 +22,8 @@
|
|
|
17
22
|
* (index.ts leaves these paths to this router so the error code is the contract's).
|
|
18
23
|
*/
|
|
19
24
|
import { Router, type Request, type Response } from 'express'
|
|
20
|
-
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
21
|
-
parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
25
|
+
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, ROUND_SOURCES, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
26
|
+
parseSendBody, parseStopBody, roundView, sameRoundContent, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
22
27
|
import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
23
28
|
import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
|
|
24
29
|
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
@@ -96,6 +101,9 @@ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> =
|
|
|
96
101
|
const input = parseLensRound(req.body)
|
|
97
102
|
// A repeat is answered from the store, even when the board has since changed or is unreachable.
|
|
98
103
|
const replay = deps.store.replay(input.clientRoundId)
|
|
104
|
+
if (replay && input.source === 'web' && !sameRoundContent(replay, input)) {
|
|
105
|
+
throw new LensRoundError('round_id_conflict', 409, 'This round id was used for a different plan. Plan again.')
|
|
106
|
+
}
|
|
99
107
|
if (!replay) {
|
|
100
108
|
let known: boolean
|
|
101
109
|
try { known = await deps.knownWork(input.workID) }
|
|
@@ -103,10 +111,16 @@ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> =
|
|
|
103
111
|
if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
|
|
104
112
|
}
|
|
105
113
|
const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
|
|
106
|
-
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
|
|
114
|
+
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}${round.source === 'web' ? ' source=web' : ''}`)
|
|
107
115
|
return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
|
|
108
116
|
}))
|
|
109
117
|
|
|
118
|
+
// COS Control Web asks this before it shows Work chat. A server without it answers 404, and the web says it needs an update.
|
|
119
|
+
router.get('/work/chat/web-plans', (_req, res) => {
|
|
120
|
+
res.set('Cache-Control', 'private, no-store')
|
|
121
|
+
return res.json({ protocolVersion: 1, webPlans: 1, sources: [...ROUND_SOURCES] })
|
|
122
|
+
})
|
|
123
|
+
|
|
110
124
|
router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
|
|
111
125
|
try {
|
|
112
126
|
const round = deps.store.find(roundIdOf(req))
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* POST /api/work/orchestrator/input {workID, meetingWorkIDs?, snapshot?}
|
|
6
6
|
* Stores Control's snapshot when one is sent (Phase A: Control pushes, the server keeps the copy), then answers
|
|
7
7
|
* the card's planner rows, what was left out and why, and the usage snapshot.
|
|
8
|
+
* POST /api/work/orchestrator/snapshot {workID, revision, serverInstanceId, snapshot}
|
|
9
|
+
* Bound to this server and a current canonical card revision. Stores validated Control evidence only;
|
|
10
|
+
* no discovery, usage refresh, model, link/receipt write or dispatch.
|
|
8
11
|
* GET /api/work/orchestrator/usage[?refresh=1]
|
|
9
12
|
* The usage snapshot per provider, read on demand: Claude and Codex are re-read when their reading is older than
|
|
10
13
|
* 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
|
|
@@ -14,6 +17,11 @@ import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/sn
|
|
|
14
17
|
import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
|
|
15
18
|
import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
|
|
16
19
|
import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
|
|
20
|
+
import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
|
|
21
|
+
import { createWorkBoardReader } from '../lib/work-board-reader.js'
|
|
22
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
23
|
+
import { maintenanceAdmissionsOpen, acquireMaintenanceWork } from '../lib/maintenance-lifecycle.js'
|
|
24
|
+
import { AskWorkPlanError, resolveAskWorkTask } from '../lib/ask-work-plan.js'
|
|
17
25
|
|
|
18
26
|
const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
|
|
19
27
|
const MEETING_ID = /^meeting:[^\u0000-\u001f]{1,250}$/
|
|
@@ -24,6 +32,10 @@ export interface WorkOrchestratorDeps {
|
|
|
24
32
|
input: (workID: string, meetingWorkIDs: string[]) => Promise<OrchestratorInput>
|
|
25
33
|
usage: (refresh: boolean) => Promise<UsageView[]>
|
|
26
34
|
now: () => number
|
|
35
|
+
board: () => Promise<TaskBoardRow[]>
|
|
36
|
+
serverInstanceId: () => string | null
|
|
37
|
+
admissions: () => boolean
|
|
38
|
+
lease: () => { release(): void }
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
/** GET /usage reads on demand: a reading older than 10 minutes is re-read (`refresh=1`: 2 minutes). */
|
|
@@ -34,12 +46,57 @@ const DEFAULTS: WorkOrchestratorDeps = {
|
|
|
34
46
|
input: orchestratorInputFor,
|
|
35
47
|
usage: refresh => readUsage(refresh),
|
|
36
48
|
now: () => Date.now(),
|
|
49
|
+
board: () => createWorkBoardReader(() => listBoard()).read(0),
|
|
50
|
+
serverInstanceId: getServerInstanceId,
|
|
51
|
+
admissions: maintenanceAdmissionsOpen,
|
|
52
|
+
lease: () => acquireMaintenanceWork('api_mutation'),
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps> = {}) {
|
|
40
56
|
const deps = { ...DEFAULTS, ...given }
|
|
41
57
|
const router = Router()
|
|
42
58
|
router.use('/work/orchestrator', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
59
|
+
router.post('/work/orchestrator/snapshot', async (req, res) => {
|
|
60
|
+
const body = req.body
|
|
61
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
62
|
+
|| Object.keys(body).some(k => !['workID', 'revision', 'serverInstanceId', 'snapshot'].includes(k))
|
|
63
|
+
|| typeof body.workID !== 'string' || body.workID.length > 256 || !WORK_ID.test(body.workID)
|
|
64
|
+
|| typeof body.revision !== 'string' || !/^[a-f0-9]{64}$/.test(body.revision)
|
|
65
|
+
|| typeof body.serverInstanceId !== 'string'
|
|
66
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.serverInstanceId)) {
|
|
67
|
+
return res.status(400).json({ error: 'invalid_snapshot_binding' })
|
|
68
|
+
}
|
|
69
|
+
if (Buffer.byteLength(JSON.stringify(body)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
|
|
70
|
+
let snapshot: ControlSnapshot
|
|
71
|
+
try { snapshot = parseSnapshot(body.snapshot, deps.now()) }
|
|
72
|
+
catch { return res.status(400).json({ error: 'invalid_snapshot' }) }
|
|
73
|
+
const binding = () => {
|
|
74
|
+
const id = deps.serverInstanceId()
|
|
75
|
+
if (!id) throw new AskWorkPlanError('server_instance_unavailable', 503)
|
|
76
|
+
if (id !== body.serverInstanceId) throw new AskWorkPlanError('server_instance_changed', 409)
|
|
77
|
+
if (!deps.admissions()) throw new AskWorkPlanError('maintenance', 503)
|
|
78
|
+
return id
|
|
79
|
+
}
|
|
80
|
+
let lease: ReturnType<WorkOrchestratorDeps['lease']> | undefined
|
|
81
|
+
try {
|
|
82
|
+
binding()
|
|
83
|
+
lease = deps.lease()
|
|
84
|
+
let rows: TaskBoardRow[]
|
|
85
|
+
try { rows = await deps.board() }
|
|
86
|
+
catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
|
|
87
|
+
resolveAskWorkTask(body, rows)
|
|
88
|
+
const serverInstanceId = binding()
|
|
89
|
+
if (res.destroyed) return
|
|
90
|
+
// This is the same paired-client evidence cache as /input, not execution authority. parseSnapshot
|
|
91
|
+
// retains only its known fields; arbitrary automatic/forkable/capability fields never enter the cache.
|
|
92
|
+
deps.putSnapshot(snapshot)
|
|
93
|
+
return res.json({ protocolVersion: 1, accepted: true, workID: body.workID, revision: body.revision, serverInstanceId })
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (res.destroyed) return
|
|
96
|
+
const known = error instanceof AskWorkPlanError
|
|
97
|
+
return res.status(known ? error.status : 503).json({ error: known ? error.code : 'snapshot_unavailable' })
|
|
98
|
+
} finally { lease?.release() }
|
|
99
|
+
})
|
|
43
100
|
router.post('/work/orchestrator/input', async (req, res) => {
|
|
44
101
|
const body = req.body
|
|
45
102
|
const meetings: unknown = body?.meetingWorkIDs ?? []
|