dsh-harbor-evolution 0.3.0 → 0.4.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 +17 -7
- package/index.js +10 -6
- package/lib/client.js +395 -0
- package/lib/dashboard.js +143 -0
- package/lib/service.js +30 -0
- package/lib/setup.js +43 -2
- package/lib/web.js +63 -0
- package/package.json +22 -4
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { access, constants, readdir, readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { resolveWithin } from './evolution.js'
|
|
5
|
+
|
|
6
|
+
const SUMMARY_NAME = 'evaluation-summary.json'
|
|
7
|
+
const PROMOTION_NAME = 'promotion-report.json'
|
|
8
|
+
const MAX_JOBS = 50
|
|
9
|
+
|
|
10
|
+
async function readJson(file) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT') return undefined
|
|
15
|
+
if (error instanceof SyntaxError) return { __readError: `invalid JSON in ${path.basename(file)}` }
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function directoryCheck(directory, { optional = false } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const details = await stat(directory)
|
|
23
|
+
if (!details.isDirectory()) return { status: 'error', detail: 'not a directory' }
|
|
24
|
+
await access(directory, constants.R_OK)
|
|
25
|
+
return { status: 'ok', detail: 'readable' }
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (optional && error.code === 'ENOENT') return { status: 'warning', detail: 'not created yet' }
|
|
28
|
+
return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : error.message }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function executableCheck(command) {
|
|
33
|
+
if (!command) return { status: 'error', detail: 'not configured' }
|
|
34
|
+
if (!path.isAbsolute(command)) return { status: 'ok', detail: `${command} (resolved from PATH)` }
|
|
35
|
+
try {
|
|
36
|
+
await access(command, constants.X_OK)
|
|
37
|
+
return { status: 'ok', detail: command }
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { status: 'error', detail: error.code === 'ENOENT' ? `${command} not found` : error.message }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function jobStatus(summary) {
|
|
44
|
+
if (!summary) return 'pending'
|
|
45
|
+
if (summary.__readError) return 'failed'
|
|
46
|
+
const trials = Number(summary.n_trials ?? 0)
|
|
47
|
+
const exceptions = Number(summary.n_exceptions ?? 0)
|
|
48
|
+
if (trials > 0 && exceptions >= trials) return 'failed'
|
|
49
|
+
if (exceptions > 0) return 'partial'
|
|
50
|
+
return 'completed'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJob(jobsDir, entry, details) {
|
|
54
|
+
const directory = path.join(jobsDir, entry.name)
|
|
55
|
+
const summary = await readJson(path.join(directory, SUMMARY_NAME))
|
|
56
|
+
const promotion = await readJson(path.join(directory, PROMOTION_NAME))
|
|
57
|
+
return {
|
|
58
|
+
name: entry.name,
|
|
59
|
+
path: directory,
|
|
60
|
+
updatedAt: details.mtime.toISOString(),
|
|
61
|
+
status: jobStatus(summary),
|
|
62
|
+
nTrials: Number(summary?.n_trials ?? 0),
|
|
63
|
+
nExceptions: Number(summary?.n_exceptions ?? 0),
|
|
64
|
+
metrics: summary?.metrics ?? {},
|
|
65
|
+
candidate: summary?.candidate ?? undefined,
|
|
66
|
+
evaluationContext: summary?.evaluation_context ?? undefined,
|
|
67
|
+
promotion: promotion ? {
|
|
68
|
+
decision: promotion.decision,
|
|
69
|
+
reasons: Array.isArray(promotion.reasons) ? promotion.reasons : [],
|
|
70
|
+
baselineJob: promotion.baseline_job,
|
|
71
|
+
candidateJob: promotion.candidate_job,
|
|
72
|
+
} : undefined,
|
|
73
|
+
readError: summary?.__readError,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function listJobs(jobsDir) {
|
|
78
|
+
let entries
|
|
79
|
+
try {
|
|
80
|
+
entries = await readdir(jobsDir, { withFileTypes: true })
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') return []
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
const recent = await Promise.all(entries
|
|
86
|
+
.filter(entry => entry.isDirectory())
|
|
87
|
+
.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
|
|
88
|
+
recent.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs)
|
|
89
|
+
return Promise.all(recent
|
|
90
|
+
.slice(0, MAX_JOBS)
|
|
91
|
+
.map(({ entry, details }) => readJob(jobsDir, entry, details)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function latestMetric(jobs) {
|
|
95
|
+
const completed = jobs.find(job => job.status === 'completed' || job.status === 'partial')
|
|
96
|
+
if (!completed) return undefined
|
|
97
|
+
const entry = Object.entries(completed.metrics).find(([, value]) => typeof value === 'number')
|
|
98
|
+
return entry ? { name: entry[0], value: entry[1] } : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function readDashboardSnapshot(config, metadata = {}) {
|
|
102
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
103
|
+
const jobsDir = resolveWithin(projectRoot, config.jobsDir, 'jobsDir')
|
|
104
|
+
const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck] = await Promise.all([
|
|
105
|
+
listJobs(jobsDir),
|
|
106
|
+
directoryCheck(projectRoot),
|
|
107
|
+
directoryCheck(jobsDir, { optional: true }),
|
|
108
|
+
executableCheck(config.harborBin),
|
|
109
|
+
executableCheck(config.harborDshBin),
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
const counts = jobs.reduce((result, job) => {
|
|
113
|
+
result[job.status] = (result[job.status] ?? 0) + 1
|
|
114
|
+
return result
|
|
115
|
+
}, {})
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
schemaVersion: 1,
|
|
119
|
+
generatedAt: new Date().toISOString(),
|
|
120
|
+
pluginVersion: metadata.pluginVersion ?? 'development',
|
|
121
|
+
config: {
|
|
122
|
+
projectRoot,
|
|
123
|
+
jobsDir,
|
|
124
|
+
dshVersion: config.dshVersion,
|
|
125
|
+
agentImportPath: config.agentImportPath,
|
|
126
|
+
pluginImportPath: config.pluginImportPath,
|
|
127
|
+
},
|
|
128
|
+
checks: {
|
|
129
|
+
projectRoot: projectRootCheck,
|
|
130
|
+
jobsDir: jobsDirCheck,
|
|
131
|
+
harbor: harborCheck,
|
|
132
|
+
harborDsh: harborDshCheck,
|
|
133
|
+
},
|
|
134
|
+
overview: {
|
|
135
|
+
totalJobs: jobs.length,
|
|
136
|
+
completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0),
|
|
137
|
+
activeJobs: counts.pending ?? 0,
|
|
138
|
+
failedJobs: counts.failed ?? 0,
|
|
139
|
+
latestMetric: latestMetric(jobs),
|
|
140
|
+
},
|
|
141
|
+
jobs,
|
|
142
|
+
}
|
|
143
|
+
}
|
package/lib/service.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readDashboardSnapshot } from './dashboard.js'
|
|
2
|
+
import { compareCandidates, readEvaluation, runEvaluation, snapshot } from './evolution.js'
|
|
3
|
+
|
|
4
|
+
/** One Host-side boundary shared by Agent tools and the Web dashboard. */
|
|
5
|
+
export class EvolutionService {
|
|
6
|
+
constructor(config, metadata = {}) {
|
|
7
|
+
this.config = config
|
|
8
|
+
this.metadata = metadata
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
snapshot(args) {
|
|
12
|
+
return snapshot(this.config, args)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
run(args) {
|
|
16
|
+
return runEvaluation(this.config, args)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
result(args) {
|
|
20
|
+
return readEvaluation(this.config, args)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
compare(args) {
|
|
24
|
+
return compareCandidates(this.config, args)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
dashboard() {
|
|
28
|
+
return readDashboardSnapshot(this.config, this.metadata)
|
|
29
|
+
}
|
|
30
|
+
}
|
package/lib/setup.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import process from 'node:process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
5
6
|
|
|
6
7
|
import { runProcess } from './process.js'
|
|
7
8
|
|
|
@@ -94,6 +95,26 @@ export function resolveSetupOptions(raw = {}, environment = {}) {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
export async function resolveLocalPluginDirectory(pluginSpec, cwd = process.cwd()) {
|
|
99
|
+
let candidate
|
|
100
|
+
if (pluginSpec.startsWith('file://')) candidate = fileURLToPath(pluginSpec)
|
|
101
|
+
else if (pluginSpec.startsWith('file:')) candidate = path.resolve(cwd, pluginSpec.slice(5))
|
|
102
|
+
else if (!pluginSpec.startsWith('github:') && !pluginSpec.startsWith('git+')) {
|
|
103
|
+
candidate = path.resolve(cwd, pluginSpec)
|
|
104
|
+
}
|
|
105
|
+
if (!candidate) return undefined
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const details = await stat(candidate)
|
|
109
|
+
if (!details.isDirectory()) return undefined
|
|
110
|
+
const manifest = JSON.parse(await readFile(path.join(candidate, 'package.json'), 'utf8'))
|
|
111
|
+
return manifest.name === 'dsh-harbor-evolution' ? candidate : undefined
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return undefined
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
function quoted(value) {
|
|
98
119
|
return JSON.stringify(value)
|
|
99
120
|
}
|
|
@@ -215,6 +236,23 @@ export async function setupIntegration(raw = {}, dependencies = {}) {
|
|
|
215
236
|
warnings.push(`Docker is not ready; installation can finish, but Harbor Jobs will fail until it is available. ${processFailure(error)}`)
|
|
216
237
|
}
|
|
217
238
|
|
|
239
|
+
const localPluginDir = await resolveLocalPluginDirectory(
|
|
240
|
+
config.pluginSpec,
|
|
241
|
+
dependencies.cwd ?? process.cwd(),
|
|
242
|
+
)
|
|
243
|
+
if (localPluginDir) {
|
|
244
|
+
progress('Preparing dependencies for the linked DSH plugin checkout...')
|
|
245
|
+
await requireCommand(run, 'npm')
|
|
246
|
+
// Node resolves a symlinked package from its real checkout path. Install
|
|
247
|
+
// the complete locked graph there so runtime dependencies and host peers
|
|
248
|
+
// do not disappear behind the profile's `link:` entry.
|
|
249
|
+
await run('npm', ['ci', '--ignore-scripts'], { cwd: localPluginDir })
|
|
250
|
+
// The browser half is generated from source and embeds its visual asset.
|
|
251
|
+
// Build explicitly because the locked install above intentionally skips
|
|
252
|
+
// lifecycle scripts for deterministic source-checkout setup.
|
|
253
|
+
await run('npm', ['run', 'build'], { cwd: localPluginDir })
|
|
254
|
+
}
|
|
255
|
+
|
|
218
256
|
progress('2/4 Installing the Harbor Python runtime...')
|
|
219
257
|
await mkdir(config.runtimeDir, { recursive: true })
|
|
220
258
|
await run('uv', ['venv', '--python', '3.12', '--allow-existing', config.venvDir])
|
|
@@ -229,7 +267,7 @@ export async function setupIntegration(raw = {}, dependencies = {}) {
|
|
|
229
267
|
try {
|
|
230
268
|
await run('pnpm', [
|
|
231
269
|
'--silent', 'dlx', `@deepseek-ai/dsh@${DSH_VERSION}`,
|
|
232
|
-
'plugin', '--profile', config.profile, 'add', '-w', config.pluginSpec,
|
|
270
|
+
'plugin', '--profile', config.profile, 'add', '-w', '--save-exact', config.pluginSpec,
|
|
233
271
|
], { env: { ...env, DSH_HOME: config.dshHome } })
|
|
234
272
|
} catch (error) {
|
|
235
273
|
throw new Error(processFailure(error))
|
|
@@ -246,6 +284,7 @@ export async function setupIntegration(raw = {}, dependencies = {}) {
|
|
|
246
284
|
|
|
247
285
|
return {
|
|
248
286
|
...config,
|
|
287
|
+
localPluginDir,
|
|
249
288
|
patchChanged,
|
|
250
289
|
harborVersion: harborVersion.stdout.trim() || harborVersion.stderr.trim(),
|
|
251
290
|
warnings,
|
|
@@ -275,7 +314,9 @@ export function renderSetupResult(result) {
|
|
|
275
314
|
`cd ${shellQuote(result.projectRoot)}`,
|
|
276
315
|
startCommand,
|
|
277
316
|
'',
|
|
278
|
-
|
|
317
|
+
result.profile === 'web'
|
|
318
|
+
? 'Open the Harbor tab, or invoke: /evolve-agent-with-harbor'
|
|
319
|
+
: 'Then invoke: /evolve-agent-with-harbor',
|
|
279
320
|
)
|
|
280
321
|
return lines.join('\n')
|
|
281
322
|
}
|
package/lib/web.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const DASHBOARD_ROUTE = '/_dsh/harbor-evolution/dashboard'
|
|
2
|
+
|
|
3
|
+
function sendJson(response, status, body) {
|
|
4
|
+
response.writeHead(status, {
|
|
5
|
+
'cache-control': 'no-store',
|
|
6
|
+
'content-type': 'application/json; charset=utf-8',
|
|
7
|
+
'x-content-type-options': 'nosniff',
|
|
8
|
+
})
|
|
9
|
+
response.end(JSON.stringify(body))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isSameOriginRequest(request) {
|
|
13
|
+
const fetchSite = request.headers['sec-fetch-site']
|
|
14
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false
|
|
15
|
+
const origin = request.headers.origin
|
|
16
|
+
if (!origin) {
|
|
17
|
+
if (fetchSite === 'same-origin' || fetchSite === 'none') return true
|
|
18
|
+
const address = request.socket?.remoteAddress ?? ''
|
|
19
|
+
return address === '::1' || address === '127.0.0.1' || address.startsWith('127.')
|
|
20
|
+
|| address.startsWith('::ffff:127.')
|
|
21
|
+
}
|
|
22
|
+
const host = request.headers.host
|
|
23
|
+
if (!host) return false
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(origin)
|
|
26
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createDashboardHandler(service) {
|
|
33
|
+
return (request, response) => {
|
|
34
|
+
if (request.method !== 'GET') {
|
|
35
|
+
response.writeHead(405, { allow: 'GET' })
|
|
36
|
+
response.end()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (!isSameOriginRequest(request)) {
|
|
40
|
+
sendJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin request required' } })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
Promise.resolve(service.dashboard()).then(
|
|
44
|
+
value => sendJson(response, 200, { ok: true, value }),
|
|
45
|
+
error => sendJson(response, 500, {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: { code: 'dashboard-unavailable', message: error instanceof Error ? error.message : String(error) },
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Add the dashboard route only in profiles that provide the optional Web service. */
|
|
54
|
+
export function installDashboardWeb(ctx, service) {
|
|
55
|
+
if (typeof ctx.inject !== 'function') return
|
|
56
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
57
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
58
|
+
kind: 'exact',
|
|
59
|
+
path: DASHBOARD_ROUTE,
|
|
60
|
+
handler: createDashboardHandler(service),
|
|
61
|
+
}), 'harbor-evolution: dashboard route')
|
|
62
|
+
})
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-harbor-evolution",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin and bundled Skill for safely evolving Cordis Candidates with Harbor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
-
"exports":
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
8
12
|
"bin": {
|
|
9
13
|
"dsh-harbor": "bin/dsh-harbor.mjs"
|
|
10
14
|
},
|
|
@@ -18,12 +22,24 @@
|
|
|
18
22
|
"LICENSE"
|
|
19
23
|
],
|
|
20
24
|
"scripts": {
|
|
25
|
+
"build": "node scripts/build-client.mjs",
|
|
26
|
+
"prepack": "npm run build",
|
|
21
27
|
"test": "node --test",
|
|
22
|
-
"check": "node --check index.js && node --check bin/dsh-harbor.mjs && node --test"
|
|
28
|
+
"check": "npm run build && node --check index.js && node --check bin/dsh-harbor.mjs && node --test"
|
|
23
29
|
},
|
|
24
30
|
"dsh": {
|
|
25
31
|
"bundle": {
|
|
26
32
|
"patch": "./cordis.patch.yml"
|
|
33
|
+
},
|
|
34
|
+
"client": {
|
|
35
|
+
"inject": [
|
|
36
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
37
|
+
"@deepseek-ai/dsh-client-locale",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-tool",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
41
|
+
],
|
|
42
|
+
"platform": "web"
|
|
27
43
|
}
|
|
28
44
|
},
|
|
29
45
|
"peerDependencies": {
|
|
@@ -37,7 +53,9 @@
|
|
|
37
53
|
"@deepseek-ai/cordis": "4.0.1",
|
|
38
54
|
"@deepseek-ai/dsh-skill": "0.1.0-rc.6",
|
|
39
55
|
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
40
|
-
"@deepseek-ai/schemastery": "3.18.1"
|
|
56
|
+
"@deepseek-ai/schemastery": "3.18.1",
|
|
57
|
+
"esbuild": "0.28.2",
|
|
58
|
+
"react": "18.3.1"
|
|
41
59
|
},
|
|
42
60
|
"engines": {
|
|
43
61
|
"node": ">=22"
|