@gotcos/glasses-server 6.64.0 → 6.65.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 +19 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +11 -0
- package/package.json +2 -1
- package/server/index.ts +7 -0
- package/server/lib/jev.ts +5 -3
- package/server/lib/task-dates.ts +369 -0
- package/server/lib/task-store.ts +30 -3
- package/server/lib/work-search.ts +226 -0
- package/server/routes/jev.ts +6 -1
- package/server/routes/tasks.ts +3 -1
- package/server/routes/work-board.ts +7 -2
- package/server/routes/work-search.ts +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## 6.65.0 — 2026-10-07 — Work search by meaning, and task dates
|
|
2
|
+
|
|
3
|
+
- `POST /api/work/search` `{ query, domain?, scope?, ids? }` finds the Work card the user means, not only the words they typed. Pairs with COS Control 0.5.259, which shows its own word matches at once and asks this after a pause.
|
|
4
|
+
- One Jev Choice per search over the board's own cards, plus `none`. The client names a view or exact card ids, never task text. A domain board searches that domain's cards, open and completed. All work searches open cards only, although Control's All work view also shows completed ones. Completed searches completed cards. Needs attention and In progress send their card ids.
|
|
5
|
+
- What goes to TypeSafe: each card's task text, on one line, at most 300 characters, without `[Work details](…)` links. URLs, email addresses and opaque ids (the shape of a key or a document id) are removed, credentials are masked by the server's secret redaction, and a markdown link keeps only its label. A bare domain such as bottlepos.com stays.
|
|
6
|
+
- The query is 2 to 200 characters, counted in code points, so 150 emoji are 150.
|
|
7
|
+
- The answer is `{ available: true, results: [{ id, p }], none, cached, tokens }`: cards at p 0.02 or more, most likely first, at most 20.
|
|
8
|
+
- When it cannot search it answers HTTP 200 with `{ available: false, reason, message }`. The reasons are `jev_not_configured`, `jev_key_rejected`, `jev_cap_reached`, `jev_breaker_open`, `jev_request_rejected` (Jev refused the request: a 400, 413 or 422), `jev_unavailable` (no answer, a 5xx, or a malformed answer), `search_off` and `too_many_candidates`. A Choice holds 254 cards, so a bigger set is refused, never cut; Completed across all domains is 198 cards today. Control keeps its word search in every case. The server logs each reason at most once per 10 minutes.
|
|
9
|
+
- Its own cost controls, apart from Work intake and session advice. A daily cap of 1,000,000 input tokens (`COS_JEV_SEARCH_DAILY_TOKENS`), about 110 to 200 searches by board size, with its own ledger (`<data>/jev-search-usage.json`); it resets at midnight UTC. Its own breaker: 3 failures, then an hour; a refused request does not count, because the same cards would be refused again. A switch, `COS_JEV_SEARCH=0`. Both come from the server's environment, so changing them takes a restart. Saving a new TypeSafe key clears this breaker too. `COS_JEV_DAILY_TOKENS` is unchanged.
|
|
10
|
+
- The same query over the same cards is answered from memory for 10 minutes (up to 200 searches).
|
|
11
|
+
- Search is read-only, so it does not take the server's request lease: an update or restart never waits on a search, and a search during one is answered rather than refused.
|
|
12
|
+
- Measured on 2026-10-06 on the live board (293 cards, 95 open): 4 searches, the right card first at p 0.99 for each of 3 real queries, `none` at 1 for an unrelated one, 4,815 to 4,963 input tokens and 268 to 431 ms each including the board read. The repeat came from the cache with no call.
|
|
13
|
+
- `GET /api/tasks` and `GET /api/work-board` rows carry `createdOn` (`YYYY-MM-DD` or null), `createdFrom` (`source`, `git` or null) and `lineChangedAt` (ISO time or null), for Control's date orders. Control reads `/api/work-board` for its Work board, so both routes carry them. Additive: every existing field and the task revision are unchanged, and the fields are present as null when unknown, so a client can tell this server from an older one.
|
|
14
|
+
- `createdOn` is the first date in the task's source label, usually the meeting date. Otherwise it is the day the task's words first appeared in the git history of its `tasks.md`, cached on disk (`<data>/task-created-dates.json`, at most 5,000) by file and a hash of the words. A lookup that times out or fails is not cached as "not in history"; it is tried again after 10 minutes.
|
|
15
|
+
- `lineChangedAt` is when git last changed the task's line (blame, once per HEAD and file change). A line that is not committed yet is null, never "now". tasks.md is committed by the COS sync every one to three days, so this is later than the real change.
|
|
16
|
+
- Every read of either route, the phone's and the glasses' included, keeps all of its git work inside 1.5 s, half the phone's 4 s timeout less an allowance for the board read. Past that the dates still unknown come back null and the git work finishes in the background for a later read.
|
|
17
|
+
- Read-only: nothing is written into tasks.md. Git runs only when a `.git` exists at or above the `tasks.md` folder. A public install keeps its tasks outside any repository, so no git process starts. If the home folder is itself a git repository, git does run, finds `tasks.md` untracked, and the git fields stay null. Both the COS bridge and the packaged task runtime get the fields.
|
|
18
|
+
- Measured on 2026-10-06 on the live board (293 cards, a read-only scratch run): 241 dated from the source label, 52 from git, none without a date; 278 with `lineChangedAt` (the other 15 are not committed yet). The first call took 1.2 s (68 git lookups), later calls about 20 ms, and a restart 74 ms with no git history lookup.
|
|
19
|
+
|
|
1
20
|
## 6.64.0 — 2026-10-06 — Meeting context keys
|
|
2
21
|
|
|
3
22
|
- Meeting rows and details carry `contextKeys`, `contextSupported`, and, when files can't be added, `contextReason` (plus `contextAmbiguous`). The fields are additive and read-only. They are the keys COS Control 0.5.258 files a meeting's dropped screenshots, slides and documents under, so the files go to every Work card linked to the meeting.
|
package/docs/work-intake.md
CHANGED
|
@@ -35,6 +35,8 @@ Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{
|
|
|
35
35
|
|
|
36
36
|
Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
|
|
37
37
|
|
|
38
|
+
Since 6.65.0 `POST /api/work/search` takes `{ query, domain?, scope?, ids? }`: a query of 2 to 200 characters (code points), an optional domain, an optional scope (`all`, `attention`, `progress` or `completed`) and optional card ids (12 hex). It asks Jev one Choice over the board's own cards, plus `none`, and answers `{ available: true, results: [{ id, p }], none, cached, tokens }` with cards at p 0.02 or more, most likely first, at most 20. Exact ids win (Control sends them for Needs attention and In progress). Otherwise a domain board covers that domain's cards, All work its open cards, and Completed its completed cards. Each card goes to TypeSafe as its task text without `[Work details](…)` links, URLs, email addresses or opaque ids, with credentials masked by the server's secret redaction, at most 300 characters. A search that cannot run answers 200 with `{ available: false, reason, message }`: `jev_not_configured`, `jev_key_rejected`, `jev_cap_reached`, `jev_breaker_open`, `jev_request_rejected` (a 400, 413 or 422 from Jev; not counted toward the breaker), `jev_unavailable`, `search_off` (`COS_JEV_SEARCH=0`) or `too_many_candidates` (more than 254 cards). Errors: 400 `invalid_search_request`, 503 `work_board_unavailable`. Search has its own daily cap (`COS_JEV_SEARCH_DAILY_TOKENS`, default 1,000,000, reset at midnight UTC), ledger (`data/jev-search-usage.json`) and breaker, so it never spends the cap Work intake and session advice share; the switch and the cap are read from the environment set at start, so a change takes a restart. The same query over the same cards is answered from memory for 10 minutes. The route is read-only and does not take the server's request lease.
|
|
39
|
+
|
|
38
40
|
Since 6.58.2 `POST /api/query-jobs` also takes an optional `sessionName` (a string; the server cleans it as the 6.58.2 changelog says and keeps at most 100 characters; anything else is no name, never an error). It names only a Claude session the job starts itself (`claude --name=<name>`, when the installed CLI lists `--name`); a Continue, a dispatch, a Codex, Cursor or Ollama job, and an older server ignore it, and it is not part of the request fingerprint. A Claude job's snapshot carries `cliSessionId` from Claude's first stream event while the job is still `running`, not only at completion. COS Control 0.5.250 sends the Work item's title for New session, Start work and Fork to Claude, and links its receipt as soon as the running job names the session.
|
|
39
41
|
|
|
40
42
|
Since 6.59.0 `GET /api/work-board/activity` also projects each handoff's progress (the report, its evidence of at most 280 characters, the newest move), the task's own revision and a saved destination, `GET /api/work-board/activity/open` lists every open handoff across the board, and `POST /api/work-board/handoff-requests` lets the glasses ask COS Control to start Work, reply, or say it is not done yet, which Control claims on this Mac and sends with its own guards. The server runs no provider and no Jev for any of it. See `docs/work-handoff-requests.md`.
|
|
@@ -30,6 +30,17 @@ single-command child pins its domain as cwd instead of relying on unsupported
|
|
|
30
30
|
atomic replacement writes use exclusive no-follow temporary files. Same-user
|
|
31
31
|
modification of the installed package/manifest is outside this integrity boundary.
|
|
32
32
|
|
|
33
|
+
Since 6.65.0 `GET /api/tasks` and `GET /api/work-board` rows also carry `createdOn`,
|
|
34
|
+
`createdFrom` and `lineChangedAt`, for both the COS bridge and this runtime. The server reads them; the
|
|
35
|
+
runtime does not. `createdOn` is the first `YYYY-MM-DD` in the source label, else the
|
|
36
|
+
day the task's words first appeared in the git history of its `tasks.md`.
|
|
37
|
+
`lineChangedAt` is the git blame time of a committed line. Each is null when unknown,
|
|
38
|
+
including every git-derived value when no git repository tracks the `tasks.md`.
|
|
39
|
+
All git work for one read (rev-parse, blame, first-seen lookups) shares a 1.5 s
|
|
40
|
+
deadline; what is still unknown then comes back null, and the git work finishes in the
|
|
41
|
+
background for a later read. Git runs only when a `.git` exists at or above the
|
|
42
|
+
`tasks.md` folder. Nothing is written into `tasks.md`.
|
|
43
|
+
|
|
33
44
|
The default lock namespace remains `~/Library/Application Support/COS/.task_locks.json`
|
|
34
45
|
and canonical `file:<domain>` entries. `COS_TASK_LOCK_STORE` is an explicit override
|
|
35
46
|
for isolated fixtures. Existing COS bridge calls keep their original environment
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.65.0",
|
|
4
4
|
"description": "COS Glasses: 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": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"!server/scripts/work-review-mutation-gate.mjs",
|
|
47
47
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
48
48
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
49
|
+
"!server/scripts/work-search-mutation-gate.mjs",
|
|
49
50
|
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
50
51
|
"!server/scripts/nemotron-mutation-gate.mjs",
|
|
51
52
|
"!server/scripts/merged-scribe-mutation-gate.mjs",
|
package/server/index.ts
CHANGED
|
@@ -142,6 +142,8 @@ import { createWorkBoardRouter } from './routes/work-board.js'
|
|
|
142
142
|
import { createWorkIntakeRouter } from './routes/work-intake.js'
|
|
143
143
|
import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
|
|
144
144
|
import { createJevRouter } from './routes/jev.js'
|
|
145
|
+
import { createWorkSearchRouter } from './routes/work-search.js'
|
|
146
|
+
import { isReadOnlyApiPost } from './lib/work-search.js'
|
|
145
147
|
import { dataPath } from './lib/data-dir.js'
|
|
146
148
|
import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
|
|
147
149
|
import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
|
|
@@ -324,6 +326,9 @@ app.use('/api', requireApiToken(API_TOKEN))
|
|
|
324
326
|
// through their true terminal boundary.
|
|
325
327
|
app.use('/api', (req, res, next) => {
|
|
326
328
|
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
|
|
329
|
+
// 6.65.0: a POST that only reads (Work search: a board read and one Jev question) is not a mutation. See
|
|
330
|
+
// lib/work-search.ts isReadOnlyApiPost for why holding the lease would hurt and why skipping it is safe.
|
|
331
|
+
if (isReadOnlyApiPost(req.method, req.path)) return next()
|
|
327
332
|
// V2 video original chunks are bounded to the advertised session size (1 MiB
|
|
328
333
|
// on new sessions, 256 KiB on leftover drafts) and commit through the upload
|
|
329
334
|
// registry's own generation lock. Holding the global mutation lease while
|
|
@@ -756,6 +761,8 @@ app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
|
|
|
756
761
|
// Jev key (Control Settings), Continue/Fork/New session recommendations for Work tasks (6.57.0) and meeting reviews
|
|
757
762
|
// (6.57.1), and the Work completion check (6.58.0).
|
|
758
763
|
app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
|
|
764
|
+
// Work search by meaning (6.65.0, Control 0.5.259): one Jev Choice over the board's cards, with its own cap, switch and breaker.
|
|
765
|
+
app.use('/api', createWorkSearchRouter())
|
|
759
766
|
app.use('/api', queryRouter)
|
|
760
767
|
// The scheduled start-of-day brief: settings, status, run-now. Same auth as
|
|
761
768
|
// every other settings route; the brief itself is an ordinary durable job.
|
package/server/lib/jev.ts
CHANGED
|
@@ -88,8 +88,10 @@ export class JevClient {
|
|
|
88
88
|
private failures = 0
|
|
89
89
|
private breakerUntil = 0
|
|
90
90
|
private lastError: string | null = null
|
|
91
|
+
/** `cap` is the daily input-token cap for THIS client's ledger. Work search (6.65.0) passes its own; the default is
|
|
92
|
+
* COS_JEV_DAILY_TOKENS, shared by Work intake advice, session recommendations and completion checks. */
|
|
91
93
|
constructor(private readonly fetchImpl: Fetch = fetch, private readonly now: () => Date = () => new Date(),
|
|
92
|
-
private readonly usageFile = JEV_USAGE_FILE) {}
|
|
94
|
+
private readonly usageFile = JEV_USAGE_FILE, private readonly cap: () => number = dailyCap) {}
|
|
93
95
|
|
|
94
96
|
usedToday(): number {
|
|
95
97
|
try {
|
|
@@ -121,7 +123,7 @@ export class JevClient {
|
|
|
121
123
|
status(): { configured: boolean; source: JevKeySource; savedAt?: string; validatedAt?: string; usedToday: number; dailyCap: number; breakerOpenUntil: string | null; lastError: string | null } {
|
|
122
124
|
const key = resolveJevKey()
|
|
123
125
|
return { configured: !!key, source: key?.source ?? 'none', savedAt: key?.savedAt, validatedAt: key?.validatedAt,
|
|
124
|
-
usedToday: this.usedToday(), dailyCap:
|
|
126
|
+
usedToday: this.usedToday(), dailyCap: this.cap(),
|
|
125
127
|
breakerOpenUntil: this.breakerUntil > this.now().getTime() ? new Date(this.breakerUntil).toISOString() : null, lastError: this.lastError }
|
|
126
128
|
}
|
|
127
129
|
|
|
@@ -130,7 +132,7 @@ export class JevClient {
|
|
|
130
132
|
const key = resolveJevKey()
|
|
131
133
|
if (!key) throw new JevError('jev_not_configured')
|
|
132
134
|
if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
|
|
133
|
-
if (this.usedToday() + estimate >
|
|
135
|
+
if (this.usedToday() + estimate > this.cap()) this.fail('jev_cap_reached', false)
|
|
134
136
|
let res: Response
|
|
135
137
|
try {
|
|
136
138
|
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a Work task was created and when its line last changed (6.65.0, Control 0.5.259 "Newest created" and
|
|
3
|
+
* "Recent activity" orders). Read-only: nothing is ever written into tasks.md (canary C6).
|
|
4
|
+
*
|
|
5
|
+
* No task carries a creation time (0 of 293 on 2026-10-06), so the date is inferred, in this order:
|
|
6
|
+
* 1. createdOn from the source label: the first YYYY-MM-DD in it (241 of 293 live tasks carry one, usually the
|
|
7
|
+
* meeting date). `createdFrom: 'source'`.
|
|
8
|
+
* 2. Else the day the task's text first appeared in the git history of its tasks.md
|
|
9
|
+
* (`git log --reverse -S<text> -- tasks.md`, author date). `createdFrom: 'git'`. Each lookup is one git process,
|
|
10
|
+
* so they are cached on disk by (file, text hash). A lookup that times out or fails is never cached as "not in
|
|
11
|
+
* history"; it rests (TASK_DATE_LIMITS.lookupBackoffMs) and is tried again.
|
|
12
|
+
* 3. Else null.
|
|
13
|
+
* lineChangedAt is `git blame` of the line (author time), for committed lines only: an uncommitted line is null, never
|
|
14
|
+
* "now". tasks.md is auto-committed by the COS sync, so this is a coarse floor. Blame runs once per (HEAD, file mtime).
|
|
15
|
+
*
|
|
16
|
+
* Every dated read keeps ALL of its git work (rev-parse, blame, first-seen lookups) inside one deadline,
|
|
17
|
+
* TASK_DATE_LIMITS.deadlineMs (1.5 s, derived from the phone's 4 s request timeout below). Past it the read answers with
|
|
18
|
+
* what it has, and git work already started keeps running to fill the caches for a later read.
|
|
19
|
+
*
|
|
20
|
+
* Git runs only when a `.git` exists at or above the tasks.md folder. A public install keeps its tasks under
|
|
21
|
+
* ~/.cos-glasses/data, normally outside any repository, so no git process starts and the git-derived fields are null.
|
|
22
|
+
* If the home folder itself is a git repository, git does run: rev-parse on each dated read and blame once per HEAD or
|
|
23
|
+
* file change. Blame finds tasks.md untracked, so the git-derived fields stay null and no first-seen lookup runs.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import { execFile } from 'node:child_process'
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
|
28
|
+
import { basename, dirname, join } from 'node:path'
|
|
29
|
+
import { dataPath } from './data-dir.js'
|
|
30
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
31
|
+
|
|
32
|
+
export interface TaskDateFields {
|
|
33
|
+
/** 'YYYY-MM-DD' or null. */
|
|
34
|
+
createdOn: string | null
|
|
35
|
+
createdFrom: 'source' | 'git' | null
|
|
36
|
+
/** ISO time of the commit that last changed the task's line, or null (uncommitted, or no git). */
|
|
37
|
+
lineChangedAt: string | null
|
|
38
|
+
}
|
|
39
|
+
export const NO_TASK_DATES: Readonly<TaskDateFields> = Object.freeze({ createdOn: null, createdFrom: null, lineChangedAt: null })
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The phone and the glasses give up on a request after 4 s. A dated board read keeps all of its git work (rev-parse,
|
|
43
|
+
* blame, first-seen lookups) inside half of that, less an allowance for the board read itself (the task bridge answers
|
|
44
|
+
* in about 0.1 s) and the answer: 4,000 / 2 - 500 = 1,500 ms.
|
|
45
|
+
*/
|
|
46
|
+
const CLIENT_TIMEOUT_MS = 4_000
|
|
47
|
+
const BOARD_READ_ALLOWANCE_MS = 500
|
|
48
|
+
|
|
49
|
+
export const TASK_DATE_LIMITS = {
|
|
50
|
+
/** All git work for one request, overall. What does not finish keeps running and answers on a later call. */
|
|
51
|
+
deadlineMs: CLIENT_TIMEOUT_MS / 2 - BOARD_READ_ALLOWANCE_MS,
|
|
52
|
+
/** resolveTaskDatesWithin's grace past the deadline before it answers null dates for every row. */
|
|
53
|
+
answerSlackMs: 100,
|
|
54
|
+
/** One git process. It may outlive the request (it fills the caches); it is killed after this. */
|
|
55
|
+
gitTimeoutMs: 8_000,
|
|
56
|
+
/** A first-seen lookup that failed is not retried for this long; it is never cached as "not in history". */
|
|
57
|
+
lookupBackoffMs: 10 * 60_000,
|
|
58
|
+
/** A file whose rev-parse or blame timed out gets no git work for this long. */
|
|
59
|
+
fileBackoffMs: 60_000,
|
|
60
|
+
concurrency: 2,
|
|
61
|
+
cacheEntries: 5_000,
|
|
62
|
+
blameFiles: 32,
|
|
63
|
+
needleMin: 8,
|
|
64
|
+
needleMax: 200,
|
|
65
|
+
/** The leading characters of a task's text that must appear on its blamed line. */
|
|
66
|
+
guardChars: 40,
|
|
67
|
+
} as const
|
|
68
|
+
export const TASK_DATES_CACHE_FILE = dataPath('task-created-dates.json')
|
|
69
|
+
|
|
70
|
+
/** What a bridge row offers (task-store.ts BridgeTaskRow). */
|
|
71
|
+
export interface DateSourceRow { id: string; domain: string; line_number: number; source: string | null; description: string }
|
|
72
|
+
export type DateGitRunner = (args: readonly string[], cwd: string, timeoutMs: number) => Promise<string>
|
|
73
|
+
|
|
74
|
+
/** Homebrew's git first: `/usr/bin/git` without the developer tools opens an install dialog. */
|
|
75
|
+
const GIT_BIN = ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/usr/bin/git'].find(path => existsSync(path)) ?? '/usr/bin/git'
|
|
76
|
+
export const realDateGitRunner: DateGitRunner = (args, cwd, timeoutMs) => new Promise((resolve, reject) => {
|
|
77
|
+
execFile(GIT_BIN, [...args], { cwd, timeout: timeoutMs, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
78
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } }, (error, stdout) => error ? reject(error) : resolve(String(stdout)))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
export function taskDateKey(domain: string, id: string): string { return `${domain}\0${id}` }
|
|
82
|
+
|
|
83
|
+
function isCalendarDay(day: string): boolean {
|
|
84
|
+
const at = new Date(`${day}T00:00:00Z`)
|
|
85
|
+
return !Number.isNaN(at.getTime()) && at.toISOString().slice(0, 10) === day
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The first real YYYY-MM-DD in a source label. Link targets are not label text (a Work details token is base64). */
|
|
89
|
+
export function sourceDate(source: string | null | undefined): string | null {
|
|
90
|
+
if (!source) return null
|
|
91
|
+
const label = source.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
92
|
+
for (const match of label.matchAll(/20\d\d-\d\d-\d\d/g)) if (isCalendarDay(match[0])) return match[0]
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface BlameLine { content: string; at: string | null }
|
|
97
|
+
/** `git blame --line-porcelain` output to line number -> content and author time (null for an uncommitted line). */
|
|
98
|
+
export function parseBlamePorcelain(output: string): Map<number, BlameLine> {
|
|
99
|
+
const lines = new Map<number, BlameLine>()
|
|
100
|
+
let current: { sha: string; line: number; time: number | null } | null = null
|
|
101
|
+
for (const raw of output.split('\n')) {
|
|
102
|
+
if (raw.startsWith('\t')) {
|
|
103
|
+
if (current) {
|
|
104
|
+
const committed = !/^0+$/.test(current.sha) && current.time !== null && Number.isFinite(current.time)
|
|
105
|
+
lines.set(current.line, { content: raw.slice(1), at: committed ? new Date(current.time! * 1000).toISOString() : null })
|
|
106
|
+
}
|
|
107
|
+
current = null
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const head = /^([0-9a-f]{40}|[0-9a-f]{64}) \d+ (\d+)(?: \d+)?$/.exec(raw)
|
|
111
|
+
if (head) { current = { sha: head[1], line: Number(head[2]), time: null }; continue }
|
|
112
|
+
if (current && raw.startsWith('author-time ')) current.time = Number(raw.slice('author-time '.length))
|
|
113
|
+
}
|
|
114
|
+
return lines
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The line still holds the task (the file can change between the board read and the blame). */
|
|
118
|
+
export function lineHolds(content: string, description: string): boolean {
|
|
119
|
+
const probe = description.trim().slice(0, TASK_DATE_LIMITS.guardChars)
|
|
120
|
+
return probe.length > 0 && content.includes(probe)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The text to find in history: the task's own words (not its checkbox, source or markers, which change later). */
|
|
124
|
+
export function pickaxeNeedle(description: string, content?: string): string | null {
|
|
125
|
+
const text = description.trim()
|
|
126
|
+
if (text.length < TASK_DATE_LIMITS.needleMin || text.includes('\0')) return null
|
|
127
|
+
const full = text.slice(0, TASK_DATE_LIMITS.needleMax)
|
|
128
|
+
if (!content || content.includes(full)) return full
|
|
129
|
+
const head = text.slice(0, TASK_DATE_LIMITS.guardChars)
|
|
130
|
+
return content.includes(head) ? head : full
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The nearest folder at or above `dir` holding `.git` (a folder, or a pointer file), or null. */
|
|
134
|
+
export function findGitWorkTree(dir: string): string | null {
|
|
135
|
+
let at = dir
|
|
136
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
137
|
+
if (existsSync(join(at, '.git'))) return at
|
|
138
|
+
const up = dirname(at)
|
|
139
|
+
if (up === at) return null
|
|
140
|
+
at = up
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface FileState { file: string; dir: string; head: string; lines: Map<number, BlameLine> }
|
|
146
|
+
/** `d`: the day, or null when history has no such text yet; then `h` is the HEAD it was looked up at. */
|
|
147
|
+
interface CreatedEntry { d: string | null; h?: string }
|
|
148
|
+
interface PickaxeJob { key: string; needle: string; state: FileState }
|
|
149
|
+
|
|
150
|
+
/** execFile kills a git that outlives its timeout: a timeout says nothing about the history or the file. */
|
|
151
|
+
export function isGitTimeout(error: unknown): boolean {
|
|
152
|
+
const e = error as { killed?: unknown; signal?: unknown; code?: unknown } | null
|
|
153
|
+
return !!e && (e.killed === true || (typeof e.signal === 'string' && e.signal.length > 0) || e.code === 'ETIMEDOUT')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface TaskDateResolverOptions {
|
|
157
|
+
git?: DateGitRunner
|
|
158
|
+
cacheFile?: string
|
|
159
|
+
/** The overall deadline for one resolve (TASK_DATE_LIMITS.deadlineMs). */
|
|
160
|
+
deadlineMs?: number
|
|
161
|
+
now?: () => number
|
|
162
|
+
findRepo?: (dir: string) => string | null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class TaskDateResolver {
|
|
166
|
+
private readonly git: DateGitRunner
|
|
167
|
+
private readonly cacheFile: string
|
|
168
|
+
private readonly deadlineMs: number
|
|
169
|
+
private readonly now: () => number
|
|
170
|
+
private readonly findRepo: (dir: string) => string | null
|
|
171
|
+
private blame = new Map<string, { key: string; lines: Promise<Map<number, BlameLine> | null> }>()
|
|
172
|
+
private created: Map<string, CreatedEntry> | null = null
|
|
173
|
+
private pending = new Map<string, Promise<void>>()
|
|
174
|
+
/** A first-seen lookup that failed (a timeout, a git error): not "absent", and not retried until this time. */
|
|
175
|
+
private lookupBackoff = new Map<string, number>()
|
|
176
|
+
/** A file whose rev-parse or blame timed out: no git for it until this time. */
|
|
177
|
+
private fileBackoff = new Map<string, number>()
|
|
178
|
+
private dirty = false
|
|
179
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
180
|
+
|
|
181
|
+
constructor(options: TaskDateResolverOptions = {}) {
|
|
182
|
+
this.git = options.git ?? realDateGitRunner
|
|
183
|
+
this.cacheFile = options.cacheFile ?? TASK_DATES_CACHE_FILE
|
|
184
|
+
this.deadlineMs = options.deadlineMs ?? TASK_DATE_LIMITS.deadlineMs
|
|
185
|
+
this.now = options.now ?? Date.now
|
|
186
|
+
this.findRepo = options.findRepo ?? findGitWorkTree
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Date fields for every row, keyed by taskDateKey(domain, id). Never throws; an unknown is null. Everything git does
|
|
191
|
+
* for this call (rev-parse, blame, first-seen lookups) shares ONE deadline: past it the call answers with what it
|
|
192
|
+
* has, source dates included, and git work already started keeps running to fill the caches for a later call.
|
|
193
|
+
*/
|
|
194
|
+
async resolve(rows: readonly DateSourceRow[], root: string): Promise<Map<string, TaskDateFields>> {
|
|
195
|
+
const deadline = this.now() + this.deadlineMs
|
|
196
|
+
const out = new Map<string, TaskDateFields>()
|
|
197
|
+
const states = new Map<string, FileState | null>()
|
|
198
|
+
const reads = [...new Set(rows.map(row => row.domain))].map(domain =>
|
|
199
|
+
this.fileState(join(root, domain, 'tasks.md')).catch(() => null).then(state => { states.set(domain, state) }))
|
|
200
|
+
await this.until(Promise.all(reads), deadline)
|
|
201
|
+
const jobs = new Map<string, PickaxeJob>()
|
|
202
|
+
const waiting: Array<{ fields: TaskDateFields; key: string }> = []
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
const fields: TaskDateFields = { ...NO_TASK_DATES }
|
|
205
|
+
out.set(taskDateKey(row.domain, row.id), fields)
|
|
206
|
+
const fromSource = sourceDate(row.source)
|
|
207
|
+
if (fromSource) { fields.createdOn = fromSource; fields.createdFrom = 'source' }
|
|
208
|
+
const state = states.get(row.domain)
|
|
209
|
+
if (!state) continue
|
|
210
|
+
const line = state.lines.get(row.line_number)
|
|
211
|
+
if (line && lineHolds(line.content, row.description)) fields.lineChangedAt = line.at
|
|
212
|
+
if (fromSource) continue
|
|
213
|
+
const needle = pickaxeNeedle(row.description, line?.content)
|
|
214
|
+
if (!needle) continue
|
|
215
|
+
const key = createHash('sha256').update(state.file + '\0' + needle).digest('hex').slice(0, 32)
|
|
216
|
+
const known = this.lookup(key, state.head)
|
|
217
|
+
if (known === undefined) { jobs.set(key, { key, needle, state }); waiting.push({ fields, key }) }
|
|
218
|
+
else if (known) { fields.createdOn = known; fields.createdFrom = 'git' }
|
|
219
|
+
}
|
|
220
|
+
if (jobs.size) {
|
|
221
|
+
await this.runWithin([...jobs.values()], deadline)
|
|
222
|
+
for (const { fields, key } of waiting) {
|
|
223
|
+
const day = this.created?.get(key)?.d
|
|
224
|
+
if (day) { fields.createdOn = day; fields.createdFrom = 'git' }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Write the first-seen cache now (it is otherwise written shortly after a lookup lands). */
|
|
231
|
+
flush(): void {
|
|
232
|
+
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null }
|
|
233
|
+
if (!this.dirty || !this.created) return
|
|
234
|
+
this.dirty = false
|
|
235
|
+
try {
|
|
236
|
+
mkdirSync(dirname(this.cacheFile), { recursive: true, mode: 0o700 })
|
|
237
|
+
durableAtomicWriteFileSync(this.cacheFile, JSON.stringify({ version: 1, created: Object.fromEntries(this.created) }) + '\n', { mode: 0o600 })
|
|
238
|
+
} catch (e) { console.warn('[task-dates] cache not saved:', e instanceof Error ? e.message : e) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Lookups still running in the background (tests). */
|
|
242
|
+
async settled(): Promise<void> {
|
|
243
|
+
while (this.pending.size) await Promise.allSettled([...this.pending.values()])
|
|
244
|
+
await Promise.allSettled([...this.blame.values()].map(entry => entry.lines))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Wait for `work` or the deadline, whichever comes first. */
|
|
248
|
+
private async until(work: Promise<unknown>, deadline: number): Promise<void> {
|
|
249
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
250
|
+
const late = new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - this.now())) })
|
|
251
|
+
try { await Promise.race([work, late]) } finally { clearTimeout(timer) }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async fileState(file: string): Promise<FileState | null> {
|
|
255
|
+
const dir = dirname(file)
|
|
256
|
+
if (!this.findRepo(dir)) return null
|
|
257
|
+
if ((this.fileBackoff.get(file) ?? 0) > this.now()) return null
|
|
258
|
+
let stat: ReturnType<typeof statSync>
|
|
259
|
+
try { stat = statSync(file) } catch { return null }
|
|
260
|
+
if (!stat.isFile()) return null
|
|
261
|
+
let head: string
|
|
262
|
+
try { head = (await this.git(['rev-parse', '--verify', '-q', 'HEAD'], dir, TASK_DATE_LIMITS.gitTimeoutMs)).trim() } catch (e) {
|
|
263
|
+
if (isGitTimeout(e)) this.fileBackoff.set(file, this.now() + TASK_DATE_LIMITS.fileBackoffMs)
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
if (!/^[0-9a-f]{40,64}$/.test(head)) return null
|
|
267
|
+
const key = `${head}|${stat.mtimeMs}|${stat.size}`
|
|
268
|
+
let entry = this.blame.get(file)
|
|
269
|
+
if (!entry || entry.key !== key) {
|
|
270
|
+
// An untracked file fails blame: nothing about it comes from git, until HEAD or the file changes. A blame that
|
|
271
|
+
// timed out says nothing about the file, so it is forgotten (and the file rests) rather than kept as "untracked".
|
|
272
|
+
const blamed = { key, lines: this.git(['blame', '--line-porcelain', '--', basename(file)], dir, TASK_DATE_LIMITS.gitTimeoutMs)
|
|
273
|
+
.then(parseBlamePorcelain, (e: unknown) => {
|
|
274
|
+
if (isGitTimeout(e)) {
|
|
275
|
+
this.fileBackoff.set(file, this.now() + TASK_DATE_LIMITS.fileBackoffMs)
|
|
276
|
+
if (this.blame.get(file) === blamed) this.blame.delete(file)
|
|
277
|
+
}
|
|
278
|
+
return null
|
|
279
|
+
}) }
|
|
280
|
+
entry = blamed
|
|
281
|
+
this.blame.delete(file)
|
|
282
|
+
this.blame.set(file, entry)
|
|
283
|
+
while (this.blame.size > TASK_DATE_LIMITS.blameFiles) this.blame.delete(this.blame.keys().next().value!)
|
|
284
|
+
}
|
|
285
|
+
const lines = await entry.lines
|
|
286
|
+
return lines ? { file, dir, head, lines } : null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The cached day, null when history had no such text at this HEAD, or undefined when it must be looked up. */
|
|
290
|
+
private lookup(key: string, head: string): string | null | undefined {
|
|
291
|
+
const entry = this.cache().get(key)
|
|
292
|
+
if (entry?.d) return entry.d
|
|
293
|
+
if (entry && entry.h === head) return null
|
|
294
|
+
// A lookup that failed recently is neither known nor retried yet: the field stays null for now.
|
|
295
|
+
if ((this.lookupBackoff.get(key) ?? 0) > this.now()) return null
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Start lookups until the deadline, then answer with what landed. Lookups already started keep running. */
|
|
300
|
+
private async runWithin(jobs: PickaxeJob[], deadline: number): Promise<void> {
|
|
301
|
+
let next = 0
|
|
302
|
+
const worker = async () => {
|
|
303
|
+
while (next < jobs.length && this.now() < deadline) await this.pickaxe(jobs[next++])
|
|
304
|
+
}
|
|
305
|
+
await this.until(Promise.all(Array.from({ length: Math.min(TASK_DATE_LIMITS.concurrency, jobs.length) }, worker)), deadline)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private pickaxe(job: PickaxeJob): Promise<void> {
|
|
309
|
+
const running = this.pending.get(job.key)
|
|
310
|
+
if (running) return running
|
|
311
|
+
const work = this.git(['log', '--reverse', '--format=%ad', '--date=short', `-S${job.needle}`, '--', basename(job.state.file)],
|
|
312
|
+
job.state.dir, TASK_DATE_LIMITS.gitTimeoutMs)
|
|
313
|
+
.then(out => {
|
|
314
|
+
const day = out.split('\n').map(line => line.trim()).find(line => /^\d{4}-\d{2}-\d{2}$/.test(line)) ?? null
|
|
315
|
+
this.lookupBackoff.delete(job.key)
|
|
316
|
+
this.remember(job.key, day ? { d: day } : { d: null, h: job.state.head })
|
|
317
|
+
}, () => {
|
|
318
|
+
// A timeout or a git error is not "not in history": nothing is cached, and the lookup rests before a retry.
|
|
319
|
+
this.lookupBackoff.set(job.key, this.now() + TASK_DATE_LIMITS.lookupBackoffMs)
|
|
320
|
+
})
|
|
321
|
+
.finally(() => this.pending.delete(job.key))
|
|
322
|
+
this.pending.set(job.key, work)
|
|
323
|
+
return work
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private cache(): Map<string, CreatedEntry> {
|
|
327
|
+
if (this.created) return this.created
|
|
328
|
+
this.created = new Map()
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as { version?: unknown; created?: Record<string, unknown> }
|
|
331
|
+
if (parsed?.version === 1 && parsed.created && typeof parsed.created === 'object') {
|
|
332
|
+
for (const [key, raw] of Object.entries(parsed.created)) {
|
|
333
|
+
const entry = raw as Partial<CreatedEntry>
|
|
334
|
+
if (typeof entry?.d === 'string' && isCalendarDay(entry.d)) this.created.set(key, { d: entry.d })
|
|
335
|
+
else if (entry?.d === null && typeof entry.h === 'string') this.created.set(key, { d: null, h: entry.h })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch { /* missing or unreadable: start empty */ }
|
|
339
|
+
return this.created
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private remember(key: string, entry: CreatedEntry): void {
|
|
343
|
+
const cache = this.cache()
|
|
344
|
+
cache.delete(key)
|
|
345
|
+
cache.set(key, entry)
|
|
346
|
+
while (cache.size > TASK_DATE_LIMITS.cacheEntries) cache.delete(cache.keys().next().value!)
|
|
347
|
+
this.dirty = true
|
|
348
|
+
if (!this.flushTimer) {
|
|
349
|
+
this.flushTimer = setTimeout(() => this.flush(), 250)
|
|
350
|
+
this.flushTimer.unref?.()
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The resolver, bounded from outside as well: if it has not answered by the deadline (plus a moment for the answer
|
|
357
|
+
* itself), every row gets null dates. A dated board read is never held past the deadline by git.
|
|
358
|
+
*/
|
|
359
|
+
export async function resolveTaskDatesWithin(resolver: Pick<TaskDateResolver, 'resolve'>, rows: readonly DateSourceRow[], root: string,
|
|
360
|
+
deadlineMs: number = TASK_DATE_LIMITS.deadlineMs): Promise<Map<string, TaskDateFields>> {
|
|
361
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
362
|
+
const late = new Promise<Map<string, TaskDateFields>>(resolve => {
|
|
363
|
+
timer = setTimeout(() => resolve(new Map()), deadlineMs + TASK_DATE_LIMITS.answerSlackMs)
|
|
364
|
+
})
|
|
365
|
+
try { return await Promise.race([resolver.resolve(rows, root), late]) } finally { clearTimeout(timer) }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let shared: TaskDateResolver | null = null
|
|
369
|
+
export function sharedTaskDateResolver(): TaskDateResolver { return shared ??= new TaskDateResolver() }
|
package/server/lib/task-store.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from './morning-brief-config.js'
|
|
11
11
|
import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
|
|
12
12
|
import { callTaskBridge, taskBridgeAvailable, taskOperationsRoot, taskBridgeUnavailableMessage } from './task-bridge.js'
|
|
13
|
+
import { NO_TASK_DATES, TASK_DATE_LIMITS, resolveTaskDatesWithin, sharedTaskDateResolver, taskDateKey, type TaskDateFields,
|
|
14
|
+
type TaskDateResolver } from './task-dates.js'
|
|
13
15
|
import { taskDomainNames, isSafeDomainName } from './domains.js'
|
|
14
16
|
import { isClientJobId } from './query-job-types.js'
|
|
15
17
|
import { DEFAULT_MODEL, isClaudeModel } from '../../shared/model-preference.js'
|
|
@@ -469,7 +471,7 @@ export function projectRow(
|
|
|
469
471
|
}
|
|
470
472
|
}
|
|
471
473
|
|
|
472
|
-
|
|
474
|
+
async function boardPairs(columnFilter: string | undefined, nowMs: number): Promise<Array<{ source: BridgeTaskRow; row: TaskBoardRow }>> {
|
|
473
475
|
if (!taskBridgeAvailable()) {
|
|
474
476
|
throw new TaskRunError(503, 'cos_pipeline_not_configured', taskBridgeUnavailableMessage())
|
|
475
477
|
}
|
|
@@ -479,8 +481,33 @@ export async function listBoard(columnFilter?: string, nowMs = Date.now()): Prom
|
|
|
479
481
|
Promise.resolve(loadTaskLedger(taskStorePaths())),
|
|
480
482
|
])
|
|
481
483
|
return rows
|
|
482
|
-
.map(
|
|
483
|
-
.filter((
|
|
484
|
+
.map(source => ({ source, row: projectRow(source, ledger, clock.day, nowMs, config.timezone, config.taskCatchUpMinutes) }))
|
|
485
|
+
.filter((pair): pair is { source: BridgeTaskRow; row: TaskBoardRow } => !!pair.row && (!columnFilter || pair.row.column === columnFilter))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function listBoard(columnFilter?: string, nowMs = Date.now()): Promise<TaskBoardRow[]> {
|
|
489
|
+
return (await boardPairs(columnFilter, nowMs)).map(pair => pair.row)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* GET /api/tasks and GET /api/work-board rows (6.65.0): the board plus `createdOn`, `createdFrom` and `lineChangedAt`
|
|
494
|
+
* (lib/task-dates.ts). Every caller of those two routes pays for the dated read, the glasses' and the phone's /api/tasks
|
|
495
|
+
* reads included: its git work is bounded by TASK_DATE_LIMITS.deadlineMs (1.5 s, under the phone's 4 s timeout), past
|
|
496
|
+
* which the dates come back null. The shared Work board reader (the glasses' activity, open-work and request glances)
|
|
497
|
+
* and Jev advice use listBoard, which does no git work.
|
|
498
|
+
* Additive: every existing field is unchanged, and older Controls ignore the new ones. The dates come from the source
|
|
499
|
+
* label and from git, read-only, for both the configured COS bridge and the packaged task runtime; a date that cannot
|
|
500
|
+
* be known is null, and a failure to date never fails the board.
|
|
501
|
+
*/
|
|
502
|
+
export async function listBoardWithDates(columnFilter?: string, nowMs = Date.now(),
|
|
503
|
+
resolver: Pick<TaskDateResolver, 'resolve'> = sharedTaskDateResolver(),
|
|
504
|
+
deadlineMs: number = TASK_DATE_LIMITS.deadlineMs): Promise<Array<TaskBoardRow & TaskDateFields>> {
|
|
505
|
+
const pairs = await boardPairs(columnFilter, nowMs)
|
|
506
|
+
let dates = new Map<string, TaskDateFields>()
|
|
507
|
+
try { dates = await resolveTaskDatesWithin(resolver, pairs.map(pair => pair.source), taskOperationsRoot(), deadlineMs) } catch (e) {
|
|
508
|
+
console.warn('[tasks] dates unavailable:', e instanceof Error ? e.message : e)
|
|
509
|
+
}
|
|
510
|
+
return pairs.map(({ row }) => ({ ...row, ...(dates.get(taskDateKey(row.domain, row.id)) ?? NO_TASK_DATES) }))
|
|
484
511
|
}
|
|
485
512
|
|
|
486
513
|
export function workBadgeCount(rows: readonly TaskBoardRow[]): number {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work search by meaning (6.65.0, Control 0.5.259).
|
|
3
|
+
*
|
|
4
|
+
* Control searches the board's cards by words on its own, instantly. After a pause it asks this for the card the user
|
|
5
|
+
* MEANS: one Jev Choice over the board's task texts, with `none` as an option. Measured 2026-10-06 on 105 real Quilt
|
|
6
|
+
* tasks and 10 queries: the right card first 10 of 10 times in 0.2 s (keyword search alone found 7 of 10).
|
|
7
|
+
*
|
|
8
|
+
* The candidates are the board's own rows (the same listBoard read /api/tasks serves), never client text: the client
|
|
9
|
+
* names a scope, a domain, or exact ids. A Choice holds at most 254 options plus `none`, so a larger set is refused as
|
|
10
|
+
* `too_many_candidates` rather than truncated (a truncated search would silently miss cards).
|
|
11
|
+
*
|
|
12
|
+
* Cost controls of its own, separate from Work intake and session advice (COS_JEV_DAILY_TOKENS): a daily input-token
|
|
13
|
+
* cap (COS_JEV_SEARCH_DAILY_TOKENS, default 1,000,000, about 200 searches at the ~5k input tokens measured per search:
|
|
14
|
+
* 64.5k over 13 live calls), its own usage ledger, its own breaker (3 failures, then 1 hour), and a switch
|
|
15
|
+
* (COS_JEV_SEARCH=0 turns it off). Key resolution and the request timeout are jev.ts's. A search that cannot run
|
|
16
|
+
* answers HTTP 200 with `available: false` and a reason, so Control keeps its word search and says one line about why.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import { dataPath } from './data-dir.js'
|
|
20
|
+
import { estimateJevTokens, JevClient, JevError } from './jev.js'
|
|
21
|
+
import { boundForRedaction, redactSecretText } from './activity-preview.js'
|
|
22
|
+
import { redactSecrets } from './lens-gist-engines.js'
|
|
23
|
+
|
|
24
|
+
export const WORK_SEARCH_LIMITS = {
|
|
25
|
+
queryMin: 2, queryMax: 200,
|
|
26
|
+
/** A Jev Choice holds 255 options; one is `none`. */
|
|
27
|
+
maxCandidates: 254,
|
|
28
|
+
optionChars: 300,
|
|
29
|
+
/** How much of a task's text the redaction reads: four times what Jev gets, so no pattern ever scans a long input. */
|
|
30
|
+
optionScanChars: 1_200,
|
|
31
|
+
minP: 0.02, maxResults: 20,
|
|
32
|
+
cacheMs: 10 * 60_000, cacheEntries: 200,
|
|
33
|
+
defaultDailyTokens: 1_000_000,
|
|
34
|
+
} as const
|
|
35
|
+
export const WORK_SEARCH_USAGE_FILE = dataPath('jev-search-usage.json')
|
|
36
|
+
export const WORK_SEARCH_SCOPES = ['all', 'attention', 'progress', 'completed'] as const
|
|
37
|
+
export type WorkSearchScope = typeof WORK_SEARCH_SCOPES[number]
|
|
38
|
+
|
|
39
|
+
export const WORK_SEARCH_INSTRUCTIONS = 'The user typed `query` into the search box of their task board. Which task are they trying '
|
|
40
|
+
+ 'to find? Pick the task they mean even when it uses different words, or none if no task fits.'
|
|
41
|
+
export const WORK_SEARCH_NONE = 'None of these tasks is what the user is looking for.'
|
|
42
|
+
|
|
43
|
+
export type WorkSearchReason = 'jev_not_configured' | 'jev_key_rejected' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_request_rejected'
|
|
44
|
+
| 'jev_unavailable' | 'search_off' | 'too_many_candidates'
|
|
45
|
+
export const WORK_SEARCH_MESSAGES: Record<WorkSearchReason, string> = {
|
|
46
|
+
jev_not_configured: 'Meaning search needs a TypeSafe key. Add one in Settings.',
|
|
47
|
+
jev_key_rejected: 'TypeSafe did not accept the saved key. Check it in Settings. Word search still works.',
|
|
48
|
+
jev_cap_reached: 'Meaning search has used today’s budget. It resets at midnight UTC. Word search still works.',
|
|
49
|
+
jev_breaker_open: 'Meaning search is paused for an hour after repeated failures. Word search still works.',
|
|
50
|
+
jev_request_rejected: 'Jev refused this search request. Word search still works.',
|
|
51
|
+
jev_unavailable: 'Meaning search got no answer from Jev. Word search still works.',
|
|
52
|
+
search_off: 'Meaning search is turned off on this server.',
|
|
53
|
+
too_many_candidates: `Meaning search covers up to ${WORK_SEARCH_LIMITS.maxCandidates} cards. Pick a domain to search this view.`,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WorkSearchHit { id: string; p: number }
|
|
57
|
+
export type WorkSearchResult =
|
|
58
|
+
| { available: true; results: WorkSearchHit[]; none: number | null; cached: boolean; tokens: number }
|
|
59
|
+
| { available: false; reason: WorkSearchReason; message: string }
|
|
60
|
+
|
|
61
|
+
export function unavailable(reason: WorkSearchReason): Extract<WorkSearchResult, { available: false }> {
|
|
62
|
+
return { available: false, reason, message: WORK_SEARCH_MESSAGES[reason] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The daily input-token cap for search alone. The ledger day is UTC, so the budget resets at midnight UTC. */
|
|
66
|
+
export function workSearchDailyCap(): number {
|
|
67
|
+
const raw = Number(process.env.COS_JEV_SEARCH_DAILY_TOKENS)
|
|
68
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : WORK_SEARCH_LIMITS.defaultDailyTokens
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* On unless COS_JEV_SEARCH says off (0, false, no, off). The value comes from the server's environment, which is fixed
|
|
73
|
+
* when the server starts (the LaunchAgent plist, or ~/.cos-glasses/.env read at boot), so changing it takes a restart.
|
|
74
|
+
* The cap, COS_JEV_SEARCH_DAILY_TOKENS, is the same.
|
|
75
|
+
*/
|
|
76
|
+
export function workSearchEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
77
|
+
return !['0', 'false', 'no', 'off'].includes((env.COS_JEV_SEARCH ?? '').trim().toLowerCase())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Search's own Jev client: its own ledger file, cap and breaker. jev.ts supplies the key and the timeout. */
|
|
81
|
+
export function createWorkSearchJevClient(fetchImpl: typeof fetch = fetch, now: () => Date = () => new Date(),
|
|
82
|
+
usageFile = WORK_SEARCH_USAGE_FILE): JevClient {
|
|
83
|
+
return new JevClient(fetchImpl, now, usageFile, workSearchDailyCap)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let sharedSearchClient: JevClient | null = null
|
|
87
|
+
/** One search client per process: it owns search's breaker. Saving a new key resets it (routes/jev.ts). */
|
|
88
|
+
export function sharedWorkSearchJevClient(): JevClient { return sharedSearchClient ??= createWorkSearchJevClient() }
|
|
89
|
+
|
|
90
|
+
const WORK_DETAILS_RE = /\[Work details\]\([^)]*\)/gi
|
|
91
|
+
const MARKDOWN_LINK_RE = /\[([^\]]*)\]\([^)\s]*\)/g
|
|
92
|
+
// Bounded scheme, as in activity-preview.ts: an unbounded one is retried from every position of a long run.
|
|
93
|
+
const SCHEME_URL_RE = /\b(?:[a-z][a-z0-9+.-]{0,31}:\/\/|www\.)\S+/gi
|
|
94
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b/g
|
|
95
|
+
// A host with a path (docs.google.com/document/d/…). A bare domain stays: it is usually a brand name people search for.
|
|
96
|
+
const PATH_URL_RE = /\b(?:[a-z0-9-]{1,63}\.){1,8}[a-z]{2,24}\/\S*/gi
|
|
97
|
+
const LONG_RUN_RE = /[A-Za-z0-9_-]{24,}/g
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What Jev reads for one card (6.65.0 QA, S-W6): the task text with `[Work details](…)` links dropped, other markdown
|
|
101
|
+
* links reduced to their label, credentials masked by the server's secret redaction (redactSecretText, then
|
|
102
|
+
* redactSecrets), and URLs, email addresses and opaque ids (24 or more letters, digits, `-` or `_` with both a letter and
|
|
103
|
+
* a digit, the shape of a key or a document id) removed. They add nothing to finding a card, and TypeSafe is a third
|
|
104
|
+
* party. One line, at most 300 characters.
|
|
105
|
+
*/
|
|
106
|
+
export function searchOptionText(text: string): string {
|
|
107
|
+
let out = boundForRedaction(text, WORK_SEARCH_LIMITS.optionScanChars).head.replace(WORK_DETAILS_RE, ' ')
|
|
108
|
+
out = redactSecrets(redactSecretText(out))
|
|
109
|
+
out = out.replace(MARKDOWN_LINK_RE, '$1').replace(SCHEME_URL_RE, ' ').replace(EMAIL_RE, ' ').replace(PATH_URL_RE, ' ')
|
|
110
|
+
out = out.replace(LONG_RUN_RE, run => /\d/.test(run) && /[A-Za-z]/.test(run) ? ' ' : run)
|
|
111
|
+
return out.replace(/\s+/g, ' ').trim().slice(0, WORK_SEARCH_LIMITS.optionChars).trim()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* API POSTs that only read (6.65.0 QA). index.ts lets them past the global mutation lease. POST /work/search reads the
|
|
116
|
+
* board and asks Jev one question; it writes nothing but its own token ledger (one atomic write). Under the lease, a
|
|
117
|
+
* drain for an update or restart would wait up to 20 s (the Jev timeout) for a search, and a drain would answer every
|
|
118
|
+
* search 503. Outside it, a restart mid-search loses at most that search's token count.
|
|
119
|
+
*/
|
|
120
|
+
export function isReadOnlyApiPost(method: string, path: string): boolean {
|
|
121
|
+
return method === 'POST' && path === '/work/search'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A query's length as people count it: code points, not UTF-16 units, so 150 emoji are 150 (Control counts characters). */
|
|
125
|
+
export function queryLength(query: string): number { return [...query].length }
|
|
126
|
+
|
|
127
|
+
export interface SearchableRow { id: string; domain: string; text: string; checked: boolean; workIdentity?: string }
|
|
128
|
+
export interface CandidateRequest { domain?: string; scope?: WorkSearchScope; ids?: string[] }
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The cards a search covers, in board order. Exact ids win: Control sends them for views only it can compute (Needs
|
|
132
|
+
* attention, In progress). Otherwise a domain board covers that domain's cards; All work covers open cards; Completed
|
|
133
|
+
* covers completed ones. Needs attention and In progress without ids cover open cards. Rows without searchable text
|
|
134
|
+
* are left out. Returns null when there are more than 254.
|
|
135
|
+
*/
|
|
136
|
+
export function selectCandidates<Row extends SearchableRow>(rows: readonly Row[], request: CandidateRequest): Row[] | null {
|
|
137
|
+
const inDomain = (row: Row) => !request.domain || row.domain === request.domain
|
|
138
|
+
let picked: Row[]
|
|
139
|
+
if (request.ids) {
|
|
140
|
+
const wanted = new Set(request.ids)
|
|
141
|
+
picked = rows.filter(row => wanted.has(row.id) || (!!row.workIdentity && wanted.has(row.workIdentity)))
|
|
142
|
+
} else if (request.scope === 'completed') {
|
|
143
|
+
picked = rows.filter(row => row.checked && inDomain(row))
|
|
144
|
+
} else if (request.scope === 'attention' || request.scope === 'progress') {
|
|
145
|
+
picked = rows.filter(row => !row.checked && inDomain(row))
|
|
146
|
+
} else if (request.domain) {
|
|
147
|
+
picked = rows.filter(row => row.domain === request.domain)
|
|
148
|
+
} else {
|
|
149
|
+
picked = rows.filter(row => !row.checked)
|
|
150
|
+
}
|
|
151
|
+
picked = picked.filter(row => searchOptionText(row.text).length > 0)
|
|
152
|
+
return picked.length > WORK_SEARCH_LIMITS.maxCandidates ? null : picked
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Jev's probabilities as hits: offered cards only, p at least 0.02, most likely first, at most 20. */
|
|
156
|
+
export function rankHits(probabilities: Record<string, unknown> | undefined, ids: readonly string[]): { results: WorkSearchHit[]; none: number | null } {
|
|
157
|
+
if (!probabilities || typeof probabilities !== 'object') throw new JevError('jev_bad_answer', 'Jev returned no probabilities')
|
|
158
|
+
const results: WorkSearchHit[] = []
|
|
159
|
+
let none: number | null = null
|
|
160
|
+
for (const [key, raw] of Object.entries(probabilities)) {
|
|
161
|
+
const p = typeof raw === 'number' && Number.isFinite(raw) ? raw : Number.NaN
|
|
162
|
+
if (Number.isNaN(p) || p < 0 || p > 1) throw new JevError('jev_bad_answer', 'Jev answered with a probability outside 0 to 1')
|
|
163
|
+
if (key === 'none') { none = round(p); continue }
|
|
164
|
+
const index = /^t(0|[1-9]\d*)$/.test(key) ? Number(key.slice(1)) : -1
|
|
165
|
+
if (index < 0 || index >= ids.length) throw new JevError('jev_bad_answer', 'Jev chose a card that was not offered')
|
|
166
|
+
if (p >= WORK_SEARCH_LIMITS.minP) results.push({ id: ids[index], p: round(p) })
|
|
167
|
+
}
|
|
168
|
+
results.sort((a, b) => b.p - a.p)
|
|
169
|
+
return { results: results.slice(0, WORK_SEARCH_LIMITS.maxResults), none }
|
|
170
|
+
}
|
|
171
|
+
const round = (n: number) => Math.round(n * 10_000) / 10_000
|
|
172
|
+
|
|
173
|
+
/** Case, spacing and Unicode form do not change what was asked. */
|
|
174
|
+
export function normalizeQuery(query: string): string {
|
|
175
|
+
return query.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const PASSED_THROUGH = new Set<string>(['jev_not_configured', 'jev_key_rejected', 'jev_cap_reached', 'jev_breaker_open', 'jev_request_rejected'])
|
|
179
|
+
|
|
180
|
+
export class WorkSearcher {
|
|
181
|
+
private cache = new Map<string, { at: number; results: WorkSearchHit[]; none: number | null }>()
|
|
182
|
+
private inflight = new Map<string, Promise<WorkSearchResult>>()
|
|
183
|
+
constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
|
|
184
|
+
|
|
185
|
+
/** One Jev Choice over `candidates` (already selected, at most 254). Never throws for a Jev failure. */
|
|
186
|
+
async search(query: string, candidates: readonly SearchableRow[]): Promise<WorkSearchResult> {
|
|
187
|
+
if (candidates.length > WORK_SEARCH_LIMITS.maxCandidates) return unavailable('too_many_candidates')
|
|
188
|
+
if (!candidates.length) return { available: true, results: [], none: 1, cached: false, tokens: 0 }
|
|
189
|
+
const texts = candidates.map(row => searchOptionText(row.text))
|
|
190
|
+
const set = createHash('sha256').update(JSON.stringify(candidates.map((row, i) => [row.id, texts[i]]))).digest('hex')
|
|
191
|
+
const key = createHash('sha256').update(normalizeQuery(query) + '\0' + set).digest('hex')
|
|
192
|
+
const hit = this.cache.get(key)
|
|
193
|
+
if (hit && this.now() - hit.at < WORK_SEARCH_LIMITS.cacheMs) {
|
|
194
|
+
return { available: true, results: hit.results, none: hit.none, cached: true, tokens: 0 }
|
|
195
|
+
}
|
|
196
|
+
if (hit) this.cache.delete(key)
|
|
197
|
+
// Typing can send the same search twice before the first answers; the second waits for the first.
|
|
198
|
+
const running = this.inflight.get(key)
|
|
199
|
+
if (running) return running
|
|
200
|
+
const work = this.ask(query, candidates.map(row => row.id), texts, key)
|
|
201
|
+
this.inflight.set(key, work)
|
|
202
|
+
try { return await work } finally { this.inflight.delete(key) }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async ask(query: string, ids: string[], texts: string[], key: string): Promise<WorkSearchResult> {
|
|
206
|
+
const state = { query: query.trim() }
|
|
207
|
+
const criteria: Record<string, string> = Object.fromEntries(texts.map((text, i) => [`t${i}`, text]))
|
|
208
|
+
criteria.none = WORK_SEARCH_NONE
|
|
209
|
+
const questions = { c: { type: 'choice', instructions: WORK_SEARCH_INSTRUCTIONS, criteria } }
|
|
210
|
+
try {
|
|
211
|
+
const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
|
|
212
|
+
const { results, none } = rankHits(reply.answers.c?.probabilities, ids)
|
|
213
|
+
if (this.cache.size >= WORK_SEARCH_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
|
|
214
|
+
this.cache.set(key, { at: this.now(), results, none })
|
|
215
|
+
return { available: true, results, none, cached: false, tokens: reply.inputTokens }
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (!(e instanceof JevError)) throw e
|
|
218
|
+
// Each says what happened. A 400, 413 or 422 is jev_request_rejected: the request itself was refused, which does
|
|
219
|
+
// not count toward the breaker (jev.ts), because the same cards would be refused again and an outage pause would
|
|
220
|
+
// only hide search for every other board. Unreachable, 5xx and a malformed answer are jev_unavailable.
|
|
221
|
+
if (PASSED_THROUGH.has(e.code)) return unavailable(e.code as WorkSearchReason)
|
|
222
|
+
console.warn('[work-search] Jev failed:', e.code)
|
|
223
|
+
return unavailable('jev_unavailable')
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
package/server/routes/jev.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { SessionRecommender, parseCandidates } from '../lib/session-recommendati
|
|
|
12
12
|
import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
|
|
13
13
|
import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
|
|
14
14
|
import { listBoard } from '../lib/task-store.js'
|
|
15
|
+
import { sharedWorkSearchJevClient } from '../lib/work-search.js'
|
|
15
16
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
16
17
|
|
|
17
18
|
/** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
|
|
@@ -30,6 +31,8 @@ export interface JevRouteDependencies {
|
|
|
30
31
|
/** 6.58.0: a session's replies since a time (or its newest reply) from this server's transcripts; null when the
|
|
31
32
|
* session is not on this Mac. */
|
|
32
33
|
replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
|
|
34
|
+
/** 6.65.0: Work search keeps its own client and breaker; a newly saved key clears that breaker too. */
|
|
35
|
+
searchJev: Pick<JevClient, 'resetForNewKey'>
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
|
|
@@ -40,7 +43,8 @@ function findTask<T extends { id: string; workIdentity?: string }>(rows: T[], id
|
|
|
40
43
|
export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
|
|
41
44
|
const jev = overrides.jev ?? sharedJevClient()
|
|
42
45
|
const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey,
|
|
43
|
-
completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
|
|
46
|
+
completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
|
|
47
|
+
searchJev: sharedWorkSearchJevClient(), ...overrides }
|
|
44
48
|
const router = Router()
|
|
45
49
|
router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
46
50
|
|
|
@@ -61,6 +65,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
61
65
|
return res.status(500).json({ error: { code: 'jev_key_not_saved', message: 'The key was valid but could not be saved. Check the server data folder.' } })
|
|
62
66
|
}
|
|
63
67
|
deps.jev.resetForNewKey()
|
|
68
|
+
deps.searchJev.resetForNewKey()
|
|
64
69
|
return res.json({ ok: true, ...deps.jev.status() })
|
|
65
70
|
})
|
|
66
71
|
|
package/server/routes/tasks.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
captureTask,
|
|
11
11
|
checkTask,
|
|
12
12
|
listBoard,
|
|
13
|
+
listBoardWithDates,
|
|
13
14
|
listProjectedTaskRuns,
|
|
14
15
|
loadDispatchCap,
|
|
15
16
|
moveTask,
|
|
@@ -175,7 +176,8 @@ tasksRouter.put('/domains', (req, res) => {
|
|
|
175
176
|
tasksRouter.get('/tasks', async (req, res) => {
|
|
176
177
|
try {
|
|
177
178
|
const column = typeof req.query.column === 'string' ? req.query.column : undefined
|
|
178
|
-
|
|
179
|
+
// 6.65.0: rows carry createdOn, createdFrom and lineChangedAt (null when unknown), as GET /api/work-board rows do.
|
|
180
|
+
const rows = await listBoardWithDates(column)
|
|
179
181
|
res.json({ tasks: rows, workBadge: workBadgeCount(rows), gate: tasksGate() })
|
|
180
182
|
} catch (error) {
|
|
181
183
|
sendError(res, error)
|
|
@@ -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, editWorkTask, TaskRunError, TaskBridgeError,
|
|
4
|
+
import { listBoard, listBoardWithDates, 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'
|
|
@@ -13,6 +13,10 @@ export interface WorkBoardDependencies {
|
|
|
13
13
|
/** The native journal reader. It takes no request-derived argument: there is no request-supplied path. */
|
|
14
14
|
journal: () => ReturnType<typeof readWorkJournal>
|
|
15
15
|
list: typeof listBoard
|
|
16
|
+
/** 6.65.0: the rows GET /api/work-board serves, with createdOn, createdFrom and lineChangedAt (task-store.ts
|
|
17
|
+
* listBoardWithDates). COS Control reads this route for its Work board and reads /api/tasks only from an older
|
|
18
|
+
* server, so the dates must be here too. Defaults to the dated read unless a caller supplies its own `list`. */
|
|
19
|
+
listDated?: () => Promise<TaskBoardRow[]>
|
|
16
20
|
capabilities: typeof workBoardCapabilities
|
|
17
21
|
stage: typeof setTaskWorkStage
|
|
18
22
|
link: typeof linkTaskMeeting
|
|
@@ -33,6 +37,7 @@ export const WORK_GLANCE_BOARD_AGE_MS = 10_000
|
|
|
33
37
|
/** Auth is supplied by the parent /api mount. No provider execution occurs here. */
|
|
34
38
|
export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies> = {}): Router {
|
|
35
39
|
const deps = { ...defaults, ...overrides }, router = Router()
|
|
40
|
+
const listForBoard = overrides.listDated ?? (overrides.list ? deps.list : () => listBoardWithDates())
|
|
36
41
|
const board = deps.readBoard ?? createWorkBoardReader(() => deps.list())
|
|
37
42
|
router.use('/work-board', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
38
43
|
const fail = (res: import('express').Response, e: unknown) => {
|
|
@@ -80,7 +85,7 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
80
85
|
router.get('/work-board', async (_req, res) => {
|
|
81
86
|
try {
|
|
82
87
|
const read = board.begin()
|
|
83
|
-
const [raw, capabilities] = await Promise.all([
|
|
88
|
+
const [raw, capabilities] = await Promise.all([listForBoard(), deps.capabilities()])
|
|
84
89
|
board.prime(raw, read)
|
|
85
90
|
// 6.59.0: taskRevision is this task's own revision (COS Control's taskSnapshot), what a handoff request names.
|
|
86
91
|
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) }))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/work/search (6.65.0): which board card the user means, by meaning. Auth comes from the parent /api mount.
|
|
3
|
+
*
|
|
4
|
+
* Body `{ query, domain?, scope?, ids? }`. The cards come from the board read /api/tasks serves; the client sends a
|
|
5
|
+
* view (domain, scope) or exact card ids, never task text. A search that cannot run answers 200 with
|
|
6
|
+
* `{ available: false, reason, message }`, so Control keeps its word search. See lib/work-search.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { listBoard } from '../lib/task-store.js'
|
|
10
|
+
import { isSafeDomainName } from '../lib/domains.js'
|
|
11
|
+
import { queryLength, sharedWorkSearchJevClient, selectCandidates, unavailable, workSearchEnabled, WorkSearcher, WORK_SEARCH_LIMITS,
|
|
12
|
+
WORK_SEARCH_SCOPES, type WorkSearchResult, type WorkSearchScope } from '../lib/work-search.js'
|
|
13
|
+
|
|
14
|
+
export interface WorkSearchRouteDependencies {
|
|
15
|
+
searcher: Pick<WorkSearcher, 'search'>
|
|
16
|
+
list: () => ReturnType<typeof listBoard>
|
|
17
|
+
enabled: () => boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BODY_KEYS = new Set(['query', 'domain', 'scope', 'ids'])
|
|
21
|
+
const TASK_ID = /^[a-f0-9]{12}$/
|
|
22
|
+
/** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. */
|
|
23
|
+
export const WORK_SEARCH_LOG_EVERY_MS = 10 * 60_000
|
|
24
|
+
|
|
25
|
+
export function createWorkSearchRouter(overrides: Partial<WorkSearchRouteDependencies> = {}): Router {
|
|
26
|
+
const deps: WorkSearchRouteDependencies = { searcher: new WorkSearcher(sharedWorkSearchJevClient()), list: () => listBoard(),
|
|
27
|
+
enabled: () => workSearchEnabled(), ...overrides }
|
|
28
|
+
const router = Router()
|
|
29
|
+
const logged = new Map<string, number>()
|
|
30
|
+
const answer = (res: import('express').Response, result: WorkSearchResult) => {
|
|
31
|
+
if (!result.available) {
|
|
32
|
+
const at = Date.now(), last = logged.get(result.reason)
|
|
33
|
+
if (last === undefined || at - last >= WORK_SEARCH_LOG_EVERY_MS) {
|
|
34
|
+
logged.set(result.reason, at)
|
|
35
|
+
console.warn(`[work-search] meaning search unavailable: ${result.reason}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return res.json(result)
|
|
39
|
+
}
|
|
40
|
+
router.use('/work/search', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
41
|
+
|
|
42
|
+
router.post('/work/search', async (req, res) => {
|
|
43
|
+
const body = req.body
|
|
44
|
+
const bad = () => res.status(400).json({ error: { code: 'invalid_search_request',
|
|
45
|
+
message: `Send a query of ${WORK_SEARCH_LIMITS.queryMin} to ${WORK_SEARCH_LIMITS.queryMax} characters, and optionally a domain, a scope or card ids.` } })
|
|
46
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !BODY_KEYS.has(k))) return bad()
|
|
47
|
+
const query = typeof body.query === 'string' ? body.query.trim() : ''
|
|
48
|
+
const length = queryLength(query)
|
|
49
|
+
if (length < WORK_SEARCH_LIMITS.queryMin || length > WORK_SEARCH_LIMITS.queryMax) return bad()
|
|
50
|
+
if (body.domain !== undefined && (typeof body.domain !== 'string' || !isSafeDomainName(body.domain))) return bad()
|
|
51
|
+
if (body.scope !== undefined && !(WORK_SEARCH_SCOPES as readonly unknown[]).includes(body.scope)) return bad()
|
|
52
|
+
if (body.ids !== undefined && (!Array.isArray(body.ids) || body.ids.some((id: unknown) => typeof id !== 'string' || !TASK_ID.test(id)))) return bad()
|
|
53
|
+
if (!deps.enabled()) return answer(res, unavailable('search_off'))
|
|
54
|
+
const ids: string[] | undefined = body.ids ? [...new Set<string>(body.ids)] : undefined
|
|
55
|
+
// Refused before reading the board: a Choice cannot hold them, and a cut list would silently miss cards.
|
|
56
|
+
if (ids && ids.length > WORK_SEARCH_LIMITS.maxCandidates) return answer(res, unavailable('too_many_candidates'))
|
|
57
|
+
let rows: Awaited<ReturnType<typeof listBoard>>
|
|
58
|
+
try { rows = await deps.list() } catch (e) {
|
|
59
|
+
console.error('[work-search] board read failed:', e instanceof Error ? e.message : e)
|
|
60
|
+
return res.status(503).json({ error: { code: 'work_board_unavailable', message: 'Work board is unavailable. Refresh before searching.' } })
|
|
61
|
+
}
|
|
62
|
+
const candidates = selectCandidates(rows, { domain: body.domain, scope: body.scope as WorkSearchScope | undefined, ids })
|
|
63
|
+
if (!candidates) return answer(res, unavailable('too_many_candidates'))
|
|
64
|
+
try {
|
|
65
|
+
return answer(res, await deps.searcher.search(query, candidates))
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error('[work-search] search failed:', e instanceof Error ? e.message : e)
|
|
68
|
+
return answer(res, unavailable('jev_unavailable'))
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
return router
|
|
72
|
+
}
|