clementine-agent 1.18.70 → 1.18.72
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/dist/cli/dashboard.js
CHANGED
|
@@ -21,7 +21,7 @@ import { discoverMcpServers, getClaudeIntegrations } from '../agent/mcp-bridge.j
|
|
|
21
21
|
import { buildBuilderEnrichedMessage, builderSessionKey } from '../dashboard/builder/prompt.js';
|
|
22
22
|
import { AGENTS_DIR, SESSIONS_FILE, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
|
|
23
23
|
import { parseTasks } from '../tools/shared.js';
|
|
24
|
-
import { todayISO } from '../gateway/cron-scheduler.js';
|
|
24
|
+
import { todayISO, CronRunLog } from '../gateway/cron-scheduler.js';
|
|
25
25
|
import { goalsRouter } from './routes/goals.js';
|
|
26
26
|
import { delegationsRouter } from './routes/delegations.js';
|
|
27
27
|
import { workflowsRouter } from './routes/workflows.js';
|
|
@@ -2516,6 +2516,10 @@ export async function cmdDashboard(opts) {
|
|
|
2516
2516
|
hasKey = true;
|
|
2517
2517
|
authMode = 'oauth-token';
|
|
2518
2518
|
}
|
|
2519
|
+
else if (/^CLAUDE_CODE_OAUTH_TOKEN=.+/m.test(content)) {
|
|
2520
|
+
hasKey = true;
|
|
2521
|
+
authMode = 'oauth-token';
|
|
2522
|
+
}
|
|
2519
2523
|
else if (/^ANTHROPIC_API_KEY=.+/m.test(content)) {
|
|
2520
2524
|
hasKey = true;
|
|
2521
2525
|
authMode = 'api-key';
|
|
@@ -2525,14 +2529,28 @@ export async function cmdDashboard(opts) {
|
|
|
2525
2529
|
hasKey = true;
|
|
2526
2530
|
authMode = 'oauth-token';
|
|
2527
2531
|
}
|
|
2532
|
+
if (!hasKey && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
2533
|
+
hasKey = true;
|
|
2534
|
+
authMode = 'oauth-token';
|
|
2535
|
+
}
|
|
2528
2536
|
if (!hasKey && process.env.ANTHROPIC_API_KEY) {
|
|
2529
2537
|
hasKey = true;
|
|
2530
2538
|
authMode = 'api-key';
|
|
2531
2539
|
}
|
|
2532
|
-
//
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2540
|
+
// Honest keychain check: only report authenticated if Claude Code actually has a session.
|
|
2541
|
+
// Previously this assumed yes blindly, which hid the welcome panel from new desktop users.
|
|
2542
|
+
if (!hasKey && process.platform === 'darwin') {
|
|
2543
|
+
try {
|
|
2544
|
+
const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', {
|
|
2545
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 1500,
|
|
2546
|
+
}).trim();
|
|
2547
|
+
const parsed = JSON.parse(raw);
|
|
2548
|
+
if (parsed?.claudeAiOauth?.accessToken) {
|
|
2549
|
+
hasKey = true;
|
|
2550
|
+
authMode = 'keychain';
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
catch { /* no keychain entry — leave hasKey false */ }
|
|
2536
2554
|
}
|
|
2537
2555
|
res.json({
|
|
2538
2556
|
authenticated: hasKey,
|
|
@@ -2544,6 +2562,65 @@ export async function cmdDashboard(opts) {
|
|
|
2544
2562
|
res.json({ authenticated: false, email: null, error: String(err).slice(0, 200) });
|
|
2545
2563
|
}
|
|
2546
2564
|
});
|
|
2565
|
+
// Restart the dashboard daemon so newly-saved auth credentials take effect.
|
|
2566
|
+
// SAFE_ENV in src/agent/assistant.ts is built once at module load, so changes
|
|
2567
|
+
// to ~/.clementine/.env after that point require a process restart to flow
|
|
2568
|
+
// through to spawned SDK subprocesses. In desktop mode (CLI spawned by
|
|
2569
|
+
// Electron) the parent supervisor respawns this process automatically; in
|
|
2570
|
+
// standalone CLI mode the caller must run `clementine restart` themselves.
|
|
2571
|
+
app.post('/api/restart-daemon', (_req, res) => {
|
|
2572
|
+
const inDesktop = process.env.__CLEM_DASHBOARD_CHILD === '1';
|
|
2573
|
+
res.json({ ok: true, willExit: inDesktop, mode: inDesktop ? 'desktop-child' : 'standalone' });
|
|
2574
|
+
if (inDesktop) {
|
|
2575
|
+
// Give the response a moment to flush before exiting.
|
|
2576
|
+
setTimeout(() => { process.exit(0); }, 250);
|
|
2577
|
+
}
|
|
2578
|
+
});
|
|
2579
|
+
// Save an API key (or OAuth token) pasted by the user. Validates against the
|
|
2580
|
+
// Anthropic API before writing to ~/.clementine/.env so we never persist a
|
|
2581
|
+
// bad key. Mirrors the `clementine login --api-key` CLI flow.
|
|
2582
|
+
app.post('/api/auth/anthropic/api-key', async (req, res) => {
|
|
2583
|
+
try {
|
|
2584
|
+
const raw = String((req.body?.apiKey ?? '')).trim();
|
|
2585
|
+
if (!raw) {
|
|
2586
|
+
res.status(400).json({ error: 'Missing apiKey in request body' });
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
// Detect token type by prefix: Anthropic OAuth tokens start with sk-ant-oat
|
|
2590
|
+
// or sk-ant-rt; everything else is treated as a standard API key.
|
|
2591
|
+
const isOAuth = /^sk-ant-(oat|rt)/.test(raw);
|
|
2592
|
+
const credKey = isOAuth ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'ANTHROPIC_API_KEY';
|
|
2593
|
+
// Validate by calling /v1/models — cheap, doesn't burn tokens.
|
|
2594
|
+
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
|
2595
|
+
const client = new Anthropic(isOAuth ? { authToken: raw } : { apiKey: raw });
|
|
2596
|
+
try {
|
|
2597
|
+
await client.models.list({ limit: 1 });
|
|
2598
|
+
}
|
|
2599
|
+
catch (err) {
|
|
2600
|
+
const status = err?.status || err?.response?.status;
|
|
2601
|
+
const detail = status === 401
|
|
2602
|
+
? 'Key was rejected by Anthropic (401). Check that you copied it fully.'
|
|
2603
|
+
: `Validation failed: ${(err?.message || String(err)).slice(0, 180)}`;
|
|
2604
|
+
res.status(400).json({ ok: false, error: detail });
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
// Persist to ~/.clementine/.env (replace any existing line for this key).
|
|
2608
|
+
const envPath = path.join(BASE_DIR, '.env');
|
|
2609
|
+
const dir = path.dirname(envPath);
|
|
2610
|
+
try {
|
|
2611
|
+
mkdirSync(dir, { recursive: true });
|
|
2612
|
+
}
|
|
2613
|
+
catch { /* exists */ }
|
|
2614
|
+
let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
|
|
2615
|
+
content = content.replace(new RegExp(`^${credKey}=.*$\\n?`, 'm'), '').trimEnd();
|
|
2616
|
+
content += `\n${credKey}=${raw}\n`;
|
|
2617
|
+
writeFileSync(envPath, content, { mode: 0o600 });
|
|
2618
|
+
res.json({ ok: true, credKey, apiKeySource: isOAuth ? 'oauth-token' : 'api-key' });
|
|
2619
|
+
}
|
|
2620
|
+
catch (err) {
|
|
2621
|
+
res.status(500).json({ ok: false, error: String(err).slice(0, 300) });
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2547
2624
|
// Start OAuth login — spawns SDK query and calls claudeAuthenticate
|
|
2548
2625
|
let oauthQuery = null;
|
|
2549
2626
|
app.post('/api/auth/anthropic/login', async (_req, res) => {
|
|
@@ -5553,6 +5630,21 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
5553
5630
|
res.status(500).json({ error: String(err) });
|
|
5554
5631
|
}
|
|
5555
5632
|
});
|
|
5633
|
+
// ── Recent runs across ALL cron jobs ───────────────────────────
|
|
5634
|
+
// Powers the "Recent History" zone on the Tasks page. Returns the most
|
|
5635
|
+
// recent N CronRunEntry rows merged from every per-job .jsonl, sorted
|
|
5636
|
+
// newest-first. Uses CronRunLog.readAllRecent which tails each file.
|
|
5637
|
+
app.get('/api/cron/runs', (req, res) => {
|
|
5638
|
+
try {
|
|
5639
|
+
const limit = Math.max(1, Math.min(200, parseInt(String(req.query.limit ?? '50'), 10) || 50));
|
|
5640
|
+
const log = new CronRunLog();
|
|
5641
|
+
const runs = log.readAllRecent(limit, 30);
|
|
5642
|
+
res.json({ ok: true, runs });
|
|
5643
|
+
}
|
|
5644
|
+
catch (err) {
|
|
5645
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
5646
|
+
}
|
|
5647
|
+
});
|
|
5556
5648
|
// ── Cron trace viewer ──────────────────────────────────────────
|
|
5557
5649
|
app.get('/api/cron/traces/:job', (req, res) => {
|
|
5558
5650
|
try {
|
|
@@ -14842,6 +14934,52 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
14842
14934
|
border-top: 1px solid var(--border);
|
|
14843
14935
|
padding-top: 12px;
|
|
14844
14936
|
}
|
|
14937
|
+
/* ── Compact (de-cluttered) task cards ──
|
|
14938
|
+
Applied to scheduled-task cards on the Tasks page. Keeps name, schedule,
|
|
14939
|
+
prompt, last-run status, and a single Edit button visible by default.
|
|
14940
|
+
Reveals badges, capability strip, and secondary actions on hover/focus
|
|
14941
|
+
so non-technical users see only what they need at a glance. */
|
|
14942
|
+
.task-card.compact .task-card-badges,
|
|
14943
|
+
.task-card.compact .task-cap-strip,
|
|
14944
|
+
.task-card.compact .task-card-actions .secondary {
|
|
14945
|
+
max-height: 0;
|
|
14946
|
+
opacity: 0;
|
|
14947
|
+
overflow: hidden;
|
|
14948
|
+
margin: 0;
|
|
14949
|
+
padding-top: 0;
|
|
14950
|
+
padding-bottom: 0;
|
|
14951
|
+
border-width: 0;
|
|
14952
|
+
pointer-events: none;
|
|
14953
|
+
transition: opacity 0.18s ease, max-height 0.22s ease, padding 0.18s ease, margin 0.18s ease;
|
|
14954
|
+
}
|
|
14955
|
+
.task-card.compact:hover .task-card-badges,
|
|
14956
|
+
.task-card.compact:focus-within .task-card-badges {
|
|
14957
|
+
max-height: 80px;
|
|
14958
|
+
opacity: 1;
|
|
14959
|
+
margin-bottom: 14px;
|
|
14960
|
+
pointer-events: auto;
|
|
14961
|
+
}
|
|
14962
|
+
.task-card.compact:hover .task-cap-strip,
|
|
14963
|
+
.task-card.compact:focus-within .task-cap-strip {
|
|
14964
|
+
max-height: 120px;
|
|
14965
|
+
opacity: 1;
|
|
14966
|
+
padding: 8px 0 10px;
|
|
14967
|
+
margin-bottom: 12px;
|
|
14968
|
+
border-top-width: 1px;
|
|
14969
|
+
pointer-events: auto;
|
|
14970
|
+
}
|
|
14971
|
+
.task-card.compact:hover .task-card-actions .secondary,
|
|
14972
|
+
.task-card.compact:focus-within .task-card-actions .secondary {
|
|
14973
|
+
max-height: 40px;
|
|
14974
|
+
opacity: 1;
|
|
14975
|
+
pointer-events: auto;
|
|
14976
|
+
}
|
|
14977
|
+
.task-card.compact .task-card-actions {
|
|
14978
|
+
flex-wrap: wrap;
|
|
14979
|
+
}
|
|
14980
|
+
.task-card.compact .task-card-actions .primary {
|
|
14981
|
+
flex: 1;
|
|
14982
|
+
}
|
|
14845
14983
|
/* ── Trick capability strip (skills + MCP + tools at a glance) ─── */
|
|
14846
14984
|
.task-cap-strip {
|
|
14847
14985
|
border-top: 1px solid var(--border-light);
|
|
@@ -15829,8 +15967,8 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
15829
15967
|
<div class="nav-item active" data-page="home" data-icon="home" title="Chat, today, activity">
|
|
15830
15968
|
<span class="nav-icon"></span> Home
|
|
15831
15969
|
</div>
|
|
15832
|
-
<div class="nav-item" data-page="build" data-icon="workflow" title="
|
|
15833
|
-
<span class="nav-icon"></span>
|
|
15970
|
+
<div class="nav-item" data-page="build" data-icon="workflow" title="Scheduled tasks Clementine runs for you">
|
|
15971
|
+
<span class="nav-icon"></span> Tasks
|
|
15834
15972
|
<span class="nav-badge" id="nav-cron-count" style="display:none">0</span>
|
|
15835
15973
|
</div>
|
|
15836
15974
|
<div class="nav-item" data-page="heartbeat" data-icon="bell" title="Heartbeat controls and queued work">
|
|
@@ -15905,7 +16043,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
15905
16043
|
<div class="gs-card-icon">🔒</div>
|
|
15906
16044
|
<div class="gs-card-title">Login with Anthropic</div>
|
|
15907
16045
|
<div class="gs-card-desc" id="gs-auth-desc">Connect your Anthropic account to power your agents.</div>
|
|
15908
|
-
<
|
|
16046
|
+
<div style="display:flex;flex-direction:column;gap:6px;align-items:stretch">
|
|
16047
|
+
<button class="btn btn-sm btn-primary" id="gs-auth-btn" onclick="startAnthropicOAuth()">Login with Claude</button>
|
|
16048
|
+
<button class="btn btn-sm" onclick="openApiKeyModal()" style="font-size:11px">Use API key instead</button>
|
|
16049
|
+
</div>
|
|
15909
16050
|
</div>
|
|
15910
16051
|
<div class="gs-card" id="gs-step-agent">
|
|
15911
16052
|
<div class="gs-step-num">2</div>
|
|
@@ -16031,15 +16172,17 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16031
16172
|
</div>
|
|
16032
16173
|
</div>
|
|
16033
16174
|
|
|
16034
|
-
<!-- ═══
|
|
16175
|
+
<!-- ═══ Tasks Page — single unified surface ═══ -->
|
|
16035
16176
|
<div class="page" id="page-build">
|
|
16036
|
-
<!--
|
|
16037
|
-
|
|
16177
|
+
<!-- Sub-tabs hidden by default. Cron is the single surface; multi-step
|
|
16178
|
+
workflows (formerly "Tricks") are still accessible via deep-link
|
|
16179
|
+
?tab=workflows or by toggling .show-workflows-tabs on the page. -->
|
|
16180
|
+
<div id="build-tabs" style="display:none;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0">
|
|
16038
16181
|
<button class="build-tab-btn active" data-build-tab="crons" onclick="switchBuildTab('crons')" style="padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-primary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
|
|
16039
|
-
<span style="margin-right:6px">📅</span>
|
|
16182
|
+
<span style="margin-right:6px">📅</span>Tasks <span id="build-tab-cron-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
|
|
16040
16183
|
</button>
|
|
16041
16184
|
<button class="build-tab-btn" data-build-tab="workflows" onclick="switchBuildTab('workflows')" style="padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-secondary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
|
|
16042
|
-
<span style="margin-right:6px">🔧</span>
|
|
16185
|
+
<span style="margin-right:6px">🔧</span>Workflows <span id="build-tab-workflows-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
|
|
16043
16186
|
</button>
|
|
16044
16187
|
</div>
|
|
16045
16188
|
<style>
|
|
@@ -16054,7 +16197,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16054
16197
|
<div id="build-tab-workflows" style="display:none;flex:1;min-height:0;display:flex;flex-direction:column">
|
|
16055
16198
|
<!-- Toolbar -->
|
|
16056
16199
|
<div id="routines-toolbar" style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border);background:var(--bg-secondary);flex-wrap:wrap">
|
|
16057
|
-
<h2 style="margin:0;font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px"><span data-icon="workflow" class="icon-slot"></span>
|
|
16200
|
+
<h2 style="margin:0;font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px"><span data-icon="workflow" class="icon-slot"></span> Workflows</h2>
|
|
16058
16201
|
<span id="routines-count" style="font-size:11px;color:var(--text-muted)"></span>
|
|
16059
16202
|
<span id="routines-editor-breadcrumb" style="display:none;font-size:12px;color:var(--text-muted)"> › <span id="routines-editor-name" style="color:var(--text-primary);font-weight:500"></span></span>
|
|
16060
16203
|
<span style="flex:1"></span>
|
|
@@ -16065,16 +16208,16 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16065
16208
|
</select>
|
|
16066
16209
|
<button id="routines-back-btn" style="display:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)" onclick="window.RoutinesUI && RoutinesUI.closeEditor()">← Back to list</button>
|
|
16067
16210
|
<button id="routines-assist-btn" class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openCreate()" title="Skip the chat and fill out a form yourself" style="padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Build manually</button>
|
|
16068
|
-
<button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New
|
|
16211
|
+
<button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New Workflow</button>
|
|
16069
16212
|
</div>
|
|
16070
16213
|
<!-- List view (default) -->
|
|
16071
16214
|
<div id="routines-list-pane" style="flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
|
|
16072
16215
|
<div id="routines-list-empty" class="empty-state" style="display:none;padding:64px 18px;text-align:center;color:var(--text-muted)">
|
|
16073
16216
|
<div style="font-size:38px;opacity:0.4;margin-bottom:14px">⚙</div>
|
|
16074
|
-
<div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No
|
|
16075
|
-
<div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A
|
|
16217
|
+
<div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No workflows yet</div>
|
|
16218
|
+
<div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A workflow is a sequence of steps Clementine performs on cue — call MCP tools, run local CLIs, prompt the agent, branch on results — that runs on a schedule or on demand. Example: “at 8am check email; if anything urgent, summarize and Slack me.”</div>
|
|
16076
16219
|
<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
|
|
16077
|
-
<button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px">+ New
|
|
16220
|
+
<button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px">+ New Workflow</button>
|
|
16078
16221
|
<button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Build manually</button>
|
|
16079
16222
|
</div>
|
|
16080
16223
|
</div>
|
|
@@ -16102,7 +16245,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16102
16245
|
<!-- Create modal -->
|
|
16103
16246
|
<div id="routines-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
|
|
16104
16247
|
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:22px;width:480px;max-width:92vw;display:flex;flex-direction:column;gap:12px">
|
|
16105
|
-
<h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New
|
|
16248
|
+
<h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New workflow</h3>
|
|
16106
16249
|
<label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Name</label>
|
|
16107
16250
|
<input type="text" id="routines-create-name" placeholder="e.g. 8am Email Triage" style="padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px">
|
|
16108
16251
|
<label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Description (optional)</label>
|
|
@@ -16137,7 +16280,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16137
16280
|
<div id="routines-chat-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
|
|
16138
16281
|
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);width:1100px;max-width:96vw;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden">
|
|
16139
16282
|
<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
|
|
16140
|
-
<h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Build a
|
|
16283
|
+
<h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Build a workflow with Clementine</h3>
|
|
16141
16284
|
<span id="routines-chat-status" style="color:var(--text-muted);flex:1;font-size:11px;min-height:14px"></span>
|
|
16142
16285
|
<button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat()" style="padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">×</button>
|
|
16143
16286
|
</div>
|
|
@@ -16246,7 +16389,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16246
16389
|
if (filter === '__global__') return r.scope === 'global';
|
|
16247
16390
|
return r.scope === 'agent' && r.agentSlug === filter;
|
|
16248
16391
|
});
|
|
16249
|
-
if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? '
|
|
16392
|
+
if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' workflow' : ' workflows');
|
|
16250
16393
|
if (rows.length === 0) {
|
|
16251
16394
|
empty.style.display = 'block';
|
|
16252
16395
|
wrap.style.display = 'none';
|
|
@@ -16287,7 +16430,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16287
16430
|
if (r.status === 409) {
|
|
16288
16431
|
return r.json().then(function(j){
|
|
16289
16432
|
var lines = (j.sideEffects || []).map(function(s){ return ' • ' + s.kind + ': ' + s.label; }).join('\\n');
|
|
16290
|
-
if (confirm('This
|
|
16433
|
+
if (confirm('This workflow has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
|
|
16291
16434
|
});
|
|
16292
16435
|
}
|
|
16293
16436
|
return r.json().then(function(j){
|
|
@@ -16301,7 +16444,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16301
16444
|
apiFetch('/api/routines/' + encodeURIComponent(id))
|
|
16302
16445
|
.then(function(r){ return r.json(); })
|
|
16303
16446
|
.then(function(data){
|
|
16304
|
-
if (!data || !data.routine) { alert('Failed to load
|
|
16447
|
+
if (!data || !data.routine) { alert('Failed to load workflow'); return; }
|
|
16305
16448
|
R.state.editing = { id: data.id, routine: data.routine, dirty: false, validation: data.validation };
|
|
16306
16449
|
R.showEditor();
|
|
16307
16450
|
}).catch(function(err){ alert('Open failed: ' + err); });
|
|
@@ -16556,7 +16699,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16556
16699
|
},
|
|
16557
16700
|
removeStep: function(idx) {
|
|
16558
16701
|
if (!R.state.editing) return;
|
|
16559
|
-
if (R.state.editing.routine.steps.length <= 1) { alert('A
|
|
16702
|
+
if (R.state.editing.routine.steps.length <= 1) { alert('A workflow must have at least one step.'); return; }
|
|
16560
16703
|
if (!confirm('Remove this step?')) return;
|
|
16561
16704
|
var removed = R.state.editing.routine.steps.splice(idx, 1)[0];
|
|
16562
16705
|
// Strip lingering dependsOn references.
|
|
@@ -16773,7 +16916,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16773
16916
|
R.renderChatMessages();
|
|
16774
16917
|
R.renderChatSpec();
|
|
16775
16918
|
// Seed with a greeting from the assistant so the panel isn't empty.
|
|
16776
|
-
R.appendChatMessage('assistant', 'Hi! Tell me what you want Clementine to do — a sentence is fine. I\\x27ll ask a couple of follow-ups (when it should run, which tools she needs, which model) and draft a
|
|
16919
|
+
R.appendChatMessage('assistant', 'Hi! Tell me what you want Clementine to do — a sentence is fine. I\\x27ll ask a couple of follow-ups (when it should run, which tools she needs, which model) and draft a workflow you can save.');
|
|
16777
16920
|
m.style.display = 'flex';
|
|
16778
16921
|
setTimeout(function(){ document.getElementById('routines-chat-input').focus(); }, 50);
|
|
16779
16922
|
},
|
|
@@ -16809,9 +16952,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16809
16952
|
pane.classList.toggle('trick-spec-streaming', !!R.state.chatStreaming);
|
|
16810
16953
|
var a = R.state.chatArtifact;
|
|
16811
16954
|
if (!a || !a.name) {
|
|
16812
|
-
pane.innerHTML = '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:10px">
|
|
16955
|
+
pane.innerHTML = '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:10px">Workflow spec</div>'
|
|
16813
16956
|
+ '<div class="trick-spec-card" style="opacity:0.7"><div class="trick-spec-skeleton-row" style="width:60%"></div><div class="trick-spec-skeleton-row" style="width:90%"></div><div class="trick-spec-skeleton-row" style="width:40%"></div></div>'
|
|
16814
|
-
+ '<div style="font-size:11px;color:var(--text-muted);line-height:1.5;margin-top:14px;font-style:italic">I\\x27ll fill this in as we chat. Each answer you give populates a field on the right — name, schedule, model, steps. When it\\x27s ready you\\x27ll see a Save
|
|
16957
|
+
+ '<div style="font-size:11px;color:var(--text-muted);line-height:1.5;margin-top:14px;font-style:italic">I\\x27ll fill this in as we chat. Each answer you give populates a field on the right — name, schedule, model, steps. When it\\x27s ready you\\x27ll see a Save workflow button.</div>';
|
|
16815
16958
|
return;
|
|
16816
16959
|
}
|
|
16817
16960
|
// Parse the YAML-ish steps string into displayable cards.
|
|
@@ -16819,7 +16962,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16819
16962
|
var stepCount = steps.length;
|
|
16820
16963
|
var schedule = a.schedule ? R.humanizeCron(a.schedule) : 'manual';
|
|
16821
16964
|
var modelLabel = a.model ? R.modelLabel(a.model) : 'inherit';
|
|
16822
|
-
var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em">
|
|
16965
|
+
var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em">Workflow spec</div><span style="flex:1"></span>'
|
|
16823
16966
|
+ (R.state.chatStreaming ? '<span style="font-size:11px;color:var(--clementine)">drafting…</span>' : '')
|
|
16824
16967
|
+ '</div>';
|
|
16825
16968
|
var meta = '<div class="trick-spec-card">'
|
|
@@ -16845,7 +16988,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
16845
16988
|
}
|
|
16846
16989
|
var ready = a.name && stepCount > 0;
|
|
16847
16990
|
var saveRow = ready
|
|
16848
|
-
? '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px"><span style="font-size:12px;color:var(--green,#22c55e);font-weight:500">✓ Ready to save</span><span style="flex:1"></span><button class="btn-sm btn-primary" onclick="window.RoutinesUI&&RoutinesUI.saveChatDraft()" style="padding:6px 18px">Save
|
|
16991
|
+
? '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px"><span style="font-size:12px;color:var(--green,#22c55e);font-weight:500">✓ Ready to save</span><span style="flex:1"></span><button class="btn-sm btn-primary" onclick="window.RoutinesUI&&RoutinesUI.saveChatDraft()" style="padding:6px 18px">Save workflow</button></div>'
|
|
16849
16992
|
: '<div style="margin-top:18px;font-size:11px;color:var(--text-muted);font-style:italic">A few more details and I\\x27ll let you save.</div>';
|
|
16850
16993
|
pane.innerHTML = head + meta + stepsHtml + saveRow;
|
|
16851
16994
|
},
|
|
@@ -17005,7 +17148,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
17005
17148
|
})
|
|
17006
17149
|
}).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
|
|
17007
17150
|
.then(function(res){
|
|
17008
|
-
if (btn) { btn.textContent = 'Save
|
|
17151
|
+
if (btn) { btn.textContent = 'Save workflow'; btn.disabled = false; }
|
|
17009
17152
|
if (!res.ok) {
|
|
17010
17153
|
alert('Save failed: ' + (res.body && res.body.error || 'unknown'));
|
|
17011
17154
|
return;
|
|
@@ -17013,7 +17156,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
17013
17156
|
R.closeChat();
|
|
17014
17157
|
if (res.body && res.body.id) R.openEditor(res.body.id);
|
|
17015
17158
|
}).catch(function(err){
|
|
17016
|
-
if (btn) { btn.textContent = 'Save
|
|
17159
|
+
if (btn) { btn.textContent = 'Save workflow'; btn.disabled = false; }
|
|
17017
17160
|
alert('Save failed: ' + err);
|
|
17018
17161
|
});
|
|
17019
17162
|
},
|
|
@@ -19655,7 +19798,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
19655
19798
|
<option value="weekly">Weekly</option>
|
|
19656
19799
|
<option value="hourly">Every N hours</option>
|
|
19657
19800
|
<option value="minutes">Every N minutes</option>
|
|
19658
|
-
|
|
19801
|
+
<!-- Custom cron is hidden from the dropdown; revealed via the
|
|
19802
|
+
"Use a cron expression" link below for power users. -->
|
|
19803
|
+
<option value="custom" style="display:none">Custom cron expression</option>
|
|
19659
19804
|
</select>
|
|
19660
19805
|
<select id="sched-day" style="display:none" onchange="updateScheduleFromBuilder()">
|
|
19661
19806
|
<option value="1">Monday</option>
|
|
@@ -19722,6 +19867,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
19722
19867
|
<input type="text" id="cron-schedule" placeholder="0 9 * * *" oninput="updateScheduleHint()">
|
|
19723
19868
|
</div>
|
|
19724
19869
|
<div class="form-hint" id="cron-schedule-hint" style="margin-top:6px"></div>
|
|
19870
|
+
<div style="margin-top:8px">
|
|
19871
|
+
<a href="#" id="sched-cron-link" onclick="event.preventDefault();var s=document.getElementById('sched-freq');s.value='custom';updateScheduleBuilder();this.style.display='none';" style="font-size:11px;color:var(--text-muted);text-decoration:none;border-bottom:1px dotted var(--border)">Use a cron expression instead</a>
|
|
19872
|
+
</div>
|
|
19725
19873
|
</div>
|
|
19726
19874
|
</div>
|
|
19727
19875
|
</div>
|
|
@@ -19751,13 +19899,17 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
19751
19899
|
</div>
|
|
19752
19900
|
</div>
|
|
19753
19901
|
|
|
19754
|
-
<!--
|
|
19902
|
+
<!-- Skills & tools: pinned skills + MCP + tools + tags -->
|
|
19755
19903
|
<div class="cron-section-card">
|
|
19756
|
-
<h4>
|
|
19757
|
-
<p class="cron-section-desc">
|
|
19758
|
-
|
|
19759
|
-
<!-- Predictable Mode
|
|
19760
|
-
|
|
19904
|
+
<h4>Skills & tools</h4>
|
|
19905
|
+
<p class="cron-section-desc">Pin the skills and tools this task should use. Switch to the <strong>What will run</strong> tab to see exactly what the agent receives.</p>
|
|
19906
|
+
|
|
19907
|
+
<!-- Predictable Mode is now hidden by default — new tasks always
|
|
19908
|
+
get predictable=true. Legacy tasks (predictable !== true) reveal
|
|
19909
|
+
the toggle automatically via openEditCronModal() so users can
|
|
19910
|
+
see/migrate. The checkbox stays in the DOM so saveCronJob()
|
|
19911
|
+
can read its value unchanged. -->
|
|
19912
|
+
<label id="cron-predictable-card" class="cron-predictable-card on" style="display:none;cursor:pointer;margin-bottom:12px">
|
|
19761
19913
|
<span class="pred-icon" id="cron-predictable-icon">🔒</span>
|
|
19762
19914
|
<input type="checkbox" id="cron-predictable" checked style="margin-top:3px" onchange="onPredictableChange()">
|
|
19763
19915
|
<span style="flex:1">
|
|
@@ -20293,6 +20445,100 @@ async function startAnthropicOAuth() {
|
|
|
20293
20445
|
}
|
|
20294
20446
|
}
|
|
20295
20447
|
|
|
20448
|
+
// ── API key paste flow (alternative to Login with Claude) ────────────
|
|
20449
|
+
function openApiKeyModal() {
|
|
20450
|
+
if (document.getElementById('api-key-modal')) return;
|
|
20451
|
+
var overlay = document.createElement('div');
|
|
20452
|
+
overlay.id = 'api-key-modal';
|
|
20453
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:1000;display:flex;align-items:center;justify-content:center';
|
|
20454
|
+
overlay.onclick = function(e) { if (e.target === overlay) closeApiKeyModal(); };
|
|
20455
|
+
overlay.innerHTML =
|
|
20456
|
+
'<div style="background:var(--bg-secondary,#1f2532);border:1px solid var(--border,#333);border-radius:10px;width:480px;max-width:92vw;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,0.4)">'
|
|
20457
|
+
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'
|
|
20458
|
+
+ '<div style="font-size:15px;font-weight:600;color:var(--text-primary,#fff)">Use an Anthropic API key</div>'
|
|
20459
|
+
+ '<button class="btn-ghost btn-sm" onclick="closeApiKeyModal()" style="background:transparent;border:0;color:var(--text-muted);font-size:18px;cursor:pointer">×</button>'
|
|
20460
|
+
+ '</div>'
|
|
20461
|
+
+ '<div style="font-size:12px;color:var(--text-muted,#888);line-height:1.5;margin-bottom:14px">'
|
|
20462
|
+
+ 'Paste an API key from your Anthropic Console. We validate it before saving.'
|
|
20463
|
+
+ ' <a href="#" id="api-key-console-link" style="color:var(--accent,#7aa2f7);text-decoration:underline">Open the keys page</a>'
|
|
20464
|
+
+ ' · key starts with <code style="color:var(--text-secondary)">sk-ant-</code>'
|
|
20465
|
+
+ '</div>'
|
|
20466
|
+
+ '<input id="api-key-input" type="password" placeholder="sk-ant-..." autocomplete="off" spellcheck="false"'
|
|
20467
|
+
+ ' style="width:100%;padding:10px 12px;background:var(--bg-input,#0e131c);border:1px solid var(--border,#333);border-radius:6px;color:var(--text-primary,#fff);font-family:monospace;font-size:13px;letter-spacing:0.5px"'
|
|
20468
|
+
+ ' onkeydown="if(event.key===\\x27Enter\\x27)submitApiKey()">'
|
|
20469
|
+
+ '<div id="api-key-status" style="font-size:12px;margin-top:10px;min-height:16px"></div>'
|
|
20470
|
+
+ '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px">'
|
|
20471
|
+
+ '<button class="btn btn-sm" onclick="closeApiKeyModal()">Cancel</button>'
|
|
20472
|
+
+ '<button class="btn btn-sm btn-primary" id="api-key-submit-btn" onclick="submitApiKey()">Validate & save</button>'
|
|
20473
|
+
+ '</div>'
|
|
20474
|
+
+ '</div>';
|
|
20475
|
+
document.body.appendChild(overlay);
|
|
20476
|
+
var link = document.getElementById('api-key-console-link');
|
|
20477
|
+
if (link) link.onclick = function(e) {
|
|
20478
|
+
e.preventDefault();
|
|
20479
|
+
window.open('https://console.anthropic.com/settings/keys', '_blank', 'noopener');
|
|
20480
|
+
};
|
|
20481
|
+
var input = document.getElementById('api-key-input');
|
|
20482
|
+
if (input) setTimeout(function() { input.focus(); }, 30);
|
|
20483
|
+
}
|
|
20484
|
+
|
|
20485
|
+
function closeApiKeyModal() {
|
|
20486
|
+
var el = document.getElementById('api-key-modal');
|
|
20487
|
+
if (el) el.remove();
|
|
20488
|
+
}
|
|
20489
|
+
|
|
20490
|
+
async function submitApiKey() {
|
|
20491
|
+
var input = document.getElementById('api-key-input');
|
|
20492
|
+
var status = document.getElementById('api-key-status');
|
|
20493
|
+
var btn = document.getElementById('api-key-submit-btn');
|
|
20494
|
+
if (!input) return;
|
|
20495
|
+
var key = String(input.value || '').trim();
|
|
20496
|
+
if (!key) { if (status) status.textContent = 'Paste a key first.'; return; }
|
|
20497
|
+
if (!/^sk-ant-/.test(key)) {
|
|
20498
|
+
if (status) status.innerHTML = '<span style="color:var(--red,#f55)">That does not look like an Anthropic key (expected sk-ant-...).</span>';
|
|
20499
|
+
return;
|
|
20500
|
+
}
|
|
20501
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Validating...'; }
|
|
20502
|
+
if (status) status.innerHTML = '<span style="color:var(--text-muted)">Calling Anthropic to verify the key...</span>';
|
|
20503
|
+
try {
|
|
20504
|
+
var r = await apiFetch('/api/auth/anthropic/api-key', {
|
|
20505
|
+
method: 'POST',
|
|
20506
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20507
|
+
body: JSON.stringify({ apiKey: key }),
|
|
20508
|
+
});
|
|
20509
|
+
var d = await r.json();
|
|
20510
|
+
if (!r.ok || !d.ok) throw new Error(d.error || 'Validation failed');
|
|
20511
|
+
// Trigger a daemon restart so SAFE_ENV picks up the new key. In desktop
|
|
20512
|
+
// mode, Electron respawns the child within ~1.5s; we then reload.
|
|
20513
|
+
if (status) status.innerHTML = '<span style="color:var(--green,#5fa)">Saved. Restarting Clementine to activate...</span>';
|
|
20514
|
+
try {
|
|
20515
|
+
var rr = await apiFetch('/api/restart-daemon', { method: 'POST' });
|
|
20516
|
+
var rd = await rr.json();
|
|
20517
|
+
if (rd && rd.willExit) {
|
|
20518
|
+
// Daemon is exiting now. Wait for it to come back, then reload so
|
|
20519
|
+
// the new auth state is reflected everywhere on the page.
|
|
20520
|
+
setTimeout(function() { location.reload(); }, 2500);
|
|
20521
|
+
} else {
|
|
20522
|
+
// Standalone (non-desktop) — daemon will not auto-restart.
|
|
20523
|
+
if (status) status.innerHTML = '<span style="color:var(--green,#5fa)">Saved. Restart Clementine (run <code>clementine restart</code>) to activate.</span>';
|
|
20524
|
+
setTimeout(function() {
|
|
20525
|
+
closeApiKeyModal();
|
|
20526
|
+
checkAnthropicAuth();
|
|
20527
|
+
}, 1800);
|
|
20528
|
+
}
|
|
20529
|
+
} catch (_) {
|
|
20530
|
+
// Restart endpoint failed — fall back to client-side refresh.
|
|
20531
|
+
setTimeout(function() {
|
|
20532
|
+
closeApiKeyModal();
|
|
20533
|
+
checkAnthropicAuth();
|
|
20534
|
+
}, 600);
|
|
20535
|
+
}
|
|
20536
|
+
} catch (err) {
|
|
20537
|
+
if (status) status.innerHTML = '<span style="color:var(--red,#f55)">' + (err && err.message ? err.message : String(err)) + '</span>';
|
|
20538
|
+
if (btn) { btn.disabled = false; btn.textContent = 'Validate & save'; }
|
|
20539
|
+
}
|
|
20540
|
+
}
|
|
20541
|
+
|
|
20296
20542
|
// ── Authenticated fetch helper ────────────
|
|
20297
20543
|
var _dashToken = document.querySelector('meta[name="dashboard-token"]')?.getAttribute('content') || '';
|
|
20298
20544
|
async function apiFetch(url, opts) {
|
|
@@ -22845,7 +23091,7 @@ function renderTrickFilterRow(allTasks, filter) {
|
|
|
22845
23091
|
|
|
22846
23092
|
function renderScheduledTaskCard(task) {
|
|
22847
23093
|
var enabled = task.enabled !== false;
|
|
22848
|
-
var cardCls = 'task-card' + (enabled ? '' : ' disabled') + (task.health === 'running' ? ' running' : '');
|
|
23094
|
+
var cardCls = 'task-card compact' + (enabled ? '' : ' disabled') + (task.health === 'running' ? ' running' : '');
|
|
22849
23095
|
var style = task.health === 'broken' ? 'border-left:3px solid var(--red)' : task.health === 'failed' ? 'border-left:3px solid var(--yellow)' : '';
|
|
22850
23096
|
var lastRunHtml = '<span style="color:var(--text-muted)">Never run</span>';
|
|
22851
23097
|
if (task.lastRun) {
|
|
@@ -22895,11 +23141,11 @@ function renderScheduledTaskCard(task) {
|
|
|
22895
23141
|
+ renderTrickTagChips(task)
|
|
22896
23142
|
+ '<div class="task-card-badges">' + badges + '</div>'
|
|
22897
23143
|
+ '<div class="task-card-actions">'
|
|
22898
|
-
+ '<button class="btn-sm
|
|
22899
|
-
+ '<button class="btn-sm" onclick="
|
|
22900
|
-
+ '<button class="btn-sm"
|
|
22901
|
-
+ '<button class="btn-sm"
|
|
22902
|
-
+ '<button class="btn-sm btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)">Del</button>'
|
|
23144
|
+
+ '<button class="btn-sm primary" onclick="openEditCronModal(\\x27' + safeName + '\\x27)" title="Edit task" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit</button>'
|
|
23145
|
+
+ '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>'
|
|
23146
|
+
+ '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
|
|
23147
|
+
+ '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
|
|
23148
|
+
+ '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>'
|
|
22903
23149
|
+ '</div></div>';
|
|
22904
23150
|
}
|
|
22905
23151
|
|
|
@@ -22927,6 +23173,67 @@ function renderScheduledWorkflowCard(wf) {
|
|
|
22927
23173
|
+ '</div></div>';
|
|
22928
23174
|
}
|
|
22929
23175
|
|
|
23176
|
+
// ── Recent history list (Tasks page, bottom zone) ───────────────────────
|
|
23177
|
+
// Renders a compact table of CronRunEntry rows fetched from /api/cron/runs.
|
|
23178
|
+
// Each row shows status icon, job name, started time, duration, and a Trace
|
|
23179
|
+
// button. Click anywhere on the row to open the full trace viewer.
|
|
23180
|
+
function renderRecentHistoryList(runs) {
|
|
23181
|
+
if (!runs || runs.length === 0) {
|
|
23182
|
+
return '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">No runs yet. History appears here once your tasks start firing.</div>';
|
|
23183
|
+
}
|
|
23184
|
+
var rowsHtml = '';
|
|
23185
|
+
for (var i = 0; i < runs.length; i++) {
|
|
23186
|
+
var entry = runs[i] || {};
|
|
23187
|
+
var status = entry.status || 'unknown';
|
|
23188
|
+
var statusColor, statusIcon;
|
|
23189
|
+
if (status === 'ok') { statusColor = 'var(--green)'; statusIcon = '✓'; }
|
|
23190
|
+
else if (status === 'error') { statusColor = 'var(--red)'; statusIcon = '✗'; }
|
|
23191
|
+
else if (status === 'retried') { statusColor = 'var(--yellow)'; statusIcon = '↻'; }
|
|
23192
|
+
else if (status === 'skipped') { statusColor = 'var(--text-muted)'; statusIcon = '−'; }
|
|
23193
|
+
else { statusColor = 'var(--text-muted)'; statusIcon = '·'; }
|
|
23194
|
+
var jobName = entry.jobName || '(unknown)';
|
|
23195
|
+
var safeName = jsStr(jobName);
|
|
23196
|
+
var startedAt = entry.startedAt ? new Date(entry.startedAt) : null;
|
|
23197
|
+
var startedLabel = startedAt ? startedAt.toLocaleString() : '—';
|
|
23198
|
+
var durationLabel = entry.durationMs != null ? formatDurationMs(entry.durationMs) : '—';
|
|
23199
|
+
var attemptLabel = entry.attempt && entry.attempt > 1 ? ' · attempt ' + esc(entry.attempt) : '';
|
|
23200
|
+
var errorPreview = '';
|
|
23201
|
+
if (status === 'error' && entry.error) {
|
|
23202
|
+
var msg = String(entry.error).slice(0, 120);
|
|
23203
|
+
errorPreview = '<div style="font-size:11px;color:var(--red);margin-top:2px;word-break:break-word">' + esc(msg) + '</div>';
|
|
23204
|
+
} else if (entry.outputPreview) {
|
|
23205
|
+
var preview = String(entry.outputPreview).slice(0, 140);
|
|
23206
|
+
errorPreview = '<div style="font-size:11px;color:var(--text-muted);margin-top:2px;word-break:break-word">' + esc(preview) + '</div>';
|
|
23207
|
+
}
|
|
23208
|
+
rowsHtml += '<div class="history-row" data-trace-job="' + esc(jobName) + '" style="display:grid;grid-template-columns:24px minmax(180px,1.2fr) minmax(180px,1fr) 90px auto;gap:10px;align-items:start;padding:8px 14px;border-bottom:1px solid var(--border);cursor:pointer" onmouseover="this.style.background=\'var(--bg-hover)\'" onmouseout="this.style.background=\'\'">'
|
|
23209
|
+
+ '<div style="color:' + statusColor + ';font-size:14px;line-height:18px;text-align:center" title="' + esc(status) + '">' + statusIcon + '</div>'
|
|
23210
|
+
+ '<div style="min-width:0">'
|
|
23211
|
+
+ '<div style="font-weight:500;color:var(--text-primary);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(jobName) + '">' + esc(jobName) + attemptLabel + '</div>'
|
|
23212
|
+
+ errorPreview
|
|
23213
|
+
+ '</div>'
|
|
23214
|
+
+ '<div style="font-size:12px;color:var(--text-secondary);line-height:18px">' + esc(startedLabel) + '</div>'
|
|
23215
|
+
+ '<div style="font-size:12px;color:var(--text-muted);line-height:18px">' + esc(durationLabel) + '</div>'
|
|
23216
|
+
+ '<div style="display:flex;gap:6px;align-items:center"><button class="btn-sm" onclick="event.stopPropagation();openTraceViewer(\\x27' + safeName + '\\x27)" style="font-size:11px;padding:3px 8px">Trace</button></div>'
|
|
23217
|
+
+ '</div>';
|
|
23218
|
+
}
|
|
23219
|
+
return '<div class="history-list" style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius)">'
|
|
23220
|
+
+ '<div style="display:grid;grid-template-columns:24px minmax(180px,1.2fr) minmax(180px,1fr) 90px auto;gap:10px;padding:8px 14px;border-bottom:1px solid var(--border);font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:500">'
|
|
23221
|
+
+ '<div></div><div>Task</div><div>Started</div><div>Duration</div><div></div>'
|
|
23222
|
+
+ '</div>'
|
|
23223
|
+
+ rowsHtml
|
|
23224
|
+
+ '</div>';
|
|
23225
|
+
}
|
|
23226
|
+
|
|
23227
|
+
function formatDurationMs(ms) {
|
|
23228
|
+
if (ms == null || isNaN(ms)) return '—';
|
|
23229
|
+
if (ms < 1000) return ms + 'ms';
|
|
23230
|
+
var s = Math.round(ms / 100) / 10;
|
|
23231
|
+
if (s < 60) return s + 's';
|
|
23232
|
+
var m = Math.floor(s / 60);
|
|
23233
|
+
var rem = Math.round(s % 60);
|
|
23234
|
+
return m + 'm ' + rem + 's';
|
|
23235
|
+
}
|
|
23236
|
+
|
|
22930
23237
|
function renderRunningCard(item) {
|
|
22931
23238
|
var runtime = item.runtime || {};
|
|
22932
23239
|
var runtimeName = runtime.runtimeName || runtime.name || runtime.jobName || runtime.id || '';
|
|
@@ -22945,8 +23252,15 @@ function renderRunningCard(item) {
|
|
|
22945
23252
|
|
|
22946
23253
|
async function refreshCron() {
|
|
22947
23254
|
try {
|
|
22948
|
-
|
|
22949
|
-
|
|
23255
|
+
// Fetch operations + cross-job recent runs in parallel for the three-zone
|
|
23256
|
+
// Tasks layout: Running now (top) / Your tasks (middle) / Recent history.
|
|
23257
|
+
var opsP = apiFetch('/api/build/operations?hours=168&limit=50').then(function(r) { return r.json().then(function(d){ return { r:r, d:d }; }); });
|
|
23258
|
+
var runsP = apiFetch('/api/cron/runs?limit=50').then(function(r) { return r.json().catch(function(){ return { ok:false, runs: [] }; }); }).catch(function() { return { ok:false, runs: [] }; });
|
|
23259
|
+
var both = await Promise.all([opsP, runsP]);
|
|
23260
|
+
var opsResp = both[0];
|
|
23261
|
+
var historyData = (both[1] && both[1].runs) || [];
|
|
23262
|
+
var r = opsResp.r;
|
|
23263
|
+
var ops = opsResp.d;
|
|
22950
23264
|
if (!r.ok || !ops || ops.ok === false) throw new Error((ops && ops.error) || 'Build operations unavailable');
|
|
22951
23265
|
mergeBuildUsageTasks(ops.usageTasks || []);
|
|
22952
23266
|
cronJobsData = (ops.scheduledTasks || []).map(function(t) { return t.definition || {}; });
|
|
@@ -22973,16 +23287,26 @@ async function refreshCron() {
|
|
|
22973
23287
|
var ownerFilter = getBuildOwnerFilter();
|
|
22974
23288
|
|
|
22975
23289
|
var html = renderOperationsSummary(ops);
|
|
23290
|
+
|
|
23291
|
+
// ── Zone 1 — Running now (promoted to top, primary "what's live" view) ──
|
|
23292
|
+
if (visibleRunning.length > 0) {
|
|
23293
|
+
html += operationSectionHeader('Running now', 'Live background, long-running, and unleashed work. These are executions, not scheduled definitions.', 'badge-blue', visibleRunning.length + ' active', '0')
|
|
23294
|
+
+ '<div class="task-grid">' + visibleRunning.slice(0, 10).map(renderRunningCard).join('') + '</div>';
|
|
23295
|
+
if (visibleRunning.length > 10) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 10 of ' + visibleRunning.length + ' active runs. Use the Owner filter to narrow this list.</div>';
|
|
23296
|
+
}
|
|
23297
|
+
|
|
23298
|
+
// ── Needs attention (only when there are issues) ──
|
|
22976
23299
|
if (visibleAttention.length > 0) {
|
|
22977
|
-
html += operationSectionHeader('Needs
|
|
23300
|
+
html += operationSectionHeader('Needs attention', 'Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.', 'badge-yellow', visibleAttention.length + ' review', visibleRunning.length > 0 ? '28px' : '0')
|
|
22978
23301
|
+ '<div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
|
|
22979
23302
|
if (visibleAttention.length > 12) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 12 of ' + visibleAttention.length + ' items. Use the Owner filter to narrow this list.</div>';
|
|
22980
23303
|
}
|
|
22981
23304
|
|
|
23305
|
+
// ── Zone 2 — Your tasks (the main card grid) ──
|
|
22982
23306
|
var filteredTasks = applyTrickFilter(visibleTasks, _trickFilter);
|
|
22983
23307
|
var filterPillsHtml = renderTrickFilterRow(visibleTasks, _trickFilter);
|
|
22984
23308
|
var taskCountLabel = (_trickFilter.kind ? filteredTasks.length + '/' + visibleTasks.length : visibleTasks.length) + ' task' + (visibleTasks.length === 1 ? '' : 's');
|
|
22985
|
-
html += operationSectionHeader('
|
|
23309
|
+
html += operationSectionHeader('Your tasks', 'Recurring jobs Clementine runs for you. Tap any card to edit; the toggle on each card pauses or resumes it.', 'badge-blue', taskCountLabel, (visibleRunning.length > 0 || visibleAttention.length > 0) ? '28px' : '0')
|
|
22986
23310
|
+ filterPillsHtml
|
|
22987
23311
|
+ '<div class="task-grid">';
|
|
22988
23312
|
if (filteredTasks.length === 0) {
|
|
@@ -22990,28 +23314,25 @@ async function refreshCron() {
|
|
|
22990
23314
|
if (_trickFilter.kind) {
|
|
22991
23315
|
emptyLabel = 'No tasks match the current filter.';
|
|
22992
23316
|
} else {
|
|
22993
|
-
emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No
|
|
23317
|
+
emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No tasks across any agent.' : (ownerFilter ? 'No tasks for ' + ownerFilter + '.' : 'No tasks yet.');
|
|
22994
23318
|
}
|
|
22995
|
-
html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New
|
|
23319
|
+
html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>'
|
|
22996
23320
|
+ '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">' + esc(emptyLabel) + '</div>';
|
|
22997
23321
|
} else {
|
|
22998
23322
|
html += filteredTasks.map(renderScheduledTaskCard).join('');
|
|
22999
|
-
html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New
|
|
23323
|
+
html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>';
|
|
23000
23324
|
}
|
|
23001
23325
|
html += '</div>';
|
|
23002
23326
|
|
|
23003
|
-
|
|
23327
|
+
// ── Workflows section (kept for back-compat; only renders when present) ──
|
|
23004
23328
|
if (visibleWorkflows.length > 0) {
|
|
23329
|
+
html += operationSectionHeader('Multi-step workflows', 'One scheduled trigger that runs chained steps. Separate from regular tasks.', 'badge-purple', visibleWorkflows.length + ' workflow' + (visibleWorkflows.length === 1 ? '' : 's'), '28px');
|
|
23005
23330
|
html += '<div class="task-grid">' + visibleWorkflows.map(renderScheduledWorkflowCard).join('') + '</div>';
|
|
23006
|
-
} else {
|
|
23007
|
-
html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">No scheduled workflows in this scope. Build and schedule chained workflows from the Workflow Builder when a multi-step run is needed.</div>';
|
|
23008
23331
|
}
|
|
23009
23332
|
|
|
23010
|
-
|
|
23011
|
-
|
|
23012
|
-
|
|
23013
|
-
if (visibleRunning.length > 10) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 10 of ' + visibleRunning.length + ' active runs. Use the Owner filter to narrow this list.</div>';
|
|
23014
|
-
}
|
|
23333
|
+
// ── Zone 3 — Recent history (last 50 runs across all jobs) ──
|
|
23334
|
+
html += operationSectionHeader('Recent history', 'The last 50 task runs across every job, newest first. Click any row to open the full trace.', 'badge-gray', historyData.length + ' run' + (historyData.length === 1 ? '' : 's'), '28px');
|
|
23335
|
+
html += renderRecentHistoryList(historyData);
|
|
23015
23336
|
|
|
23016
23337
|
panel.innerHTML = html;
|
|
23017
23338
|
panel.onclick = function(ev) {
|
|
@@ -24277,18 +24598,29 @@ function renderCronLegacyBanner(job) {
|
|
|
24277
24598
|
+ '<h5>⚠ Legacy mode — output may not match what you see here</h5>'
|
|
24278
24599
|
+ '<div>' + msg + '</div>'
|
|
24279
24600
|
+ '<div class="banner-actions">'
|
|
24280
|
-
+ '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()">
|
|
24601
|
+
+ '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()">Migrate now</button>'
|
|
24281
24602
|
+ '<button class="btn-sm" onclick="switchCronTab(\\x27preview\\x27)" style="background:transparent;border:1px solid var(--border);color:var(--text-primary)">See what will run</button>'
|
|
24282
24603
|
+ '</div>'
|
|
24283
24604
|
+ '</div>';
|
|
24284
24605
|
}
|
|
24285
24606
|
|
|
24286
|
-
|
|
24607
|
+
// One-click migration: flip predictable=true AND save immediately so the
|
|
24608
|
+
// user doesn't have to remember to also click Save Changes.
|
|
24609
|
+
async function enablePredictableFromBanner() {
|
|
24287
24610
|
var predEl = document.getElementById('cron-predictable');
|
|
24288
24611
|
if (!predEl) return;
|
|
24289
24612
|
predEl.checked = true;
|
|
24290
24613
|
onPredictableChange();
|
|
24291
|
-
|
|
24614
|
+
try {
|
|
24615
|
+
if (typeof saveCronJob === 'function') {
|
|
24616
|
+
await saveCronJob();
|
|
24617
|
+
toast('Migrated to Predictable Mode.', 'success');
|
|
24618
|
+
} else {
|
|
24619
|
+
toast('Predictable Mode enabled — click Save Changes to lock it in.', 'success');
|
|
24620
|
+
}
|
|
24621
|
+
} catch (err) {
|
|
24622
|
+
toast('Toggled to Predictable Mode but save failed: ' + String(err), 'error');
|
|
24623
|
+
}
|
|
24292
24624
|
}
|
|
24293
24625
|
|
|
24294
24626
|
function openCreateCronModal(agentSlug) {
|
|
@@ -24317,11 +24649,20 @@ function openCreateCronModal(agentSlug) {
|
|
|
24317
24649
|
document.getElementById('cron-train-btn').style.display = '';
|
|
24318
24650
|
resetCronTrainingChat();
|
|
24319
24651
|
resetTrickCapabilityState();
|
|
24652
|
+
// New tasks default to predictable=true. The visible card stays hidden;
|
|
24653
|
+
// the checkbox in the DOM carries the value through to saveCronJob().
|
|
24654
|
+
var predElNew = document.getElementById('cron-predictable');
|
|
24655
|
+
if (predElNew) predElNew.checked = true;
|
|
24656
|
+
var predCardNew = document.getElementById('cron-predictable-card');
|
|
24657
|
+
if (predCardNew) predCardNew.style.display = 'none';
|
|
24320
24658
|
// No saved state to preview when creating — disable the Preview tab.
|
|
24321
24659
|
var previewBtn = document.getElementById('cron-tab-btn-preview');
|
|
24322
24660
|
if (previewBtn) previewBtn.setAttribute('disabled', 'disabled');
|
|
24323
24661
|
var host = document.getElementById('cron-legacy-banner-host');
|
|
24324
24662
|
if (host) host.innerHTML = '';
|
|
24663
|
+
// Reset the "Use a cron expression" link in case it was hidden last time.
|
|
24664
|
+
var schedLink = document.getElementById('sched-cron-link');
|
|
24665
|
+
if (schedLink) schedLink.style.display = '';
|
|
24325
24666
|
switchCronTab('configure');
|
|
24326
24667
|
onPredictableChange();
|
|
24327
24668
|
document.getElementById('cron-modal').classList.add('show');
|
|
@@ -24370,6 +24711,10 @@ function openEditCronModal(jobName) {
|
|
|
24370
24711
|
// banner surfaces the migration choice instead.
|
|
24371
24712
|
var predEl = document.getElementById('cron-predictable');
|
|
24372
24713
|
if (predEl) predEl.checked = (job.predictable === true);
|
|
24714
|
+
// Reveal the Predictable Mode card only for legacy jobs that need
|
|
24715
|
+
// attention. Predictable jobs keep it hidden — there's nothing to do.
|
|
24716
|
+
var predCardEdit = document.getElementById('cron-predictable-card');
|
|
24717
|
+
if (predCardEdit) predCardEdit.style.display = (job.predictable === true) ? 'none' : '';
|
|
24373
24718
|
onPredictableChange();
|
|
24374
24719
|
renderCronLegacyBanner(job);
|
|
24375
24720
|
renderSkillsPickerChips();
|
|
@@ -34149,7 +34494,7 @@ try {
|
|
|
34149
34494
|
if (evt.type === 'connected') return;
|
|
34150
34495
|
if (evt.type === 'cron_complete' || evt.type === 'cron_triggered') {
|
|
34151
34496
|
refreshActivity();
|
|
34152
|
-
if (currentPage === '
|
|
34497
|
+
if (currentPage === 'build') refreshCron();
|
|
34153
34498
|
refreshTeamNav();
|
|
34154
34499
|
}
|
|
34155
34500
|
if (evt.type === 'agent_created' || evt.type === 'agent_updated' || evt.type === 'agent_deleted' || evt.type === 'agent_status') {
|
|
@@ -34225,6 +34570,16 @@ try {
|
|
|
34225
34570
|
// Poll interval: 30s when SSE connected, 10s otherwise (re-evaluates each tick)
|
|
34226
34571
|
setInterval(function() { if (!sseConnected) refreshAll(); }, 10000);
|
|
34227
34572
|
setInterval(function() { if (sseConnected) refreshAll(); }, 30000);
|
|
34573
|
+
|
|
34574
|
+
// Tasks page: keep "Running now" fresh while the user is looking at it.
|
|
34575
|
+
// 12s feels alive without thrashing the API. Skip if SSE just delivered an
|
|
34576
|
+
// event in the last few seconds (refreshCron will already have fired).
|
|
34577
|
+
setInterval(function() {
|
|
34578
|
+
if (currentPage !== 'build') return;
|
|
34579
|
+
if (typeof refreshCron === 'function') {
|
|
34580
|
+
try { refreshCron(); } catch (e) { /* non-fatal */ }
|
|
34581
|
+
}
|
|
34582
|
+
}, 12000);
|
|
34228
34583
|
</script>
|
|
34229
34584
|
</body>
|
|
34230
34585
|
</html>`;
|
package/dist/desktop/main.js
CHANGED
|
@@ -33,7 +33,11 @@ const CLI_ENTRY = path.join(PACKAGE_ROOT, 'dist', 'cli', 'index.js');
|
|
|
33
33
|
const ICONS_DIR = path.join(PACKAGE_ROOT, 'build', 'icons');
|
|
34
34
|
const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
35
35
|
const DEFAULT_PORT = Number(process.env.CLEMENTINE_DESKTOP_PORT || process.env.DASHBOARD_PORT || 3030);
|
|
36
|
-
|
|
36
|
+
// Use Electron's bundled Node runtime (via ELECTRON_RUN_AS_NODE) so the
|
|
37
|
+
// desktop app does not depend on a `node` binary being on the host PATH.
|
|
38
|
+
// macOS GUI launches (Finder/Spotlight/Dock) get a barebones PATH that
|
|
39
|
+
// excludes Homebrew and NVM, which previously caused `spawn node ENOENT`.
|
|
40
|
+
const NODE_BINARY = process.env.CLEMENTINE_NODE_PATH || process.execPath;
|
|
37
41
|
let mainWindow = null;
|
|
38
42
|
let tray = null;
|
|
39
43
|
let dashboardProcess = null;
|
|
@@ -138,6 +142,9 @@ function startDashboardProcess(port) {
|
|
|
138
142
|
CLEMENTINE_HOME: BASE_DIR,
|
|
139
143
|
CLEMENTINE_NO_OPEN: '1',
|
|
140
144
|
__CLEM_DASHBOARD_CHILD: '1',
|
|
145
|
+
// Run Electron's binary as plain Node when no host node is configured.
|
|
146
|
+
// No-op when the user pinned a real node via CLEMENTINE_NODE_PATH.
|
|
147
|
+
ELECTRON_RUN_AS_NODE: process.env.CLEMENTINE_NODE_PATH ? '0' : '1',
|
|
141
148
|
},
|
|
142
149
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
150
|
});
|
|
@@ -70,6 +70,14 @@ export declare class CronRunLog {
|
|
|
70
70
|
append(entry: CronRunEntry): void;
|
|
71
71
|
readRecent(jobName: string, count?: number): CronRunEntry[];
|
|
72
72
|
consecutiveErrors(jobName: string): number;
|
|
73
|
+
/**
|
|
74
|
+
* Read the most recent run entries across ALL jobs, sorted newest-first.
|
|
75
|
+
* Each .jsonl file is already pruned to MAX_LINES, so this is bounded by
|
|
76
|
+
* (number of jobs × tail of recent entries). For dashboard "Recent History"
|
|
77
|
+
* this is fast enough — we tail the last `tailPerFile` lines from each file
|
|
78
|
+
* before merging, then trim the merged set to `count`.
|
|
79
|
+
*/
|
|
80
|
+
readAllRecent(count?: number, tailPerFile?: number): CronRunEntry[];
|
|
73
81
|
private maybePrune;
|
|
74
82
|
}
|
|
75
83
|
export declare class CronScheduler {
|
|
@@ -395,6 +395,49 @@ export class CronRunLog {
|
|
|
395
395
|
}
|
|
396
396
|
return count;
|
|
397
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Read the most recent run entries across ALL jobs, sorted newest-first.
|
|
400
|
+
* Each .jsonl file is already pruned to MAX_LINES, so this is bounded by
|
|
401
|
+
* (number of jobs × tail of recent entries). For dashboard "Recent History"
|
|
402
|
+
* this is fast enough — we tail the last `tailPerFile` lines from each file
|
|
403
|
+
* before merging, then trim the merged set to `count`.
|
|
404
|
+
*/
|
|
405
|
+
readAllRecent(count = 50, tailPerFile = 30) {
|
|
406
|
+
if (!existsSync(this.dir))
|
|
407
|
+
return [];
|
|
408
|
+
let files;
|
|
409
|
+
try {
|
|
410
|
+
files = readdirSync(this.dir).filter((n) => n.endsWith('.jsonl'));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
const merged = [];
|
|
416
|
+
for (const f of files) {
|
|
417
|
+
const filePath = path.join(this.dir, f);
|
|
418
|
+
try {
|
|
419
|
+
const lines = readFileSync(filePath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
420
|
+
const tail = lines.slice(-tailPerFile);
|
|
421
|
+
for (const l of tail) {
|
|
422
|
+
try {
|
|
423
|
+
merged.push(JSON.parse(l));
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* skip malformed line */
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
/* skip unreadable file */
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
merged.sort((a, b) => {
|
|
435
|
+
const at = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
436
|
+
const bt = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
437
|
+
return bt - at;
|
|
438
|
+
});
|
|
439
|
+
return merged.slice(0, count);
|
|
440
|
+
}
|
|
398
441
|
maybePrune(filePath) {
|
|
399
442
|
try {
|
|
400
443
|
const { size } = statSync(filePath);
|