@jhytabest/plashboard 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/openclaw.plugin.json +52 -0
- package/package.json +39 -0
- package/schema/fill-response.schema.json +14 -0
- package/schema/template.schema.json +124 -0
- package/scripts/dashboard_write.py +543 -0
- package/skills/plashboard-admin/SKILL.md +46 -0
- package/src/config.ts +73 -0
- package/src/fill-runner.ts +122 -0
- package/src/index.ts +7 -0
- package/src/json-pointer.ts +102 -0
- package/src/merge.test.ts +65 -0
- package/src/merge.ts +108 -0
- package/src/plugin.ts +272 -0
- package/src/publisher.ts +98 -0
- package/src/runtime.test.ts +163 -0
- package/src/runtime.ts +622 -0
- package/src/schema-validation.ts +35 -0
- package/src/stores.ts +127 -0
- package/src/types.ts +139 -0
- package/src/utils.ts +46 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +8 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
DashboardTemplate,
|
|
6
|
+
DisplayProfile,
|
|
7
|
+
FillResponse,
|
|
8
|
+
PlashboardConfig,
|
|
9
|
+
PlashboardState,
|
|
10
|
+
RunArtifact,
|
|
11
|
+
RuntimeStatus,
|
|
12
|
+
ToolResponse
|
|
13
|
+
} from './types.js';
|
|
14
|
+
import { Paths, RunStore, StateStore, TemplateStore } from './stores.js';
|
|
15
|
+
import { asErrorMessage, atomicWriteJson, deepClone, ensureDir, nowIso, sleep } from './utils.js';
|
|
16
|
+
import { collectCurrentValues, mergeTemplateValues, validateFieldPointers } from './merge.js';
|
|
17
|
+
import { validateFillShape, validateTemplateShape } from './schema-validation.js';
|
|
18
|
+
import { DashboardValidatorPublisher } from './publisher.js';
|
|
19
|
+
import { createFillRunner } from './fill-runner.js';
|
|
20
|
+
|
|
21
|
+
interface Logger {
|
|
22
|
+
info(message: string, ...args: unknown[]): void;
|
|
23
|
+
warn(message: string, ...args: unknown[]): void;
|
|
24
|
+
error(message: string, ...args: unknown[]): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const NOOP_LOGGER: Logger = {
|
|
28
|
+
info: () => {},
|
|
29
|
+
warn: () => {},
|
|
30
|
+
error: () => {}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function resolveLastAttemptMs(state: PlashboardState, templateId: string): number | null {
|
|
34
|
+
const runState = state.template_runs[templateId];
|
|
35
|
+
if (!runState) return null;
|
|
36
|
+
const candidates = [runState.last_attempt_at, runState.last_success_at]
|
|
37
|
+
.filter((value): value is string => typeof value === 'string')
|
|
38
|
+
.map((value) => Date.parse(value))
|
|
39
|
+
.filter((value) => Number.isFinite(value));
|
|
40
|
+
|
|
41
|
+
if (!candidates.length) return null;
|
|
42
|
+
return Math.max(...candidates);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function coerceTemplate(input: unknown): DashboardTemplate {
|
|
46
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
47
|
+
throw new Error('template must be an object');
|
|
48
|
+
}
|
|
49
|
+
return input as DashboardTemplate;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class PlashboardRuntime {
|
|
53
|
+
private readonly paths: Paths;
|
|
54
|
+
private readonly stateStore: StateStore;
|
|
55
|
+
private readonly templateStore: TemplateStore;
|
|
56
|
+
private readonly runStore: RunStore;
|
|
57
|
+
private readonly publisher: DashboardValidatorPublisher;
|
|
58
|
+
private readonly fillRunner;
|
|
59
|
+
|
|
60
|
+
private schedulerTimer: NodeJS.Timeout | null = null;
|
|
61
|
+
private tickInProgress = false;
|
|
62
|
+
private readonly runningTemplates = new Set<string>();
|
|
63
|
+
private stateCache: PlashboardState | null = null;
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly config: PlashboardConfig,
|
|
67
|
+
private readonly logger: Logger = NOOP_LOGGER
|
|
68
|
+
) {
|
|
69
|
+
this.paths = new Paths(config);
|
|
70
|
+
this.stateStore = new StateStore(this.paths);
|
|
71
|
+
this.templateStore = new TemplateStore(this.paths);
|
|
72
|
+
this.runStore = new RunStore(this.paths);
|
|
73
|
+
this.publisher = new DashboardValidatorPublisher(config);
|
|
74
|
+
this.fillRunner = createFillRunner(config);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async start(): Promise<void> {
|
|
78
|
+
await this.init();
|
|
79
|
+
if (this.schedulerTimer) return;
|
|
80
|
+
|
|
81
|
+
const intervalMs = this.config.scheduler_tick_seconds * 1000;
|
|
82
|
+
this.schedulerTimer = setInterval(() => {
|
|
83
|
+
void this.tickScheduler();
|
|
84
|
+
}, intervalMs);
|
|
85
|
+
this.logger.info('plashboard scheduler started (tick=%ds)', this.config.scheduler_tick_seconds);
|
|
86
|
+
|
|
87
|
+
void this.tickScheduler();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async stop(): Promise<void> {
|
|
91
|
+
if (this.schedulerTimer) {
|
|
92
|
+
clearInterval(this.schedulerTimer);
|
|
93
|
+
this.schedulerTimer = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async init(): Promise<ToolResponse<Record<string, unknown>>> {
|
|
98
|
+
await this.paths.ensure();
|
|
99
|
+
await this.loadState();
|
|
100
|
+
|
|
101
|
+
const templates = await this.templateStore.list();
|
|
102
|
+
const state = await this.loadState();
|
|
103
|
+
|
|
104
|
+
if (!state.display_profile) {
|
|
105
|
+
state.display_profile = this.config.display_profile;
|
|
106
|
+
await this.saveState(state);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!templates.length) {
|
|
110
|
+
const seeded = await this.seedDefaultTemplate();
|
|
111
|
+
if (seeded) {
|
|
112
|
+
const next = await this.templateStore.list();
|
|
113
|
+
if (!state.active_template_id && next.length) {
|
|
114
|
+
state.active_template_id = next[0].id;
|
|
115
|
+
await this.saveState(state);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
errors: [],
|
|
123
|
+
data: {
|
|
124
|
+
data_dir: this.paths.dataDir,
|
|
125
|
+
dashboard_output_path: this.paths.liveDashboardPath,
|
|
126
|
+
scheduler_tick_seconds: this.config.scheduler_tick_seconds
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async templateCreate(payload: unknown): Promise<ToolResponse<{ template_id: string }>> {
|
|
132
|
+
let template: DashboardTemplate;
|
|
133
|
+
try {
|
|
134
|
+
template = coerceTemplate(payload);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { ok: false, errors: [asErrorMessage(error)] };
|
|
137
|
+
}
|
|
138
|
+
const exists = await this.templateStore.get(template.id);
|
|
139
|
+
if (exists) {
|
|
140
|
+
return { ok: false, errors: [`template already exists: ${template.id}`] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const errors = await this.validateTemplate(template);
|
|
144
|
+
if (errors.length) return { ok: false, errors };
|
|
145
|
+
|
|
146
|
+
await this.templateStore.upsert(template);
|
|
147
|
+
const state = await this.loadState();
|
|
148
|
+
if (!state.active_template_id) {
|
|
149
|
+
state.active_template_id = template.id;
|
|
150
|
+
await this.saveState(state);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { ok: true, errors: [], data: { template_id: template.id } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async templateUpdate(templateId: string, payload: unknown): Promise<ToolResponse<{ template_id: string }>> {
|
|
157
|
+
const existing = await this.templateStore.get(templateId);
|
|
158
|
+
if (!existing) {
|
|
159
|
+
return { ok: false, errors: [`template not found: ${templateId}`] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let template: DashboardTemplate;
|
|
163
|
+
try {
|
|
164
|
+
template = coerceTemplate(payload);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return { ok: false, errors: [asErrorMessage(error)] };
|
|
167
|
+
}
|
|
168
|
+
if (template.id !== templateId) {
|
|
169
|
+
return { ok: false, errors: ['template id mismatch'] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const errors = await this.validateTemplate(template);
|
|
173
|
+
if (errors.length) return { ok: false, errors };
|
|
174
|
+
|
|
175
|
+
await this.templateStore.upsert(template);
|
|
176
|
+
return { ok: true, errors: [], data: { template_id: template.id } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async templateDelete(templateId: string): Promise<ToolResponse<{ deleted_template_id: string; active_template_id: string | null }>> {
|
|
180
|
+
const existing = await this.templateStore.get(templateId);
|
|
181
|
+
if (!existing) {
|
|
182
|
+
return { ok: false, errors: [`template not found: ${templateId}`] };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await this.templateStore.remove(templateId);
|
|
186
|
+
|
|
187
|
+
const state = await this.loadState();
|
|
188
|
+
delete state.template_runs[templateId];
|
|
189
|
+
|
|
190
|
+
let activeTemplateId = state.active_template_id;
|
|
191
|
+
if (activeTemplateId === templateId) {
|
|
192
|
+
const remaining = await this.templateStore.list();
|
|
193
|
+
activeTemplateId = remaining.length ? remaining[0].id : null;
|
|
194
|
+
state.active_template_id = activeTemplateId;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await this.saveState(state);
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
errors: [],
|
|
201
|
+
data: {
|
|
202
|
+
deleted_template_id: templateId,
|
|
203
|
+
active_template_id: activeTemplateId
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async templateCopy(
|
|
209
|
+
sourceTemplateId: string,
|
|
210
|
+
newTemplateId: string,
|
|
211
|
+
newName?: string,
|
|
212
|
+
activate = false
|
|
213
|
+
): Promise<ToolResponse<{ source_template_id: string; template_id: string; active_template_id: string | null }>> {
|
|
214
|
+
if (!newTemplateId || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(newTemplateId)) {
|
|
215
|
+
return { ok: false, errors: ['new_template_id is invalid'] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const source = await this.templateStore.get(sourceTemplateId);
|
|
219
|
+
if (!source) {
|
|
220
|
+
return { ok: false, errors: [`source template not found: ${sourceTemplateId}`] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const exists = await this.templateStore.get(newTemplateId);
|
|
224
|
+
if (exists) {
|
|
225
|
+
return { ok: false, errors: [`template already exists: ${newTemplateId}`] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const copy: DashboardTemplate = {
|
|
229
|
+
...deepClone(source),
|
|
230
|
+
id: newTemplateId,
|
|
231
|
+
name: newName && newName.trim() ? newName.trim() : `${source.name} Copy`
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const errors = await this.validateTemplate(copy);
|
|
235
|
+
if (errors.length) return { ok: false, errors };
|
|
236
|
+
|
|
237
|
+
await this.templateStore.upsert(copy);
|
|
238
|
+
|
|
239
|
+
const state = await this.loadState();
|
|
240
|
+
if (activate || !state.active_template_id) {
|
|
241
|
+
state.active_template_id = newTemplateId;
|
|
242
|
+
await this.saveState(state);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
errors: [],
|
|
248
|
+
data: {
|
|
249
|
+
source_template_id: sourceTemplateId,
|
|
250
|
+
template_id: newTemplateId,
|
|
251
|
+
active_template_id: state.active_template_id
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async templateValidate(payload: unknown): Promise<ToolResponse<{ valid: boolean }>> {
|
|
257
|
+
let template: DashboardTemplate;
|
|
258
|
+
try {
|
|
259
|
+
template = coerceTemplate(payload);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { ok: false, errors: [asErrorMessage(error)], data: { valid: false } };
|
|
262
|
+
}
|
|
263
|
+
const errors = await this.validateTemplate(template);
|
|
264
|
+
return {
|
|
265
|
+
ok: errors.length === 0,
|
|
266
|
+
errors,
|
|
267
|
+
data: { valid: errors.length === 0 }
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async templateList(): Promise<ToolResponse<{ templates: Array<Record<string, unknown>> }>> {
|
|
272
|
+
const templates = await this.templateStore.list();
|
|
273
|
+
const state = await this.loadState();
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
|
|
276
|
+
const items = templates.map((template) => {
|
|
277
|
+
const runState = state.template_runs[template.id] || {};
|
|
278
|
+
const lastAttemptMs = resolveLastAttemptMs(state, template.id);
|
|
279
|
+
const intervalMs = template.schedule.every_minutes * 60_000;
|
|
280
|
+
const nextDueAt = lastAttemptMs ? new Date(lastAttemptMs + intervalMs).toISOString() : null;
|
|
281
|
+
const dueNow = !lastAttemptMs || now >= lastAttemptMs + intervalMs;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
id: template.id,
|
|
285
|
+
name: template.name,
|
|
286
|
+
enabled: template.enabled,
|
|
287
|
+
active: state.active_template_id === template.id,
|
|
288
|
+
schedule: template.schedule,
|
|
289
|
+
due_now: dueNow,
|
|
290
|
+
next_due_at: nextDueAt,
|
|
291
|
+
last_attempt_at: runState.last_attempt_at || null,
|
|
292
|
+
last_success_at: runState.last_success_at || null,
|
|
293
|
+
last_status: runState.last_status || null,
|
|
294
|
+
last_error: runState.last_error || null
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return { ok: true, errors: [], data: { templates: items } };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async templateActivate(templateId: string): Promise<ToolResponse<{ active_template_id: string }>> {
|
|
302
|
+
const template = await this.templateStore.get(templateId);
|
|
303
|
+
if (!template) {
|
|
304
|
+
return { ok: false, errors: [`template not found: ${templateId}`] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const state = await this.loadState();
|
|
308
|
+
state.active_template_id = templateId;
|
|
309
|
+
await this.saveState(state);
|
|
310
|
+
return { ok: true, errors: [], data: { active_template_id: templateId } };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async runNow(templateId: string): Promise<ToolResponse<Record<string, unknown>>> {
|
|
314
|
+
const template = await this.templateStore.get(templateId);
|
|
315
|
+
if (!template) {
|
|
316
|
+
return { ok: false, errors: [`template not found: ${templateId}`] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const run = await this.executeAndPersist(template, 'manual');
|
|
320
|
+
return {
|
|
321
|
+
ok: run.status === 'success',
|
|
322
|
+
errors: run.errors,
|
|
323
|
+
data: {
|
|
324
|
+
template_id: run.template_id,
|
|
325
|
+
status: run.status,
|
|
326
|
+
published: run.published,
|
|
327
|
+
attempt_count: run.attempt_count,
|
|
328
|
+
started_at: run.started_at,
|
|
329
|
+
finished_at: run.finished_at
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async status(): Promise<ToolResponse<RuntimeStatus>> {
|
|
335
|
+
const state = await this.loadState();
|
|
336
|
+
const templates = await this.templateStore.list();
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
errors: [],
|
|
341
|
+
data: {
|
|
342
|
+
active_template_id: state.active_template_id,
|
|
343
|
+
template_count: templates.length,
|
|
344
|
+
enabled_template_count: templates.filter((entry) => entry.enabled).length,
|
|
345
|
+
running_template_ids: [...this.runningTemplates],
|
|
346
|
+
state
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async displayProfileSet(profile: Partial<DisplayProfile>): Promise<ToolResponse<{ display_profile: DisplayProfile }>> {
|
|
352
|
+
const state = await this.loadState();
|
|
353
|
+
const current = this.effectiveDisplayProfile(state);
|
|
354
|
+
|
|
355
|
+
const next: DisplayProfile = {
|
|
356
|
+
width_px: Math.max(320, Math.floor(profile.width_px ?? current.width_px)),
|
|
357
|
+
height_px: Math.max(240, Math.floor(profile.height_px ?? current.height_px)),
|
|
358
|
+
safe_top_px: Math.max(0, Math.floor(profile.safe_top_px ?? current.safe_top_px)),
|
|
359
|
+
safe_bottom_px: Math.max(0, Math.floor(profile.safe_bottom_px ?? current.safe_bottom_px)),
|
|
360
|
+
safe_side_px: Math.max(0, Math.floor(profile.safe_side_px ?? current.safe_side_px)),
|
|
361
|
+
layout_safety_margin_px: Math.max(0, Math.floor(profile.layout_safety_margin_px ?? current.layout_safety_margin_px))
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
state.display_profile = next;
|
|
365
|
+
await this.saveState(state);
|
|
366
|
+
return { ok: true, errors: [], data: { display_profile: next } };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async tickScheduler(): Promise<void> {
|
|
370
|
+
if (this.tickInProgress) return;
|
|
371
|
+
this.tickInProgress = true;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const templates = await this.templateStore.list();
|
|
375
|
+
const state = await this.loadState();
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
|
|
378
|
+
for (const template of templates) {
|
|
379
|
+
if (!template.enabled) continue;
|
|
380
|
+
if (this.runningTemplates.has(template.id)) continue;
|
|
381
|
+
if (this.runningTemplates.size >= this.config.max_parallel_runs) break;
|
|
382
|
+
|
|
383
|
+
const lastAttempt = resolveLastAttemptMs(state, template.id);
|
|
384
|
+
const intervalMs = template.schedule.every_minutes * 60_000;
|
|
385
|
+
const due = !lastAttempt || now >= lastAttempt + intervalMs;
|
|
386
|
+
|
|
387
|
+
if (!due) continue;
|
|
388
|
+
|
|
389
|
+
void this.executeAndPersist(template, 'schedule');
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
this.tickInProgress = false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async executeAndPersist(
|
|
397
|
+
template: DashboardTemplate,
|
|
398
|
+
trigger: 'schedule' | 'manual'
|
|
399
|
+
): Promise<RunArtifact> {
|
|
400
|
+
const artifact = await this.executeTemplateRun(template, trigger);
|
|
401
|
+
await this.persistRunArtifact(artifact);
|
|
402
|
+
return artifact;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private async executeTemplateRun(
|
|
406
|
+
template: DashboardTemplate,
|
|
407
|
+
trigger: 'schedule' | 'manual'
|
|
408
|
+
): Promise<RunArtifact> {
|
|
409
|
+
if (this.runningTemplates.has(template.id)) {
|
|
410
|
+
const now = nowIso();
|
|
411
|
+
return {
|
|
412
|
+
template_id: template.id,
|
|
413
|
+
trigger,
|
|
414
|
+
status: 'failed',
|
|
415
|
+
started_at: now,
|
|
416
|
+
finished_at: now,
|
|
417
|
+
duration_ms: 0,
|
|
418
|
+
attempt_count: 0,
|
|
419
|
+
published: false,
|
|
420
|
+
errors: ['template run already in progress']
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.runningTemplates.add(template.id);
|
|
425
|
+
const startedAt = Date.now();
|
|
426
|
+
const startedAtIso = new Date(startedAt).toISOString();
|
|
427
|
+
const errors: string[] = [];
|
|
428
|
+
let attemptCount = 0;
|
|
429
|
+
let published = false;
|
|
430
|
+
let response: FillResponse | undefined;
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const state = await this.loadState();
|
|
434
|
+
state.template_runs[template.id] = {
|
|
435
|
+
...(state.template_runs[template.id] || {}),
|
|
436
|
+
last_attempt_at: startedAtIso,
|
|
437
|
+
last_status: 'failed',
|
|
438
|
+
last_error: undefined
|
|
439
|
+
};
|
|
440
|
+
await this.saveState(state);
|
|
441
|
+
|
|
442
|
+
const retryCount = Math.max(0, template.run?.retry_count ?? this.config.default_retry_count);
|
|
443
|
+
const repairAttempts = Math.max(0, template.run?.repair_attempts ?? 1);
|
|
444
|
+
const currentValues = collectCurrentValues(template);
|
|
445
|
+
|
|
446
|
+
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
|
|
447
|
+
attemptCount = attempt + 1;
|
|
448
|
+
try {
|
|
449
|
+
let fillErrorHint: string | undefined;
|
|
450
|
+
for (let repair = 0; repair <= repairAttempts; repair += 1) {
|
|
451
|
+
const fillResponse = await this.fillRunner.run({
|
|
452
|
+
template,
|
|
453
|
+
currentValues,
|
|
454
|
+
attempt: attempt + 1,
|
|
455
|
+
errorHint: fillErrorHint
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const shapeErrors = validateFillShape(fillResponse);
|
|
459
|
+
if (shapeErrors.length) {
|
|
460
|
+
throw new Error(shapeErrors.join('; '));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
response = fillResponse;
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
const merged = mergeTemplateValues(template, fillResponse.values);
|
|
467
|
+
const profile = this.effectiveDisplayProfile(await this.loadState());
|
|
468
|
+
await this.publisher.validateOnly(merged, profile);
|
|
469
|
+
|
|
470
|
+
await this.writeRenderedSnapshot(template.id, merged);
|
|
471
|
+
|
|
472
|
+
const latestState = await this.loadState();
|
|
473
|
+
if (latestState.active_template_id === template.id) {
|
|
474
|
+
await this.publisher.publish(merged, profile);
|
|
475
|
+
published = true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
latestState.template_runs[template.id] = {
|
|
479
|
+
...(latestState.template_runs[template.id] || {}),
|
|
480
|
+
last_attempt_at: startedAtIso,
|
|
481
|
+
last_success_at: nowIso(),
|
|
482
|
+
last_status: 'success',
|
|
483
|
+
last_error: undefined
|
|
484
|
+
};
|
|
485
|
+
await this.saveState(latestState);
|
|
486
|
+
return {
|
|
487
|
+
template_id: template.id,
|
|
488
|
+
trigger,
|
|
489
|
+
status: 'success',
|
|
490
|
+
started_at: startedAtIso,
|
|
491
|
+
finished_at: nowIso(),
|
|
492
|
+
duration_ms: Date.now() - startedAt,
|
|
493
|
+
attempt_count: attemptCount,
|
|
494
|
+
published,
|
|
495
|
+
errors,
|
|
496
|
+
response
|
|
497
|
+
};
|
|
498
|
+
} catch (error) {
|
|
499
|
+
const message = asErrorMessage(error);
|
|
500
|
+
if (repair < repairAttempts) {
|
|
501
|
+
fillErrorHint = message;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
const message = asErrorMessage(error);
|
|
509
|
+
errors.push(message);
|
|
510
|
+
if (attempt < retryCount) {
|
|
511
|
+
await sleep(this.config.retry_backoff_seconds * 1000);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const failedState = await this.loadState();
|
|
519
|
+
failedState.template_runs[template.id] = {
|
|
520
|
+
...(failedState.template_runs[template.id] || {}),
|
|
521
|
+
last_attempt_at: startedAtIso,
|
|
522
|
+
last_status: 'failed',
|
|
523
|
+
last_error: errors[errors.length - 1] || 'run failed'
|
|
524
|
+
};
|
|
525
|
+
await this.saveState(failedState);
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
template_id: template.id,
|
|
529
|
+
trigger,
|
|
530
|
+
status: 'failed',
|
|
531
|
+
started_at: startedAtIso,
|
|
532
|
+
finished_at: nowIso(),
|
|
533
|
+
duration_ms: Date.now() - startedAt,
|
|
534
|
+
attempt_count: attemptCount,
|
|
535
|
+
published,
|
|
536
|
+
errors,
|
|
537
|
+
response
|
|
538
|
+
};
|
|
539
|
+
} finally {
|
|
540
|
+
this.runningTemplates.delete(template.id);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async persistRunArtifact(artifact: RunArtifact): Promise<void> {
|
|
545
|
+
await this.runStore.write(artifact.template_id, artifact);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private async validateTemplate(template: DashboardTemplate): Promise<string[]> {
|
|
549
|
+
const errors = validateTemplateShape(template);
|
|
550
|
+
if (errors.length) return errors;
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
validateFieldPointers(template);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
return [asErrorMessage(error)];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const profile = this.effectiveDisplayProfile(await this.loadState());
|
|
560
|
+
await this.publisher.validateOnly(template.base_dashboard, profile);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return [asErrorMessage(error)];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private effectiveDisplayProfile(state: PlashboardState): DisplayProfile {
|
|
569
|
+
return state.display_profile || this.config.display_profile;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async seedDefaultTemplate(): Promise<boolean> {
|
|
573
|
+
try {
|
|
574
|
+
await access(this.paths.liveDashboardPath, fsConstants.R_OK);
|
|
575
|
+
} catch {
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const text = await readFile(this.paths.liveDashboardPath, 'utf8');
|
|
580
|
+
const dashboard = JSON.parse(text) as Record<string, unknown>;
|
|
581
|
+
|
|
582
|
+
const template: DashboardTemplate = {
|
|
583
|
+
id: 'default',
|
|
584
|
+
name: 'Default Dashboard Template',
|
|
585
|
+
enabled: true,
|
|
586
|
+
schedule: {
|
|
587
|
+
mode: 'interval',
|
|
588
|
+
every_minutes: 10,
|
|
589
|
+
timezone: this.config.timezone
|
|
590
|
+
},
|
|
591
|
+
base_dashboard: dashboard,
|
|
592
|
+
fields: [],
|
|
593
|
+
context: {
|
|
594
|
+
dashboard_prompt: 'Fill dashboard values from current system state.'
|
|
595
|
+
},
|
|
596
|
+
run: {
|
|
597
|
+
retry_count: this.config.default_retry_count,
|
|
598
|
+
repair_attempts: 1
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
await this.templateStore.upsert(template);
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async writeRenderedSnapshot(templateId: string, payload: Record<string, unknown>): Promise<void> {
|
|
607
|
+
const dir = join(this.paths.renderedDir, templateId);
|
|
608
|
+
await ensureDir(dir);
|
|
609
|
+
await atomicWriteJson(join(dir, 'latest.json'), payload);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async loadState(): Promise<PlashboardState> {
|
|
613
|
+
if (this.stateCache) return this.stateCache;
|
|
614
|
+
this.stateCache = await this.stateStore.load();
|
|
615
|
+
return this.stateCache;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private async saveState(state: PlashboardState): Promise<void> {
|
|
619
|
+
this.stateCache = state;
|
|
620
|
+
await this.stateStore.save(state);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Ajv2020Module from 'ajv/dist/2020.js';
|
|
2
|
+
import templateSchema from '../schema/template.schema.json' with { type: 'json' };
|
|
3
|
+
import fillResponseSchema from '../schema/fill-response.schema.json' with { type: 'json' };
|
|
4
|
+
import type { DashboardTemplate, FillResponse } from './types.js';
|
|
5
|
+
|
|
6
|
+
const Ajv2020Ctor = (Ajv2020Module as unknown as { default?: new (...args: any[]) => any }).default
|
|
7
|
+
?? (Ajv2020Module as unknown as new (...args: any[]) => any);
|
|
8
|
+
const ajv = new Ajv2020Ctor({ allErrors: true, strict: false });
|
|
9
|
+
|
|
10
|
+
type AjvValidator<T> = ((value: unknown) => value is T) & { errors?: unknown };
|
|
11
|
+
|
|
12
|
+
const validateTemplateSchema = ajv.compile(templateSchema) as AjvValidator<DashboardTemplate>;
|
|
13
|
+
const validateFillSchema = ajv.compile(fillResponseSchema) as AjvValidator<FillResponse>;
|
|
14
|
+
|
|
15
|
+
function errorsToStrings(errors: unknown): string[] {
|
|
16
|
+
if (!Array.isArray(errors)) return ['schema validation failed'];
|
|
17
|
+
return errors.map((entry) => {
|
|
18
|
+
const item = entry as { instancePath?: string; message?: string };
|
|
19
|
+
return `${item.instancePath || '/'} ${item.message || 'invalid'}`.trim();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateTemplateShape(template: unknown): string[] {
|
|
24
|
+
if (validateTemplateSchema(template)) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
return errorsToStrings(validateTemplateSchema.errors);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateFillShape(response: unknown): string[] {
|
|
31
|
+
if (validateFillSchema(response)) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return errorsToStrings(validateFillSchema.errors);
|
|
35
|
+
}
|