@gotcos/glasses-server 6.61.2 → 6.61.5
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 +43 -0
- package/README.md +24 -1
- package/package.json +2 -1
- package/server/index.ts +1 -1
- package/server/lib/cos-operations-meetings.ts +11 -17
- package/server/lib/dictation-clean-models.ts +7 -0
- package/server/lib/dictation-clean.ts +113 -56
- package/server/lib/message-era-reset.ts +14 -5
- package/server/lib/query-job-coordinator.ts +195 -19
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/query-job-store.ts +96 -15
- package/server/lib/query-job-types.ts +4 -0
- package/server/lib/task-bridge.ts +1 -1
- package/server/lib/task-dispatcher.ts +4 -5
- package/server/lib/task-store.ts +17 -3
- package/server/routes/health.ts +5 -0
- package/server/routes/maintenance.ts +5 -0
- package/server/routes/meeting.ts +48 -6
- package/server/routes/prompt-drafts.ts +13 -5
- package/server/routes/query-jobs.ts +2 -0
- package/server/routes/work-board.ts +13 -5
- package/work-task-runtime/manifest.json +6 -6
- package/work-task-runtime/task_bridge.py +3 -2
- package/work-task-runtime/task_commands.py +10 -6
- package/work-task-runtime/task_write.py +131 -1
package/server/routes/meeting.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
7
7
|
import { resolve } from 'node:path'
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
8
9
|
import { Router } from 'express'
|
|
9
10
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
10
11
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
@@ -847,6 +848,31 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
847
848
|
// rebuilding are the /api/voice/* routes, each with its own confirmation.
|
|
848
849
|
//
|
|
849
850
|
// Keyed on sessionId so it can reuse the store's traversal-hardened readers
|
|
851
|
+
function reviewRevision(meetingPath: string, sidecarPath: string): string {
|
|
852
|
+
const hash = createHash('sha256')
|
|
853
|
+
for (const path of [meetingPath, sidecarPath]) {
|
|
854
|
+
let text: string | null = null
|
|
855
|
+
try { text = readFileSync(path, 'utf-8') } catch { /* missing content is part of the revision */ }
|
|
856
|
+
hash.update(JSON.stringify(text))
|
|
857
|
+
}
|
|
858
|
+
return hash.digest('hex')
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function selectedReadMatches(requested: unknown, sessionId: string, actual: string): boolean {
|
|
862
|
+
return requestedRecordMatches(requested, actual) || blendedRecordFor(requested, sessionId) !== undefined
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function sourceRevisionRefusal(sessionId: string, body: any): { status: number; body: Record<string, unknown> } | null {
|
|
866
|
+
const ops = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
867
|
+
if (ops?.mergedScribeConflict?.length) return { status: 409, body: { reason: 'record_source_mismatch', error: markdownUnreadableReason(ops) } }
|
|
868
|
+
if (body?.expectedRevision == null) return null // older clients keep their existing record guard
|
|
869
|
+
const saved = ops ? null : store.findBySessionId(sessionId)
|
|
870
|
+
if (!ops && !saved) return { status: 409, body: { reason: 'record_source_mismatch', error: 'Meeting source changed; reopen the meeting' } }
|
|
871
|
+
const actual = reviewRevision(ops?.meetingPath ?? saved!.filepath, ops?.sidecarPath ?? saved!.sidecarPath)
|
|
872
|
+
if (typeof body.expectedRevision !== 'string' || body.expectedRevision !== actual) return { status: 409, body: { reason: 'record_source_mismatch', error: 'Meeting changed since review; reopen before correcting it' } }
|
|
873
|
+
return null
|
|
874
|
+
}
|
|
875
|
+
|
|
850
876
|
// (safeDirectoryRealpath / safeReadFile) instead of reassembling a path from
|
|
851
877
|
// client-supplied domain and filename components.
|
|
852
878
|
router.get('/meeting/:sessionId/speakers', (req, res) => {
|
|
@@ -878,6 +904,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
878
904
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
879
905
|
return
|
|
880
906
|
}
|
|
907
|
+
const identity = speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId))
|
|
908
|
+
if (!selectedReadMatches(req.query.recordId, sessionId, identity.recordId)) {
|
|
909
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the selected meeting', reason: 'record_source_mismatch' })
|
|
910
|
+
return
|
|
911
|
+
}
|
|
881
912
|
const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
|
|
882
913
|
const title = operations?.title ?? direct?.title ?? saved!.title
|
|
883
914
|
const domain = operations?.domain ?? direct?.domain ?? saved!.domain
|
|
@@ -932,7 +963,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
932
963
|
title,
|
|
933
964
|
domain,
|
|
934
965
|
filename,
|
|
935
|
-
...
|
|
966
|
+
...identity,
|
|
967
|
+
reviewIdentityVersion: 1,
|
|
968
|
+
sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
|
|
969
|
+
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
936
970
|
...(saved ? { durationMin: saved.durationMin } : {}),
|
|
937
971
|
...review,
|
|
938
972
|
})
|
|
@@ -985,6 +1019,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
985
1019
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
986
1020
|
return
|
|
987
1021
|
}
|
|
1022
|
+
const identity = speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId))
|
|
1023
|
+
if (!selectedReadMatches(req.query.recordId, sessionId, identity.recordId)) {
|
|
1024
|
+
res.status(409).json({ error: 'Meeting source changed; reopen the selected meeting', reason: 'record_source_mismatch' })
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
988
1027
|
const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
|
|
989
1028
|
const title = operations?.title ?? direct?.title ?? saved!.title
|
|
990
1029
|
const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
@@ -1112,7 +1151,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1112
1151
|
// no write-up yet.
|
|
1113
1152
|
capturedChars: clip.capturedChars,
|
|
1114
1153
|
domain: clip.domain,
|
|
1115
|
-
...
|
|
1154
|
+
...identity,
|
|
1155
|
+
reviewIdentityVersion: 1,
|
|
1156
|
+
sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
|
|
1157
|
+
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
1116
1158
|
// So the panel can warn above the write-up, not just the clipboard.
|
|
1117
1159
|
removedNames: clip.removed,
|
|
1118
1160
|
coverage,
|
|
@@ -1169,7 +1211,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1169
1211
|
// Enrolment writes to the voice store, so the same two gates as the other
|
|
1170
1212
|
// corrections: the session must be a capture, and the record aimed at must
|
|
1171
1213
|
// not be a derived one.
|
|
1172
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1214
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
1173
1215
|
if (evidenceRefusal) {
|
|
1174
1216
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1175
1217
|
return
|
|
@@ -1235,7 +1277,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1235
1277
|
// A merged G2 session is NOT refused here: it is a real capture with real
|
|
1236
1278
|
// audio, and relabelling it is how its profile gets better. Only an imported
|
|
1237
1279
|
// or derived IDENTITY is refused.
|
|
1238
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
|
|
1280
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
1239
1281
|
if (evidenceRefusal) {
|
|
1240
1282
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1241
1283
|
return
|
|
@@ -1478,7 +1520,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1478
1520
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1479
1521
|
return
|
|
1480
1522
|
}
|
|
1481
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1523
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
1482
1524
|
if (evidenceRefusal) {
|
|
1483
1525
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1484
1526
|
return
|
|
@@ -1569,7 +1611,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1569
1611
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1570
1612
|
return
|
|
1571
1613
|
}
|
|
1572
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
|
|
1614
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
1573
1615
|
if (evidenceRefusal) {
|
|
1574
1616
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1575
1617
|
return
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDictationCleanModel, type DictationCleanModel } from '../lib/dictation-clean-models.js'
|
|
1
2
|
import { Router } from 'express'
|
|
2
3
|
import { createHash } from 'node:crypto'
|
|
3
4
|
import type { Response } from 'express'
|
|
@@ -75,7 +76,11 @@ function speculativeHqWarmEnabled(): boolean {
|
|
|
75
76
|
return !['0', 'false', 'off'].includes((process.env.COS_HQ_SPECULATIVE_WARM ?? '1').toLowerCase())
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
const
|
|
79
|
+
const autoCleanBreakers = {
|
|
80
|
+
sonnet: createBreaker({ label: 'dictation-autoclean-sonnet' }),
|
|
81
|
+
'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
|
|
82
|
+
haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
|
|
83
|
+
}
|
|
79
84
|
const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
|
|
80
85
|
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
81
86
|
const autoCleanDailyCap = () => {
|
|
@@ -97,12 +102,14 @@ function recordAutoCleanCall(): void {
|
|
|
97
102
|
} catch {}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
interface AutoCleanRequest { enabled?: boolean; model?:
|
|
105
|
+
interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
|
|
101
106
|
function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
102
107
|
const rawEnabled = req.body?.autoclean ?? req.query?.autoclean
|
|
103
108
|
const enabled = rawEnabled === undefined ? undefined : ['1', 'true', 'on'].includes(String(rawEnabled).toLowerCase())
|
|
104
109
|
const rawModel = String(req.body?.autocleanModel ?? req.query?.autocleanModel ?? '').toLowerCase()
|
|
105
|
-
|
|
110
|
+
// An unknown model must never silently select another provider.
|
|
111
|
+
if (rawModel && !isDictationCleanModel(rawModel)) return { enabled: false }
|
|
112
|
+
return { enabled, model: isDictationCleanModel(rawModel) ? rawModel : undefined }
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { signal?: AbortSignal }): Promise<string> {
|
|
@@ -129,9 +136,10 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
|
|
|
129
136
|
console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
|
|
130
137
|
}
|
|
131
138
|
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
|
|
139
|
+
const model = opts.model ?? 'sonnet'
|
|
140
|
+
const autoCleanBreaker = autoCleanBreakers[model]
|
|
132
141
|
if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
|
|
133
142
|
const startedAt = Date.now()
|
|
134
|
-
const model = opts.model === 'sonnet' ? 'sonnet' : 'haiku'
|
|
135
143
|
recordAutoCleanCall()
|
|
136
144
|
try {
|
|
137
145
|
const polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
|
|
@@ -162,7 +170,7 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
162
170
|
if (surface !== 'message' && surface !== 'meeting') {
|
|
163
171
|
return res.status(400).json({ error: 'invalid_surface' })
|
|
164
172
|
}
|
|
165
|
-
if (model !== undefined && model
|
|
173
|
+
if (model !== undefined && !isDictationCleanModel(model)) {
|
|
166
174
|
return res.status(400).json({ error: 'invalid_autoclean_model' })
|
|
167
175
|
}
|
|
168
176
|
|
|
@@ -49,6 +49,8 @@ function wireError(error: unknown): { status: number; body: WireError } {
|
|
|
49
49
|
|| error instanceof QueryJobIdentityConflictError
|
|
50
50
|
|| error instanceof QueryJobActiveGenerationError
|
|
51
51
|
|| error instanceof QueryJobGenerationOrderError
|
|
52
|
+
// Since 6.61.3 admission never throws this (the fence holds a job at its start);
|
|
53
|
+
// kept so the store error map stays complete for any caller of markStarting.
|
|
52
54
|
|| error instanceof QueryJobProviderOrphanFenceError
|
|
53
55
|
|| error instanceof QueryJobAnswerCommittingError
|
|
54
56
|
|| error instanceof QueryJobNotTerminalError) status = 409
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
2
|
import { readWorkJournal, projectWorkActivity, projectOpenWork, savedDestinationFor, controlSnapshotRevision,
|
|
3
3
|
type WorkActivityCapabilities, type WorkActivityResult } from '../lib/work-activity.js'
|
|
4
|
-
import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, TaskRunError, TaskBridgeError,
|
|
4
|
+
import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, editWorkTask, TaskRunError, TaskBridgeError,
|
|
5
5
|
WORK_STAGES, type WorkStage, type WorkMeetingRef, type TaskBoardRow } from '../lib/task-store.js'
|
|
6
6
|
import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
7
|
import type { MeetingDescriptor } from '../lib/work-review-store.js'
|
|
@@ -16,6 +16,7 @@ export interface WorkBoardDependencies {
|
|
|
16
16
|
capabilities: typeof workBoardCapabilities
|
|
17
17
|
stage: typeof setTaskWorkStage
|
|
18
18
|
link: typeof linkTaskMeeting
|
|
19
|
+
edit: typeof editWorkTask
|
|
19
20
|
resolveMeeting: (descriptor: MeetingDescriptor) => MeetingDetail | Promise<MeetingDetail>
|
|
20
21
|
/** 6.59.0: the handoff request inbox as the glasses should see it (lib/work-handoff-requests.ts handoffRequestCapabilities). */
|
|
21
22
|
requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
|
|
@@ -24,7 +25,7 @@ export interface WorkBoardDependencies {
|
|
|
24
25
|
now: () => number
|
|
25
26
|
}
|
|
26
27
|
const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
|
|
27
|
-
stage: setTaskWorkStage, link: linkTaskMeeting, resolveMeeting: resolveSavedMeetingDetail,
|
|
28
|
+
stage: setTaskWorkStage, link: linkTaskMeeting, edit: editWorkTask, resolveMeeting: resolveSavedMeetingDetail,
|
|
28
29
|
requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
|
|
29
30
|
/** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
|
|
30
31
|
export const WORK_GLANCE_BOARD_AGE_MS = 10_000
|
|
@@ -87,10 +88,10 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
87
88
|
}
|
|
88
89
|
catch (e) { fail(res, e) }
|
|
89
90
|
})
|
|
90
|
-
for (const action of ['stage', 'meeting'] as const) router.post('/work-board/' + action, async (req, res) => {
|
|
91
|
+
for (const action of ['stage', 'meeting', 'edit'] as const) router.post('/work-board/' + action, async (req, res) => {
|
|
91
92
|
try {
|
|
92
93
|
const body = req.body
|
|
93
|
-
const keys = ['domain', 'id', 'expectedText', 'expectedRevision', action === 'stage' ? 'workStage' : 'meeting']
|
|
94
|
+
const keys = ['domain', 'id', 'expectedText', 'expectedRevision', ...(action === 'edit' ? ['text', 'doneWhen'] : [action === 'stage' ? 'workStage' : 'meeting'])]
|
|
94
95
|
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !keys.includes(k))
|
|
95
96
|
|| Buffer.byteLength(JSON.stringify(body)) > 16_384 || !['domain','id','expectedText','expectedRevision'].every(k => typeof body[k] === 'string')
|
|
96
97
|
|| !isSafeDomainName(body.domain) || !/^[a-f0-9]{12}$/.test(body.id)
|
|
@@ -99,7 +100,14 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
99
100
|
}
|
|
100
101
|
const capability = await deps.capabilities()
|
|
101
102
|
if (capability.version !== 1 || !capability.writable) throw new TaskRunError(409, 'work_board_read_only', 'The canonical task bridge needs a compatible update')
|
|
102
|
-
if (action === '
|
|
103
|
+
if (action === 'edit') {
|
|
104
|
+
if (capability.editTasks !== 1) throw new TaskRunError(409, 'task_edit_unavailable', 'Update the canonical task bridge before editing task names')
|
|
105
|
+
if (typeof body.text !== 'string' || typeof body.doneWhen !== 'string') throw new TaskRunError(400, 'invalid_task_edit', 'Task name and finish line are required')
|
|
106
|
+
try {
|
|
107
|
+
const saved = await deps.edit(body.domain, body.id, body.text, body.doneWhen, body.expectedText, body.expectedRevision)
|
|
108
|
+
return res.json(saved)
|
|
109
|
+
} finally { board.invalidate() }
|
|
110
|
+
} else if (action === 'stage') {
|
|
103
111
|
if (!WORK_STAGES.includes(body.workStage as WorkStage)) throw new TaskRunError(422, 'invalid_work_stage', 'Choose a valid Work stage')
|
|
104
112
|
// The glasses' shared board read must not serve the board as it was before this write.
|
|
105
113
|
try { await deps.stage(body.domain, body.id, body.workStage, body.expectedText, body.expectedRevision) } finally { board.invalidate() }
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"protocol": "cos-control-task-write/1",
|
|
4
4
|
"pythonMinimum": "3.10",
|
|
5
|
-
"sourceCommit": "
|
|
5
|
+
"sourceCommit": "2fd2be86f072781adc9ca856194325ea263858ae",
|
|
6
6
|
"sourceHashes": {
|
|
7
7
|
"task_rows.py": "58b568fd4081072eddc3110b4c3397f5e5048ede9e052391e1d09f919e8c2e7d",
|
|
8
|
-
"task_write.py": "
|
|
8
|
+
"task_write.py": "fb618ba4a75cdd40859587cf0d74b5d5c8d005c0cc0eaab3573412ac028171a1",
|
|
9
9
|
"task_work_metadata.py": "534c45910e2109ac1e18eeb236c6b286d8a899f96c7a153d0187aee5839854a5",
|
|
10
10
|
"task_checkout.py": "56c94c5b28282398abc1f8daea9d910e64228afb8ac8cfae89b54117a7e30b86",
|
|
11
11
|
"cos_atomic.py": "055f7d6bc3093aeec7b051f87cb68c543637603a538f383a614c1903bd1b503e",
|
|
12
|
-
"cos_api_bridge.py": "
|
|
12
|
+
"cos_api_bridge.py": "539cd27d3929d46205cfa77482b0d5f3e8d9385d0fc839cce7bdcd5437c7b2fd",
|
|
13
13
|
"task_dedup.py": "5331fdea01c293ecd2773726acea110e9339ff26e72e5bcdce66777ab518bfe8"
|
|
14
14
|
},
|
|
15
15
|
"adaptations": [
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
"files": {
|
|
23
23
|
"cos_atomic.py": "0da53eecaf07331b008c10b1e06c39701ecc3291c511fd0779e35795b3b22534",
|
|
24
24
|
"runtime_io.py": "e5b191b6af3a4966f517ecd05d3dcae9f4136a9e99cf36d7102303ca55ae928d",
|
|
25
|
-
"task_bridge.py": "
|
|
25
|
+
"task_bridge.py": "45d5afcfa3b951b6ed3143a636ef6f36c49de1394728280b1ff7bfe7cd138006",
|
|
26
26
|
"task_checkout.py": "669af045b2f64b9879df03aacae7a4550b46291974ed2df2ba2ed0775c1be78f",
|
|
27
|
-
"task_commands.py": "
|
|
27
|
+
"task_commands.py": "b020b8249cbbf91661f63dd660b10994fe01b91bad1dfa9f92196cc94782cc6d",
|
|
28
28
|
"task_dedup.py": "745971722898a3de520f8bb5246d714c70f3189dbe53bf8f42feafaaf241a51b",
|
|
29
29
|
"task_rows.py": "9e48375d9d6ee5d29bf3cc1f92bdf1182a6eea40ee305ff1fe1632b9f3a9fcf7",
|
|
30
30
|
"task_work_metadata.py": "534c45910e2109ac1e18eeb236c6b286d8a899f96c7a153d0187aee5839854a5",
|
|
31
|
-
"task_write.py": "
|
|
31
|
+
"task_write.py": "6aea995bd213515eaae00e6e9dae16d77c7de579151fe6a717e6ad9e83b7db63"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -31,7 +31,7 @@ def main():
|
|
|
31
31
|
raw=sys.stdin.buffer.read(32769)
|
|
32
32
|
if len(raw)>32768: raise ValueError('Task input exceeds supported limit')
|
|
33
33
|
sys.stdin=io.StringIO(raw.decode('utf-8'))
|
|
34
|
-
commands={'task-rows':'cmd_task_rows','task-capture':'cmd_task_capture','task-set-text':'cmd_task_set_text','task-set-run-at':'cmd_task_set_run_at','task-set-marker':'cmd_task_set_marker','task-set-stage':'cmd_task_set_stage','task-set-done-when':'cmd_task_set_done_when','task-move':'cmd_task_move','task-check':'cmd_task_check','task-set-work-stage':'cmd_task_work_write','task-link-meeting':'cmd_task_work_write'}
|
|
34
|
+
commands={'task-rows':'cmd_task_rows','task-capture':'cmd_task_capture','task-set-text':'cmd_task_set_text','task-set-run-at':'cmd_task_set_run_at','task-set-marker':'cmd_task_set_marker','task-set-stage':'cmd_task_set_stage','task-set-done-when':'cmd_task_set_done_when','task-move':'cmd_task_move','task-check':'cmd_task_check','task-set-work-stage':'cmd_task_work_write','task-link-meeting':'cmd_task_work_write','task-edit-work':'cmd_task_work_write'}
|
|
35
35
|
if opts.command not in commands and opts.command!='task-work-capabilities': raise ValueError('Unsupported task command')
|
|
36
36
|
rootfd=directory(opts.root,create=True); domainfds={}; lockfd=None
|
|
37
37
|
try:
|
|
@@ -68,13 +68,14 @@ def main():
|
|
|
68
68
|
original_write(domain,identity,path,text)
|
|
69
69
|
task_write._guarded_write=guarded
|
|
70
70
|
import task_commands
|
|
71
|
-
if opts.command=='task-work-capabilities': print(json.dumps({'version':1,'protocol':'cos-control-task-write/1'}));return
|
|
71
|
+
if opts.command=='task-work-capabilities': print(json.dumps({'version':1,'protocol':'cos-control-task-write/1','editTasks':1}));return
|
|
72
72
|
if not opts.args or opts.args[0] not in domains: raise ValueError('Unknown task domain')
|
|
73
73
|
path=domain_path(opts.args[0])
|
|
74
74
|
try: safe_read_text(path)
|
|
75
75
|
except FileNotFoundError: pass
|
|
76
76
|
fn=getattr(task_commands,commands[opts.command])
|
|
77
77
|
if opts.command=='task-link-meeting':fn(opts.args,linking=True)
|
|
78
|
+
elif opts.command=='task-edit-work':fn(opts.args,editing=True)
|
|
78
79
|
else:fn(opts.args)
|
|
79
80
|
finally:
|
|
80
81
|
for fd in domainfds.values():os.close(fd)
|
|
@@ -132,12 +132,12 @@ def cmd_task_set_text(args):
|
|
|
132
132
|
return
|
|
133
133
|
print(json.dumps({"ok": True}))
|
|
134
134
|
|
|
135
|
-
def cmd_task_work_write(args, *, linking=False):
|
|
136
|
-
from task_write import set_work_stage, link_meeting
|
|
135
|
+
def cmd_task_work_write(args, *, linking=False, editing=False):
|
|
136
|
+
from task_write import set_work_stage, link_meeting, edit_work_task
|
|
137
137
|
domain = _bridge_domain(args)
|
|
138
138
|
if domain is None:
|
|
139
139
|
return
|
|
140
|
-
if len(args) != (2 if linking else 3):
|
|
140
|
+
if len(args) != (2 if linking or editing else 3):
|
|
141
141
|
_bridge_error("argv_required", "Expected domain, task ID, and phase for stage writes.")
|
|
142
142
|
return
|
|
143
143
|
try:
|
|
@@ -147,20 +147,24 @@ def cmd_task_work_write(args, *, linking=False):
|
|
|
147
147
|
body = json.loads(raw)
|
|
148
148
|
if not isinstance(body, dict) or not isinstance(body.get("expectedText"), str) or not isinstance(body.get("expectedRevision"), str):
|
|
149
149
|
raise ValueError()
|
|
150
|
-
allowed = {"expectedText", "expectedRevision", "meeting", "removeRecordId"} if linking else {"expectedText", "expectedRevision"}
|
|
150
|
+
allowed = {"expectedText", "expectedRevision", "text", "doneWhen"} if editing else {"expectedText", "expectedRevision", "meeting", "removeRecordId"} if linking else {"expectedText", "expectedRevision"}
|
|
151
151
|
if set(body) - allowed:
|
|
152
152
|
raise ValueError()
|
|
153
|
+
if editing and (not isinstance(body.get("text"), str) or not isinstance(body.get("doneWhen"), str)):
|
|
154
|
+
raise ValueError()
|
|
153
155
|
except (ValueError, TypeError):
|
|
154
156
|
_bridge_error("invalid_work_request", "Bounded JSON with expectedText and expectedRevision is required.")
|
|
155
157
|
return
|
|
156
|
-
if
|
|
158
|
+
if editing:
|
|
159
|
+
ok = _bridge_write(lambda: edit_work_task(domain, args[1], body["text"], body["doneWhen"], expected_text=body["expectedText"], expected_revision=body["expectedRevision"]))
|
|
160
|
+
elif linking:
|
|
157
161
|
ok = _bridge_write(lambda: link_meeting(domain, args[1], expected_text=body["expectedText"], expected_revision=body["expectedRevision"], meeting=body.get("meeting"), remove_record_id=body.get("removeRecordId")))
|
|
158
162
|
else:
|
|
159
163
|
ok = _bridge_write(lambda: set_work_stage(domain, args[1], args[2], expected_text=body["expectedText"], expected_revision=body["expectedRevision"]))
|
|
160
164
|
if ok is False:
|
|
161
165
|
_bridge_error("task_not_found", "Task not found in this domain.")
|
|
162
166
|
elif ok is not None:
|
|
163
|
-
print(json.dumps({"ok": True}))
|
|
167
|
+
print(json.dumps(ok if editing else {"ok": True}))
|
|
164
168
|
|
|
165
169
|
def cmd_task_set_stage(args):
|
|
166
170
|
"""Move a task between board stages. Clearing returns it to planning, which
|
|
@@ -24,6 +24,7 @@ from task_rows import (
|
|
|
24
24
|
classify_heading,
|
|
25
25
|
domain_path,
|
|
26
26
|
parse_path,
|
|
27
|
+
parse_owner,
|
|
27
28
|
split_source,
|
|
28
29
|
split_done_when,
|
|
29
30
|
STAGES,
|
|
@@ -576,7 +577,106 @@ def check(domain: str, task_id: Optional[str] = None, *, text: Optional[str] = N
|
|
|
576
577
|
LockStoreCorrupt = LockStoreCorrupt
|
|
577
578
|
|
|
578
579
|
|
|
579
|
-
def
|
|
580
|
+
def _probe_rows(full: str, line: str) -> list:
|
|
581
|
+
"""Parse one rebuilt line exactly as parse_path will, in a scratch file, before it touches tasks.md."""
|
|
582
|
+
import tempfile
|
|
583
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
584
|
+
probe = Path(scratch) / "tasks.md"
|
|
585
|
+
probe.write_text("## INBOX\n" + line + "\n")
|
|
586
|
+
return parse_path(probe, full)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def capture_work_item(domain: str, text: str, *, meeting, phase: str = "mentioned") -> dict:
|
|
590
|
+
"""Create one Work card from a meeting action item, linked to that meeting, in ONE locked write.
|
|
591
|
+
|
|
592
|
+
The row lands at the top of INBOX as `<text> — **Source:** <meeting title> [<date>]` carrying
|
|
593
|
+
`workflowPhase` and the exact meeting reference, so no second write is needed to link it.
|
|
594
|
+
|
|
595
|
+
Never a second copy. Task ids are sha256(domain|normalized text|ordinal), so a copy inserted
|
|
596
|
+
above an existing row would silently shift that row's id; and a renamed card keeps its first id
|
|
597
|
+
as its Work identity, so recapturing the old wording would duplicate that identity and lock both
|
|
598
|
+
rows. Either kind of match returns the existing row instead: `created` False, `linked` says
|
|
599
|
+
whether it already carries this meeting, and checked/archived/delegated describe its state.
|
|
600
|
+
The rebuilt line is parsed in isolation first and refused unless it reads back as exactly this
|
|
601
|
+
text, meeting and phase (the parser drops leading owners, [REVIEW], a trailing dash, comments).
|
|
602
|
+
"""
|
|
603
|
+
from task_dedup import TaskDeduplicator
|
|
604
|
+
|
|
605
|
+
text = " ".join(text.split()) if isinstance(text, str) else ""
|
|
606
|
+
if not text or len(text) > 2000:
|
|
607
|
+
raise WorkMetadataError("invalid_task_text", "Card text must be 1 to 2,000 characters.")
|
|
608
|
+
try:
|
|
609
|
+
reject_marker_text(text, "task text")
|
|
610
|
+
except WorkMetadataError:
|
|
611
|
+
raise
|
|
612
|
+
except ValueError as exc:
|
|
613
|
+
raise WorkMetadataError("invalid_task_text", str(exc)) from exc
|
|
614
|
+
meeting = validate_meeting(meeting)
|
|
615
|
+
if phase not in PHASES:
|
|
616
|
+
raise WorkMetadataError("invalid_work_stage", "Unknown Work phase.")
|
|
617
|
+
full = _full_domain(domain)
|
|
618
|
+
path = domain_path(full)
|
|
619
|
+
date = meeting["filename"][:10] if re.match(r"\d{4}-\d{2}-\d{2}_", meeting["filename"]) else datetime.now().strftime("%Y-%m-%d")
|
|
620
|
+
title = re.sub(r"[\[\]*]", "", meeting["title"]).strip() or "Meeting"
|
|
621
|
+
line = _rebuild_line(text, write_source(f"{title} [{date}]", {"workflowPhase": phase, "meetingRefs": [meeting]}),
|
|
622
|
+
None, None, None, False, False)
|
|
623
|
+
probe = _probe_rows(full, line)
|
|
624
|
+
if (len(probe) != 1 or probe[0].description != text or probe[0].work_metadata_error
|
|
625
|
+
or probe[0].meeting_refs != [meeting] or probe[0].work_stage != phase):
|
|
626
|
+
raise WorkMetadataError("invalid_task_text", "This text would not read back as the same card.")
|
|
627
|
+
norm, candidate = TaskDeduplicator.normalize(text), probe[0].id
|
|
628
|
+
if not norm:
|
|
629
|
+
raise WorkMetadataError("invalid_task_text", "Card text has no words.")
|
|
630
|
+
with file_lock(full) as identity:
|
|
631
|
+
same = [r for r in parse_path(path, full)
|
|
632
|
+
if TaskDeduplicator.normalize(r.description) == norm or candidate in (r.id, r.work_identity)]
|
|
633
|
+
if same:
|
|
634
|
+
row = next((r for r in same if any(ref["recordId"] == meeting["recordId"] for ref in r.meeting_refs)), same[0])
|
|
635
|
+
return {"ok": True, "created": False, "id": row.id,
|
|
636
|
+
"linked": any(ref["recordId"] == meeting["recordId"] for ref in row.meeting_refs),
|
|
637
|
+
"checked": row.is_checked, "archived": row.archived, "delegated": row.delegated}
|
|
638
|
+
lines = safe_read_text(path).splitlines() if path.exists() else []
|
|
639
|
+
lines, heading_idx, _ = _locked_ensure_section(lines, "inbox")
|
|
640
|
+
_top_insert(lines, heading_idx, line)
|
|
641
|
+
_guarded_write(full, identity, path, "\n".join(lines) + "\n")
|
|
642
|
+
created = [r for r in parse_path(path, full) if r.id == candidate]
|
|
643
|
+
if len(created) != 1 or created[0].meeting_refs != [meeting]:
|
|
644
|
+
raise WorkMetadataError("capture_unverified", "The new card could not be read back exactly once.")
|
|
645
|
+
return {"ok": True, "created": True, "id": candidate, "linked": True,
|
|
646
|
+
"checked": False, "archived": False, "delegated": created[0].delegated}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def archive_work_item(domain: str, task_id: str, *, record_id: str, expected_text: Optional[str] = None) -> str:
|
|
650
|
+
"""Undo for capture_work_item: archive ([~]) the card only while it is still untouched.
|
|
651
|
+
|
|
652
|
+
Untouched means open and still Mentioned (so not completed or moved to another stage), no run or agent
|
|
653
|
+
marker, no finish line, linked to exactly the one meeting it was created from, and, when `expected_text`
|
|
654
|
+
is given, still worded as written. Anything else is left alone. A move between sections is not checked.
|
|
655
|
+
Returns archived | already_archived | changed | absent.
|
|
656
|
+
"""
|
|
657
|
+
if not isinstance(task_id, str) or not re.fullmatch(r"[a-f0-9]{12}", task_id):
|
|
658
|
+
raise WorkMetadataError("invalid_task_id", "An exact canonical task ID is required.")
|
|
659
|
+
full = _full_domain(domain)
|
|
660
|
+
path = domain_path(full)
|
|
661
|
+
with file_lock(full) as identity:
|
|
662
|
+
matches = [r for r in parse_path(path, full) if r.id == task_id]
|
|
663
|
+
if len(matches) != 1:
|
|
664
|
+
return "absent"
|
|
665
|
+
row = matches[0]
|
|
666
|
+
if row.archived:
|
|
667
|
+
return "already_archived"
|
|
668
|
+
if (row.work_stage != "mentioned" or row.run_at or row.agent_state or row.done_when
|
|
669
|
+
or [ref["recordId"] for ref in row.meeting_refs] != [record_id]
|
|
670
|
+
or (expected_text is not None and row.description != expected_text)):
|
|
671
|
+
return "changed"
|
|
672
|
+
result = _rewrite_row(path, full, task_id, lambda state: state.update(archived=True))
|
|
673
|
+
if result is False:
|
|
674
|
+
return "absent"
|
|
675
|
+
_guarded_write(full, identity, path, result[1])
|
|
676
|
+
return "archived"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revision: str, change, *, return_row=False):
|
|
580
680
|
if not isinstance(expected_text, str) or not expected_text or len(expected_text) > 20_000:
|
|
581
681
|
raise WorkMetadataError("expected_text_required", "Full current task text is required.")
|
|
582
682
|
if not isinstance(task_id, str) or not re.fullmatch(r"[a-f0-9]{12}", task_id):
|
|
@@ -612,9 +712,39 @@ def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revis
|
|
|
612
712
|
return False
|
|
613
713
|
_, contents = result
|
|
614
714
|
_guarded_write(full, identity, path, contents)
|
|
715
|
+
if return_row:
|
|
716
|
+
# Still under the file lock: the response describes this exact commit.
|
|
717
|
+
row = next(r for r in parse_path(path, full) if r.line_number == target.line_number)
|
|
718
|
+
return {"ok": True, "id": row.id, "workIdentity": row.work_identity,
|
|
719
|
+
"workRevision": row.work_revision, "text": row.description,
|
|
720
|
+
"doneWhen": row.done_when or ""}
|
|
615
721
|
return True
|
|
616
722
|
|
|
617
723
|
|
|
724
|
+
def edit_work_task(domain: str, task_id: str, text: str, done_when: str, *, expected_text: str, expected_revision: str):
|
|
725
|
+
"""One guarded replacement for task name and finish line; preserve Work identity."""
|
|
726
|
+
if not isinstance(text, str) or not isinstance(done_when, str):
|
|
727
|
+
raise ValueError("Task name and finish line must be text")
|
|
728
|
+
clean = " ".join(text.split())
|
|
729
|
+
finish = " ".join(done_when.split())
|
|
730
|
+
if not clean or len(clean.encode("utf-16-le")) // 2 > 2000:
|
|
731
|
+
raise ValueError("Task name must contain 1 to 2,000 characters")
|
|
732
|
+
if len(finish.encode("utf-16-le")) // 2 > 500 or "**" in finish:
|
|
733
|
+
raise ValueError("Finish line must be at most 500 characters without ** markup")
|
|
734
|
+
reject_marker_text(clean, "task text")
|
|
735
|
+
reject_marker_text(finish, "done_when")
|
|
736
|
+
if parse_owner(clean)[0] is not None or "[REVIEW]" in clean:
|
|
737
|
+
raise WorkMetadataError("reserved_task_name", "Task names cannot contain an owner prefix or the reserved [REVIEW] flag.")
|
|
738
|
+
|
|
739
|
+
def change(state, metadata):
|
|
740
|
+
if state["agent_state"] == "running":
|
|
741
|
+
raise TaskRunning("This task is already running; its name cannot change during the run")
|
|
742
|
+
state["description"] = clean
|
|
743
|
+
state["done_when"] = finish or None
|
|
744
|
+
|
|
745
|
+
return _work_mutation(domain, task_id, expected_text, expected_revision, change, return_row=True)
|
|
746
|
+
|
|
747
|
+
|
|
618
748
|
def set_work_stage(domain: str, task_id: str, phase: str, *, expected_text: str, expected_revision: str) -> bool:
|
|
619
749
|
if phase not in (*PHASES, 'complete'):
|
|
620
750
|
raise WorkMetadataError("invalid_work_stage", "Unknown Work phase.")
|