@gotcos/glasses-server 6.44.2 → 6.44.4
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 +27 -0
- package/package.json +1 -1
- package/server/lib/task-dispatcher.ts +10 -0
- package/server/lib/task-store.ts +63 -0
- package/server/routes/tasks.ts +54 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
## 6.44.4
|
|
2
|
+
|
|
3
|
+
Stage, a finish line, and no dispatch without one.
|
|
4
|
+
|
|
5
|
+
- A task row carries `stage` (planning, active or review) and `doneWhen`. Both
|
|
6
|
+
live in the tasks.md line, not a sidecar: the task id is a sha256 of the
|
|
7
|
+
normalized description, so an id-keyed store would orphan itself the first time
|
|
8
|
+
the text was edited. An unmarked row reads as planning, so nothing needed
|
|
9
|
+
migrating.
|
|
10
|
+
- `PATCH /api/tasks/:id` accepts `stage` and `doneWhen`.
|
|
11
|
+
- **`runTaskNow` refuses a task with no finish line**, `409 done_when_required`,
|
|
12
|
+
at the single choke point every dispatch passes through. An agent sent at a
|
|
13
|
+
task with no definition of done cannot succeed at it and cannot be judged to
|
|
14
|
+
have failed either.
|
|
15
|
+
- Fixes a round-trip bug older than this release: the writer joins end-of-line
|
|
16
|
+
markers with spaces while the reader's regex allowed none between them, so a
|
|
17
|
+
row carrying both a run time and an agent marker silently lost the run time on
|
|
18
|
+
every read.
|
|
19
|
+
|
|
20
|
+
## 6.44.3
|
|
21
|
+
|
|
22
|
+
A task row carries enough to show and edit it.
|
|
23
|
+
|
|
24
|
+
- `GET /api/tasks` rows gain `text` (the whole description), `source`, `checked`, and `owner`/`agentState`/`agentNo` when present. `title` still exists and is still capped at 44 characters, which is a G2 lens row budget: applying it to every surface is why COS Control rendered "Send James/John the call recording + recap…" in a window 1900px wide. Additive on purpose so clients through 6.9.451, which read `title`, keep working.
|
|
25
|
+
- `PUT /api/domains` sets the user's domain list, so a settings pane can write it instead of the user hand-editing `~/.cos-glasses/.cos-profile.json`. It replaces the configured list wholesale and validates each name for path safety; discovered folders are never written, so removing a name from config cannot hide a folder that still holds tasks.
|
|
26
|
+
- `PATCH /api/tasks/:id` accepts `text`, rewriting a task's words through a new `task-set-text` bridge subcommand. The source block, the `[run ...]` schedule, and any agent marker are rebuilt from parsed state, so editing the words never drops provenance. A task with a running agent is refused, since rewriting the line would change the task the agent was dispatched against.
|
|
27
|
+
|
|
1
28
|
## 6.44.2
|
|
2
29
|
|
|
3
30
|
Task domains come from the user, not from the build.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.44.
|
|
3
|
+
"version": "6.44.4",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -358,6 +358,16 @@ export async function runTaskNow(id: string, domain: string, injected?: TaskDisp
|
|
|
358
358
|
if (row.agent_state === 'running') {
|
|
359
359
|
throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
|
|
360
360
|
}
|
|
361
|
+
// No finish line, no dispatch. An agent sent at a task with no definition of
|
|
362
|
+
// done cannot succeed at it and cannot be judged to have failed either, so
|
|
363
|
+
// this fails closed at the one choke point every dispatch passes through.
|
|
364
|
+
if (!row.done_when || !row.done_when.trim()) {
|
|
365
|
+
throw new TaskRunError(
|
|
366
|
+
409,
|
|
367
|
+
'done_when_required',
|
|
368
|
+
'Say what done looks like before running this task.',
|
|
369
|
+
)
|
|
370
|
+
}
|
|
361
371
|
const runAt = parseRunAt(row.run_at)
|
|
362
372
|
if (runAt && runAt.day > clock.day) {
|
|
363
373
|
throw new TaskRunError(409, 'scheduled_future', 'Run now is not allowed on a future scheduled task.')
|
package/server/lib/task-store.ts
CHANGED
|
@@ -102,6 +102,12 @@ export interface BridgeTaskRow {
|
|
|
102
102
|
agent_no: number | null
|
|
103
103
|
section: string
|
|
104
104
|
section_day: string | null
|
|
105
|
+
/** Workflow stage. null/absent means planning, the unmarked default.
|
|
106
|
+
* Optional so new server code against an older bridge degrades to planning
|
|
107
|
+
* rather than throwing during a partial deploy. */
|
|
108
|
+
stage?: 'planning' | 'active' | 'review' | null
|
|
109
|
+
/** The finish line. A dispatch is refused while this is empty. */
|
|
110
|
+
done_when?: string | null
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
export interface TaskFlags {
|
|
@@ -112,16 +118,32 @@ export interface TaskFlags {
|
|
|
112
118
|
carriedOver: boolean
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
/** Board stage. Planning is the default and carries no marker in tasks.md. */
|
|
122
|
+
export type TaskStage = 'planning' | 'active' | 'review'
|
|
123
|
+
export const TASK_STAGES: readonly TaskStage[] = ['planning', 'active', 'review']
|
|
124
|
+
|
|
115
125
|
export interface TaskBoardRow {
|
|
116
126
|
id: string
|
|
117
127
|
ref: string
|
|
118
128
|
domain: string
|
|
129
|
+
/** Capped to TASK_TITLE_MAX for a lens row. */
|
|
119
130
|
title: string
|
|
131
|
+
/** The whole description, for surfaces with room. */
|
|
132
|
+
text: string
|
|
133
|
+
source?: string
|
|
134
|
+
owner?: string
|
|
135
|
+
delegated?: boolean
|
|
136
|
+
agentState?: 'running' | 'done' | 'failed'
|
|
137
|
+
agentNo?: number
|
|
138
|
+
checked: boolean
|
|
120
139
|
column: TaskColumn
|
|
121
140
|
priority: string
|
|
122
141
|
runAt?: string
|
|
123
142
|
section: string
|
|
124
143
|
sectionDay?: string
|
|
144
|
+
stage: TaskStage
|
|
145
|
+
/** Absent until someone says what finished looks like. Gates `run`. */
|
|
146
|
+
doneWhen?: string
|
|
125
147
|
due: boolean
|
|
126
148
|
missed: boolean
|
|
127
149
|
failed: boolean
|
|
@@ -389,11 +411,24 @@ export function projectRow(
|
|
|
389
411
|
ref: row.ref,
|
|
390
412
|
domain: row.domain,
|
|
391
413
|
title: taskTitle(row.description),
|
|
414
|
+
// `title` stays capped for the lens; `text` is the whole line. The cap is a
|
|
415
|
+
// G2 row budget and was being applied to every surface, so a Mac window
|
|
416
|
+
// 1900px wide showed "Send James/John the call recording + recap…". Additive
|
|
417
|
+
// on purpose: clients through 6.9.451 read `title` and must keep working.
|
|
418
|
+
text: row.description,
|
|
419
|
+
...(row.source ? { source: row.source } : {}),
|
|
420
|
+
...(row.owner ? { owner: row.owner } : {}),
|
|
421
|
+
...(row.delegated ? { delegated: true } : {}),
|
|
422
|
+
...(row.agent_state ? { agentState: row.agent_state } : {}),
|
|
423
|
+
...(row.agent_no != null ? { agentNo: row.agent_no } : {}),
|
|
424
|
+
checked: row.is_checked,
|
|
392
425
|
column: col,
|
|
393
426
|
priority: row.priority,
|
|
394
427
|
...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
|
|
395
428
|
section: row.section,
|
|
396
429
|
...(row.section_day ? { sectionDay: row.section_day } : {}),
|
|
430
|
+
stage: row.stage ?? 'planning',
|
|
431
|
+
...(row.done_when ? { doneWhen: row.done_when } : {}),
|
|
397
432
|
...mark,
|
|
398
433
|
}
|
|
399
434
|
}
|
|
@@ -458,6 +493,34 @@ export async function setTaskRunAt(domain: string, id: string, runAt: string | n
|
|
|
458
493
|
await withLockRetry(() => bridge(args))
|
|
459
494
|
}
|
|
460
495
|
|
|
496
|
+
/** Rewrite a task's words. The bridge preserves its source block and schedule. */
|
|
497
|
+
export async function setTaskText(domain: string, id: string, text: string): Promise<void> {
|
|
498
|
+
const clean = text.replace(/\s+/g, ' ').trim()
|
|
499
|
+
if (!clean) throw new TaskRunError(400, 'text_required', 'text is required')
|
|
500
|
+
if (clean.length > 2000) throw new TaskRunError(422, 'text_too_long', 'text must be 2000 characters or fewer')
|
|
501
|
+
await withLockRetry(() => bridge(['task-set-text', domain, id], clean))
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Move a task between board stages. 'planning' clears the marker. */
|
|
505
|
+
export async function setTaskStage(domain: string, id: string, stage: TaskStage): Promise<void> {
|
|
506
|
+
if (!TASK_STAGES.includes(stage)) {
|
|
507
|
+
throw new TaskRunError(422, 'invalid_stage', `stage must be one of ${TASK_STAGES.join(', ')}`)
|
|
508
|
+
}
|
|
509
|
+
await withLockRetry(() => bridge(['task-set-stage', domain, id, stage === 'planning' ? '--clear' : stage]))
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Set or clear the finish line. Empty clears it, which re-blocks `run`. */
|
|
513
|
+
export async function setTaskDoneWhen(domain: string, id: string, text: string): Promise<void> {
|
|
514
|
+
const clean = text.replace(/\s+/g, ' ').trim()
|
|
515
|
+
if (clean.length > 500) {
|
|
516
|
+
throw new TaskRunError(422, 'done_when_too_long', 'done_when must be 500 characters or fewer')
|
|
517
|
+
}
|
|
518
|
+
if (clean.includes('**')) {
|
|
519
|
+
throw new TaskRunError(422, 'invalid_done_when', 'done_when cannot contain ** markup')
|
|
520
|
+
}
|
|
521
|
+
await withLockRetry(() => bridge(['task-set-done-when', domain, id], clean))
|
|
522
|
+
}
|
|
523
|
+
|
|
461
524
|
export async function moveTask(domain: string, id: string, section: string, nowMs = Date.now()): Promise<void> {
|
|
462
525
|
const { clock } = briefContext(nowMs)
|
|
463
526
|
const args = ['task-move', domain, id, '--section', section]
|
package/server/routes/tasks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
2
|
import { queryJobCoordinator } from '../lib/query-job-runtime.js'
|
|
3
3
|
import { runTaskNow } from '../lib/task-dispatcher.js'
|
|
4
|
-
import { domainAbbreviation } from '../lib/domains.js'
|
|
4
|
+
import { domainAbbreviation, isSafeDomainName } from '../lib/domains.js'
|
|
5
|
+
import { updateProfileFields } from '../lib/profile.js'
|
|
5
6
|
import { domainLabel } from '../lib/domain-label.js'
|
|
6
7
|
import {
|
|
7
8
|
TaskBridgeError,
|
|
@@ -14,9 +15,13 @@ import {
|
|
|
14
15
|
moveTask,
|
|
15
16
|
saveDispatchCap,
|
|
16
17
|
setTaskRunAt,
|
|
18
|
+
setTaskText,
|
|
17
19
|
taskDomains,
|
|
18
20
|
tasksGate,
|
|
19
21
|
workBadgeCount,
|
|
22
|
+
setTaskStage,
|
|
23
|
+
setTaskDoneWhen,
|
|
24
|
+
type TaskStage,
|
|
20
25
|
} from '../lib/task-store.js'
|
|
21
26
|
|
|
22
27
|
export const tasksRouter = Router()
|
|
@@ -127,6 +132,46 @@ tasksRouter.get('/domains', (_req, res) => {
|
|
|
127
132
|
}
|
|
128
133
|
})
|
|
129
134
|
|
|
135
|
+
/** Set the user's domain list. Replaces it wholesale: the client shows the
|
|
136
|
+
* current list and sends back what it wants, which is what a settings pane does.
|
|
137
|
+
* Discovered folders are NOT written here — they keep showing because the read
|
|
138
|
+
* side unions them in, so removing a name from config never hides a folder that
|
|
139
|
+
* still holds tasks. */
|
|
140
|
+
tasksRouter.put('/domains', (req, res) => {
|
|
141
|
+
try {
|
|
142
|
+
const raw = (req.body ?? {}).domains
|
|
143
|
+
if (!Array.isArray(raw)) throw new TaskRunError(400, 'domains_required', 'domains must be an array')
|
|
144
|
+
if (raw.length > 32) throw new TaskRunError(422, 'too_many_domains', 'at most 32 domains')
|
|
145
|
+
const names: string[] = []
|
|
146
|
+
const seen = new Set<string>()
|
|
147
|
+
for (const entry of raw) {
|
|
148
|
+
const name = typeof entry === 'string'
|
|
149
|
+
? entry
|
|
150
|
+
: (entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string')
|
|
151
|
+
? (entry as { name: string }).name
|
|
152
|
+
: ''
|
|
153
|
+
const trimmed = name.trim()
|
|
154
|
+
if (!trimmed) continue
|
|
155
|
+
if (!isSafeDomainName(trimmed)) {
|
|
156
|
+
throw new TaskRunError(422, 'invalid_domain_name', `not a usable domain name: ${trimmed}`)
|
|
157
|
+
}
|
|
158
|
+
const key = trimmed.toLowerCase()
|
|
159
|
+
if (seen.has(key)) continue
|
|
160
|
+
seen.add(key)
|
|
161
|
+
names.push(trimmed)
|
|
162
|
+
}
|
|
163
|
+
updateProfileFields({ domains: names })
|
|
164
|
+
const resolved = taskDomains()
|
|
165
|
+
res.json({
|
|
166
|
+
domains: resolved.map(name => ({ name, abbr: domainAbbreviation(name), label: domainLabel(name) })),
|
|
167
|
+
configured: names,
|
|
168
|
+
gate: tasksGate(),
|
|
169
|
+
})
|
|
170
|
+
} catch (error) {
|
|
171
|
+
sendError(res, error)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
130
175
|
tasksRouter.get('/tasks', async (req, res) => {
|
|
131
176
|
try {
|
|
132
177
|
const column = typeof req.query.column === 'string' ? req.query.column : undefined
|
|
@@ -141,14 +186,20 @@ tasksRouter.patch('/tasks/:id', async (req, res) => {
|
|
|
141
186
|
try {
|
|
142
187
|
const body = req.body ?? {}
|
|
143
188
|
const domain = requireDomain(body.domain)
|
|
144
|
-
if ('
|
|
189
|
+
if ('text' in body) {
|
|
190
|
+
await setTaskText(domain, req.params.id, String(body.text))
|
|
191
|
+
} else if ('runAt' in body) {
|
|
145
192
|
await setTaskRunAt(domain, req.params.id, body.runAt == null || body.runAt === '' ? null : String(body.runAt))
|
|
146
193
|
} else if ('section' in body) {
|
|
147
194
|
await moveTask(domain, req.params.id, String(body.section))
|
|
148
195
|
} else if ('checked' in body) {
|
|
149
196
|
await checkTask({ domain, id: req.params.id, checked: body.checked === true })
|
|
197
|
+
} else if ('stage' in body) {
|
|
198
|
+
await setTaskStage(domain, req.params.id, String(body.stage) as TaskStage)
|
|
199
|
+
} else if ('doneWhen' in body) {
|
|
200
|
+
await setTaskDoneWhen(domain, req.params.id, body.doneWhen == null ? '' : String(body.doneWhen))
|
|
150
201
|
} else {
|
|
151
|
-
throw new TaskRunError(400, 'invalid_patch', 'Expected runAt, section, or
|
|
202
|
+
throw new TaskRunError(400, 'invalid_patch', 'Expected text, runAt, section, checked, stage, or doneWhen.')
|
|
152
203
|
}
|
|
153
204
|
res.json({ ok: true })
|
|
154
205
|
} catch (error) {
|