mixdog 0.9.70 → 0.9.71
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/package.json +2 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +18 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +45 -12
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +51 -18
- package/src/runtime/agent/orchestrator/session/store.mjs +3 -0
- package/src/runtime/media/index.mjs +1 -0
- package/src/runtime/media/renditions.mjs +203 -0
- package/src/runtime/media/store.mjs +210 -18
- package/src/runtime/media/store.test.mjs +103 -0
- package/src/session-runtime/lifecycle-api.mjs +13 -4
- package/src/session-runtime/media-api.mjs +5 -2
- package/src/session-runtime/session-text.mjs +0 -1
- package/src/session-runtime/tool-catalog.mjs +32 -0
- package/src/tui/dist/index.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.71",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"test:workflow-editor": "node --test scripts/workflow-id-test.mjs scripts/workflow-pack-editor-test.mjs",
|
|
88
88
|
"test:route-scope": "node --test scripts/route-scope-isolation-test.mjs",
|
|
89
89
|
"test:schedule-reload": "node --test scripts/schedule-reload-arm-test.mjs",
|
|
90
|
+
"test:media": "node --test src/runtime/media/store.test.mjs",
|
|
90
91
|
"failures": "node scripts/tool-failures.mjs",
|
|
91
92
|
"trace:llm": "node scripts/llm-trace-summary.mjs",
|
|
92
93
|
"diag:sessions": "node scripts/session-diag.mjs",
|
|
@@ -706,8 +706,22 @@ export function enqueueRemotePendingMessage(sessionId, message) {
|
|
|
706
706
|
}
|
|
707
707
|
|
|
708
708
|
// Spool-file mtime gate so the owner's idle poller costs one stat per tick,
|
|
709
|
-
// not a locked read-modify-write.
|
|
710
|
-
|
|
709
|
+
// not a locked read-modify-write. Keyed PER SESSION: one process can own
|
|
710
|
+
// several sessions (desktop tabs, TUI + engine hosts) and a single shared
|
|
711
|
+
// counter let the first drain of a tick swallow the mtime bump for every
|
|
712
|
+
// other session, stranding their foreign submits until the next spool write.
|
|
713
|
+
const FOREIGN_SPOOL_SCAN_LIMIT = 64;
|
|
714
|
+
const _foreignSpoolScanMtimes = new Map();
|
|
715
|
+
|
|
716
|
+
function _rememberForeignSpoolScan(sessionId, mtime) {
|
|
717
|
+
_foreignSpoolScanMtimes.delete(sessionId);
|
|
718
|
+
_foreignSpoolScanMtimes.set(sessionId, mtime);
|
|
719
|
+
while (_foreignSpoolScanMtimes.size > FOREIGN_SPOOL_SCAN_LIMIT) {
|
|
720
|
+
const oldest = _foreignSpoolScanMtimes.keys().next().value;
|
|
721
|
+
if (oldest === undefined) break;
|
|
722
|
+
_foreignSpoolScanMtimes.delete(oldest);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
711
725
|
|
|
712
726
|
/**
|
|
713
727
|
* Owner-side drain of FOREIGN user injections for a session this process
|
|
@@ -721,8 +735,8 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
721
735
|
if (!isValidPendingSessionId(sessionId)) return [];
|
|
722
736
|
let mtime = 0;
|
|
723
737
|
try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
|
|
724
|
-
if (
|
|
725
|
-
|
|
738
|
+
if (_foreignSpoolScanMtimes.get(sessionId) === mtime) return [];
|
|
739
|
+
_rememberForeignSpoolScan(sessionId, mtime);
|
|
726
740
|
const localIds = new Set();
|
|
727
741
|
for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
|
|
728
742
|
for (const entry of map.get(sessionId) || []) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { getProvider } from '../../providers/registry.mjs';
|
|
7
7
|
import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
|
|
8
8
|
import { collectPromptSkillsCached, buildSkillManifest, composeSystemPrompt } from '../../context/collect.mjs';
|
|
9
|
-
import { saveSession, saveSessionAsync, saveSessionAsyncDeferred, loadSession, setLiveSession, readSessionHeartbeatMtime, readSessionPresenceMtime, isSessionPresenceOwnerDead, deleteSessionPresence } from '../store.mjs';
|
|
9
|
+
import { saveSession, saveSessionAsync, saveSessionAsyncDeferred, loadSession, setLiveSession, readSessionHeartbeatMtime, readSessionPresenceMtime, isSessionPresenceOwnerDead, deleteSessionPresence, isSessionHeartbeatOwnerDead, readSessionHeartbeatOwnerPid, deleteHeartbeat, isProcessAlive } from '../store.mjs';
|
|
10
10
|
import { _getRuntimeEntry } from './runtime-liveness.mjs';
|
|
11
11
|
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
12
12
|
import { getHiddenAgent } from '../../internal-agents.mjs';
|
|
@@ -547,6 +547,18 @@ export function prefetchSession(sessionId, preset = 'full') {
|
|
|
547
547
|
return true;
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
// Owner-pid hint for liveness signals that carry NO pid of their own:
|
|
551
|
+
// `session.lastHeartbeatAt` is persisted in the session file and therefore
|
|
552
|
+
// survives its writer forever, and pre-pid `.hb` sidecars only hold a
|
|
553
|
+
// timestamp. The recorded client host is the process that created/claimed the
|
|
554
|
+
// runtime for this session; the session-id prefix is the legacy fallback.
|
|
555
|
+
function _recordedOwnerPid(session, sessionId) {
|
|
556
|
+
const recorded = Number(session?.clientHostPid) || 0;
|
|
557
|
+
if (recorded > 0) return recorded;
|
|
558
|
+
const match = /^sess_(\d+)_/.exec(String(sessionId || ''));
|
|
559
|
+
return Number(match?.[1]) || 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
550
562
|
function _isActivelyOwnedElsewhere(session, sessionId) {
|
|
551
563
|
// This process already owns the runtime for the id — switching back to
|
|
552
564
|
// one of our own sessions (desktop tab switch, TUI /resume) never attaches.
|
|
@@ -560,19 +572,40 @@ function _isActivelyOwnedElsewhere(session, sessionId) {
|
|
|
560
572
|
deleteSessionPresence(sessionId);
|
|
561
573
|
return false;
|
|
562
574
|
}
|
|
575
|
+
const now = Date.now();
|
|
576
|
+
// Presence (`.own`, pid-verified just above) covers the idle gaps between
|
|
577
|
+
// turns: a live interactive surface keeps refreshing it (~20s) for its
|
|
578
|
+
// CURRENT session, so cross-opening an idle-but-open session still
|
|
579
|
+
// attaches as a viewer instead of splitting ownership into two writers
|
|
580
|
+
// that clobber each other's saves.
|
|
581
|
+
const presenceAt = Number(readSessionPresenceMtime(sessionId)) || 0;
|
|
582
|
+
if (presenceAt > 0 && now - presenceAt <= ACTIVE_OWNER_HB_FRESH_MS) return true;
|
|
563
583
|
// Heartbeats publish only while a turn is running (≤5s cadence) and the
|
|
564
584
|
// sidecar is deleted on detach/close, so freshness here means another
|
|
565
|
-
// process is mid-conversation on this session right now
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
);
|
|
575
|
-
|
|
585
|
+
// process is mid-conversation on this session right now — PROVIDED that
|
|
586
|
+
// process still exists. Without the pid check a force-killed owner (app
|
|
587
|
+
// upgrade restart, crash) kept looking live for the whole freshness
|
|
588
|
+
// window, so every cross-open attached as a viewer and the user's
|
|
589
|
+
// messages spooled to a queue nobody drains (silently dropped 30m later).
|
|
590
|
+
if (isSessionHeartbeatOwnerDead(sessionId)) {
|
|
591
|
+
void deleteHeartbeat(sessionId);
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
const sidecarAt = Number(readSessionHeartbeatMtime(sessionId)) || 0;
|
|
595
|
+
if (sidecarAt > 0 && now - sidecarAt <= ACTIVE_OWNER_HB_FRESH_MS
|
|
596
|
+
&& readSessionHeartbeatOwnerPid(sessionId) > 0) {
|
|
597
|
+
// Fresh sidecar whose recorded pid is alive: a real owner is driving
|
|
598
|
+
// this session right now, whatever the session file remembers.
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
const heartbeatAt = Math.max(sidecarAt, Number(session.lastHeartbeatAt) || 0);
|
|
602
|
+
if (!(heartbeatAt > 0 && now - heartbeatAt <= ACTIVE_OWNER_HB_FRESH_MS)) return false;
|
|
603
|
+
// Pid-less evidence only (persisted field / legacy sidecar): fall back to
|
|
604
|
+
// the recorded client-host pid. A dead host means no owner; an unknown pid
|
|
605
|
+
// keeps the conservative attach.
|
|
606
|
+
const ownerPid = _recordedOwnerPid(session, sessionId);
|
|
607
|
+
if (ownerPid > 0 && !isProcessAlive(ownerPid)) return false;
|
|
608
|
+
return true;
|
|
576
609
|
}
|
|
577
610
|
|
|
578
611
|
// Viewer self-heal probe: true when a re-resume of this session would NO
|
|
@@ -10,6 +10,7 @@ import { resolveAgentTerminalReapMs } from '../../../../../session-runtime/confi
|
|
|
10
10
|
import { getStoreDir, sessionPath } from './paths-heartbeat.mjs';
|
|
11
11
|
import { isCancelledWrite as _isCancelledWrite } from './write-guards.mjs';
|
|
12
12
|
import {
|
|
13
|
+
SESSION_SUMMARY_INDEX_VERSION,
|
|
13
14
|
summaryIndexPath,
|
|
14
15
|
_sessionSummary,
|
|
15
16
|
_normalizeSummaryIndex,
|
|
@@ -19,13 +19,40 @@ export function sessionPath(id) {
|
|
|
19
19
|
return join(getStoreDir(), `${id}.json`);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// ── Sidecar owner-pid helpers ─────────────────────────────
|
|
23
|
+
// Liveness sidecars record the publishing process id on their SECOND line so
|
|
24
|
+
// a reader can tell a genuinely live owner from a force-killed one whose
|
|
25
|
+
// timestamp still looks fresh. Legacy (pid-less) sidecars read back as 0.
|
|
26
|
+
export function isProcessAlive(pid) {
|
|
27
|
+
const target = Number(pid) || 0;
|
|
28
|
+
if (target <= 0) return false;
|
|
29
|
+
try {
|
|
30
|
+
process.kill(target, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// ESRCH: no such process. EPERM means the pid exists (alive).
|
|
34
|
+
return error?.code !== 'ESRCH';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _readSidecarOwnerPid(path) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(path)) return 0;
|
|
41
|
+
return Number(String(readFileSync(path, 'utf8')).split('\n')[1]) || 0;
|
|
42
|
+
} catch {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
// ── Heartbeat publish ─────────────────────────────────────
|
|
23
48
|
// Lightweight per-session timestamp file (`<id>.hb`) consumed by the
|
|
24
49
|
// status aggregator for fresh-session detection. Decoupled from the
|
|
25
50
|
// full session JSON save so it can fire at a tight cadence (≤5s)
|
|
26
|
-
// without serialising the whole payload. The .hb file holds
|
|
27
|
-
//
|
|
28
|
-
//
|
|
51
|
+
// without serialising the whole payload. The .hb file holds
|
|
52
|
+
// `<msTimestamp>\n<pid>\n` — readers that only need liveness use the file
|
|
53
|
+
// mtime, while the attach-on-resume guard verifies the recorded pid.
|
|
54
|
+
// Aggregator scans the same sessions/ directory and matches `<id>.hb` to
|
|
55
|
+
// `<id>.json`.
|
|
29
56
|
const _HEARTBEAT_THROTTLE_MS = 5_000;
|
|
30
57
|
const _hbLastAt = new Map();
|
|
31
58
|
const _hbOperations = new Map();
|
|
@@ -56,7 +83,7 @@ export function publishHeartbeat(id, ts) {
|
|
|
56
83
|
}
|
|
57
84
|
const target = _heartbeatPath(id);
|
|
58
85
|
_hbLastAt.set(id, now);
|
|
59
|
-
return _queueHeartbeatOperation(id, () => fsp.writeFile(target, `${now}\n`, 'utf8'));
|
|
86
|
+
return _queueHeartbeatOperation(id, () => fsp.writeFile(target, `${now}\n${process.pid}\n`, 'utf8'));
|
|
60
87
|
}
|
|
61
88
|
|
|
62
89
|
export function deleteHeartbeat(id) {
|
|
@@ -91,6 +118,24 @@ export function listSessionHeartbeatMtimes() {
|
|
|
91
118
|
return result;
|
|
92
119
|
}
|
|
93
120
|
|
|
121
|
+
// Owner pid recorded in the `<id>.hb` sidecar (0 when absent or written by a
|
|
122
|
+
// pre-pid build).
|
|
123
|
+
export function readSessionHeartbeatOwnerPid(id) {
|
|
124
|
+
if (!id) return 0;
|
|
125
|
+
try { return _readSidecarOwnerPid(_heartbeatPath(id)); } catch { return 0; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A killed owner never deletes its `.hb` sidecar, so bare mtime freshness
|
|
129
|
+
// would keep the session "actively driven" for the whole freshness window and
|
|
130
|
+
// every cross-open would attach as a viewer whose submits spool to nobody.
|
|
131
|
+
// The recorded pid is authoritative proof; pid-less legacy sidecars return
|
|
132
|
+
// false (unknown, not dead).
|
|
133
|
+
export function isSessionHeartbeatOwnerDead(id) {
|
|
134
|
+
const pid = readSessionHeartbeatOwnerPid(id);
|
|
135
|
+
if (!pid) return false;
|
|
136
|
+
return !isProcessAlive(pid);
|
|
137
|
+
}
|
|
138
|
+
|
|
94
139
|
// ── Interactive presence publish ──────────────────────────
|
|
95
140
|
// `<id>.own` marks a live interactive surface (TUI/desktop engine) HOLDING
|
|
96
141
|
// the session open — including idle time between turns. Kept separate from
|
|
@@ -144,19 +189,7 @@ export function readSessionPresenceMtime(id) {
|
|
|
144
189
|
export function isSessionPresenceOwnerDead(id) {
|
|
145
190
|
if (!id) return false;
|
|
146
191
|
let pid = 0;
|
|
147
|
-
try {
|
|
148
|
-
const path = _presencePath(id);
|
|
149
|
-
if (!existsSync(path)) return false;
|
|
150
|
-
pid = Number(String(readFileSync(path, 'utf8')).split('\n')[1]) || 0;
|
|
151
|
-
} catch {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
192
|
+
try { pid = _readSidecarOwnerPid(_presencePath(id)); } catch { return false; }
|
|
154
193
|
if (!pid) return false;
|
|
155
|
-
|
|
156
|
-
process.kill(pid, 0);
|
|
157
|
-
return false;
|
|
158
|
-
} catch (error) {
|
|
159
|
-
// ESRCH: no such process. EPERM means the pid exists (alive).
|
|
160
|
-
return error?.code === 'ESRCH';
|
|
161
|
-
}
|
|
194
|
+
return !isProcessAlive(pid);
|
|
162
195
|
}
|
|
@@ -54,6 +54,9 @@ export {
|
|
|
54
54
|
deleteSessionPresence,
|
|
55
55
|
readSessionPresenceMtime,
|
|
56
56
|
isSessionPresenceOwnerDead,
|
|
57
|
+
readSessionHeartbeatOwnerPid,
|
|
58
|
+
isSessionHeartbeatOwnerDead,
|
|
59
|
+
isProcessAlive,
|
|
57
60
|
} from './store/paths-heartbeat.mjs';
|
|
58
61
|
import { _readStoredSessionCached } from './store/load-cache.mjs';
|
|
59
62
|
import { _sessionForDisk, _renameWithRetrySync, _ensureLifecycleFields, _storedSessionFromFile } from './store/serialize.mjs';
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived renditions for the media gallery (thumb / display / video poster).
|
|
3
|
+
*
|
|
4
|
+
* A gallery tile is ~512px and a detail view never exceeds the viewport, so
|
|
5
|
+
* the original bytes are the wrong unit of transfer: over the remote link one
|
|
6
|
+
* full-size still costs more than the entire visible grid. Renditions are
|
|
7
|
+
* generated once, cached on disk beside the assets, and afterwards read back
|
|
8
|
+
* as small files.
|
|
9
|
+
*
|
|
10
|
+
* sharp (stills) and ffmpeg (video posters) are OPTIONAL. When neither can
|
|
11
|
+
* produce a rendition this module returns null and the caller reduces the
|
|
12
|
+
* feature (glyph tile) or explicitly asks for the original. It never silently
|
|
13
|
+
* substitutes full-size bytes for a thumbnail — that hidden path is exactly
|
|
14
|
+
* what made the remote gallery slow.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'child_process';
|
|
17
|
+
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
|
|
18
|
+
import { dirname, join } from 'path';
|
|
19
|
+
|
|
20
|
+
import { resolvePluginData } from '../shared/plugin-paths.mjs';
|
|
21
|
+
|
|
22
|
+
// maxEdge is the long edge in pixels; quality is the webp setting.
|
|
23
|
+
const SPECS = {
|
|
24
|
+
thumb: { maxEdge: 512, quality: 70 },
|
|
25
|
+
display: { maxEdge: 2048, quality: 82 },
|
|
26
|
+
};
|
|
27
|
+
// Probe order when reading a cached rendition: sharp writes webp, the
|
|
28
|
+
// ffmpeg-only poster path writes png.
|
|
29
|
+
const EXTENSION_MIME = { '.webp': 'image/webp', '.png': 'image/png' };
|
|
30
|
+
const POSTER_TIMEOUT_MS = 20_000;
|
|
31
|
+
const MAX_POSTER_BYTES = 32 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
export const MEDIA_VARIANTS = Object.keys(SPECS);
|
|
34
|
+
|
|
35
|
+
/** Spec for a variant name, or null when the caller asked for the original. */
|
|
36
|
+
export function renditionSpec(variant) {
|
|
37
|
+
return SPECS[String(variant || '')] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let sharpPromise;
|
|
41
|
+
async function loadSharp() {
|
|
42
|
+
sharpPromise ??= import('sharp').then((mod) => mod?.default || mod || null, () => null);
|
|
43
|
+
return sharpPromise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let ffmpegPromise;
|
|
47
|
+
// The managed voice runtime already installs ffmpeg; reuse it rather than
|
|
48
|
+
// shipping a second copy. MIXDOG_FFMPEG_PATH overrides for hosts that have
|
|
49
|
+
// their own build.
|
|
50
|
+
async function resolveFfmpeg() {
|
|
51
|
+
ffmpegPromise ??= (async () => {
|
|
52
|
+
const explicit = String(process.env.MIXDOG_FFMPEG_PATH || '').trim();
|
|
53
|
+
if (explicit && existsSync(explicit)) return explicit;
|
|
54
|
+
try {
|
|
55
|
+
const { resolveVoiceRuntime } = await import('../channels/lib/voice-runtime-fetcher.mjs');
|
|
56
|
+
const runtime = resolveVoiceRuntime(resolvePluginData());
|
|
57
|
+
return runtime?.ffmpegPath && existsSync(runtime.ffmpegPath) ? runtime.ffmpegPath : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
return ffmpegPromise;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renditionPath(cacheDir, variant, id, extension) {
|
|
66
|
+
return join(cacheDir, variant, `${id}${extension}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cachedRendition(cacheDir, variant, id) {
|
|
70
|
+
for (const [extension, mime] of Object.entries(EXTENSION_MIME)) {
|
|
71
|
+
const path = renditionPath(cacheDir, variant, id, extension);
|
|
72
|
+
if (existsSync(path)) return { path, mime, bytes: statSync(path).size };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Drop every cached rendition of one asset (called when the asset dies). */
|
|
78
|
+
export function removeRenditions(cacheDir, id) {
|
|
79
|
+
for (const variant of MEDIA_VARIANTS) {
|
|
80
|
+
for (const extension of Object.keys(EXTENSION_MIME)) {
|
|
81
|
+
try { unlinkSync(renditionPath(cacheDir, variant, id, extension)); } catch { /* absent */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeRendition(cacheDir, variant, id, extension, buffer) {
|
|
87
|
+
const path = renditionPath(cacheDir, variant, id, extension);
|
|
88
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
89
|
+
// Stage-then-rename: a reader must never observe a half-written cache file.
|
|
90
|
+
const staging = `${path}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(staging, buffer);
|
|
92
|
+
renameSync(staging, path);
|
|
93
|
+
return { path, mime: EXTENSION_MIME[extension], bytes: buffer.length };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Downscale a still (path or buffer) into a webp; null without sharp. */
|
|
97
|
+
async function encodeStill(input, spec) {
|
|
98
|
+
const sharp = await loadSharp();
|
|
99
|
+
if (!sharp) return null;
|
|
100
|
+
try {
|
|
101
|
+
const buffer = await sharp(input)
|
|
102
|
+
// EXIF-rotated phone photos would otherwise land sideways in the grid.
|
|
103
|
+
.rotate()
|
|
104
|
+
.resize(spec.maxEdge, spec.maxEdge, { fit: 'inside', withoutEnlargement: true })
|
|
105
|
+
.webp({ quality: spec.quality })
|
|
106
|
+
.toBuffer();
|
|
107
|
+
return { extension: '.webp', buffer };
|
|
108
|
+
} catch {
|
|
109
|
+
// sharp present but the source is not a decodable image.
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseDurationSeconds(text) {
|
|
115
|
+
const match = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text || '');
|
|
116
|
+
if (!match) return 0;
|
|
117
|
+
const seconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
|
|
118
|
+
return Number.isFinite(seconds) ? Math.round(seconds) : 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** First frame + duration of a clip, straight from ffmpeg; null when the
|
|
122
|
+
* managed runtime is not installed. */
|
|
123
|
+
async function grabVideoFrame(sourcePath, spec) {
|
|
124
|
+
const ffmpeg = await resolveFfmpeg();
|
|
125
|
+
if (!ffmpeg) return null;
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const child = spawn(ffmpeg, [
|
|
128
|
+
'-hide_banner', '-nostdin',
|
|
129
|
+
'-i', sourcePath,
|
|
130
|
+
'-frames:v', '1',
|
|
131
|
+
// Never upscale a small clip: -2 keeps the height even for the encoder.
|
|
132
|
+
'-vf', `scale='min(${spec.maxEdge},iw)':-2`,
|
|
133
|
+
'-f', 'image2', '-vcodec', 'png', 'pipe:1',
|
|
134
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
135
|
+
const chunks = [];
|
|
136
|
+
let total = 0;
|
|
137
|
+
let stderr = '';
|
|
138
|
+
let settled = false;
|
|
139
|
+
const finish = (value) => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
144
|
+
resolve(value);
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => finish(null), POSTER_TIMEOUT_MS);
|
|
147
|
+
timer.unref?.();
|
|
148
|
+
child.stdout.on('data', (chunk) => {
|
|
149
|
+
total += chunk.length;
|
|
150
|
+
// A corrupt clip could stream frames forever; the poster is one image.
|
|
151
|
+
if (total > MAX_POSTER_BYTES) {
|
|
152
|
+
finish(null);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
chunks.push(chunk);
|
|
156
|
+
});
|
|
157
|
+
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
|
|
158
|
+
child.on('error', () => finish(null));
|
|
159
|
+
child.on('close', () => {
|
|
160
|
+
const buffer = Buffer.concat(chunks);
|
|
161
|
+
finish(buffer.length ? { buffer, durationSeconds: parseDurationSeconds(stderr) } : null);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Two tiles asking for the same missing rendition must not both encode it.
|
|
167
|
+
const inflight = new Map();
|
|
168
|
+
|
|
169
|
+
async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
|
|
170
|
+
if (kind === 'video') {
|
|
171
|
+
// Only the tile-sized poster is worth extracting: a "display" frame would
|
|
172
|
+
// be a still pretending to be a clip.
|
|
173
|
+
if (variant !== 'thumb') return null;
|
|
174
|
+
const frame = await grabVideoFrame(sourcePath, spec);
|
|
175
|
+
if (!frame) return null;
|
|
176
|
+
const encoded = await encodeStill(frame.buffer, spec);
|
|
177
|
+
const written = encoded
|
|
178
|
+
? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer)
|
|
179
|
+
: writeRendition(cacheDir, variant, id, '.png', frame.buffer);
|
|
180
|
+
return { ...written, durationSeconds: frame.durationSeconds };
|
|
181
|
+
}
|
|
182
|
+
const encoded = await encodeStill(sourcePath, spec);
|
|
183
|
+
return encoded ? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Cached rendition for one asset, generating it on first use.
|
|
188
|
+
* Returns `{ path, mime, bytes, durationSeconds? }`, or null when this host
|
|
189
|
+
* cannot produce it (no sharp / no ffmpeg / undecodable source).
|
|
190
|
+
*/
|
|
191
|
+
export async function ensureRendition({ id, kind, sourcePath, variant, cacheDir }) {
|
|
192
|
+
const spec = renditionSpec(variant);
|
|
193
|
+
if (!spec) return null;
|
|
194
|
+
const cached = cachedRendition(cacheDir, variant, id);
|
|
195
|
+
if (cached) return cached;
|
|
196
|
+
const key = `${variant}:${id}`;
|
|
197
|
+
const pending = inflight.get(key)
|
|
198
|
+
?? generate({ id, kind, sourcePath, variant, spec, cacheDir })
|
|
199
|
+
.catch(() => null)
|
|
200
|
+
.finally(() => { inflight.delete(key); });
|
|
201
|
+
inflight.set(key, pending);
|
|
202
|
+
return pending;
|
|
203
|
+
}
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* (newest first) and is written atomically under a file lock because the
|
|
7
7
|
* desktop and CLI can generate concurrently.
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, statSync } from 'fs';
|
|
10
|
-
import { join } from 'path';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, statSync } from 'fs';
|
|
10
|
+
import { dirname, join, resolve, sep } from 'path';
|
|
11
11
|
import { randomUUID } from 'crypto';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
13
|
import { resolvePluginData } from '../shared/plugin-paths.mjs';
|
|
14
14
|
import { writeJsonAtomicSync, withFileLockSync } from '../shared/atomic-file.mjs';
|
|
15
|
+
import { ensureRendition, removeRenditions, renditionSpec } from './renditions.mjs';
|
|
15
16
|
|
|
16
17
|
const MAX_INDEX_ENTRIES = 2_000;
|
|
17
18
|
// Renderer transport is base64 over IPC: refuse to inline anything larger.
|
|
@@ -23,6 +24,7 @@ const EXTENSIONS = {
|
|
|
23
24
|
'image/webp': 'webp',
|
|
24
25
|
'video/mp4': 'mp4',
|
|
25
26
|
};
|
|
27
|
+
let layoutMigrationChecked = false;
|
|
26
28
|
|
|
27
29
|
function mediaDir() {
|
|
28
30
|
return join(resolvePluginData(), 'media');
|
|
@@ -32,6 +34,12 @@ function assetsDir() {
|
|
|
32
34
|
return join(mediaDir(), 'assets');
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// Derived tile/detail renditions live beside the assets: they are a rebuildable
|
|
38
|
+
// cache, so they never share the asset tree the user browses.
|
|
39
|
+
function renditionsDir() {
|
|
40
|
+
return join(mediaDir(), 'renditions');
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
function indexPath() {
|
|
36
44
|
return join(mediaDir(), 'index.json');
|
|
37
45
|
}
|
|
@@ -42,6 +50,10 @@ function indexLockPath() {
|
|
|
42
50
|
|
|
43
51
|
function ensureDirs() {
|
|
44
52
|
mkdirSync(assetsDir(), { recursive: true });
|
|
53
|
+
if (!layoutMigrationChecked) {
|
|
54
|
+
migrateAssetLayout();
|
|
55
|
+
layoutMigrationChecked = true;
|
|
56
|
+
}
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
function readIndex() {
|
|
@@ -55,22 +67,86 @@ function readIndex() {
|
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
function writeIndex(assets) {
|
|
58
|
-
writeJsonAtomicSync(indexPath(), { version:
|
|
70
|
+
writeJsonAtomicSync(indexPath(), { version: 2, assets });
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
function extensionFor(mime) {
|
|
62
74
|
return EXTENSIONS[String(mime || '').toLowerCase()] || 'bin';
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
function folderSegment(value, fallback) {
|
|
78
|
+
const normalized = String(value || '')
|
|
79
|
+
.normalize('NFKC')
|
|
80
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
|
|
81
|
+
.replace(/\s+/g, ' ')
|
|
82
|
+
.trim()
|
|
83
|
+
.replace(/[. ]+$/g, '')
|
|
84
|
+
.slice(0, 80);
|
|
85
|
+
if (!normalized || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(normalized)) {
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
return normalized;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function localDateFolder(createdAt) {
|
|
92
|
+
const date = new Date(Number(createdAt) || Date.now());
|
|
93
|
+
const year = date.getFullYear();
|
|
94
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
95
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
96
|
+
return `${year}-${month}-${day}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function organizedAssetFile(entry) {
|
|
100
|
+
const kind = entry.kind === 'video' ? 'videos' : 'images';
|
|
101
|
+
const lane = folderSegment(entry.lane, 'unknown-provider');
|
|
102
|
+
const model = folderSegment(entry.model, 'unknown-model');
|
|
103
|
+
const date = localDateFolder(entry.createdAt);
|
|
104
|
+
const id = folderSegment(entry.id, 'asset');
|
|
105
|
+
return [kind, lane, model, date, `${id}.${extensionFor(entry.mime)}`].join('/');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function storedAssetPath(file) {
|
|
109
|
+
const root = resolve(assetsDir());
|
|
110
|
+
const path = resolve(root, String(file || ''));
|
|
111
|
+
return path === root || path.startsWith(`${root}${sep}`) ? path : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Move flat v1 assets into kind/provider/model/date folders without losing old indexes. */
|
|
115
|
+
function migrateAssetLayout() {
|
|
116
|
+
withFileLockSync(indexLockPath(), () => {
|
|
117
|
+
const current = readIndex();
|
|
118
|
+
let changed = false;
|
|
119
|
+
const assets = current.map((entry) => {
|
|
120
|
+
const targetFile = organizedAssetFile(entry);
|
|
121
|
+
const sourcePath = storedAssetPath(entry.file);
|
|
122
|
+
const targetPath = storedAssetPath(targetFile);
|
|
123
|
+
if (!targetPath) return entry;
|
|
124
|
+
if (String(entry.file || '').replace(/\\/g, '/') === targetFile) return entry;
|
|
125
|
+
if (existsSync(targetPath)) {
|
|
126
|
+
changed = true;
|
|
127
|
+
return { ...entry, file: targetFile };
|
|
128
|
+
}
|
|
129
|
+
if (!sourcePath || !existsSync(sourcePath)) return entry;
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
132
|
+
renameSync(sourcePath, targetPath);
|
|
133
|
+
changed = true;
|
|
134
|
+
return { ...entry, file: targetFile };
|
|
135
|
+
} catch {
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
if (changed) writeIndex(assets);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
65
143
|
/** Persist one generated artifact and return its index entry. */
|
|
66
144
|
export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime, bytes, meta = {} }) {
|
|
67
145
|
ensureDirs();
|
|
68
146
|
const id = randomUUID();
|
|
69
|
-
const
|
|
70
|
-
writeFileSync(join(assetsDir(), file), bytes);
|
|
147
|
+
const createdAt = Date.now();
|
|
71
148
|
const entry = {
|
|
72
149
|
id,
|
|
73
|
-
file,
|
|
74
150
|
kind,
|
|
75
151
|
lane,
|
|
76
152
|
model,
|
|
@@ -78,49 +154,162 @@ export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime,
|
|
|
78
154
|
options,
|
|
79
155
|
mime,
|
|
80
156
|
bytes: bytes.length,
|
|
81
|
-
createdAt
|
|
157
|
+
createdAt,
|
|
82
158
|
...meta,
|
|
83
159
|
};
|
|
160
|
+
const file = organizedAssetFile(entry);
|
|
161
|
+
const path = storedAssetPath(file);
|
|
162
|
+
if (!path) throw new Error('invalid media asset path');
|
|
163
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
164
|
+
writeFileSync(path, bytes);
|
|
165
|
+
entry.file = file;
|
|
84
166
|
withFileLockSync(indexLockPath(), () => {
|
|
85
167
|
const assets = [entry, ...readIndex()];
|
|
86
168
|
const kept = assets.slice(0, MAX_INDEX_ENTRIES);
|
|
87
169
|
for (const dropped of assets.slice(MAX_INDEX_ENTRIES)) {
|
|
88
|
-
|
|
170
|
+
const droppedPath = storedAssetPath(dropped.file);
|
|
171
|
+
try { if (droppedPath) unlinkSync(droppedPath); } catch {}
|
|
89
172
|
}
|
|
90
173
|
writeIndex(kept);
|
|
91
174
|
});
|
|
175
|
+
// The first gallery paint after a generation would otherwise be the one that
|
|
176
|
+
// pays for the tile rendition; build it now, off the caller's path.
|
|
177
|
+
void ensureRendition({
|
|
178
|
+
id: entry.id,
|
|
179
|
+
kind: entry.kind,
|
|
180
|
+
sourcePath: path,
|
|
181
|
+
variant: 'thumb',
|
|
182
|
+
cacheDir: renditionsDir(),
|
|
183
|
+
});
|
|
92
184
|
return entry;
|
|
93
185
|
}
|
|
94
186
|
|
|
95
187
|
export function listMediaAssets({ limit = 60, offset = 0, kind = null } = {}) {
|
|
188
|
+
ensureDirs();
|
|
96
189
|
const all = readIndex().filter((entry) => (kind ? entry.kind === kind : true));
|
|
97
190
|
const start = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
98
191
|
const count = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 60)));
|
|
99
|
-
|
|
192
|
+
const assets = all.slice(start, start + count);
|
|
193
|
+
// Assets generated before this cache existed have no tile rendition, so the
|
|
194
|
+
// first gallery paint used to wait on an encode per visible tile. Listing is
|
|
195
|
+
// the earliest moment the host knows WHICH tiles are coming: warm them here,
|
|
196
|
+
// off the caller's path (ensureRendition dedupes the concurrent request).
|
|
197
|
+
void prewarmThumbs(assets);
|
|
198
|
+
return { total: all.length, assets };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Bounded: the encoder is shared with generation, and a large page must not
|
|
202
|
+
// turn one listing into dozens of parallel sharp/ffmpeg jobs.
|
|
203
|
+
const PREWARM_LIMIT = 24;
|
|
204
|
+
const PREWARM_CONCURRENCY = 2;
|
|
205
|
+
|
|
206
|
+
async function prewarmThumbs(assets) {
|
|
207
|
+
const queue = assets.slice(0, PREWARM_LIMIT);
|
|
208
|
+
const workers = Array.from({ length: PREWARM_CONCURRENCY }, async () => {
|
|
209
|
+
for (let entry = queue.shift(); entry; entry = queue.shift()) {
|
|
210
|
+
const path = storedAssetPath(entry.file);
|
|
211
|
+
if (!path || !existsSync(path)) continue;
|
|
212
|
+
const rendition = await ensureRendition({
|
|
213
|
+
id: entry.id,
|
|
214
|
+
kind: entry.kind,
|
|
215
|
+
sourcePath: path,
|
|
216
|
+
variant: 'thumb',
|
|
217
|
+
cacheDir: renditionsDir(),
|
|
218
|
+
}).catch(() => null);
|
|
219
|
+
if (rendition?.durationSeconds) rememberDuration(entry, rendition.durationSeconds);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
await Promise.all(workers).catch(() => {});
|
|
100
223
|
}
|
|
101
224
|
|
|
102
225
|
export function getMediaAsset(id) {
|
|
226
|
+
ensureDirs();
|
|
103
227
|
return readIndex().find((entry) => entry.id === id) || null;
|
|
104
228
|
}
|
|
105
229
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!entry) return
|
|
110
|
-
|
|
111
|
-
|
|
230
|
+
/** Persist a duration probed while building a poster, so the gallery badge
|
|
231
|
+
* survives a restart without decoding the clip again. */
|
|
232
|
+
function rememberDuration(entry, durationSeconds) {
|
|
233
|
+
if (!durationSeconds || entry.durationSeconds === durationSeconds) return;
|
|
234
|
+
withFileLockSync(indexLockPath(), () => {
|
|
235
|
+
const assets = readIndex();
|
|
236
|
+
const row = assets.find((item) => item.id === entry.id);
|
|
237
|
+
if (!row || row.durationSeconds === durationSeconds) return;
|
|
238
|
+
row.durationSeconds = durationSeconds;
|
|
239
|
+
writeIndex(assets);
|
|
240
|
+
});
|
|
241
|
+
entry.durationSeconds = durationSeconds;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Base64 for the inline (RPC) path. Large files are refused: bytes that big
|
|
245
|
+
* belong on the file route, not in a JSON frame. */
|
|
246
|
+
function inlineBase64(path) {
|
|
112
247
|
const size = statSync(path).size;
|
|
113
248
|
if (size > MAX_INLINE_BYTES) {
|
|
114
249
|
const err = new Error('media asset is too large to preview');
|
|
115
250
|
err.code = 'MEDIA_ASSET_TOO_LARGE';
|
|
116
251
|
throw err;
|
|
117
252
|
}
|
|
118
|
-
return
|
|
253
|
+
return readFileSync(path).toString('base64');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Locate an asset (or one of its renditions) as a FILE — no inlining.
|
|
258
|
+
*
|
|
259
|
+
* This is the unit the media routes serve: the client receives a cacheable,
|
|
260
|
+
* range-able response instead of a base64 payload riding the RPC lane.
|
|
261
|
+
* `available: false` means this host cannot build the rendition (no sharp /
|
|
262
|
+
* no ffmpeg); the caller reduces the feature instead of substituting the
|
|
263
|
+
* original behind the client's back.
|
|
264
|
+
*/
|
|
265
|
+
export async function resolveMediaFile(id, { variant = 'original' } = {}) {
|
|
266
|
+
const entry = getMediaAsset(id);
|
|
267
|
+
if (!entry) return null;
|
|
268
|
+
const path = storedAssetPath(entry.file);
|
|
269
|
+
if (!path || !existsSync(path)) return null;
|
|
270
|
+
if (variant === 'original') return { ...entry, variant: 'original', path, available: true };
|
|
271
|
+
if (!renditionSpec(variant)) throw new Error(`unknown media variant: ${variant}`);
|
|
272
|
+
const rendition = await ensureRendition({
|
|
273
|
+
id: entry.id,
|
|
274
|
+
kind: entry.kind,
|
|
275
|
+
sourcePath: path,
|
|
276
|
+
variant,
|
|
277
|
+
cacheDir: renditionsDir(),
|
|
278
|
+
});
|
|
279
|
+
if (!rendition) return { ...entry, variant, path: '', available: false };
|
|
280
|
+
rememberDuration(entry, rendition.durationSeconds);
|
|
281
|
+
return {
|
|
282
|
+
...entry,
|
|
283
|
+
...(rendition.durationSeconds ? { durationSeconds: rendition.durationSeconds } : {}),
|
|
284
|
+
variant,
|
|
285
|
+
path: rendition.path,
|
|
286
|
+
mime: rendition.mime,
|
|
287
|
+
bytes: rendition.bytes,
|
|
288
|
+
available: true,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Inline an asset for the renderer (base64 + mime).
|
|
294
|
+
*
|
|
295
|
+
* `variant` selects a derived rendition ('thumb' | 'display'); the original is
|
|
296
|
+
* read only when it is asked for. A host that cannot build the rendition says
|
|
297
|
+
* so (`available: false`) instead of quietly shipping full-size bytes — the
|
|
298
|
+
* caller that can afford them (local IPC) opts in with `allowOriginal`.
|
|
299
|
+
*/
|
|
300
|
+
export async function readMediaAsset(id, { variant = 'original', allowOriginal = false } = {}) {
|
|
301
|
+
const file = await resolveMediaFile(id, { variant });
|
|
302
|
+
if (!file) return null;
|
|
303
|
+
if (file.available) return { ...file, base64: inlineBase64(file.path) };
|
|
304
|
+
if (!allowOriginal) return { ...file, base64: '' };
|
|
305
|
+
const original = await resolveMediaFile(id, { variant: 'original' });
|
|
306
|
+
if (!original) return null;
|
|
307
|
+
return { ...original, downgraded: true, base64: inlineBase64(original.path) };
|
|
119
308
|
}
|
|
120
309
|
|
|
121
310
|
export function mediaAssetPath(id) {
|
|
122
311
|
const entry = getMediaAsset(id);
|
|
123
|
-
return entry ?
|
|
312
|
+
return entry ? storedAssetPath(entry.file) : null;
|
|
124
313
|
}
|
|
125
314
|
|
|
126
315
|
/**
|
|
@@ -151,12 +340,15 @@ function openWithOs(path) {
|
|
|
151
340
|
}
|
|
152
341
|
|
|
153
342
|
export function deleteMediaAsset(id) {
|
|
343
|
+
ensureDirs();
|
|
154
344
|
let removed = false;
|
|
155
345
|
withFileLockSync(indexLockPath(), () => {
|
|
156
346
|
const assets = readIndex();
|
|
157
347
|
const entry = assets.find((row) => row.id === id);
|
|
158
348
|
if (!entry) return;
|
|
159
|
-
|
|
349
|
+
const path = storedAssetPath(entry.file);
|
|
350
|
+
try { if (path) unlinkSync(path); } catch {}
|
|
351
|
+
removeRenditions(renditionsDir(), id);
|
|
160
352
|
writeIndex(assets.filter((row) => row.id !== id));
|
|
161
353
|
removed = true;
|
|
162
354
|
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
|
|
14
|
+
test('media assets are organized by kind, provider, model, and local date while flat files migrate', async () => {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-media-store-'));
|
|
16
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
17
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
18
|
+
const assetsDir = join(root, 'media', 'assets');
|
|
19
|
+
const createdAt = new Date(2026, 6, 5, 12, 0, 0).getTime();
|
|
20
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
21
|
+
writeFileSync(join(assetsDir, 'legacy-image.jpg'), Buffer.from('image'));
|
|
22
|
+
writeFileSync(join(assetsDir, 'legacy-video.mp4'), Buffer.from('video'));
|
|
23
|
+
writeFileSync(join(root, 'media', 'index.json'), JSON.stringify({
|
|
24
|
+
version: 1,
|
|
25
|
+
assets: [
|
|
26
|
+
{
|
|
27
|
+
id: 'legacy-image',
|
|
28
|
+
file: 'legacy-image.jpg',
|
|
29
|
+
kind: 'image',
|
|
30
|
+
lane: 'gemini',
|
|
31
|
+
model: 'image-alpha',
|
|
32
|
+
prompt: 'image',
|
|
33
|
+
options: { aspectRatio: '4:3' },
|
|
34
|
+
mime: 'image/jpeg',
|
|
35
|
+
bytes: 5,
|
|
36
|
+
createdAt,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'legacy-video',
|
|
40
|
+
file: 'legacy-video.mp4',
|
|
41
|
+
kind: 'video',
|
|
42
|
+
lane: 'grok',
|
|
43
|
+
model: 'video-beta',
|
|
44
|
+
prompt: 'video',
|
|
45
|
+
options: { aspectRatio: '16:9' },
|
|
46
|
+
mime: 'video/mp4',
|
|
47
|
+
bytes: 5,
|
|
48
|
+
createdAt,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const store = await import(`./store.mjs?test=${Date.now()}`);
|
|
55
|
+
const listed = store.listMediaAssets({ limit: 10 }).assets;
|
|
56
|
+
assert.equal(listed[0].file, 'images/gemini/image-alpha/2026-07-05/legacy-image.jpg');
|
|
57
|
+
assert.equal(listed[1].file, 'videos/grok/video-beta/2026-07-05/legacy-video.mp4');
|
|
58
|
+
assert.equal(existsSync(join(assetsDir, ...listed[0].file.split('/'))), true);
|
|
59
|
+
assert.equal(existsSync(join(assetsDir, ...listed[1].file.split('/'))), true);
|
|
60
|
+
assert.equal(existsSync(join(assetsDir, 'legacy-image.jpg')), false);
|
|
61
|
+
assert.equal(
|
|
62
|
+
(await store.readMediaAsset('legacy-video')).base64,
|
|
63
|
+
Buffer.from('video').toString('base64'),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// A tile-sized rendition is impossible for these stub bytes (and video
|
|
67
|
+
// needs ffmpeg): the read must REPORT the miss instead of quietly
|
|
68
|
+
// shipping the original, which is what made the remote gallery slow.
|
|
69
|
+
const missing = await store.readMediaAsset('legacy-image', { variant: 'thumb' });
|
|
70
|
+
assert.equal(missing.available, false);
|
|
71
|
+
assert.equal(missing.base64, '');
|
|
72
|
+
// Callers that can afford full-size bytes (local IPC) opt in explicitly
|
|
73
|
+
// and get them labelled as a downgrade.
|
|
74
|
+
const downgraded = await store.readMediaAsset('legacy-image', {
|
|
75
|
+
variant: 'thumb',
|
|
76
|
+
allowOriginal: true,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(downgraded.variant, 'original');
|
|
79
|
+
assert.equal(downgraded.downgraded, true);
|
|
80
|
+
assert.equal(downgraded.base64, Buffer.from('image').toString('base64'));
|
|
81
|
+
|
|
82
|
+
const saved = store.saveMediaAsset({
|
|
83
|
+
kind: 'video',
|
|
84
|
+
lane: 'gemini',
|
|
85
|
+
model: 'veo:3/preview',
|
|
86
|
+
prompt: 'new clip',
|
|
87
|
+
options: { aspectRatio: '16:9' },
|
|
88
|
+
mime: 'video/mp4',
|
|
89
|
+
bytes: Buffer.from('new-video'),
|
|
90
|
+
});
|
|
91
|
+
assert.match(saved.file, /^videos\/gemini\/veo-3-preview\/\d{4}-\d{2}-\d{2}\/[0-9a-f-]+\.mp4$/);
|
|
92
|
+
assert.equal(existsSync(join(assetsDir, ...saved.file.split('/'))), true);
|
|
93
|
+
assert.equal(store.deleteMediaAsset(saved.id).removed, true);
|
|
94
|
+
assert.equal(existsSync(join(assetsDir, ...saved.file.split('/'))), false);
|
|
95
|
+
|
|
96
|
+
const persisted = JSON.parse(readFileSync(join(root, 'media', 'index.json'), 'utf8'));
|
|
97
|
+
assert.equal(persisted.version, 2);
|
|
98
|
+
} finally {
|
|
99
|
+
if (previousDataDir === undefined) delete process.env.MIXDOG_DATA_DIR;
|
|
100
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
101
|
+
rmSync(root, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -52,6 +52,15 @@ export function createLifecycleApi(deps) {
|
|
|
52
52
|
pushTranscriptRebind,
|
|
53
53
|
notificationListeners, remoteStateListeners, desktopSession,
|
|
54
54
|
} = deps;
|
|
55
|
+
const closeSurfaceSession = (session, reason, options) => {
|
|
56
|
+
if (!session?.id) return false;
|
|
57
|
+
// A remote-attached session is only a viewer handle owned by this surface.
|
|
58
|
+
// Closing it through the shared manager bumps the durable generation and
|
|
59
|
+
// invalidates the real owner's in-flight turn. Viewer exits therefore
|
|
60
|
+
// detach locally; only the process that owns the runtime may close it.
|
|
61
|
+
if (session.remoteAttached === true) return true;
|
|
62
|
+
return mgr.closeSession(session.id, reason, options);
|
|
63
|
+
};
|
|
55
64
|
const listLeadSessions = (options = {}) => {
|
|
56
65
|
const heartbeatMtimes = listSessionHeartbeatMtimes();
|
|
57
66
|
return mgr.listSessions({
|
|
@@ -245,7 +254,7 @@ export function createLifecycleApi(deps) {
|
|
|
245
254
|
// first-turn exit could still burn a real session.
|
|
246
255
|
const tombstone = !hasUserConversationMessage(session.messages)
|
|
247
256
|
&& !hasUserConversationMessage(session.liveTurnMessages);
|
|
248
|
-
ok =
|
|
257
|
+
ok = closeSurfaceSession(session, reason, { tombstone });
|
|
249
258
|
setSession(null);
|
|
250
259
|
}
|
|
251
260
|
invalidateContextStatusCache();
|
|
@@ -345,7 +354,7 @@ export function createLifecycleApi(deps) {
|
|
|
345
354
|
statusRoutes?.clearGatewaySessionRoute?.(session.id);
|
|
346
355
|
const tombstone = !hasUserConversationMessage(session.messages)
|
|
347
356
|
&& !hasUserConversationMessage(session.liveTurnMessages);
|
|
348
|
-
|
|
357
|
+
closeSurfaceSession(session, cleanupReason, { tombstone });
|
|
349
358
|
setSession(null);
|
|
350
359
|
}
|
|
351
360
|
setDesktopSession(nextDesktopSession && typeof nextDesktopSession === 'object'
|
|
@@ -376,7 +385,7 @@ export function createLifecycleApi(deps) {
|
|
|
376
385
|
void ingestSessionIntoMemory(session);
|
|
377
386
|
const tombstone = !hasUserConversationMessage(session.messages)
|
|
378
387
|
&& !hasUserConversationMessage(session.liveTurnMessages);
|
|
379
|
-
|
|
388
|
+
closeSurfaceSession(session, 'cli-new', { tombstone });
|
|
380
389
|
setSession(null);
|
|
381
390
|
}
|
|
382
391
|
invalidateContextStatusCache();
|
|
@@ -411,7 +420,7 @@ export function createLifecycleApi(deps) {
|
|
|
411
420
|
void ingestSessionIntoMemory(prev);
|
|
412
421
|
const tombstone = !hasUserConversationMessage(previousMessages)
|
|
413
422
|
&& !hasUserConversationMessage(previousLive);
|
|
414
|
-
|
|
423
|
+
closeSurfaceSession(prev, 'cli-resume', { tombstone });
|
|
415
424
|
}
|
|
416
425
|
setSession(resumed);
|
|
417
426
|
applyResolvedCwd(resolveResumeCwd(resumed, getCurrentCwd()), { markRefresh: false });
|
|
@@ -31,8 +31,11 @@ export function createMediaApi() {
|
|
|
31
31
|
async listMediaAssets(options) {
|
|
32
32
|
return (await media()).listMediaAssets(options || {});
|
|
33
33
|
},
|
|
34
|
-
async readMediaAsset(id) {
|
|
35
|
-
return (await media()).readMediaAsset(id);
|
|
34
|
+
async readMediaAsset(id, options) {
|
|
35
|
+
return (await media()).readMediaAsset(id, options || {});
|
|
36
|
+
},
|
|
37
|
+
async resolveMediaFile(id, options) {
|
|
38
|
+
return (await media()).resolveMediaFile(id, options || {});
|
|
36
39
|
},
|
|
37
40
|
async deleteMediaAsset(id) {
|
|
38
41
|
return (await media()).deleteMediaAsset(id);
|
|
@@ -44,7 +44,6 @@ const SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
|
|
|
44
44
|
// "Re-attached after compaction…" rows in Recent (user report). Skipping
|
|
45
45
|
// them titles the session from its first REAL user message instead.
|
|
46
46
|
/^re-attached after compaction\b/i,
|
|
47
|
-
/^reference files:\s/i,
|
|
48
47
|
/^the async (?:agent|shell) task\b/i,
|
|
49
48
|
]);
|
|
50
49
|
|
|
@@ -131,6 +131,32 @@ function toolSearchNativePayload(catalog, names, provider = '') {
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Schemas for tools that are ALREADY active. They are deliberately NOT added
|
|
135
|
+
// to toolReferences/openaiTools: re-announcing them would rewrite the request
|
|
136
|
+
// tools array and break the cached prefix. The caller only ever sees names
|
|
137
|
+
// otherwise, so the parameter contract (enums, limits) rides in the RESULT.
|
|
138
|
+
function activeToolSchemas(catalog, session, names) {
|
|
139
|
+
const wanted = new Set((names || []).map(clean).filter(Boolean));
|
|
140
|
+
if (!wanted.size) return [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const specs = [];
|
|
143
|
+
for (const pool of [Array.isArray(session?.tools) ? session.tools : [], catalog || []]) {
|
|
144
|
+
for (const tool of pool) {
|
|
145
|
+
const name = clean(tool?.name);
|
|
146
|
+
if (!name || !wanted.has(name) || seen.has(name)) continue;
|
|
147
|
+
seen.add(name);
|
|
148
|
+
specs.push({
|
|
149
|
+
name,
|
|
150
|
+
description: clean(tool?.description),
|
|
151
|
+
parameters: tool?.inputSchema && typeof tool.inputSchema === 'object'
|
|
152
|
+
? tool.inputSchema
|
|
153
|
+
: { type: 'object', properties: {} },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return specs;
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
// Plain case-insensitive substring filter over name + description. No scores,
|
|
135
161
|
// no aliases, no auto-selection: tool_search only lists and (via select)
|
|
136
162
|
// loads. Empty query matches every row.
|
|
@@ -734,9 +760,14 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
|
|
|
734
760
|
summary: '',
|
|
735
761
|
})
|
|
736
762
|
: null;
|
|
763
|
+
const alreadyActiveSchemas = activeToolSchemas(catalog, session, alreadyActive);
|
|
737
764
|
const nativeSummary = [
|
|
738
765
|
...(loaded.length ? [`Loaded deferred tools: ${loaded.join(', ')}`] : []),
|
|
739
766
|
...(alreadyActive.length ? [`Already active: ${alreadyActive.join(', ')}`] : []),
|
|
767
|
+
// The native path replaces the whole JSON result with this summary
|
|
768
|
+
// (tool-batch), so an already-active tool's schema has to travel here or
|
|
769
|
+
// the caller keeps guessing its parameters.
|
|
770
|
+
...(alreadyActiveSchemas.length ? [`Already-active schemas: ${JSON.stringify(alreadyActiveSchemas)}`] : []),
|
|
740
771
|
].join('\n');
|
|
741
772
|
const nativeToolSearch = nativeToolSearchBase
|
|
742
773
|
? { ...nativeToolSearchBase, summary: nativeSummary || nativeToolSearchBase.summary }
|
|
@@ -754,6 +785,7 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
|
|
|
754
785
|
...(nativeToolSearch ? { nativeToolSearch } : {}),
|
|
755
786
|
loaded,
|
|
756
787
|
alreadyActive,
|
|
788
|
+
...(alreadyActiveSchemas.length ? { alreadyActiveSchemas } : {}),
|
|
757
789
|
missing,
|
|
758
790
|
...(blocked.length ? { blocked } : {}),
|
|
759
791
|
...mcpFields,
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -23945,7 +23945,6 @@ var SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
|
|
|
23945
23945
|
// "Re-attached after compaction…" rows in Recent (user report). Skipping
|
|
23946
23946
|
// them titles the session from its first REAL user message instead.
|
|
23947
23947
|
/^re-attached after compaction\b/i,
|
|
23948
|
-
/^reference files:\s/i,
|
|
23949
23948
|
/^the async (?:agent|shell) task\b/i
|
|
23950
23949
|
]);
|
|
23951
23950
|
var LATE_TOOL_ANNOUNCEMENT_SENTINEL = "connected after this session started";
|
|
@@ -28406,7 +28405,8 @@ function createEngineApiB(bag) {
|
|
|
28406
28405
|
// generation start posts a notice so the TUI shows background work.
|
|
28407
28406
|
listMediaLanes: () => runtime.listMediaLanes?.(),
|
|
28408
28407
|
listMediaAssets: (options) => runtime.listMediaAssets?.(options),
|
|
28409
|
-
readMediaAsset: (id) => runtime.readMediaAsset?.(id),
|
|
28408
|
+
readMediaAsset: (id, options) => runtime.readMediaAsset?.(id, options),
|
|
28409
|
+
resolveMediaFile: (id, options) => runtime.resolveMediaFile?.(id, options),
|
|
28410
28410
|
getMediaJob: (id) => runtime.getMediaJob?.(id),
|
|
28411
28411
|
listMediaJobs: () => runtime.listMediaJobs?.(),
|
|
28412
28412
|
// No notice on start: the Studio surfaces progress on its own pending tile,
|
|
@@ -677,7 +677,8 @@ export function createEngineApiB(bag) {
|
|
|
677
677
|
// generation start posts a notice so the TUI shows background work.
|
|
678
678
|
listMediaLanes: () => runtime.listMediaLanes?.(),
|
|
679
679
|
listMediaAssets: (options) => runtime.listMediaAssets?.(options),
|
|
680
|
-
readMediaAsset: (id) => runtime.readMediaAsset?.(id),
|
|
680
|
+
readMediaAsset: (id, options) => runtime.readMediaAsset?.(id, options),
|
|
681
|
+
resolveMediaFile: (id, options) => runtime.resolveMediaFile?.(id, options),
|
|
681
682
|
getMediaJob: (id) => runtime.getMediaJob?.(id),
|
|
682
683
|
listMediaJobs: () => runtime.listMediaJobs?.(),
|
|
683
684
|
// No notice on start: the Studio surfaces progress on its own pending tile,
|