dsh-job-progress 0.1.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/LICENSE +21 -0
- package/README.md +332 -0
- package/README.zh.md +297 -0
- package/assets/logo.png +0 -0
- package/assets/screenshot.png +0 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +490 -0
- package/lib/dsh-progress.mjs +241 -0
- package/lib/index.js +362 -0
- package/package.json +61 -0
- package/test/preflight-client.mjs +121 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-progress — the producer side of the dsh-job-progress protocol.
|
|
4
|
+
*
|
|
5
|
+
* A long-running producer (a downloader, an encoder, a batch job, anything that
|
|
6
|
+
* runs inside an agent's shell) writes its progress as a small JSON file in the
|
|
7
|
+
* session's progress directory. The plugin's host half reads that directory and
|
|
8
|
+
* the web GUI renders it next to the background job. Nothing polls the job's
|
|
9
|
+
* stdout — that stream is incremental and belongs to the model — so this file is
|
|
10
|
+
* the only channel that costs the reader nothing.
|
|
11
|
+
*
|
|
12
|
+
* The directory is derived from the environment every agent shell already has:
|
|
13
|
+
* DSH_HOME (e.g. C:\Users\me\.dsh) → <DSH_HOME>/job-progress
|
|
14
|
+
* DSH_SESSION_ID (e.g. session-abc…) → <DSH_HOME>/job-progress/<session>
|
|
15
|
+
* Both may be overridden with --dir / DSH_JOB_PROGRESS_DIR.
|
|
16
|
+
*
|
|
17
|
+
* File contract (one file per task, key = file name):
|
|
18
|
+
* {
|
|
19
|
+
* "label": "anima_5B.safetensors", // shown in the panel
|
|
20
|
+
* "done": 12345678, // units completed
|
|
21
|
+
* "total": 66000000, // units total (0 when unknown)
|
|
22
|
+
* "unit": "bytes" | "count", // how to render the numbers
|
|
23
|
+
* "speed": 12500000, // units/second (optional)
|
|
24
|
+
* "eta": 5, // seconds remaining (optional)
|
|
25
|
+
* "phase": "download", // download | merge | verify | custom
|
|
26
|
+
* "status": "running", // running | done | failed
|
|
27
|
+
* "note": "", // free text shown on failure
|
|
28
|
+
* "jobId": "bash-3", // optional: pin to a registry job
|
|
29
|
+
* "updatedAt": 1758000000000 // ms epoch; readers age entries out
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* Any language can write that file; this module is the JavaScript convenience
|
|
33
|
+
* layer plus a CLI:
|
|
34
|
+
*
|
|
35
|
+
* import { track } from 'dsh-job-progress/progress';
|
|
36
|
+
* const t = track({ label: 'model.safetensors', total: 66000000 });
|
|
37
|
+
* t.update(bytes);
|
|
38
|
+
* t.phase('verify');
|
|
39
|
+
* t.finish('done');
|
|
40
|
+
*
|
|
41
|
+
* node dsh-progress.mjs set --label model.safetensors --done 12 --total 100
|
|
42
|
+
* node dsh-progress.mjs done --key model.safetensors
|
|
43
|
+
*/
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import os from 'node:os';
|
|
46
|
+
import path from 'node:path';
|
|
47
|
+
import { pathToFileURL } from 'node:url';
|
|
48
|
+
|
|
49
|
+
/** Readers treat an entry whose heartbeat stopped as gone. */
|
|
50
|
+
export const HEARTBEAT_MS = 5000;
|
|
51
|
+
|
|
52
|
+
/** Resolve the progress directory for one session from the environment. */
|
|
53
|
+
export function progressDir(opts = {}) {
|
|
54
|
+
const explicit = opts.dir || process.env.DSH_JOB_PROGRESS_DIR;
|
|
55
|
+
const base = explicit || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'job-progress');
|
|
56
|
+
const session = opts.session || process.env.DSH_SESSION_ID || 'unscoped';
|
|
57
|
+
return path.join(base, session);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function slugify(value, fallback = 'task') {
|
|
61
|
+
const cleaned = String(value ?? '')
|
|
62
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_')
|
|
63
|
+
.replace(/\s+/g, '_')
|
|
64
|
+
.slice(0, 80);
|
|
65
|
+
return cleaned.length > 0 ? cleaned : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeAtomic(file, record) {
|
|
69
|
+
const tmp = `${file}.tmp`;
|
|
70
|
+
fs.writeFileSync(tmp, JSON.stringify(record));
|
|
71
|
+
fs.renameSync(tmp, file);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Track one task. Every call rewrites the task's JSON file atomically, so a
|
|
76
|
+
* reader never observes a half-written record.
|
|
77
|
+
*
|
|
78
|
+
* @param options - `{ label, total, unit, key, jobId, dir, session }`.
|
|
79
|
+
* @returns a handle with `update`, `phase`, `finish`, and its `file` path.
|
|
80
|
+
*/
|
|
81
|
+
export function track(options = {}) {
|
|
82
|
+
const dir = progressDir(options);
|
|
83
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
84
|
+
const label = options.label || options.key || 'task';
|
|
85
|
+
const key = slugify(options.key || label);
|
|
86
|
+
const file = path.join(dir, `${key}.json`);
|
|
87
|
+
// `resume` continues an existing record instead of starting a new one. The CLI
|
|
88
|
+
// needs it because `set` and `done` are two separate processes: without it the
|
|
89
|
+
// second invocation would publish a fresh, zeroed record and lose the numbers.
|
|
90
|
+
let seed = null;
|
|
91
|
+
if (options.resume === true) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
94
|
+
if (parsed !== null && typeof parsed === 'object') seed = parsed;
|
|
95
|
+
} catch {
|
|
96
|
+
/* no previous record — start one */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const record = {
|
|
100
|
+
label,
|
|
101
|
+
key,
|
|
102
|
+
unit: options.unit === 'count' || seed?.unit === 'count' ? 'count' : 'bytes',
|
|
103
|
+
total: Number(options.total) || Number(seed?.total) || 0,
|
|
104
|
+
done: options.done !== undefined ? Number(options.done) || 0 : Number(seed?.done) || 0,
|
|
105
|
+
speed: 0,
|
|
106
|
+
eta: null,
|
|
107
|
+
phase: options.phase || seed?.phase || 'running',
|
|
108
|
+
status: 'running',
|
|
109
|
+
note: '',
|
|
110
|
+
pid: process.pid,
|
|
111
|
+
...(options.jobId ? { jobId: options.jobId } : seed?.jobId ? { jobId: seed.jobId } : {}),
|
|
112
|
+
startedAt: Number(seed?.startedAt) || Date.now(),
|
|
113
|
+
updatedAt: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
let lastBytes = 0;
|
|
116
|
+
let lastAt = Date.now();
|
|
117
|
+
let stopped = false;
|
|
118
|
+
|
|
119
|
+
const flush = () => {
|
|
120
|
+
record.updatedAt = Date.now();
|
|
121
|
+
try {
|
|
122
|
+
writeAtomic(file, record);
|
|
123
|
+
} catch {
|
|
124
|
+
/* reporting must never break the work it reports on */
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
flush();
|
|
128
|
+
|
|
129
|
+
// Heartbeat: a producer that stalls (or dies) stops refreshing `updatedAt`,
|
|
130
|
+
// which is how the panel tells "slow" from "gone".
|
|
131
|
+
const beat = setInterval(() => {
|
|
132
|
+
if (!stopped) flush();
|
|
133
|
+
}, HEARTBEAT_MS);
|
|
134
|
+
if (typeof beat.unref === 'function') beat.unref();
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
file,
|
|
138
|
+
record,
|
|
139
|
+
update(done, extra = {}) {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const dt = (now - lastAt) / 1000;
|
|
142
|
+
if (dt >= 0.4) {
|
|
143
|
+
record.speed = Math.max(0, (Number(done) - lastBytes) / dt);
|
|
144
|
+
lastBytes = Number(done);
|
|
145
|
+
lastAt = now;
|
|
146
|
+
}
|
|
147
|
+
record.done = Number(done) || 0;
|
|
148
|
+
record.eta = record.total > 0 && record.speed > 0 ? Math.round((record.total - record.done) / record.speed) : null;
|
|
149
|
+
Object.assign(record, extra);
|
|
150
|
+
flush();
|
|
151
|
+
return this;
|
|
152
|
+
},
|
|
153
|
+
phase(phase, extra = {}) {
|
|
154
|
+
record.phase = phase;
|
|
155
|
+
Object.assign(record, extra);
|
|
156
|
+
flush();
|
|
157
|
+
return this;
|
|
158
|
+
},
|
|
159
|
+
finish(status = 'done', note = '') {
|
|
160
|
+
stopped = true;
|
|
161
|
+
clearInterval(beat);
|
|
162
|
+
record.status = status === 'failed' ? 'failed' : 'done';
|
|
163
|
+
record.phase = record.status;
|
|
164
|
+
record.note = note || '';
|
|
165
|
+
record.eta = null;
|
|
166
|
+
if (record.status === 'done' && record.total > 0) record.done = record.total;
|
|
167
|
+
record.finishedAt = Date.now();
|
|
168
|
+
flush();
|
|
169
|
+
return file;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseArgv(argv) {
|
|
175
|
+
const out = { _: [] };
|
|
176
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
177
|
+
const token = argv[i];
|
|
178
|
+
if (!token.startsWith('--')) {
|
|
179
|
+
out._.push(token);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const eq = token.indexOf('=');
|
|
183
|
+
if (eq > 0) out[token.slice(2, eq)] = token.slice(eq + 1);
|
|
184
|
+
else {
|
|
185
|
+
const next = argv[i + 1];
|
|
186
|
+
if (next === undefined || next.startsWith('--')) out[token.slice(2)] = true;
|
|
187
|
+
else {
|
|
188
|
+
out[token.slice(2)] = next;
|
|
189
|
+
i += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function main(argv) {
|
|
197
|
+
const args = parseArgv(argv);
|
|
198
|
+
const command = args._[0] || 'set';
|
|
199
|
+
const opts = { dir: args.dir, session: args.session, key: args.key, label: args.label, jobId: args.jobId };
|
|
200
|
+
if (command === 'dir') {
|
|
201
|
+
process.stdout.write(`${progressDir(opts)}\n`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
if (command === 'clear') {
|
|
205
|
+
const dir = progressDir(opts);
|
|
206
|
+
let removed = 0;
|
|
207
|
+
if (fs.existsSync(dir)) {
|
|
208
|
+
for (const name of fs.readdirSync(dir)) {
|
|
209
|
+
if (!name.endsWith('.json')) continue;
|
|
210
|
+
if (args.key && name !== `${slugify(args.key)}.json`) continue;
|
|
211
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
212
|
+
removed += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
process.stdout.write(`cleared ${removed}\n`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const handle = track({
|
|
219
|
+
...opts,
|
|
220
|
+
resume: true,
|
|
221
|
+
total: args.total,
|
|
222
|
+
done: args.done,
|
|
223
|
+
unit: args.unit,
|
|
224
|
+
phase: args.phase,
|
|
225
|
+
});
|
|
226
|
+
if (command === 'done' || command === 'failed') {
|
|
227
|
+
handle.finish(command === 'done' ? 'done' : 'failed', args.note);
|
|
228
|
+
} else {
|
|
229
|
+
handle.update(args.done ?? 0, args.speed ? { speed: Number(args.speed) } : {});
|
|
230
|
+
}
|
|
231
|
+
process.stdout.write(`${handle.file}\n`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// `pathToFileURL` — not string concatenation — because a Windows path yields
|
|
236
|
+
// `file:///C:/…` (three slashes) and a hand-built URL silently never matches.
|
|
237
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
238
|
+
process.exit(main(process.argv.slice(2)));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default { track, progressDir };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-job-progress — host half.
|
|
3
|
+
*
|
|
4
|
+
* Two questions, one answer each:
|
|
5
|
+
* 1. "Which background jobs does this session have?" → read the job registry
|
|
6
|
+
* (read-only snapshots; the plugin NEVER calls read(), because that stream
|
|
7
|
+
* is incremental and marks terminal jobs reported — polling it would steal
|
|
8
|
+
* the model's own `job_output` reads and swallow completion notices).
|
|
9
|
+
* 2. "How far along are they?" → read the progress directory that producers
|
|
10
|
+
* write to (see ./dsh-progress.mjs for the file contract).
|
|
11
|
+
*
|
|
12
|
+
* The web GUI reaches this through the standard `/api` Remote gateway as
|
|
13
|
+
* `jobProgress/snapshot`; nothing is generated ahead of time (SRC discovery).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
18
|
+
import z from '@deepseek-ai/schemastery';
|
|
19
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
20
|
+
|
|
21
|
+
// ── decorator support (stage-3 decorators, transpiled — Node has none) ─────
|
|
22
|
+
var __esDecorate = function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
23
|
+
function accept(f) {
|
|
24
|
+
if (f !== void 0 && typeof f !== 'function') throw new TypeError('Function expected');
|
|
25
|
+
return f;
|
|
26
|
+
}
|
|
27
|
+
var kind = contextIn.kind, key = kind === 'getter' ? 'get' : kind === 'setter' ? 'set' : 'value';
|
|
28
|
+
var target = !descriptorIn && ctor ? (contextIn['static'] ? ctor : ctor.prototype) : null;
|
|
29
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
30
|
+
var _, done = false;
|
|
31
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
32
|
+
var context = {};
|
|
33
|
+
for (var p in contextIn) context[p] = p === 'access' ? {} : contextIn[p];
|
|
34
|
+
for (var p in contextIn.access) context.access[p] = contextIn[p];
|
|
35
|
+
context.addInitializer = function (f) {
|
|
36
|
+
if (done) throw new Error('Cannot add initializers after decoration has completed');
|
|
37
|
+
extraInitializers.push(accept(f || null));
|
|
38
|
+
};
|
|
39
|
+
var result = (0, decorators[i])(kind === 'accessor' ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
40
|
+
if (kind === 'accessor') {
|
|
41
|
+
if (result === void 0) continue;
|
|
42
|
+
if (result === null || typeof result !== 'object') throw new TypeError('Object expected');
|
|
43
|
+
if ((_ = accept(result.get))) descriptor.get = _;
|
|
44
|
+
if ((_ = accept(result.set))) descriptor.set = _;
|
|
45
|
+
if ((_ = accept(result.init))) initializers.unshift(_);
|
|
46
|
+
} else if ((_ = accept(result))) {
|
|
47
|
+
if (kind === 'field') initializers.unshift(_);
|
|
48
|
+
else descriptor[key] = _;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
52
|
+
done = true;
|
|
53
|
+
};
|
|
54
|
+
var __runInitializers = function (thisArg, initializers, value) {
|
|
55
|
+
var useValue = arguments.length > 2;
|
|
56
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
57
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
58
|
+
}
|
|
59
|
+
return useValue ? value : void 0;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A producer that stopped refreshing `updatedAt` for this long is gone. */
|
|
63
|
+
const STALE_MS = 20000;
|
|
64
|
+
/** A finished task stays on the panel this long before it ages out. */
|
|
65
|
+
const KEEP_MS = 120000;
|
|
66
|
+
|
|
67
|
+
function finiteNumber(value) {
|
|
68
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readDir(dir) {
|
|
72
|
+
try {
|
|
73
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let JobProgressService = (() => {
|
|
80
|
+
let _classSuper = TypertRemoteService;
|
|
81
|
+
let _instanceExtraInitializers = [];
|
|
82
|
+
let _snapshot_decorators;
|
|
83
|
+
let _clear_decorators;
|
|
84
|
+
return class JobProgressService extends _classSuper {
|
|
85
|
+
static {
|
|
86
|
+
const _metadata = typeof Symbol === 'function' && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
87
|
+
_snapshot_decorators = [Remote('snapshot')];
|
|
88
|
+
_clear_decorators = [Remote('clear')];
|
|
89
|
+
__esDecorate(this, null, _clear_decorators, {
|
|
90
|
+
kind: 'method',
|
|
91
|
+
name: 'clear',
|
|
92
|
+
static: false,
|
|
93
|
+
private: false,
|
|
94
|
+
access: { has: (obj) => 'clear' in obj, get: (obj) => obj.clear },
|
|
95
|
+
metadata: _metadata
|
|
96
|
+
}, null, _instanceExtraInitializers);
|
|
97
|
+
__esDecorate(this, null, _snapshot_decorators, {
|
|
98
|
+
kind: 'method',
|
|
99
|
+
name: 'snapshot',
|
|
100
|
+
static: false,
|
|
101
|
+
private: false,
|
|
102
|
+
access: { has: (obj) => 'snapshot' in obj, get: (obj) => obj.snapshot },
|
|
103
|
+
metadata: _metadata
|
|
104
|
+
}, null, _instanceExtraInitializers);
|
|
105
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
106
|
+
enumerable: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
writable: true,
|
|
109
|
+
value: _metadata
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Required service: live sessions (the job registry is reached optionally). */
|
|
114
|
+
static inject = ['sessions'];
|
|
115
|
+
|
|
116
|
+
/** Optional: `dir` overrides the progress root, `debug` adds diagnostics. */
|
|
117
|
+
static Config = z.object({
|
|
118
|
+
dir: z.string().default(''),
|
|
119
|
+
debug: z.boolean().default(false)
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
root;
|
|
123
|
+
debug;
|
|
124
|
+
|
|
125
|
+
constructor(ctx, config = {}) {
|
|
126
|
+
super(ctx, 'jobProgress');
|
|
127
|
+
__runInitializers(this, _instanceExtraInitializers);
|
|
128
|
+
this.root = config.dir && config.dir.length > 0 ? config.dir : dshHomePath('job-progress');
|
|
129
|
+
this.debug = config.debug === true;
|
|
130
|
+
ctx.logger?.info?.('dsh-job-progress: mounted, progress root ' + this.root);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `/api` endpoint `jobProgress/snapshot` → this session's tasks. */
|
|
134
|
+
async snapshot(request) {
|
|
135
|
+
const sessionId = typeof request?.sessionId === 'string' ? request.sessionId : '';
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
if (sessionId.length === 0) return { sessionId: null, now, tasks: [], debug: this.diagnostics() };
|
|
138
|
+
const entries = this.readEntries(sessionId, now);
|
|
139
|
+
const jobs = this.readJobs(sessionId);
|
|
140
|
+
const tasks = this.merge(entries, jobs, now);
|
|
141
|
+
return {
|
|
142
|
+
sessionId,
|
|
143
|
+
now,
|
|
144
|
+
tasks,
|
|
145
|
+
debug: this.debug ? { ...this.diagnostics(), entries: entries.length, jobs: jobs.length } : undefined
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Progress files for one session, newest first. Reading these is free: the
|
|
151
|
+
* files belong to this plugin and no other reader drains them.
|
|
152
|
+
*/
|
|
153
|
+
readEntries(sessionId, now) {
|
|
154
|
+
const dir = path.join(this.root, sessionId);
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const dirent of readDir(dir)) {
|
|
157
|
+
if (!dirent.isFile() || !dirent.name.endsWith('.json')) continue;
|
|
158
|
+
let record;
|
|
159
|
+
try {
|
|
160
|
+
record = JSON.parse(fs.readFileSync(path.join(dir, dirent.name), 'utf8'));
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (record === null || typeof record !== 'object') continue;
|
|
165
|
+
const status = typeof record.status === 'string' ? record.status : 'running';
|
|
166
|
+
const updatedAt = finiteNumber(record.updatedAt) ?? finiteNumber(record.startedAt) ?? 0;
|
|
167
|
+
const finishedAt = finiteNumber(record.finishedAt);
|
|
168
|
+
const stale = now - updatedAt > STALE_MS;
|
|
169
|
+
// A live entry whose heartbeat stopped is gone; a finished one is kept
|
|
170
|
+
// for a while so the panel can show how it ended.
|
|
171
|
+
if (status === 'running') {
|
|
172
|
+
if (stale) continue;
|
|
173
|
+
} else if (now - (finishedAt ?? updatedAt) > KEEP_MS) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
out.push({
|
|
177
|
+
key: typeof record.key === 'string' && record.key.length > 0 ? record.key : dirent.name.replace(/\.json$/, ''),
|
|
178
|
+
label: typeof record.label === 'string' && record.label.length > 0 ? record.label : dirent.name,
|
|
179
|
+
status: stale && status === 'running' ? 'lost' : status,
|
|
180
|
+
phase: typeof record.phase === 'string' ? record.phase : '',
|
|
181
|
+
unit: record.unit === 'count' ? 'count' : 'bytes',
|
|
182
|
+
done: finiteNumber(record.done) ?? 0,
|
|
183
|
+
total: finiteNumber(record.total) ?? 0,
|
|
184
|
+
speed: finiteNumber(record.speed) ?? 0,
|
|
185
|
+
eta: finiteNumber(record.eta) ?? null,
|
|
186
|
+
note: typeof record.note === 'string' ? record.note : '',
|
|
187
|
+
jobId: typeof record.jobId === 'string' ? record.jobId : undefined,
|
|
188
|
+
startedAt: finiteNumber(record.startedAt),
|
|
189
|
+
updatedAt,
|
|
190
|
+
finishedAt,
|
|
191
|
+
hasProgress: true
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Registry snapshots for the session's own owner. `list()` is a pure
|
|
199
|
+
* projection; unlike `read()` it neither drains output nor marks a terminal
|
|
200
|
+
* job reported, so a panel can watch jobs without stealing them from the
|
|
201
|
+
* agent that started them.
|
|
202
|
+
*/
|
|
203
|
+
readJobs(sessionId) {
|
|
204
|
+
const registry = this.ctx.get('jobs');
|
|
205
|
+
if (registry === undefined || typeof registry.list !== 'function') return [];
|
|
206
|
+
const owners = [];
|
|
207
|
+
for (const name of ['agents', 'sessions']) {
|
|
208
|
+
let service;
|
|
209
|
+
try {
|
|
210
|
+
service = this.ctx.get(name);
|
|
211
|
+
} catch {
|
|
212
|
+
service = undefined;
|
|
213
|
+
}
|
|
214
|
+
if (service !== undefined && typeof service.list === 'function') {
|
|
215
|
+
try {
|
|
216
|
+
owners.push(...service.list());
|
|
217
|
+
} catch {
|
|
218
|
+
/* a registry that refuses to enumerate is simply not a source */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
const seenJobs = new Set();
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const owner of owners) {
|
|
226
|
+
if (owner === null || typeof owner !== 'object' || typeof owner.id !== 'string') continue;
|
|
227
|
+
if (owner.id !== sessionId) continue;
|
|
228
|
+
if (seen.has(owner)) continue;
|
|
229
|
+
seen.add(owner);
|
|
230
|
+
try {
|
|
231
|
+
for (const snapshot of registry.list(owner)) {
|
|
232
|
+
if (snapshot === null || typeof snapshot !== 'object') continue;
|
|
233
|
+
// 同一个作业会从 agents 与 sessions 两个注册表各返回一次(id 相同、对象不同)。
|
|
234
|
+
// 不按作业 id 去重,面板会把一条作业列两遍、角标计数也会多算(2026-09-20 实测)。
|
|
235
|
+
if (typeof snapshot.id === 'string') {
|
|
236
|
+
if (seenJobs.has(snapshot.id)) continue;
|
|
237
|
+
seenJobs.add(snapshot.id);
|
|
238
|
+
}
|
|
239
|
+
out.push(snapshot);
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
/* access is owner-relative; a refusal means "not this owner's job" */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 删除本会话**已结束**的进度文件(正在跑的绝不动)。
|
|
250
|
+
* 注册表里的终态作业删不掉(只读投影),前端会自行把它们记入忽略名单。
|
|
251
|
+
*/
|
|
252
|
+
async clear(request) {
|
|
253
|
+
const sessionId = typeof request?.sessionId === 'string' ? request.sessionId : '';
|
|
254
|
+
if (!this.isSafeSessionId(sessionId)) return { removed: 0, reason: 'invalid-session' };
|
|
255
|
+
const dir = path.join(this.root, sessionId);
|
|
256
|
+
let removed = 0;
|
|
257
|
+
for (const dirent of readDir(dir)) {
|
|
258
|
+
if (!dirent.isFile() || !dirent.name.endsWith('.json')) continue;
|
|
259
|
+
const file = path.join(dir, dirent.name);
|
|
260
|
+
let status = 'running';
|
|
261
|
+
try {
|
|
262
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
263
|
+
if (typeof record?.status === 'string') status = record.status;
|
|
264
|
+
} catch {
|
|
265
|
+
continue; // 读不出来就当它不属于我们,不删
|
|
266
|
+
}
|
|
267
|
+
if (status === 'running') continue;
|
|
268
|
+
try {
|
|
269
|
+
fs.rmSync(file, { force: true });
|
|
270
|
+
removed += 1;
|
|
271
|
+
} catch {
|
|
272
|
+
/* 删不掉就留着,下次清除再试 */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { removed };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 会话 id 既是目录名又是网络入参,所以设两道闸:不含路径分隔符与 ..,
|
|
280
|
+
* 且必须是当前真实存在的会话 —— 不给路径穿越留缝。
|
|
281
|
+
*/
|
|
282
|
+
isSafeSessionId(sessionId) {
|
|
283
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return false;
|
|
284
|
+
if (sessionId.includes('/') || sessionId.includes('\\') || sessionId.includes('..')) return false;
|
|
285
|
+
let list = [];
|
|
286
|
+
try {
|
|
287
|
+
const sessions = this.ctx.get('sessions');
|
|
288
|
+
if (typeof sessions?.list === 'function') list = sessions.list();
|
|
289
|
+
} catch {
|
|
290
|
+
list = [];
|
|
291
|
+
}
|
|
292
|
+
return list.some((session) => session?.id === sessionId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Join progress entries with registry jobs, keyed by job id then label. */
|
|
296
|
+
merge(entries, jobs, now) {
|
|
297
|
+
const tasks = [];
|
|
298
|
+
const byJobId = new Map();
|
|
299
|
+
const byLabel = new Map();
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
if (entry.jobId) byJobId.set(entry.jobId, entry);
|
|
302
|
+
byLabel.set(entry.label, entry);
|
|
303
|
+
tasks.push(entry);
|
|
304
|
+
}
|
|
305
|
+
for (const job of jobs) {
|
|
306
|
+
const match = (job.id !== undefined ? byJobId.get(job.id) : undefined) ?? byLabel.get(job.label);
|
|
307
|
+
if (match !== undefined) {
|
|
308
|
+
// The registry is authoritative for status and timing; the producer owns
|
|
309
|
+
// the numbers. A terminal job wins over a stale "running" file.
|
|
310
|
+
match.jobId = job.id;
|
|
311
|
+
match.kind = job.kind;
|
|
312
|
+
if (job.status !== undefined && job.status !== 'running') match.status = job.status;
|
|
313
|
+
if (job.finishedAt !== undefined) match.finishedAt = job.finishedAt;
|
|
314
|
+
if (job.startedAt !== undefined && match.startedAt === undefined) match.startedAt = job.startedAt;
|
|
315
|
+
if (job.detail !== undefined) match.note = match.note || job.detail;
|
|
316
|
+
byJobId.set(job.id, match);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
tasks.push({
|
|
320
|
+
key: job.id,
|
|
321
|
+
label: job.label,
|
|
322
|
+
kind: job.kind,
|
|
323
|
+
status: job.status,
|
|
324
|
+
phase: '',
|
|
325
|
+
unit: 'count',
|
|
326
|
+
done: 0,
|
|
327
|
+
total: 0,
|
|
328
|
+
speed: 0,
|
|
329
|
+
eta: null,
|
|
330
|
+
note: job.detail ?? '',
|
|
331
|
+
jobId: job.id,
|
|
332
|
+
startedAt: job.startedAt,
|
|
333
|
+
updatedAt: job.finishedAt ?? job.startedAt ?? now,
|
|
334
|
+
finishedAt: job.finishedAt,
|
|
335
|
+
hasProgress: false
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
tasks.sort((left, right) => {
|
|
339
|
+
const live = (task) => (task.status === 'running' || task.status === 'lost' ? 0 : 1);
|
|
340
|
+
if (live(left) !== live(right)) return live(left) - live(right);
|
|
341
|
+
return (right.finishedAt ?? right.startedAt ?? 0) - (left.finishedAt ?? left.startedAt ?? 0);
|
|
342
|
+
});
|
|
343
|
+
return tasks;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Names of the registries this plugin actually found (diagnostics only). */
|
|
347
|
+
diagnostics() {
|
|
348
|
+
const present = {};
|
|
349
|
+
for (const name of ['jobs', 'agents', 'sessions']) {
|
|
350
|
+
try {
|
|
351
|
+
const service = this.ctx.get(name);
|
|
352
|
+
present[name] = service === undefined ? 'missing' : typeof service.list === 'function' ? 'listable' : 'present';
|
|
353
|
+
} catch {
|
|
354
|
+
present[name] = 'error';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { root: this.root, registries: present };
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
})();
|
|
361
|
+
|
|
362
|
+
export { JobProgressService, JobProgressService as default };
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-job-progress",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live progress for long-running background jobs in the DeepSeek Harness web GUI: a draggable floating ball with a per-session task panel showing done/total, speed and ETA.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./progress": "./lib/dsh-progress.mjs",
|
|
11
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"test",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md",
|
|
19
|
+
"README.zh.md",
|
|
20
|
+
"assets"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"dsh",
|
|
24
|
+
"dsh-plugin",
|
|
25
|
+
"deepseek-harness",
|
|
26
|
+
"progress",
|
|
27
|
+
"background-jobs",
|
|
28
|
+
"download"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://github.com/Rice00/dsh-job-progress#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/Rice00/dsh-job-progress/issues"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Rice00/dsh-job-progress.git"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": "Rice00",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.19.0"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web",
|
|
49
|
+
"inject": [
|
|
50
|
+
"@deepseek-ai/dsh-client-connection",
|
|
51
|
+
"@deepseek-ai/dsh-client-locale"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
57
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.5-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.1.5-rc.1",
|
|
59
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
60
|
+
}
|
|
61
|
+
}
|