dsh-harbor-evolution 0.3.1 → 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.
@@ -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
@@ -247,6 +247,10 @@ export async function setupIntegration(raw = {}, dependencies = {}) {
247
247
  // the complete locked graph there so runtime dependencies and host peers
248
248
  // do not disappear behind the profile's `link:` entry.
249
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 })
250
254
  }
251
255
 
252
256
  progress('2/4 Installing the Harbor Python runtime...')
@@ -310,7 +314,9 @@ export function renderSetupResult(result) {
310
314
  `cd ${shellQuote(result.projectRoot)}`,
311
315
  startCommand,
312
316
  '',
313
- 'Then invoke: /evolve-agent-with-harbor',
317
+ result.profile === 'web'
318
+ ? 'Open the Harbor tab, or invoke: /evolve-agent-with-harbor'
319
+ : 'Then invoke: /evolve-agent-with-harbor',
314
320
  )
315
321
  return lines.join('\n')
316
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.1",
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": "./index.js",
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"