@gotcos/glasses-server 6.58.2 → 6.59.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 +29 -0
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +16 -6
- package/package.json +4 -2
- package/server/index.ts +12 -1
- package/server/lib/query-job-types.ts +11 -2
- 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/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work handoff requests (6.59.0): the glasses ask, COS Control does it.
|
|
3
|
+
*
|
|
4
|
+
* The glasses cannot send a tracked Work handoff themselves: the handoff journal, its one-active-handoff fence and
|
|
5
|
+
* the COS-WORK tracker are Control's, on the Mac. So the glasses leave a request here, and Control claims it, runs
|
|
6
|
+
* its own `submit()` with every guard, and reports the result. This server never runs a provider for a request and
|
|
7
|
+
* never asks Jev.
|
|
8
|
+
*
|
|
9
|
+
* Time bounds a request so a tap can never fire later: a pending request expires 10 minutes after it was made and can
|
|
10
|
+
* never be claimed after that; a claimed request carries `claimExpiresAt` (claim plus 10 minutes), after which Control
|
|
11
|
+
* must not send it, and with no result by then it reads `unconfirmed` (Control may have sent it) and still takes
|
|
12
|
+
* exactly one late result. Both follow from the stored times on every read and are written on the next write, so a
|
|
13
|
+
* GET never writes.
|
|
14
|
+
*
|
|
15
|
+
* One process owns the journal (instance lock), writes are durable and atomic, and a bad record is quarantined on
|
|
16
|
+
* its own, as in work-intake-store.ts.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
19
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
22
|
+
import { isSafeDomainName } from './domains.js'
|
|
23
|
+
import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
|
|
24
|
+
import { parseWorkSessionId, modelProvider, UUID_V4, FORK_PROVIDERS } from './work-activity.js'
|
|
25
|
+
import { normalizeModelPreference } from '../../shared/model-preference.js'
|
|
26
|
+
|
|
27
|
+
export class WorkHandoffRequestError extends Error {
|
|
28
|
+
/** `extra` goes into the error body next to the code (the daily cap's `resetsAt`). */
|
|
29
|
+
constructor(readonly code: string, readonly status = 409, message?: string, readonly extra: Record<string, string> = {}) { super(message ?? code) }
|
|
30
|
+
}
|
|
31
|
+
export type HandoffRequestMode = 'continueSession' | 'fork' | 'newSession'
|
|
32
|
+
export type HandoffDestinationSource = 'saved' | 'jev' | 'user'
|
|
33
|
+
export type HandoffRequestState = 'pending' | 'claimed' | 'sent' | 'refused' | 'unresolved' | 'unconfirmed' | 'expired'
|
|
34
|
+
/** Start work, Reply by voice (needs input or blocked), Not done yet (a reported done). */
|
|
35
|
+
export type HandoffIntent = 'start' | 'reply' | 'notDone'
|
|
36
|
+
export interface HandoffRequestInput {
|
|
37
|
+
clientRequestId: string; domain: string; workIdentity: string; expectedTaskRevision: string; intent: HandoffIntent
|
|
38
|
+
mode: HandoffRequestMode; sessionId?: string; model?: string; note?: string
|
|
39
|
+
/** A hint for Control's copy; Control never trusts it. */
|
|
40
|
+
destinationSource: HandoffDestinationSource
|
|
41
|
+
/** Reply and Not done yet: the item's newest receipt, which Control sends back to its session. */
|
|
42
|
+
replyTo?: string
|
|
43
|
+
}
|
|
44
|
+
export interface HandoffRequestRecord extends HandoffRequestInput {
|
|
45
|
+
state: HandoffRequestState; createdAt: string; claimedAt?: string; resultAt?: string; receiptId?: string; reason?: string
|
|
46
|
+
/** SHA-256 of the request as submitted, for idempotency. Never returned. */
|
|
47
|
+
fingerprint: string
|
|
48
|
+
/** Returned by the claim only; the result must carry it. */
|
|
49
|
+
claimToken?: string
|
|
50
|
+
}
|
|
51
|
+
export type HandoffResultState = 'sent' | 'refused' | 'unresolved'
|
|
52
|
+
export interface HandoffResult { state: HandoffResultState; receiptId?: string; reason?: string; claimToken: string }
|
|
53
|
+
|
|
54
|
+
export const HANDOFF_REQUEST_LIMITS = {
|
|
55
|
+
pendingMs: 10 * 60_000, claimMs: 10 * 60_000, dailyCap: 40, note: 2_000, reason: 500, body: 16_384,
|
|
56
|
+
retainDays: 7, consumerMs: 120_000, skewMs: 5 * 60_000, quarantined: 500,
|
|
57
|
+
} as const
|
|
58
|
+
const MODES: readonly string[] = ['continueSession', 'fork', 'newSession']
|
|
59
|
+
const SOURCES: readonly string[] = ['saved', 'jev', 'user']
|
|
60
|
+
const STORED_STATES: readonly string[] = ['pending', 'claimed', 'sent', 'refused', 'unresolved', 'unconfirmed', 'expired']
|
|
61
|
+
const RESULT_STATES: readonly string[] = ['sent', 'refused', 'unresolved']
|
|
62
|
+
const INTENTS: readonly string[] = ['start', 'reply', 'notDone']
|
|
63
|
+
const INPUT_KEYS = ['clientRequestId', 'domain', 'workIdentity', 'expectedTaskRevision', 'intent', 'mode', 'sessionId', 'model', 'note', 'destinationSource', 'replyTo']
|
|
64
|
+
const RECEIPT_ID = /^[^\s\u0000-\u001f\u007f-\u009f]{1,256}$/
|
|
65
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
66
|
+
/**
|
|
67
|
+
* Refused in a note: every control character except newline and tab; U+2028 and U+2029; invisible format characters
|
|
68
|
+
* (direction marks and overrides, zero-width space, soft hyphen, byte-order mark and the rest of Unicode's Cf) except
|
|
69
|
+
* the joiners U+200C and U+200D that scripts and emoji need; and a lone surrogate.
|
|
70
|
+
*/
|
|
71
|
+
const NOTE_REFUSED = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u2028\u2029]|(?![\u200c\u200d])\p{Cf}|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u
|
|
72
|
+
/**
|
|
73
|
+
* A note must never carry a COS-WORK status line: an agent quoting it back could move the card. Control reads a line
|
|
74
|
+
* after dropping emphasis and code marks (WorkProgress.reports), so the note is checked the same way: `*`, `_`,
|
|
75
|
+
* backticks and the joiners a note may carry are taken out first, so COS**-WORK and its like are refused too.
|
|
76
|
+
*/
|
|
77
|
+
const STATUS_LINE = /COS-WORK/i
|
|
78
|
+
const carriesStatusLine = (note: string) => STATUS_LINE.test(note.replace(/[*_`\u200c\u200d]/g, ''))
|
|
79
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
80
|
+
const countGraphemes = (value: string): number => { let n = 0; for (const _ of graphemes.segment(value)) n++; return n }
|
|
81
|
+
|
|
82
|
+
const invalid = (message: string) => new WorkHandoffRequestError('invalid_handoff_request', 400, message)
|
|
83
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
84
|
+
/** Absent, null and the empty string all mean "not given" for an optional string. */
|
|
85
|
+
const given = (value: unknown): boolean => value !== undefined && value !== null && value !== ''
|
|
86
|
+
const iso = (ms: number) => new Date(ms).toISOString()
|
|
87
|
+
/**
|
|
88
|
+
* The Mac's local calendar: the daily cap resets at local midnight. `timeZone` is for tests (an IANA name); left out,
|
|
89
|
+
* it is the system's zone.
|
|
90
|
+
*/
|
|
91
|
+
function localCalendar(timeZone?: string): { day: (ms: number) => string; nextMidnight: (ms: number) => number } {
|
|
92
|
+
const format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' })
|
|
93
|
+
const day = (ms: number) => format.format(new Date(ms))
|
|
94
|
+
// The first instant of the next local day, to the millisecond, whatever the zone's offset does that night.
|
|
95
|
+
const nextMidnight = (ms: number) => {
|
|
96
|
+
const today = day(ms)
|
|
97
|
+
let low = ms, high = ms + 36 * 3_600_000
|
|
98
|
+
while (high - low > 1) { const mid = Math.floor((low + high) / 2); if (day(mid) === today) low = mid; else high = mid }
|
|
99
|
+
return high
|
|
100
|
+
}
|
|
101
|
+
return { day, nextMidnight }
|
|
102
|
+
}
|
|
103
|
+
const log = (message: string) => console.warn(`[work-handoff-requests] ${message}`)
|
|
104
|
+
|
|
105
|
+
/** A request id as stored and looked up: a UUIDv4 in lowercase, or null. */
|
|
106
|
+
export function normalizeRequestId(value: unknown): string | null {
|
|
107
|
+
if (typeof value !== 'string') return null
|
|
108
|
+
const id = value.toLowerCase()
|
|
109
|
+
return UUID_V4.test(id) ? id : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate one glasses request. Anything malformed is 400 invalid_handoff_request. A newSession without a model takes
|
|
114
|
+
* `defaultModel` (the server's configured default), and the stored request names it.
|
|
115
|
+
*/
|
|
116
|
+
export function parseHandoffRequest(body: unknown, defaultModel: string): { input: HandoffRequestInput; fingerprint: string } {
|
|
117
|
+
if (!object(body)) throw invalid('Send one handoff request.')
|
|
118
|
+
if (Object.keys(body).some(key => !INPUT_KEYS.includes(key))) throw invalid('Unknown field in the handoff request.')
|
|
119
|
+
if (Buffer.byteLength(JSON.stringify(body)) > HANDOFF_REQUEST_LIMITS.body) throw invalid('The handoff request is too large.')
|
|
120
|
+
const clientRequestId = normalizeRequestId(body.clientRequestId)
|
|
121
|
+
if (!clientRequestId) throw invalid('clientRequestId must be a UUID v4.')
|
|
122
|
+
const { domain, workIdentity, expectedTaskRevision, intent, mode, destinationSource } = body
|
|
123
|
+
if (typeof domain !== 'string' || !isSafeDomainName(domain)) throw invalid('Select an exact domain.')
|
|
124
|
+
if (typeof workIdentity !== 'string' || !/^[a-f0-9]{12}$/.test(workIdentity)) throw invalid('Select an exact task.')
|
|
125
|
+
if (typeof expectedTaskRevision !== 'string' || !/^[a-f0-9]{64}$/.test(expectedTaskRevision)) throw invalid('Refresh the task before starting work.')
|
|
126
|
+
if (typeof intent !== 'string' || !INTENTS.includes(intent)) throw invalid('intent must be start, reply or notDone.')
|
|
127
|
+
if (typeof mode !== 'string' || !MODES.includes(mode)) throw invalid('mode must be continueSession, fork or newSession.')
|
|
128
|
+
if (typeof destinationSource !== 'string' || !SOURCES.includes(destinationSource)) throw invalid('destinationSource must be saved, jev or user.')
|
|
129
|
+
let sessionId: string | undefined
|
|
130
|
+
let provider: string | undefined
|
|
131
|
+
if (given(body.sessionId)) {
|
|
132
|
+
const session = parseWorkSessionId(body.sessionId)
|
|
133
|
+
if (!session) throw invalid('sessionId must be exactly "provider:native": claude, codex or cursor and a full session id.')
|
|
134
|
+
sessionId = body.sessionId as string; provider = session.provider
|
|
135
|
+
}
|
|
136
|
+
let model: string | undefined
|
|
137
|
+
if (given(body.model)) {
|
|
138
|
+
model = normalizeModelPreference(body.model)
|
|
139
|
+
if (!model) throw invalid('Choose a model slot this server knows.')
|
|
140
|
+
}
|
|
141
|
+
let note: string | undefined
|
|
142
|
+
if (given(body.note)) {
|
|
143
|
+
if (typeof body.note !== 'string') throw invalid('note must be text.')
|
|
144
|
+
if (NOTE_REFUSED.test(body.note)) throw invalid('The note has a control or invisible character. Only newlines and tabs are allowed.')
|
|
145
|
+
if (carriesStatusLine(body.note)) throw invalid('A note cannot carry a COS-WORK status line.')
|
|
146
|
+
const trimmed = body.note.trim()
|
|
147
|
+
if ([...trimmed].length > HANDOFF_REQUEST_LIMITS.note) throw invalid('The note is over 2,000 characters.')
|
|
148
|
+
if (trimmed) note = trimmed
|
|
149
|
+
}
|
|
150
|
+
let replyTo: string | undefined
|
|
151
|
+
if (given(body.replyTo)) {
|
|
152
|
+
if (typeof body.replyTo !== 'string' || !RECEIPT_ID.test(body.replyTo)) throw invalid('replyTo must be a receipt id.')
|
|
153
|
+
replyTo = body.replyTo
|
|
154
|
+
}
|
|
155
|
+
if (mode === 'newSession') {
|
|
156
|
+
if (sessionId) throw invalid('A New session has no source session.')
|
|
157
|
+
} else {
|
|
158
|
+
if (!sessionId || !provider) throw invalid('Continue and Fork need sessionId ("provider:native").')
|
|
159
|
+
if (mode === 'continueSession') {
|
|
160
|
+
if (model) throw invalid('Continue sends to the session as it is; it takes no model.')
|
|
161
|
+
} else {
|
|
162
|
+
if (!FORK_PROVIDERS.has(provider)) throw invalid('Fork works with Claude and Codex sessions only.')
|
|
163
|
+
// A model on a Fork means a Fork to the other platform.
|
|
164
|
+
if (model && (!FORK_PROVIDERS.has(modelProvider(model as never)) || modelProvider(model as never) === provider)) {
|
|
165
|
+
throw invalid('A Fork model belongs to the other platform (Claude or Codex).')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if ((intent === 'reply' || intent === 'notDone') !== !!replyTo) throw invalid('Reply and Not done yet name replyTo; Start does not.')
|
|
170
|
+
if (replyTo && (mode !== 'continueSession' || !note)) throw invalid('A reply continues its session with a note.')
|
|
171
|
+
const fingerprint = createHash('sha256').update(JSON.stringify([clientRequestId, domain, workIdentity, expectedTaskRevision, intent, mode,
|
|
172
|
+
sessionId ?? null, model ?? null, note ?? null, destinationSource, replyTo ?? null])).digest('hex')
|
|
173
|
+
const resolvedModel = mode === 'newSession' ? model ?? defaultModel : model
|
|
174
|
+
const input: HandoffRequestInput = { clientRequestId, domain, workIdentity, expectedTaskRevision, intent: intent as HandoffIntent, mode: mode as HandoffRequestMode,
|
|
175
|
+
...(sessionId ? { sessionId } : {}), ...(resolvedModel ? { model: resolvedModel } : {}), ...(note ? { note } : {}),
|
|
176
|
+
destinationSource: destinationSource as HandoffDestinationSource, ...(replyTo ? { replyTo } : {}) }
|
|
177
|
+
return { input, fingerprint }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Control's report: `sent` names its receipt, `refused` says why (and names a receipt when there is one), `unresolved`
|
|
182
|
+
* names the receipt whose delivery Control could not confirm. A reason is cleaned (control characters as spaces) and
|
|
183
|
+
* kept to 500 characters.
|
|
184
|
+
*/
|
|
185
|
+
export function parseHandoffResult(body: unknown): HandoffResult {
|
|
186
|
+
if (!object(body) || Object.keys(body).some(key => !['state', 'receiptId', 'reason', 'claimToken'].includes(key))) throw invalid('Send { state, receiptId?, reason?, claimToken }.')
|
|
187
|
+
if (typeof body.state !== 'string' || !RESULT_STATES.includes(body.state)) throw invalid('state must be sent, refused or unresolved.')
|
|
188
|
+
if (typeof body.claimToken !== 'string' || !CLAIM_TOKEN.test(body.claimToken)) throw invalid('A result carries the claimToken its claim returned.')
|
|
189
|
+
const result: HandoffResult = { state: body.state as HandoffResultState, claimToken: body.claimToken }
|
|
190
|
+
if (given(body.receiptId)) {
|
|
191
|
+
if (typeof body.receiptId !== 'string' || !RECEIPT_ID.test(body.receiptId)) throw invalid('receiptId must be a receipt id.')
|
|
192
|
+
result.receiptId = body.receiptId
|
|
193
|
+
}
|
|
194
|
+
if (given(body.reason)) {
|
|
195
|
+
if (typeof body.reason !== 'string') throw invalid('reason must be text.')
|
|
196
|
+
const flat = body.reason.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
197
|
+
// Cut in whole characters; the loader (parseStored) counts the same way, or a long reason would not survive a restart.
|
|
198
|
+
if (flat) result.reason = Array.from(graphemes.segment(flat), part => part.segment).slice(0, HANDOFF_REQUEST_LIMITS.reason).join('')
|
|
199
|
+
}
|
|
200
|
+
if ((result.state === 'sent' || result.state === 'unresolved') && !result.receiptId) throw invalid(`A ${result.state} result names its receipt.`)
|
|
201
|
+
if (result.state === 'refused' && !result.reason) throw invalid('A refused result says why.')
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const CLAIM_TOKEN = /^[a-f0-9]{32}$/
|
|
206
|
+
const claimExpiry = (r: HandoffRequestRecord) => r.claimedAt ? { claimExpiresAt: iso(Date.parse(r.claimedAt) + HANDOFF_REQUEST_LIMITS.claimMs) } : {}
|
|
207
|
+
/** What the glasses poll: the state and times, never the note. */
|
|
208
|
+
export function handoffStatusView(r: HandoffRequestRecord) {
|
|
209
|
+
return {
|
|
210
|
+
clientRequestId: r.clientRequestId, state: r.state,
|
|
211
|
+
...(r.receiptId ? { receiptId: r.receiptId } : {}), ...(r.reason ? { reason: r.reason } : {}),
|
|
212
|
+
createdAt: r.createdAt, expiresAt: iso(Date.parse(r.createdAt) + HANDOFF_REQUEST_LIMITS.pendingMs),
|
|
213
|
+
...(r.claimedAt ? { claimedAt: r.claimedAt } : {}), ...claimExpiry(r), ...(r.resultAt ? { resultAt: r.resultAt } : {}),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** What Control reads: the whole request, with the deadline it must send by. The fingerprint stays inside. */
|
|
217
|
+
export function handoffFullView(r: HandoffRequestRecord) {
|
|
218
|
+
const { fingerprint: _fingerprint, claimToken: _claimToken, ...rest } = r
|
|
219
|
+
return { ...rest, ...handoffStatusView(r) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseStored(raw: unknown): HandoffRequestRecord {
|
|
223
|
+
if (!object(raw)) throw new Error('record')
|
|
224
|
+
// A stored New session always names its model; one without is damaged, never loaded as the default.
|
|
225
|
+
if (raw.mode === 'newSession' && !given(raw.model)) throw new Error('record')
|
|
226
|
+
const { input } = parseHandoffRequest(Object.fromEntries(INPUT_KEYS.filter(k => raw[k] !== undefined).map(k => [k, raw[k]])), 'sonnet')
|
|
227
|
+
const { state, createdAt, claimedAt, resultAt, receiptId, reason, fingerprint, claimToken } = raw
|
|
228
|
+
if (typeof state !== 'string' || !STORED_STATES.includes(state) || typeof createdAt !== 'string' || !ISO.test(createdAt)
|
|
229
|
+
|| typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(fingerprint)
|
|
230
|
+
|| (claimedAt !== undefined && (typeof claimedAt !== 'string' || !ISO.test(claimedAt)))
|
|
231
|
+
|| (resultAt !== undefined && (typeof resultAt !== 'string' || !ISO.test(resultAt)))
|
|
232
|
+
|| (receiptId !== undefined && (typeof receiptId !== 'string' || !RECEIPT_ID.test(receiptId)))
|
|
233
|
+
|| (reason !== undefined && (typeof reason !== 'string' || countGraphemes(reason) > HANDOFF_REQUEST_LIMITS.reason))
|
|
234
|
+
|| (claimToken !== undefined && (typeof claimToken !== 'string' || !CLAIM_TOKEN.test(claimToken)))
|
|
235
|
+
|| ((state === 'claimed' || state === 'unconfirmed') && (claimedAt === undefined || claimToken === undefined))) throw new Error('record')
|
|
236
|
+
return { ...input, state: state as HandoffRequestState, createdAt, fingerprint, ...(claimToken ? { claimToken } : {}),
|
|
237
|
+
...(claimedAt ? { claimedAt } : {}), ...(resultAt ? { resultAt } : {}), ...(receiptId ? { receiptId } : {}), ...(reason ? { reason } : {}) }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A record the inbox could not read, kept aside with when it was set aside so it ages out on the 7-day schedule. */
|
|
241
|
+
interface QuarantinedRecord { at: string; record: unknown }
|
|
242
|
+
|
|
243
|
+
export class WorkHandoffRequestStore {
|
|
244
|
+
private rows: HandoffRequestRecord[] = []
|
|
245
|
+
private quarantined: QuarantinedRecord[] = []
|
|
246
|
+
private readonly path: string
|
|
247
|
+
private readonly lock: ServerInstanceLock
|
|
248
|
+
private closed = false
|
|
249
|
+
/** When COS Control last listed pending requests. Memory only: after a restart the inbox waits for its next poll. */
|
|
250
|
+
private consumerAt: number | null = null
|
|
251
|
+
/** Time-made transitions already logged (once each per process). */
|
|
252
|
+
private readonly noticed = new Set<string>()
|
|
253
|
+
private readonly calendar: ReturnType<typeof localCalendar>
|
|
254
|
+
constructor(root: string, private readonly now: () => Date = () => new Date(),
|
|
255
|
+
private readonly writeFile: (path: string, data: string) => void = durableAtomicWriteFileSync, options: { timeZone?: string } = {}) {
|
|
256
|
+
this.calendar = localCalendar(options.timeZone)
|
|
257
|
+
const dir = resolve(root)
|
|
258
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
259
|
+
const stat = lstatSync(dir)
|
|
260
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new WorkHandoffRequestError('handoff_store_unsafe', 503)
|
|
261
|
+
this.path = join(dir, 'requests.json')
|
|
262
|
+
try { this.lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') }) }
|
|
263
|
+
catch { throw new WorkHandoffRequestError('handoff_store_locked', 503) }
|
|
264
|
+
if (!existsSync(this.path)) return
|
|
265
|
+
try {
|
|
266
|
+
const file = lstatSync(this.path)
|
|
267
|
+
if (!file.isFile() || file.isSymbolicLink() || file.uid !== process.getuid?.() || (file.mode & 0o077) || file.size > 8_000_000) throw new Error('unsafe')
|
|
268
|
+
const journal = JSON.parse(readFileSync(this.path, 'utf8'))
|
|
269
|
+
if (journal?.version !== 1 || !Array.isArray(journal.requests)) throw new Error('version')
|
|
270
|
+
const loadedAt = iso(this.now().getTime()), seen = new Set<string>()
|
|
271
|
+
for (const raw of journal.requests) {
|
|
272
|
+
try {
|
|
273
|
+
const record = parseStored(raw)
|
|
274
|
+
if (seen.has(record.clientRequestId)) throw new Error('duplicate')
|
|
275
|
+
seen.add(record.clientRequestId); this.rows.push(record)
|
|
276
|
+
} catch { this.quarantined.push({ at: loadedAt, record: raw }) } // one bad record never takes the inbox down
|
|
277
|
+
}
|
|
278
|
+
for (const entry of Array.isArray(journal.quarantined) ? journal.quarantined.slice(0, HANDOFF_REQUEST_LIMITS.quarantined) : []) {
|
|
279
|
+
this.quarantined.push(object(entry) && typeof entry.at === 'string' && ISO.test(entry.at) && 'record' in entry ? { at: entry.at, record: entry.record } : { at: loadedAt, record: entry })
|
|
280
|
+
}
|
|
281
|
+
if (this.quarantined.length) log(`${this.quarantined.length} unreadable record(s) set aside in ${this.path}`)
|
|
282
|
+
} catch { this.close(); throw new WorkHandoffRequestError('handoff_store_recovery_required', 503) }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The state a stored request is in now. Time alone never needs a write. A time more than 5 minutes ahead of the
|
|
287
|
+
* clock (a clock that jumped) ends the wait at once: a pending request is expired and can never be claimed; a
|
|
288
|
+
* claimed one is unconfirmed, never expired, because COS Control may have sent it and must still be able to say so.
|
|
289
|
+
*/
|
|
290
|
+
private effective(r: HandoffRequestRecord, nowMs: number): HandoffRequestRecord {
|
|
291
|
+
const created = Date.parse(r.createdAt), claimed = r.claimedAt ? Date.parse(r.claimedAt) : undefined
|
|
292
|
+
const ahead = (at: number | undefined) => at !== undefined && at > nowMs + HANDOFF_REQUEST_LIMITS.skewMs
|
|
293
|
+
let state = r.state, why = ''
|
|
294
|
+
if (state === 'pending' && ahead(created)) { state = 'expired'; why = 'it was made more than 5 minutes ahead of this clock' }
|
|
295
|
+
else if (state === 'pending' && nowMs >= created + HANDOFF_REQUEST_LIMITS.pendingMs) { state = 'expired'; why = 'COS Control did not claim it in time' }
|
|
296
|
+
else if (state === 'claimed' && (ahead(created) || ahead(claimed))) { state = 'unconfirmed'; why = 'its claim is more than 5 minutes ahead of this clock' }
|
|
297
|
+
else if (state === 'claimed' && nowMs >= claimed! + HANDOFF_REQUEST_LIMITS.claimMs) { state = 'unconfirmed'; why = 'COS Control claimed it and did not report back' }
|
|
298
|
+
if (state !== r.state && !this.noticed.has(r.clientRequestId + state)) {
|
|
299
|
+
this.noticed.add(r.clientRequestId + state)
|
|
300
|
+
log(`${r.clientRequestId} ${state}: ${why}`)
|
|
301
|
+
}
|
|
302
|
+
return { ...r, state }
|
|
303
|
+
}
|
|
304
|
+
private current(): HandoffRequestRecord[] {
|
|
305
|
+
const nowMs = this.now().getTime()
|
|
306
|
+
return this.rows.map(r => this.effective(r, nowMs))
|
|
307
|
+
}
|
|
308
|
+
get(id: string): HandoffRequestRecord | undefined { return this.current().find(r => r.clientRequestId === id) }
|
|
309
|
+
/** Oldest first, so Control works through them in the order they were asked. */
|
|
310
|
+
list(state?: HandoffRequestState): HandoffRequestRecord[] {
|
|
311
|
+
return this.current().filter(r => !state || r.state === state).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
312
|
+
}
|
|
313
|
+
quarantineCount(): number { return this.quarantined.length }
|
|
314
|
+
/** COS Control listed pending requests: it is consuming the inbox. */
|
|
315
|
+
noteConsumer(): void { this.consumerAt = this.now().getTime() }
|
|
316
|
+
consumerSeenAt(): number | null { return this.consumerAt }
|
|
317
|
+
|
|
318
|
+
/** The stored request when this is a retry of it; a different request under the same id is a conflict. */
|
|
319
|
+
replay(id: string, fingerprint: string): HandoffRequestRecord | undefined {
|
|
320
|
+
const existing = this.get(id)
|
|
321
|
+
if (existing && existing.fingerprint !== fingerprint) throw new WorkHandoffRequestError('request_id_conflict', 409, 'This request id was already used for a different request.')
|
|
322
|
+
return existing
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Record a request. Synchronous from the replay check to the write, so two requests for one item cannot both pass
|
|
327
|
+
* the one-per-item check. `taskRevision` is the task's own revision (Control's taskSnapshot) read from the board
|
|
328
|
+
* just before.
|
|
329
|
+
*/
|
|
330
|
+
create(input: HandoffRequestInput, fingerprint: string, taskRevision: string | undefined): { request: HandoffRequestRecord; created: boolean } {
|
|
331
|
+
const existing = this.replay(input.clientRequestId, fingerprint)
|
|
332
|
+
if (existing) return { request: existing, created: false }
|
|
333
|
+
if (taskRevision !== input.expectedTaskRevision) throw new WorkHandoffRequestError('revision_changed', 409, 'This task changed. Refresh it before starting work.')
|
|
334
|
+
const nowMs = this.now().getTime(), rows = this.current()
|
|
335
|
+
if (rows.some(r => r.domain === input.domain && r.workIdentity === input.workIdentity && (r.state === 'pending' || r.state === 'claimed'))) {
|
|
336
|
+
throw new WorkHandoffRequestError('request_pending', 409, 'This task already has a request waiting for COS Control.')
|
|
337
|
+
}
|
|
338
|
+
const today = this.calendar.day(nowMs)
|
|
339
|
+
if (rows.filter(r => this.calendar.day(Date.parse(r.createdAt)) === today).length >= HANDOFF_REQUEST_LIMITS.dailyCap) {
|
|
340
|
+
// `resetsAt` is the Mac's next local midnight; the glasses format it. The words carry no time.
|
|
341
|
+
throw new WorkHandoffRequestError('daily_cap', 429, 'The daily limit of 40 handoff requests is reached. Try again after the daily reset.', { resetsAt: iso(this.calendar.nextMidnight(nowMs)) })
|
|
342
|
+
}
|
|
343
|
+
const record: HandoffRequestRecord = { ...input, state: 'pending', createdAt: iso(nowMs), fingerprint }
|
|
344
|
+
this.write([...rows, record], nowMs)
|
|
345
|
+
return { request: { ...record }, created: true }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Control takes one pending request and gets a claim token. Exactly one claim wins; the same token claims again
|
|
350
|
+
* idempotently while the claim holds; an expired request can never be claimed.
|
|
351
|
+
*/
|
|
352
|
+
claim(id: string, claimToken?: string): HandoffRequestRecord {
|
|
353
|
+
const nowMs = this.now().getTime(), rows = this.current(), index = rows.findIndex(r => r.clientRequestId === id)
|
|
354
|
+
if (index < 0) throw new WorkHandoffRequestError('request_not_found', 404, 'No such handoff request.')
|
|
355
|
+
const request = rows[index]
|
|
356
|
+
if (request.state === 'expired') throw new WorkHandoffRequestError('request_expired', 410, 'This request expired before COS Control claimed it.')
|
|
357
|
+
if (request.state === 'claimed' && claimToken !== undefined && claimToken === request.claimToken) return request
|
|
358
|
+
if (request.state !== 'pending') throw new WorkHandoffRequestError('already_claimed', 409, `This request is already ${request.state}.`)
|
|
359
|
+
rows[index] = { ...request, state: 'claimed', claimedAt: iso(nowMs), claimToken: randomBytes(16).toString('hex') }
|
|
360
|
+
this.write(rows, nowMs)
|
|
361
|
+
return { ...rows[index] }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Control's result, under its claim token: for a claimed request, or once for one that went unconfirmed (Control may
|
|
366
|
+
* have sent it after all). The same result again is answered as it was.
|
|
367
|
+
*/
|
|
368
|
+
report(id: string, result: HandoffResult): HandoffRequestRecord {
|
|
369
|
+
const nowMs = this.now().getTime(), rows = this.current(), index = rows.findIndex(r => r.clientRequestId === id)
|
|
370
|
+
if (index < 0) throw new WorkHandoffRequestError('request_not_found', 404, 'No such handoff request.')
|
|
371
|
+
const request = rows[index]
|
|
372
|
+
if (request.state === 'expired') throw new WorkHandoffRequestError('request_expired', 410, 'This request expired before COS Control claimed it.')
|
|
373
|
+
if (!request.claimToken) throw new WorkHandoffRequestError('request_not_claimed', 409, 'A result is taken only for a claimed request; this one was never claimed.')
|
|
374
|
+
if (request.claimToken !== result.claimToken) throw new WorkHandoffRequestError('claim_token_mismatch', 409, 'This result does not carry the token of the claim.')
|
|
375
|
+
if (request.state === result.state && request.resultAt && request.receiptId === result.receiptId && request.reason === result.reason) return request
|
|
376
|
+
if (request.state !== 'claimed' && request.state !== 'unconfirmed') {
|
|
377
|
+
throw new WorkHandoffRequestError('request_not_claimed', 409, `A result is taken only for a claimed request; this one is ${request.state}.`)
|
|
378
|
+
}
|
|
379
|
+
const { receiptId: _receiptId, reason: _reason, ...base } = request
|
|
380
|
+
rows[index] = { ...base, state: result.state, resultAt: iso(nowMs),
|
|
381
|
+
...(result.receiptId ? { receiptId: result.receiptId } : {}), ...(result.reason ? { reason: result.reason } : {}) }
|
|
382
|
+
this.write(rows, nowMs)
|
|
383
|
+
return { ...rows[index] }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Every write stores the states as they are now, and drops requests (and set-aside records) older than a week. At
|
|
388
|
+
* 40 requests a day that is at most a few hundred records, so there is no separate size cap.
|
|
389
|
+
*/
|
|
390
|
+
private write(rows: HandoffRequestRecord[], nowMs: number): void {
|
|
391
|
+
if (this.closed) throw new WorkHandoffRequestError('handoff_store_closed', 503)
|
|
392
|
+
const cutoff = nowMs - HANDOFF_REQUEST_LIMITS.retainDays * 86_400_000
|
|
393
|
+
const kept = rows.filter(r => Date.parse(r.createdAt) >= cutoff)
|
|
394
|
+
const quarantined = this.quarantined.filter(q => Date.parse(q.at) >= cutoff)
|
|
395
|
+
this.writeFile(this.path, JSON.stringify({ version: 1, requests: kept, quarantined }) + '\n')
|
|
396
|
+
this.rows = kept
|
|
397
|
+
this.quarantined = quarantined
|
|
398
|
+
}
|
|
399
|
+
close(): void {
|
|
400
|
+
if (this.closed) return
|
|
401
|
+
this.closed = true
|
|
402
|
+
this.lock.release()
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* What the glasses are told (capabilities on the Work activity routes): `requests` is 1 only while COS Control is
|
|
408
|
+
* taking requests, meaning it listed pending ones in the last 120 s; `requestsInbox` is 1 when the inbox opened.
|
|
409
|
+
*/
|
|
410
|
+
export function handoffRequestCapabilities(store: WorkHandoffRequestStore | null, nowMs: number): { requests: 0 | 1; requestsInbox: 0 | 1; requestsConsumerSeenAt: string | null } {
|
|
411
|
+
const seen = store?.consumerSeenAt() ?? null
|
|
412
|
+
return {
|
|
413
|
+
requests: store && seen !== null && nowMs - seen <= HANDOFF_REQUEST_LIMITS.consumerMs ? 1 : 0,
|
|
414
|
+
requestsInbox: store ? 1 : 0,
|
|
415
|
+
requestsConsumerSeenAt: seen === null ? null : iso(seen),
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Optional: a locked or unreadable inbox must never keep the base API from starting. */
|
|
420
|
+
export function createOptionalWorkHandoffRequestStore(create: () => WorkHandoffRequestStore): WorkHandoffRequestStore | null {
|
|
421
|
+
try { return create() }
|
|
422
|
+
catch (error) {
|
|
423
|
+
console.error('[work-handoff-requests] store unavailable; base API remains available:', error instanceof WorkHandoffRequestError ? error.code : error)
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -1,44 +1,88 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
|
-
import {
|
|
2
|
+
import { readWorkJournal, projectWorkActivity, projectOpenWork, savedDestinationFor, controlSnapshotRevision,
|
|
3
|
+
type WorkActivityCapabilities, type WorkActivityResult } from '../lib/work-activity.js'
|
|
3
4
|
import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, TaskRunError, TaskBridgeError,
|
|
4
|
-
WORK_STAGES, type WorkStage, type WorkMeetingRef } from '../lib/task-store.js'
|
|
5
|
+
WORK_STAGES, type WorkStage, type WorkMeetingRef, type TaskBoardRow } from '../lib/task-store.js'
|
|
6
|
+
import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
5
7
|
import type { MeetingDescriptor } from '../lib/work-review-store.js'
|
|
6
8
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
7
9
|
import { resolveSavedMeetingDetail } from './meetings.js'
|
|
8
10
|
import type { MeetingDetail } from '../lib/meeting-store.js'
|
|
9
11
|
|
|
10
12
|
export interface WorkBoardDependencies {
|
|
11
|
-
|
|
13
|
+
/** The native journal reader. It takes no request-derived argument: there is no request-supplied path. */
|
|
14
|
+
journal: () => ReturnType<typeof readWorkJournal>
|
|
12
15
|
list: typeof listBoard
|
|
13
16
|
capabilities: typeof workBoardCapabilities
|
|
14
17
|
stage: typeof setTaskWorkStage
|
|
15
18
|
link: typeof linkTaskMeeting
|
|
16
19
|
resolveMeeting: (descriptor: MeetingDescriptor) => MeetingDetail | Promise<MeetingDetail>
|
|
20
|
+
/** 6.59.0: the handoff request inbox as the glasses should see it (lib/work-handoff-requests.ts handoffRequestCapabilities). */
|
|
21
|
+
requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
|
|
22
|
+
/** 6.59.0: the bounded, shared board read (5 s). Defaults to one over `list`. */
|
|
23
|
+
readBoard?: WorkBoardReader<TaskBoardRow>
|
|
24
|
+
now: () => number
|
|
17
25
|
}
|
|
18
|
-
const defaults: WorkBoardDependencies = {
|
|
19
|
-
stage: setTaskWorkStage, link: linkTaskMeeting, resolveMeeting: resolveSavedMeetingDetail
|
|
26
|
+
const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
|
|
27
|
+
stage: setTaskWorkStage, link: linkTaskMeeting, resolveMeeting: resolveSavedMeetingDetail,
|
|
28
|
+
requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
|
|
29
|
+
/** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
|
|
30
|
+
export const WORK_GLANCE_BOARD_AGE_MS = 10_000
|
|
20
31
|
|
|
21
32
|
/** Auth is supplied by the parent /api mount. No provider execution occurs here. */
|
|
22
33
|
export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies> = {}): Router {
|
|
23
34
|
const deps = { ...defaults, ...overrides }, router = Router()
|
|
35
|
+
const board = deps.readBoard ?? createWorkBoardReader(() => deps.list())
|
|
24
36
|
router.use('/work-board', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
25
37
|
const fail = (res: import('express').Response, e: unknown) => {
|
|
26
38
|
const status = e instanceof TaskRunError ? e.status : e instanceof TaskBridgeError ? (e.code === 'task_not_found' ? 404 : 409) : 503
|
|
27
39
|
return res.status(status).json({ error: { code: e instanceof TaskRunError || e instanceof TaskBridgeError ? e.code : 'work_board_unavailable', message: e instanceof TaskRunError || e instanceof TaskBridgeError ? e.message : 'Work board is unavailable. Refresh before trying again.' } })
|
|
28
40
|
}
|
|
29
|
-
|
|
41
|
+
const activityCapabilities = (): WorkActivityCapabilities => ({ progress: 1, ...deps.requests() })
|
|
42
|
+
/** The board within its bound, or null: the glances answer without it rather than keep the glasses waiting. */
|
|
43
|
+
const glanceBoard = async (where: string, timeoutMs?: number): Promise<TaskBoardRow[] | null> => {
|
|
44
|
+
try { return await board.read(WORK_GLANCE_BOARD_AGE_MS, timeoutMs) }
|
|
45
|
+
catch (e) {
|
|
46
|
+
const cause = e instanceof WorkBoardTimeoutError ? 'work_board_timeout' : e instanceof TaskRunError || e instanceof TaskBridgeError ? e.code : 'work_board_unavailable'
|
|
47
|
+
console.warn(`[work-board] ${where}: answered without the board (${cause})`)
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
router.get('/work-board/activity', async (req, res) => {
|
|
30
52
|
const { domain, workIdentity } = req.query
|
|
31
53
|
if (Object.keys(req.query).some(key => !['domain', 'workIdentity'].includes(key))
|
|
32
54
|
|| typeof domain !== 'string' || !isSafeDomainName(domain)
|
|
33
55
|
|| typeof workIdentity !== 'string' || !/^[a-f0-9]{12}$/.test(workIdentity)) {
|
|
34
56
|
return res.status(400).json({ error: { code: 'invalid_work_identity', message: 'Select an exact task and domain.' } })
|
|
35
57
|
}
|
|
36
|
-
|
|
58
|
+
const capabilities = activityCapabilities(), journal = deps.journal()
|
|
59
|
+
// The task's revision and saved destination need its board row. Past the 5 s bound (or with no board) the activity
|
|
60
|
+
// still answers, without them.
|
|
61
|
+
// S4's budget is 3 s: the progress answers on time with or without the board.
|
|
62
|
+
const row = (await glanceBoard('activity', WORK_BOARD_READ_LIMITS.savedDestinationMs))?.find(r => r.domain === domain && (r.workIdentity || r.id) === workIdentity)
|
|
63
|
+
const taskRevision = row ? { taskRevision: controlSnapshotRevision(row) } : {}
|
|
64
|
+
if (!journal.available) return res.json({ version: 1, available: false, reason: journal.reason, capabilities, activities: [], ...taskRevision } satisfies WorkActivityResult)
|
|
65
|
+
const result: WorkActivityResult = { version: 1, available: true, capabilities, activities: projectWorkActivity(journal, domain, workIdentity, deps.now()), ...taskRevision }
|
|
66
|
+
const destination = row ? savedDestinationFor(journal, row) : undefined
|
|
67
|
+
if (destination) result.savedDestination = destination
|
|
68
|
+
return res.json(result)
|
|
69
|
+
})
|
|
70
|
+
// S2: every open handoff across the board, needs input first. Read-only: no provider call, no Jev.
|
|
71
|
+
router.get('/work-board/activity/open', async (req, res) => {
|
|
72
|
+
if (Object.keys(req.query).length) return res.status(400).json({ error: { code: 'invalid_open_activity_request', message: 'This list takes no parameters.' } })
|
|
73
|
+
const capabilities = activityCapabilities(), journal = deps.journal()
|
|
74
|
+
if (!journal.available) return res.json({ version: 1, available: false, reason: journal.reason, capabilities, items: [], total: 0, truncated: false })
|
|
75
|
+
const rows = await glanceBoard('open work')
|
|
76
|
+
if (!rows) return res.json({ version: 1, available: false, reason: 'Work board is unavailable. Refresh before trying again.', capabilities, items: [], total: 0, truncated: false })
|
|
77
|
+
return res.json({ version: 1, available: true, capabilities, ...projectOpenWork(journal, rows, deps.now()) })
|
|
37
78
|
})
|
|
38
79
|
router.get('/work-board', async (_req, res) => {
|
|
39
80
|
try {
|
|
81
|
+
const read = board.begin()
|
|
40
82
|
const [raw, capabilities] = await Promise.all([deps.list(), deps.capabilities()])
|
|
41
|
-
|
|
83
|
+
board.prime(raw, read)
|
|
84
|
+
// 6.59.0: taskRevision is this task's own revision (COS Control's taskSnapshot), what a handoff request names.
|
|
85
|
+
const tasks = raw.map(row => ({ ...row, workStage: row.checked ? 'complete' : row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned'), workIdentity: row.workIdentity || row.id, meetingRefs: row.meetingRefs ?? [], taskRevision: controlSnapshotRevision(row) }))
|
|
42
86
|
res.json({ tasks, capabilities, complete: true })
|
|
43
87
|
}
|
|
44
88
|
catch (e) { fail(res, e) }
|
|
@@ -57,7 +101,8 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
57
101
|
if (capability.version !== 1 || !capability.writable) throw new TaskRunError(409, 'work_board_read_only', 'The canonical task bridge needs a compatible update')
|
|
58
102
|
if (action === 'stage') {
|
|
59
103
|
if (!WORK_STAGES.includes(body.workStage as WorkStage)) throw new TaskRunError(422, 'invalid_work_stage', 'Choose a valid Work stage')
|
|
60
|
-
|
|
104
|
+
// The glasses' shared board read must not serve the board as it was before this write.
|
|
105
|
+
try { await deps.stage(body.domain, body.id, body.workStage, body.expectedText, body.expectedRevision) } finally { board.invalidate() }
|
|
61
106
|
} else {
|
|
62
107
|
const input = body.meeting
|
|
63
108
|
if (!input || typeof input !== 'object' || Array.isArray(input)
|
|
@@ -70,7 +115,7 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
70
115
|
const resolved = await deps.resolveMeeting(descriptor)
|
|
71
116
|
if (resolved.recordId !== descriptor.recordId) throw new TaskRunError(409, 'meeting_identity_changed', 'The selected meeting changed; refresh before linking')
|
|
72
117
|
const meeting: WorkMeetingRef = { ...descriptor, recordId: resolved.recordId, title: resolved.title.replace(/\s+/g, ' ').trim().slice(0, 300) }
|
|
73
|
-
await deps.link(body.domain, body.id, body.expectedText, body.expectedRevision, meeting)
|
|
118
|
+
try { await deps.link(body.domain, body.id, body.expectedText, body.expectedRevision, meeting) } finally { board.invalidate() }
|
|
74
119
|
}
|
|
75
120
|
res.json({ ok: true })
|
|
76
121
|
} catch (e) { fail(res, e) }
|