@ddtcorex/dsh-maestro-review 0.9.0 → 0.11.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/README.md +10 -10
- package/cordis.patch.yml +4 -4
- package/lib/client.js +484 -0
- package/lib/config-store.d.ts +1 -1
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js +43 -3
- package/lib/config-store.js.map +1 -1
- package/lib/govard-lint-detection.d.ts +20 -0
- package/lib/govard-lint-detection.d.ts.map +1 -0
- package/lib/govard-lint-detection.js +48 -0
- package/lib/govard-lint-detection.js.map +1 -0
- package/lib/orchestrator.d.ts +45 -9
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +82 -24
- package/lib/orchestrator.js.map +1 -1
- package/lib/search-tool.d.ts.map +1 -1
- package/lib/search-tool.js.map +1 -1
- package/lib/settings-rpc.d.ts +13 -11
- package/lib/settings-rpc.d.ts.map +1 -1
- package/lib/settings-rpc.js +45 -142
- package/lib/settings-rpc.js.map +1 -1
- package/lib/skill-provider.d.ts.map +1 -1
- package/lib/skill-provider.js +5 -2
- package/lib/skill-provider.js.map +1 -1
- package/lib/source.d.ts +23 -0
- package/lib/source.d.ts.map +1 -0
- package/lib/source.js +16 -0
- package/lib/source.js.map +1 -0
- package/lib/types/client/BrandMark.d.ts +22 -0
- package/lib/types/client/BrandMark.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/maestro-mark.d.ts +20 -0
- package/lib/types/client/maestro-mark.d.ts.map +1 -0
- package/lib/types/client/review/ReviewSettings.d.ts +15 -0
- package/lib/types/client/review/ReviewSettings.d.ts.map +1 -0
- package/lib/types/client/review/styles.d.ts +11 -0
- package/lib/types/client/review/styles.d.ts.map +1 -0
- package/lib/types/client/settings-nav-icon.d.ts +20 -0
- package/lib/types/client/settings-nav-icon.d.ts.map +1 -0
- package/lib/vendor/store.d.ts +60 -0
- package/lib/vendor/store.d.ts.map +1 -0
- package/lib/vendor/store.js +500 -0
- package/lib/vendor/store.js.map +1 -0
- package/lib/webhook-secret.d.ts +31 -0
- package/lib/webhook-secret.d.ts.map +1 -0
- package/lib/webhook-secret.js +38 -0
- package/lib/webhook-secret.js.map +1 -0
- package/package.json +36 -18
- package/presets/maestro-auditor.patch.yml +7 -4
- package/presets/maestro-coder/agent.cordis.yml +3 -11
- package/presets/maestro-coder/preset.yml +1 -1
- package/profiles/reviewer-ci/cordis.patch.yml +5 -4
- package/profiles/reviewer-ci/package.json +3 -3
- package/profiles/reviewer-ci/pnpm-lock.yaml +3524 -3239
- package/profiles/reviewer-ci/pnpm-workspace.yaml +1 -1
- package/skills/dsh-native-tools/SKILL.md +5 -5
- package/src/client/BrandMark.tsx +42 -0
- package/src/client/index.tsx +109 -0
- package/src/client/maestro-mark.ts +21 -0
- package/src/client/review/ReviewSettings.tsx +236 -0
- package/src/client/review/styles.ts +108 -0
- package/src/client/settings-nav-icon.ts +68 -0
- package/src/host/config-store.ts +42 -3
- package/src/host/govard-lint-detection.ts +47 -0
- package/src/host/orchestrator.ts +98 -25
- package/src/host/search-tool.ts +3 -4
- package/src/host/settings-rpc.ts +48 -145
- package/src/host/skill-provider.ts +5 -2
- package/src/host/source.ts +26 -0
- package/src/host/vendor/store.ts +531 -0
- package/src/host/webhook-secret.ts +43 -0
- package/templates/reviewer-project.gitlab-ci.yml +1 -1
- package/lib/govard-audit-lint-tool.d.ts +0 -95
- package/lib/govard-audit-lint-tool.d.ts.map +0 -1
- package/lib/govard-audit-lint-tool.js +0 -286
- package/lib/govard-audit-lint-tool.js.map +0 -1
- package/lib/govard-tool.d.ts +0 -11
- package/lib/govard-tool.d.ts.map +0 -1
- package/lib/govard-tool.js +0 -95
- package/lib/govard-tool.js.map +0 -1
- package/lib/workspace-tool.d.ts +0 -10
- package/lib/workspace-tool.d.ts.map +0 -1
- package/lib/workspace-tool.js +0 -92
- package/lib/workspace-tool.js.map +0 -1
- package/src/host/govard-audit-lint-tool.ts +0 -286
- package/src/host/govard-tool.ts +0 -114
- package/src/host/workspace-tool.ts +0 -108
|
@@ -26,9 +26,12 @@ export function resolveSkillsDir(fromDir: string): string {
|
|
|
26
26
|
|
|
27
27
|
/** Minimal frontmatter reader for our own SKILL.md — enough for the provider contract. */
|
|
28
28
|
function parseFrontmatter(raw: string): { name: string; description: string; body: string } {
|
|
29
|
-
|
|
29
|
+
// `\r?\n` on both delimiters and on the line split: a SKILL.md checked out
|
|
30
|
+
// with CRLF (git core.autocrlf, a Windows editor) otherwise parses to an
|
|
31
|
+
// empty description with no error at all.
|
|
32
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
|
|
30
33
|
if (!m) return { name: SKILL_NAME, description: '', body: raw }
|
|
31
|
-
const fm = m[1].split(
|
|
34
|
+
const fm = m[1].split(/\r?\n/).reduce<Record<string, string>>((acc, line) => {
|
|
32
35
|
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
|
33
36
|
if (kv) acc[kv[1]] = kv[2].replace(/^["']|["']$/g, '')
|
|
34
37
|
return acc
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The producer-owned source kind this plugin stamps on every message it injects
|
|
3
|
+
* into a review session.
|
|
4
|
+
*
|
|
5
|
+
* Session format v4 requires the producer to name itself (`plugin:<package>`)
|
|
6
|
+
* and rejects the retired bare `kind: 'plugin'`. `kind: 'user'` is admissible
|
|
7
|
+
* but false: these prompts are machine-initiated, and consumers classify a turn
|
|
8
|
+
* by this kind — dsh-maestro-memory's write guard documents that
|
|
9
|
+
* plugin-initiated turns carry no per-turn write duty.
|
|
10
|
+
*
|
|
11
|
+
* The reviewer and auditor sessions keep their own explicit
|
|
12
|
+
* `sessionTitle.rename(...)`: the harness only auto-titles a `kind: 'user'`
|
|
13
|
+
* message, so a plugin-attributed prompt feeds no title.
|
|
14
|
+
*/
|
|
15
|
+
export const REVIEW_SOURCE_KIND = 'plugin:@ddtcorex/dsh-maestro-review'
|
|
16
|
+
|
|
17
|
+
// `MessageSourceMap` is the harness's merge-extensible sum type: each producer
|
|
18
|
+
// declares its own kind in its own module (see `goal-round-driver` and
|
|
19
|
+
// `schedule`), and there is deliberately no shared catch-all kind to reuse.
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-llm'
|
|
21
|
+
|
|
22
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
23
|
+
interface MessageSourceMap {
|
|
24
|
+
'plugin:@ddtcorex/dsh-maestro-review': { readonly kind: 'plugin:@ddtcorex/dsh-maestro-review' }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// vendored from dsh-maestro-core store, sha256:ca062c527bb30c030c086751be275ee379071f33691568ff7d0eae4372012829
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { mkdir, open, readFile, rename, unlink, stat, rm, writeFile, link } from 'node:fs/promises'
|
|
6
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { watch, type FSWatcher } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
export interface SettingsDoc {
|
|
10
|
+
version: 1
|
|
11
|
+
domains: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Minimal structural validator a domain owner supplies (schemastery/zod adapters fit). */
|
|
15
|
+
export interface DomainValidator {
|
|
16
|
+
parse(value: unknown): { ok: true; value?: unknown } | { ok: false; error: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY_DOC: SettingsDoc = { version: 1, domains: {} }
|
|
20
|
+
|
|
21
|
+
const domainValidators = new Map<string, DomainValidator>()
|
|
22
|
+
const changeCbs = new Set<(domain: string) => void>()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Identity of the settings file a cached document was read from. mtime alone is
|
|
26
|
+
* not enough: two copies writing within one filesystem timestamp tick, or an
|
|
27
|
+
* editor that restores the mtime, would leave a stale document in the cache.
|
|
28
|
+
* Every store write is a temp file + rename, so a changed inode is the reliable
|
|
29
|
+
* signal; size catches an in-place edit that kept the mtime.
|
|
30
|
+
*/
|
|
31
|
+
interface FileSig { mtimeMs: number; ino: number; size: number }
|
|
32
|
+
|
|
33
|
+
let cached: { key: string; doc: SettingsDoc; sig: FileSig | null } | null = null
|
|
34
|
+
|
|
35
|
+
async function fileSig(path: string): Promise<FileSig | null> {
|
|
36
|
+
try {
|
|
37
|
+
const st = await stat(path)
|
|
38
|
+
return { mtimeMs: st.mtimeMs, ino: st.ino, size: st.size }
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sameSig(a: FileSig | null, b: FileSig | null): boolean {
|
|
45
|
+
return a !== null && b !== null && a.mtimeMs === b.mtimeMs && a.ino === b.ino && a.size === b.size
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveDshHome(explicit?: string): string {
|
|
49
|
+
return explicit ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function storePath(opts?: { dshHome?: string }): string {
|
|
53
|
+
return join(resolveDshHome(opts?.dshHome), 'dsh-maestro-config', 'settings.json')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Register a validator for a domain you own. Writes to it are validated on every set(). */
|
|
57
|
+
export function defineDomain(name: string, validator: DomainValidator): void {
|
|
58
|
+
domainValidators.set(name, validator)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// change notification
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every plugin embeds its own copy of this module, so a listener has to hear
|
|
67
|
+
* writes made by another copy (or by another process), not only writes made
|
|
68
|
+
* through the copy that owns it. While at least one listener exists we watch
|
|
69
|
+
* the settings file and compare every domain against `snapshot`; a local write
|
|
70
|
+
* refreshes `snapshot` before it fires the callbacks, so it never fires twice.
|
|
71
|
+
*
|
|
72
|
+
* The watcher resolves DSH_HOME once, when it starts. A store pinned to an
|
|
73
|
+
* explicit `dshHome` therefore stays out of the watched home and neither fires
|
|
74
|
+
* nor receives notifications for writes that go somewhere else.
|
|
75
|
+
*/
|
|
76
|
+
const WATCH_DEBOUNCE_MS = 50
|
|
77
|
+
const POLL_MS = 2_000
|
|
78
|
+
const FILE_BASENAME = 'settings.json'
|
|
79
|
+
|
|
80
|
+
let watcher: FSWatcher | null = null
|
|
81
|
+
let pollTimer: NodeJS.Timeout | null = null
|
|
82
|
+
let debounceTimer: NodeJS.Timeout | null = null
|
|
83
|
+
let snapshot: Record<string, string> = {}
|
|
84
|
+
let watchedHome: string | null = null
|
|
85
|
+
let checking = false
|
|
86
|
+
|
|
87
|
+
/** JSON text per domain, so a value change is a cheap string comparison. */
|
|
88
|
+
function fingerprint(doc: SettingsDoc): Record<string, string> {
|
|
89
|
+
const out: Record<string, string> = {}
|
|
90
|
+
for (const [domain, value] of Object.entries(doc.domains)) {
|
|
91
|
+
out[domain] = JSON.stringify(value) ?? 'undefined'
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Seed the snapshot synchronously so a write racing `onChange` cannot slip
|
|
98
|
+
* between "listener registered" and "snapshot taken" and go unnoticed.
|
|
99
|
+
*/
|
|
100
|
+
function readSnapshotSync(home: string): Record<string, string> {
|
|
101
|
+
try {
|
|
102
|
+
return fingerprint(parseDoc(readFileSync(storePath({ dshHome: home }), 'utf8')))
|
|
103
|
+
} catch {
|
|
104
|
+
return {} // no store on disk yet: every domain is new
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run every listener for one domain. Each callback gets its own try/catch: a
|
|
110
|
+
* throwing listener must neither reject the write that already committed nor
|
|
111
|
+
* hide the change from the listeners registered after it.
|
|
112
|
+
*/
|
|
113
|
+
function fire(domain: string): void {
|
|
114
|
+
for (const cb of [...changeCbs]) {
|
|
115
|
+
try {
|
|
116
|
+
cb(domain)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.warn(`config-lib: onChange callback for '${domain}' threw: ${err instanceof Error ? err.message : String(err)}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function checkExternal(): Promise<void> {
|
|
124
|
+
if (changeCbs.size === 0 || watchedHome === null || checking) return
|
|
125
|
+
checking = true
|
|
126
|
+
try {
|
|
127
|
+
// load() invalidates its own cache on an mtime change, so this sees other
|
|
128
|
+
// writers' values without an extra stat.
|
|
129
|
+
const next = fingerprint(await load({ dshHome: watchedHome }))
|
|
130
|
+
const changed: string[] = []
|
|
131
|
+
for (const domain of new Set([...Object.keys(snapshot), ...Object.keys(next)])) {
|
|
132
|
+
// A domain that disappeared compares against undefined and counts as changed.
|
|
133
|
+
if (snapshot[domain] !== next[domain]) changed.push(domain)
|
|
134
|
+
}
|
|
135
|
+
snapshot = next
|
|
136
|
+
for (const domain of changed) fire(domain)
|
|
137
|
+
} catch {
|
|
138
|
+
// A transient read failure must never break boot; the next tick retries.
|
|
139
|
+
} finally {
|
|
140
|
+
checking = false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scheduleCheck(): void {
|
|
145
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
146
|
+
debounceTimer = setTimeout(() => {
|
|
147
|
+
debounceTimer = null
|
|
148
|
+
void checkExternal()
|
|
149
|
+
}, WATCH_DEBOUNCE_MS)
|
|
150
|
+
debounceTimer.unref()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stopWatching(): void {
|
|
154
|
+
watcher?.close()
|
|
155
|
+
watcher = null
|
|
156
|
+
if (pollTimer) clearInterval(pollTimer)
|
|
157
|
+
pollTimer = null
|
|
158
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
159
|
+
debounceTimer = null
|
|
160
|
+
snapshot = {}
|
|
161
|
+
watchedHome = null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function startWatching(): void {
|
|
165
|
+
if (watcher || pollTimer) return
|
|
166
|
+
const home = resolveDshHome()
|
|
167
|
+
const path = storePath({ dshHome: home })
|
|
168
|
+
watchedHome = home
|
|
169
|
+
snapshot = readSnapshotSync(home)
|
|
170
|
+
try {
|
|
171
|
+
// fs.watch is not recursive, so it only sees the file when its own
|
|
172
|
+
// directory exists; the store is the only writer of that path either way.
|
|
173
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
174
|
+
const w = watch(dirname(path), { persistent: false }, (_event, filename) => {
|
|
175
|
+
if (filename === null || filename === FILE_BASENAME) scheduleCheck()
|
|
176
|
+
})
|
|
177
|
+
// An unhandled 'error' event (EMFILE, directory removed, ...) would crash
|
|
178
|
+
// the host. Drop the watcher and let the poll below carry on alone.
|
|
179
|
+
w.on('error', () => {
|
|
180
|
+
try { w.close() } catch { /* already closed */ }
|
|
181
|
+
if (watcher === w) watcher = null
|
|
182
|
+
})
|
|
183
|
+
watcher = w
|
|
184
|
+
} catch {
|
|
185
|
+
watcher = null // polling below still covers the change
|
|
186
|
+
}
|
|
187
|
+
pollTimer = setInterval(() => void checkExternal(), POLL_MS)
|
|
188
|
+
pollTimer.unref()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Fire callbacks registered through this instance after a successful set(). */
|
|
192
|
+
export function onChange(cb: (domain: string) => void): () => void {
|
|
193
|
+
changeCbs.add(cb)
|
|
194
|
+
startWatching()
|
|
195
|
+
let disposed = false
|
|
196
|
+
return () => {
|
|
197
|
+
if (disposed) return
|
|
198
|
+
disposed = true
|
|
199
|
+
changeCbs.delete(cb)
|
|
200
|
+
if (changeCbs.size === 0) stopWatching()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Test seam: drop the memoized document and listeners (schemas are kept). */
|
|
205
|
+
export function resetForTests(): void {
|
|
206
|
+
cached = null
|
|
207
|
+
changeCbs.clear()
|
|
208
|
+
stopWatching()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// locking + io
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Lock timing. A lock is only stolen once it is older than STALE_MS, which is
|
|
217
|
+
* deliberately longer than the time a waiter is willing to wait: a live writer
|
|
218
|
+
* that is merely slow must time its waiters out, never get robbed.
|
|
219
|
+
*/
|
|
220
|
+
const LOCK_TIMEOUT_MS = 5_000
|
|
221
|
+
const LOCK_STALE_MS = 30_000
|
|
222
|
+
|
|
223
|
+
/** Settings directory: created 0700, lock file 0600 (the settings file is 0600 too). Modes apply at creation only; existing paths are never chmod-ed. */
|
|
224
|
+
const DIR_MODE = 0o700
|
|
225
|
+
const FILE_MODE = 0o600
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Move a stale lock aside and delete it. rename() is atomic, so of several
|
|
229
|
+
* waiters only one moves the file. If the moved file turns out not to be the
|
|
230
|
+
* one we judged stale (a new owner took the path between our stat and the
|
|
231
|
+
* rename), it is put back with link(), which refuses to overwrite.
|
|
232
|
+
*/
|
|
233
|
+
async function stealStaleLock(lockPath: string, observedIno: number): Promise<void> {
|
|
234
|
+
const aside = `${lockPath}.stale-${randomBytes(4).toString('hex')}`
|
|
235
|
+
try {
|
|
236
|
+
await rename(lockPath, aside)
|
|
237
|
+
} catch {
|
|
238
|
+
return // someone else removed or stole it first
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const moved = await stat(aside)
|
|
242
|
+
if (moved.ino !== observedIno) await link(aside, lockPath).catch(() => {})
|
|
243
|
+
} catch {
|
|
244
|
+
/* nothing to restore */
|
|
245
|
+
} finally {
|
|
246
|
+
await rm(aside, { force: true }).catch(() => {})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function withLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
|
251
|
+
await mkdir(dirname(path), { recursive: true, mode: DIR_MODE }) // the store dir may not exist on first write
|
|
252
|
+
const lockPath = `${path}.lock`
|
|
253
|
+
const token = `${process.pid}-${randomBytes(8).toString('hex')}`
|
|
254
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
255
|
+
for (;;) {
|
|
256
|
+
try {
|
|
257
|
+
const handle = await open(lockPath, 'wx', FILE_MODE)
|
|
258
|
+
try {
|
|
259
|
+
await handle.writeFile(token)
|
|
260
|
+
} finally {
|
|
261
|
+
await handle.close()
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
} catch (err: any) {
|
|
265
|
+
if (err?.code !== 'EEXIST') throw err
|
|
266
|
+
// Break stale locks left behind by a crashed writer.
|
|
267
|
+
try {
|
|
268
|
+
const st = await stat(lockPath)
|
|
269
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
270
|
+
await stealStaleLock(lockPath, st.ino)
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
/* lock vanished between stat and now — just retry */
|
|
275
|
+
}
|
|
276
|
+
if (Date.now() > deadline) throw new Error(`config-lib: lock timeout at ${lockPath}`)
|
|
277
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
return await fn()
|
|
282
|
+
} finally {
|
|
283
|
+
// Only release a lock that is still ours: if it was stolen and re-acquired
|
|
284
|
+
// meanwhile, deleting it would break the new owner's mutual exclusion.
|
|
285
|
+
try {
|
|
286
|
+
if ((await readFile(lockPath, 'utf8')) === token) await unlink(lockPath)
|
|
287
|
+
} catch {
|
|
288
|
+
/* already gone */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseDoc(raw: string): SettingsDoc {
|
|
294
|
+
const parsed = JSON.parse(raw) as Partial<SettingsDoc>
|
|
295
|
+
if (typeof parsed !== 'object' || parsed === null || typeof parsed.version !== 'number') {
|
|
296
|
+
throw new Error('config-lib: malformed settings document')
|
|
297
|
+
}
|
|
298
|
+
return { version: 1, domains: (parsed.domains as Record<string, unknown>) ?? {} }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function readDoc(path: string): Promise<SettingsDoc> {
|
|
302
|
+
let raw: string
|
|
303
|
+
try {
|
|
304
|
+
raw = await readFile(path, 'utf8')
|
|
305
|
+
} catch (err: any) {
|
|
306
|
+
if (err?.code === 'ENOENT') return { ...EMPTY_DOC, domains: {} }
|
|
307
|
+
throw err
|
|
308
|
+
}
|
|
309
|
+
return parseDoc(raw)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function deepMerge(base: unknown, patch: unknown): unknown {
|
|
313
|
+
if (
|
|
314
|
+
base !== null && typeof base === 'object' && !Array.isArray(base) &&
|
|
315
|
+
patch !== null && typeof patch === 'object' && !Array.isArray(patch)
|
|
316
|
+
) {
|
|
317
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }
|
|
318
|
+
for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
|
|
319
|
+
out[k] = k in out ? deepMerge(out[k], v) : v
|
|
320
|
+
}
|
|
321
|
+
return out
|
|
322
|
+
}
|
|
323
|
+
return patch
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function writeDocLocked(path: string, doc: SettingsDoc): Promise<void> {
|
|
327
|
+
await mkdir(dirname(path), { recursive: true, mode: DIR_MODE })
|
|
328
|
+
const tmp = `${path}.tmp-${Math.random().toString(16).slice(2, 10)}`
|
|
329
|
+
try {
|
|
330
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', { mode: FILE_MODE })
|
|
331
|
+
await rename(tmp, path) // atomic on the same filesystem; rename carries the 600 mode
|
|
332
|
+
} finally {
|
|
333
|
+
// After a successful rename the temp name is gone and this is a no-op; after
|
|
334
|
+
// a failed write or rename it removes the orphan instead of leaking one per try.
|
|
335
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// public API
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export async function load(opts?: { dshHome?: string }): Promise<SettingsDoc> {
|
|
344
|
+
const path = storePath(opts)
|
|
345
|
+
const homeKey = resolveDshHome(opts?.dshHome)
|
|
346
|
+
if (cached && cached.key === homeKey) {
|
|
347
|
+
// One stat per load: out-of-band edits (other processes) must surface
|
|
348
|
+
// without a restart.
|
|
349
|
+
const now = await fileSig(path)
|
|
350
|
+
if (now === null) return cached.doc // stat failed (vanished/locked) — serve stale, never break boot
|
|
351
|
+
if (sameSig(now, cached.sig)) return cached.doc
|
|
352
|
+
}
|
|
353
|
+
const doc = await readDoc(path)
|
|
354
|
+
cached = { key: homeKey, doc, sig: await fileSig(path) }
|
|
355
|
+
return doc
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function get(domain: string, opts?: { dshHome?: string }): Promise<unknown> {
|
|
359
|
+
const doc = await load(opts)
|
|
360
|
+
return doc.domains[domain]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function set(
|
|
364
|
+
domain: string,
|
|
365
|
+
patch: object,
|
|
366
|
+
opts?: { dshHome?: string },
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const path = storePath(opts)
|
|
369
|
+
const key = resolveDshHome(opts?.dshHome)
|
|
370
|
+
let written: unknown
|
|
371
|
+
await withLock(path, async () => {
|
|
372
|
+
const doc = await readDoc(path)
|
|
373
|
+
const validator = domainValidators.get(domain)
|
|
374
|
+
const merged = deepMerge(doc.domains[domain], patch)
|
|
375
|
+
if (validator) {
|
|
376
|
+
const res = validator.parse(merged)
|
|
377
|
+
if (!res.ok) throw new Error(`config-lib: validation failed for '${domain}': ${res.error}`)
|
|
378
|
+
}
|
|
379
|
+
doc.domains[domain] = merged
|
|
380
|
+
written = merged
|
|
381
|
+
await writeDocLocked(path, doc)
|
|
382
|
+
cached = { key, doc, sig: await fileSig(path) }
|
|
383
|
+
})
|
|
384
|
+
// Refresh the watched copy first so the watcher sees no diff for this write.
|
|
385
|
+
if (watchedHome === key) snapshot[domain] = JSON.stringify(written) ?? 'undefined'
|
|
386
|
+
fire(domain)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Delete one TOP-LEVEL key of a domain — the delete operation `set()` cannot
|
|
391
|
+
* express (a merge patch has no delete signal: `null` persists as null).
|
|
392
|
+
* Dotted paths are rejected to keep the semantics exactly one key. No-op
|
|
393
|
+
* (`false`, no write, no callbacks) when the domain is absent/not an object
|
|
394
|
+
* or the key is absent. Otherwise the resulting domain is validated like a
|
|
395
|
+
* `set()` before the atomic write; `true` means the key is gone.
|
|
396
|
+
*/
|
|
397
|
+
export async function unset(
|
|
398
|
+
domain: string,
|
|
399
|
+
key: string,
|
|
400
|
+
opts?: { dshHome?: string },
|
|
401
|
+
): Promise<boolean> {
|
|
402
|
+
if (typeof key !== 'string' || key === '' || key.includes('.')) {
|
|
403
|
+
throw new Error(`config-lib: unset key must be a top-level name, got ${JSON.stringify(key)}`)
|
|
404
|
+
}
|
|
405
|
+
const path = storePath(opts)
|
|
406
|
+
const homeKey = resolveDshHome(opts?.dshHome)
|
|
407
|
+
let written: Record<string, unknown> | undefined
|
|
408
|
+
const deleted = await withLock(path, async () => {
|
|
409
|
+
const doc = await readDoc(path)
|
|
410
|
+
const bucket = doc.domains[domain]
|
|
411
|
+
if (typeof bucket !== 'object' || bucket === null || Array.isArray(bucket)) return false
|
|
412
|
+
if (!(key in bucket)) return false
|
|
413
|
+
const next = { ...(bucket as Record<string, unknown>) }
|
|
414
|
+
delete next[key]
|
|
415
|
+
const validator = domainValidators.get(domain)
|
|
416
|
+
if (validator) {
|
|
417
|
+
const res = validator.parse(next)
|
|
418
|
+
if (!res.ok) throw new Error(`config-lib: validation failed for '${domain}': ${res.error}`)
|
|
419
|
+
}
|
|
420
|
+
doc.domains[domain] = next
|
|
421
|
+
written = next
|
|
422
|
+
await writeDocLocked(path, doc)
|
|
423
|
+
cached = { key: homeKey, doc, sig: await fileSig(path) }
|
|
424
|
+
return true
|
|
425
|
+
})
|
|
426
|
+
if (deleted) {
|
|
427
|
+
if (watchedHome === homeKey) snapshot[domain] = JSON.stringify(written) ?? 'undefined'
|
|
428
|
+
fire(domain)
|
|
429
|
+
}
|
|
430
|
+
return deleted
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Names of domains registered via defineDomain (schema owners). */
|
|
434
|
+
export function definedDomains(): string[] {
|
|
435
|
+
return [...domainValidators.keys()]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// flat-key adapter map (live: remote/review/supervisor read through readFlat)
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
export const DOMAIN_KEY_MAP: Record<string, string> = {
|
|
443
|
+
gitlabBaseUrl: 'gitlab.baseUrl',
|
|
444
|
+
gitlabToken: 'gitlab.token',
|
|
445
|
+
botUsername: 'gitlab.botUsername',
|
|
446
|
+
webhookSecret: 'gitlab.webhookSecret',
|
|
447
|
+
webhookPort: 'gitlab.webhookPort',
|
|
448
|
+
projectMappings: 'gitlab.projectMappings',
|
|
449
|
+
autoRereviewOnPush: 'gitlab.autoRereviewOnPush',
|
|
450
|
+
autoReviewOnAssign: 'gitlab.autoReviewOnAssign',
|
|
451
|
+
reviewModel: 'review.model',
|
|
452
|
+
agentTimeoutMs: 'review.agentTimeoutMs',
|
|
453
|
+
reviewSessionRetentionDays: 'review.sessionRetentionDays',
|
|
454
|
+
tunnelHostname: 'tunnel.hostname',
|
|
455
|
+
tunnelCredentialsFile: 'tunnel.credentialsFile',
|
|
456
|
+
tunnelId: 'tunnel.id',
|
|
457
|
+
tunnelMode: 'tunnel.mode',
|
|
458
|
+
quickTarget: 'tunnel.quickTarget',
|
|
459
|
+
proxyPort: 'tunnel.proxyPort',
|
|
460
|
+
proxyHost: 'tunnel.proxyHost',
|
|
461
|
+
lanPinEnabled: 'tunnel.lanPinEnabled',
|
|
462
|
+
lanPort: 'tunnel.lanPort',
|
|
463
|
+
lanHost: 'tunnel.lanHost',
|
|
464
|
+
/** Login-cookie lifetime in hours for the remote PIN gate (0 = session cookie). */
|
|
465
|
+
pinSessionTtlHours: 'tunnel.pinSessionTtlHours',
|
|
466
|
+
telegramBotToken: 'notifier.telegram.botToken',
|
|
467
|
+
telegramChatId: 'notifier.telegram.chatId',
|
|
468
|
+
telegramReviewNotifications: 'notifier.policy.reviewNotifications',
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Machine runtime state — never settings; owning adapters persist these in their own sidecar. */
|
|
472
|
+
export const RUNTIME_KEYS: readonly string[] = ['lastTunnelRunning']
|
|
473
|
+
|
|
474
|
+
function setIn(obj: Record<string, unknown>, dotted: string, value: unknown): void {
|
|
475
|
+
const parts = dotted.split('.')
|
|
476
|
+
let cur = obj
|
|
477
|
+
for (const part of parts.slice(0, -1)) {
|
|
478
|
+
if (typeof cur[part] !== 'object' || cur[part] === null) cur[part] = {}
|
|
479
|
+
cur = cur[part] as Record<string, unknown>
|
|
480
|
+
}
|
|
481
|
+
cur[parts[parts.length - 1]] = value
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// adapter helpers — one mapping source for consumer config-stores
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
export interface DomainWrite {
|
|
489
|
+
domain: string
|
|
490
|
+
patch: Record<string, unknown>
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Route a flat legacy-keyed patch into per-domain writes; runtime keys are skipped. */
|
|
494
|
+
export function splitLegacyPatch(patch: Record<string, unknown>): DomainWrite[] {
|
|
495
|
+
const byDomain = new Map<string, Record<string, unknown>>()
|
|
496
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
497
|
+
if (RUNTIME_KEYS.includes(key)) continue
|
|
498
|
+
const dotted = DOMAIN_KEY_MAP[key]
|
|
499
|
+
if (!dotted) continue
|
|
500
|
+
const domain = dotted.split('.')[0]
|
|
501
|
+
const group = byDomain.get(domain) ?? {}
|
|
502
|
+
setIn(group, dotted.slice(domain.length + 1), value)
|
|
503
|
+
byDomain.set(domain, group)
|
|
504
|
+
}
|
|
505
|
+
return [...byDomain].map(([domain, patch]) => ({ domain, patch }))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Write a flat legacy-keyed patch through the domain store (multiple atomic set()s). */
|
|
509
|
+
export async function writeLegacyPatch(
|
|
510
|
+
patch: Record<string, unknown>,
|
|
511
|
+
opts?: { dshHome?: string },
|
|
512
|
+
): Promise<void> {
|
|
513
|
+
for (const { domain, patch: group } of splitLegacyPatch(patch)) {
|
|
514
|
+
await set(domain, group, opts)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Inverse view: domains flattened back into legacy key names (undefined keys omitted). */
|
|
519
|
+
export async function readFlat(opts?: { dshHome?: string }): Promise<Record<string, unknown>> {
|
|
520
|
+
const doc = await load(opts)
|
|
521
|
+
const flat: Record<string, unknown> = {}
|
|
522
|
+
for (const [key, dotted] of Object.entries(DOMAIN_KEY_MAP)) {
|
|
523
|
+
let cur: unknown = doc.domains
|
|
524
|
+
for (const part of dotted.split('.')) {
|
|
525
|
+
if (cur !== null && typeof cur === 'object') cur = (cur as Record<string, unknown>)[part]
|
|
526
|
+
else { cur = undefined; break }
|
|
527
|
+
}
|
|
528
|
+
if (cur !== undefined) flat[key] = cur
|
|
529
|
+
}
|
|
530
|
+
return flat
|
|
531
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook secret generation and the URL an operator pastes into GitLab.
|
|
3
|
+
*
|
|
4
|
+
* Parked at the meta root when the Maestro settings card lost its GitLab tab,
|
|
5
|
+
* and never ported: `review` kept the secret FIELD in its own settings section
|
|
6
|
+
* but had no way to produce a value for it, so the operator had to invent a
|
|
7
|
+
* 32-character base64url string by hand. A pasted secret with a `+`, a `/` or
|
|
8
|
+
* a `=` is a common outcome, and those characters are exactly what GitLab's
|
|
9
|
+
* token comparison and a URL path segment cannot carry.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
|
13
|
+
|
|
14
|
+
/** The merge-request webhook path this plugin serves. */
|
|
15
|
+
export const GITLAB_WEBHOOK_PATH = '/hooks/gitlab-mr'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate an opaque 256-bit secret suitable for GitLab's webhook token.
|
|
19
|
+
*
|
|
20
|
+
* Each byte contributes 6 bits (`byte & 0b00111111`), so the result is always
|
|
21
|
+
* 32 base64url characters with no padding and no character outside the
|
|
22
|
+
* alphabet — the two properties GitLab's comparison and a URL path segment
|
|
23
|
+
* both need. The randomness source is injectable so the mapping is testable
|
|
24
|
+
* without weakening the production path.
|
|
25
|
+
*/
|
|
26
|
+
export function generateWebhookSecret(
|
|
27
|
+
randomValues: (array: Uint8Array) => Uint8Array = globalThis.crypto.getRandomValues.bind(globalThis.crypto),
|
|
28
|
+
): string {
|
|
29
|
+
const bytes = randomValues(new Uint8Array(32))
|
|
30
|
+
return Array.from(bytes, byte => BASE64URL_ALPHABET[byte & 0b00111111]).join('')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GitLab's merge-request webhook endpoint for the configured public hostname.
|
|
35
|
+
*
|
|
36
|
+
* A user pasting the value back out of the settings field usually brings a
|
|
37
|
+
* scheme and a trailing slash with it, so both are stripped. An absent
|
|
38
|
+
* hostname names itself rather than producing a URL that looks real.
|
|
39
|
+
*/
|
|
40
|
+
export function gitlabWebhookUrl(hostname?: string): string {
|
|
41
|
+
const authority = hostname?.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '') || '<your-hostname>'
|
|
42
|
+
return `https://${authority}${GITLAB_WEBHOOK_PATH}`
|
|
43
|
+
}
|
|
@@ -3,7 +3,7 @@ stages: [review]
|
|
|
3
3
|
|
|
4
4
|
variables:
|
|
5
5
|
# Image registry — pin the SHA for reproducibility
|
|
6
|
-
REVIEWER_IMAGE: "ddtcorex/maestro-reviewer:0.
|
|
6
|
+
REVIEWER_IMAGE: "ddtcorex/maestro-reviewer:0.10.0"
|
|
7
7
|
# Or use $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA when building the image in the same project
|
|
8
8
|
|
|
9
9
|
review:
|