claude-code-kanban 4.26.0 → 4.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/inline-assets.js +1 -1
- package/lib/parsers.js +113 -0
- package/lib/scratch-files.js +47 -0
- package/package.json +1 -1
- package/public/app.js +496 -83
- package/public/index.html +5 -0
- package/public/style.css +214 -17
- package/server.js +407 -73
package/server.js
CHANGED
|
@@ -13,6 +13,7 @@ const { spawnSync, spawn } = require('node:child_process');
|
|
|
13
13
|
const { assertOpenTarget, openInEditor, whichSync, exeBehindShim } = require('./lib/open-editor');
|
|
14
14
|
const { createNetGuard } = require('./lib/net-guard');
|
|
15
15
|
const { isContained } = require('./lib/contain');
|
|
16
|
+
const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
|
|
16
17
|
const { fileUrlToPath } = require('./lib/file-url');
|
|
17
18
|
|
|
18
19
|
const {
|
|
@@ -22,6 +23,7 @@ const {
|
|
|
22
23
|
buildSessionDigest,
|
|
23
24
|
readCompactSummaries,
|
|
24
25
|
readArtifactLinks,
|
|
26
|
+
readScratchpadCreations,
|
|
25
27
|
extractPromptFromTranscript,
|
|
26
28
|
extractModelFromTranscript,
|
|
27
29
|
extractStructuredResultFromTranscript,
|
|
@@ -33,7 +35,7 @@ const {
|
|
|
33
35
|
updateLoopInfo,
|
|
34
36
|
buildLoopInfoFromState
|
|
35
37
|
} = require('./lib/parsers');
|
|
36
|
-
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
38
|
+
const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
37
39
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
38
40
|
const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
|
|
39
41
|
|
|
@@ -1159,7 +1161,9 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1159
1161
|
const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
|
|
1160
1162
|
|
|
1161
1163
|
const pinnedParam = req.query.pinned;
|
|
1164
|
+
const includeIds = req.query.include ? new Set(req.query.include.split(',').filter(Boolean)) : new Set();
|
|
1162
1165
|
const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
|
|
1166
|
+
for (const id of includeIds) pinnedIds.add(id);
|
|
1163
1167
|
const activeFilter = req.query.filter === 'active';
|
|
1164
1168
|
|
|
1165
1169
|
const metadata = loadSessionMetadata();
|
|
@@ -1502,8 +1506,12 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1502
1506
|
|
|
1503
1507
|
// Apply project filter before limit so the limit is per-project
|
|
1504
1508
|
const projectFilter = req.query.project;
|
|
1509
|
+
// `include` is narrower than `pinned`: pinned rows survive the limit but still obey
|
|
1510
|
+
// the project filter, because pinning is a preference and the filter is an intent.
|
|
1511
|
+
// The client sends the session it currently has open, which it cannot render at all
|
|
1512
|
+
// if the row is missing — that one is not a preference.
|
|
1505
1513
|
if (projectFilter) {
|
|
1506
|
-
sessions = sessions.filter(s => s.project === projectFilter);
|
|
1514
|
+
sessions = sessions.filter(s => s.project === projectFilter || includeIds.has(s.id));
|
|
1507
1515
|
}
|
|
1508
1516
|
|
|
1509
1517
|
// Apply limit if specified, but always include pinned sessions
|
|
@@ -1544,8 +1552,10 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
|
1544
1552
|
try {
|
|
1545
1553
|
const sessionPath = taskDirFor(req.params.sessionId);
|
|
1546
1554
|
|
|
1555
|
+
// A session that never used the board has no task dir. That is the common case and
|
|
1556
|
+
// an answer, not a failure; a 404 only reached the browser console.
|
|
1547
1557
|
if (!existsSync(sessionPath)) {
|
|
1548
|
-
return res.
|
|
1558
|
+
return res.json([]);
|
|
1549
1559
|
}
|
|
1550
1560
|
|
|
1551
1561
|
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
@@ -1621,11 +1631,13 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
1621
1631
|
try {
|
|
1622
1632
|
const metadata = loadSessionMetadata();
|
|
1623
1633
|
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1634
|
+
// Most sessions have no saved plan, and the info modal asks for one every time it
|
|
1635
|
+
// opens, so "no plan" is a normal answer rather than a 404 in the console.
|
|
1624
1636
|
const slug = meta?.slug;
|
|
1625
|
-
if (!slug) return res.
|
|
1637
|
+
if (!slug) return res.json({ content: null });
|
|
1626
1638
|
|
|
1627
1639
|
const planPath = path.join(PLANS_DIR, `${slug}.md`);
|
|
1628
|
-
if (!existsSync(planPath)) return res.
|
|
1640
|
+
if (!existsSync(planPath)) return res.json({ content: null });
|
|
1629
1641
|
|
|
1630
1642
|
const content = await fs.readFile(planPath, 'utf8');
|
|
1631
1643
|
res.json({ content, slug });
|
|
@@ -1652,18 +1664,25 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
|
1652
1664
|
}
|
|
1653
1665
|
});
|
|
1654
1666
|
|
|
1667
|
+
// Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
|
|
1668
|
+
// it already pays to notice the file grew. `load` may return a promise: the promise is
|
|
1669
|
+
// what gets cached, so concurrent callers share a single read. Null when the file is gone.
|
|
1670
|
+
function cachedByFileStat(cache, filePath, load) {
|
|
1671
|
+
let stat;
|
|
1672
|
+
try { stat = statSync(filePath); } catch (_) { return null; }
|
|
1673
|
+
const hit = cache.get(filePath);
|
|
1674
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
|
|
1675
|
+
const value = load();
|
|
1676
|
+
cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
|
1677
|
+
return value;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1655
1680
|
// Cold path — a full transcript scan, so the result is held until the file grows.
|
|
1656
1681
|
// The zen panel asks again on every re-render, which then costs one stat.
|
|
1657
1682
|
const artifactsByPath = new Map();
|
|
1658
1683
|
|
|
1659
1684
|
function getArtifactLinks(jsonlPath) {
|
|
1660
|
-
|
|
1661
|
-
try { stat = statSync(jsonlPath); } catch (_) { return []; }
|
|
1662
|
-
const hit = artifactsByPath.get(jsonlPath);
|
|
1663
|
-
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.artifacts;
|
|
1664
|
-
const artifacts = readArtifactLinks(jsonlPath);
|
|
1665
|
-
artifactsByPath.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, artifacts });
|
|
1666
|
-
return artifacts;
|
|
1685
|
+
return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
|
|
1667
1686
|
}
|
|
1668
1687
|
|
|
1669
1688
|
app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
@@ -1678,23 +1697,148 @@ app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
|
1678
1697
|
}
|
|
1679
1698
|
});
|
|
1680
1699
|
|
|
1700
|
+
// A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
|
|
1701
|
+
// external `scratch` CLI. cck only recognizes the shape and launches the viewer —
|
|
1702
|
+
// it reads nothing from the manifest but `name`, `created` and `id`, so the two stay
|
|
1703
|
+
// independently versioned.
|
|
1704
|
+
const SCRATCHPAD_MANIFEST = 'scratchpad.json';
|
|
1705
|
+
|
|
1706
|
+
// Fallback only, for a `scratch new` whose output the transcript never captured. A pad
|
|
1707
|
+
// has no central registry — the CLI treats the folder path as the pad's identity and
|
|
1708
|
+
// finds pads by scanning — and `_scratchpads/` is one user's habit, not a convention it
|
|
1709
|
+
// knows, so there is no layout to guess. Depth 3 rather than a deeper walk for the
|
|
1710
|
+
// reason docs/session-scanning.md records: pruned, it measures 0-3 ms on the worst-case
|
|
1711
|
+
// project dirs, while an unpruned walk of the same dirs costs ~800 ms.
|
|
1712
|
+
const PAD_SCAN_IGNORE = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build']);
|
|
1713
|
+
const PAD_SCAN_MAX_DEPTH = 3;
|
|
1714
|
+
// A pad manifest's `created` is written after the tool call starts, so the match is
|
|
1715
|
+
// a window, not an instant. Measured lag on a real `scratch new` was 4.1 s; the
|
|
1716
|
+
// window is wide enough to survive a slow disk and far too narrow to reach a pad
|
|
1717
|
+
// made in an earlier session.
|
|
1718
|
+
const PAD_CREATE_WINDOW_MS = 120000;
|
|
1719
|
+
|
|
1720
|
+
async function findPads(root, depth = 0) {
|
|
1721
|
+
let entries;
|
|
1722
|
+
try { entries = await fs.readdir(root, { withFileTypes: true }); } catch (_) { return []; }
|
|
1723
|
+
// A pad is never nested inside another pad, so a manifest ends the descent.
|
|
1724
|
+
if (entries.some((e) => e.isFile() && e.name === SCRATCHPAD_MANIFEST)) return [root];
|
|
1725
|
+
if (depth >= PAD_SCAN_MAX_DEPTH) return [];
|
|
1726
|
+
const nested = await Promise.all(
|
|
1727
|
+
entries
|
|
1728
|
+
.filter((e) => e.isDirectory() && !PAD_SCAN_IGNORE.has(e.name))
|
|
1729
|
+
.map((e) => findPads(path.join(root, e.name), depth + 1)),
|
|
1730
|
+
);
|
|
1731
|
+
return nested.flat();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// The pads a session made, as linked-doc rows. Derived on every read rather than
|
|
1735
|
+
// stored, so re-reading a transcript cannot drift; the client links each pad once
|
|
1736
|
+
// and records that it did, which is what stops a re-read undoing an unlink.
|
|
1737
|
+
async function readCreatedPads(meta) {
|
|
1738
|
+
const creations = readScratchpadCreations(meta.jsonlPath);
|
|
1739
|
+
// No `scratch new` in the transcript means no disk work at all, so sessions that
|
|
1740
|
+
// never touch the CLI pay a substring pass over the transcript and nothing more.
|
|
1741
|
+
if (!creations.length) return [];
|
|
1742
|
+
const reported = new Set(creations.filter((c) => c.path).map((c) => c.path));
|
|
1743
|
+
const unresolved = creations.filter((c) => !c.path);
|
|
1744
|
+
const askedFor = new Set(unresolved.map((c) => c.name).filter(Boolean));
|
|
1745
|
+
// Only a call whose name could not be read falls back to the clock, and it compares
|
|
1746
|
+
// against the call's second, not the call: the manifest records `created` to the
|
|
1747
|
+
// second while the transcript timestamps to the millisecond, so a pad written during
|
|
1748
|
+
// the same second as its own command otherwise reads as older than it.
|
|
1749
|
+
const byClock = unresolved.filter((c) => !c.name).map((c) => Math.floor(c.ts / 1000) * 1000);
|
|
1750
|
+
const sessionId = path.basename(meta.jsonlPath, '.jsonl');
|
|
1751
|
+
// The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
|
|
1752
|
+
// into subdirectories — a session that made a pad and then worked inside it reports
|
|
1753
|
+
// a cwd below the pad, and a scan from there finds nothing above it.
|
|
1754
|
+
const root = meta.project || meta.cwd;
|
|
1755
|
+
const scanned = unresolved.length && root ? await findPads(root) : [];
|
|
1756
|
+
const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
|
|
1757
|
+
const rows = await Promise.all(
|
|
1758
|
+
candidates.map(async (file) => {
|
|
1759
|
+
let manifest;
|
|
1760
|
+
try { manifest = JSON.parse(await fs.readFile(file, 'utf8')); } catch (_) { return null; }
|
|
1761
|
+
const created = Date.parse(manifest?.created);
|
|
1762
|
+
if (!Number.isFinite(created)) return null;
|
|
1763
|
+
const name = manifest.name || path.basename(path.dirname(file));
|
|
1764
|
+
// A path the CLI printed names its pad outright. Otherwise `scratch new --id`
|
|
1765
|
+
// settles it: the flag stamps the session that asked for the pad into the
|
|
1766
|
+
// manifest, so an id decides ownership both ways — one naming another session
|
|
1767
|
+
// disowns the pad whatever the name or the clock say. Only a pad with no id is
|
|
1768
|
+
// guessed at, by the name the command asked for and then by the clock.
|
|
1769
|
+
const claimed =
|
|
1770
|
+
reported.has(file) ||
|
|
1771
|
+
(manifest.id
|
|
1772
|
+
? manifest.id === sessionId
|
|
1773
|
+
: askedFor.has(name) || byClock.some((from) => created >= from && created - from <= PAD_CREATE_WINDOW_MS));
|
|
1774
|
+
if (!claimed) return null;
|
|
1775
|
+
return { path: file, name, created: manifest.created, ts: created };
|
|
1776
|
+
}),
|
|
1777
|
+
);
|
|
1778
|
+
return rows
|
|
1779
|
+
.filter(Boolean)
|
|
1780
|
+
.sort((a, b) => a.ts - b.ts)
|
|
1781
|
+
.map(({ ts: _ts, ...row }) => row);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Cold path — a full transcript scan, and a directory walk when the transcript did not
|
|
1785
|
+
// carry the pad paths, so the result is held until the transcript grows.
|
|
1786
|
+
const padsByPath = new Map();
|
|
1787
|
+
|
|
1788
|
+
function getCreatedPads(meta) {
|
|
1789
|
+
return cachedByFileStat(padsByPath, meta.jsonlPath, () => readCreatedPads(meta)) || [];
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
app.get('/api/sessions/:sessionId/pads', async (req, res) => {
|
|
1793
|
+
try {
|
|
1794
|
+
const metadata = loadSessionMetadata();
|
|
1795
|
+
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1796
|
+
if (!meta?.jsonlPath) return res.json({ pads: [] });
|
|
1797
|
+
res.json({ pads: await getCreatedPads(meta) });
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
console.error('Error reading created pads:', error);
|
|
1800
|
+
res.status(500).json({ error: 'Failed to read created pads' });
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
// API: List one level of a session's scratchpad dir — the root, or the folder named by
|
|
1805
|
+
// `?path=`, which is the absolute path a previous listing handed out and must still sit
|
|
1806
|
+
// under the root. One readdir per request, never a walk: sessions drop clones and
|
|
1807
|
+
// build output in there, and an unpruned walk costs three orders of magnitude more than
|
|
1808
|
+
// the readdir. The client asks for a folder only when the user opens it. Measurements
|
|
1809
|
+
// in docs/session-scanning.md.
|
|
1810
|
+
app.get('/api/sessions/:sessionId/scratchpad-files', async (req, res) => {
|
|
1811
|
+
try {
|
|
1812
|
+
const metadata = loadSessionMetadata();
|
|
1813
|
+
const id = metadata[req.params.sessionId] ? req.params.sessionId : resolveSessionId(req.params.sessionId);
|
|
1814
|
+
const meta = metadata[id];
|
|
1815
|
+
const root = meta ? getScratchpadDir(id, meta) : null;
|
|
1816
|
+
if (!root) return res.json({ files: [] });
|
|
1817
|
+
|
|
1818
|
+
const dir = resolveScratchSubdir(root, req.query.path);
|
|
1819
|
+
if (!dir) return res.status(400).json({ error: 'Path is outside the scratchpad dir' });
|
|
1820
|
+
|
|
1821
|
+
// The harness creates the dir lazily, so "missing" is the common case and lists as empty.
|
|
1822
|
+
res.json({ files: await listScratchDir(dir) });
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
console.error('Error listing scratchpad files:', error);
|
|
1825
|
+
res.status(500).json({ error: 'Failed to list scratchpad files' });
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1681
1829
|
// API: List workflow scripts for a session. Parses each script's meta for the
|
|
1682
1830
|
// canonical name + description (cold path — only when the workflow modal opens).
|
|
1683
|
-
app.get('/api/sessions/:sessionId/workflows',
|
|
1831
|
+
app.get('/api/sessions/:sessionId/workflows', (req, res) => {
|
|
1684
1832
|
try {
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
|
|
1695
|
-
};
|
|
1696
|
-
}),
|
|
1697
|
-
);
|
|
1833
|
+
const workflows = getWorkflowScripts(req.params.sessionId).map((w) => {
|
|
1834
|
+
const meta = getWorkflowMeta(w.path);
|
|
1835
|
+
return {
|
|
1836
|
+
id: w.id,
|
|
1837
|
+
name: meta.name || w.name,
|
|
1838
|
+
description: meta.description || null,
|
|
1839
|
+
modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
|
|
1840
|
+
};
|
|
1841
|
+
});
|
|
1698
1842
|
res.json({ workflows });
|
|
1699
1843
|
} catch (error) {
|
|
1700
1844
|
console.error('Error listing workflows:', error);
|
|
@@ -1728,6 +1872,20 @@ function matchStr(src, key) {
|
|
|
1728
1872
|
return m ? m[2] : null;
|
|
1729
1873
|
}
|
|
1730
1874
|
|
|
1875
|
+
const workflowMetaCache = new Map();
|
|
1876
|
+
const EMPTY_WORKFLOW_META = { name: null, description: null, phases: [] };
|
|
1877
|
+
// A script never changes mid-run, and three endpoints parse the same file — one of them
|
|
1878
|
+
// on a poll — so key the parse by the script's mtime.
|
|
1879
|
+
function getWorkflowMeta(scriptPath) {
|
|
1880
|
+
return cachedByMtime(
|
|
1881
|
+
workflowMetaCache,
|
|
1882
|
+
scriptPath,
|
|
1883
|
+
scriptPath,
|
|
1884
|
+
() => parseWorkflowMeta(readFileSync(scriptPath, 'utf8')),
|
|
1885
|
+
EMPTY_WORKFLOW_META,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1731
1889
|
function parseWorkflowMeta(source) {
|
|
1732
1890
|
const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
|
|
1733
1891
|
const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
|
|
@@ -1740,26 +1898,61 @@ function parseWorkflowMeta(source) {
|
|
|
1740
1898
|
return meta;
|
|
1741
1899
|
}
|
|
1742
1900
|
|
|
1743
|
-
// journal.jsonl records {type:'started',agentId} then
|
|
1744
|
-
// per workflow agent. Map agentId →
|
|
1745
|
-
// "N/M agents" ratio the
|
|
1901
|
+
// journal.jsonl records {type:'started',agentId,phase,label} then
|
|
1902
|
+
// {type:'result',agentId,...} per workflow agent. Map agentId →
|
|
1903
|
+
// {started, done, phase, label}; done count matches the "N/M agents" ratio the
|
|
1904
|
+
// /workflows viewer shows.
|
|
1905
|
+
const workflowJournalCache = new Map();
|
|
1906
|
+
function workflowJournalPath(runDir) {
|
|
1907
|
+
return path.join(runDir, 'journal.jsonl');
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1746
1910
|
function readWorkflowJournal(runDir) {
|
|
1911
|
+
const journalPath = workflowJournalPath(runDir);
|
|
1912
|
+
return cachedByMtime(workflowJournalCache, journalPath, journalPath, () => parseWorkflowJournal(journalPath), new Map());
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function parseWorkflowJournal(journalPath) {
|
|
1747
1916
|
const status = new Map();
|
|
1748
1917
|
let content;
|
|
1749
|
-
try { content = readFileSync(
|
|
1918
|
+
try { content = readFileSync(journalPath, 'utf8'); } catch { return status; }
|
|
1750
1919
|
for (const line of content.split('\n')) {
|
|
1751
1920
|
if (!line.trim()) continue;
|
|
1752
1921
|
let o;
|
|
1753
1922
|
try { o = JSON.parse(line); } catch { continue; }
|
|
1754
1923
|
if (!o.agentId) continue;
|
|
1755
|
-
const e = status.get(o.agentId) || { started: false, done: false };
|
|
1924
|
+
const e = status.get(o.agentId) || { started: false, done: false, phase: null, label: null };
|
|
1756
1925
|
if (o.type === 'started') e.started = true;
|
|
1757
1926
|
else if (o.type === 'result') e.done = true;
|
|
1927
|
+
if (o.phase) e.phase = o.phase;
|
|
1928
|
+
if (o.label) e.label = o.label;
|
|
1758
1929
|
status.set(o.agentId, e);
|
|
1759
1930
|
}
|
|
1760
1931
|
return status;
|
|
1761
1932
|
}
|
|
1762
1933
|
|
|
1934
|
+
// One definition of "how far along is this run", shared by the modal's /run view and
|
|
1935
|
+
// the sidebar widget, so the two can never disagree about what counts as done.
|
|
1936
|
+
function summarizeWorkflowJournal(journal) {
|
|
1937
|
+
const byPhase = new Map();
|
|
1938
|
+
const running = [];
|
|
1939
|
+
let startedCount = 0;
|
|
1940
|
+
let doneCount = 0;
|
|
1941
|
+
for (const e of journal.values()) {
|
|
1942
|
+
if (!e.started) continue;
|
|
1943
|
+
const phase = e.phase || '';
|
|
1944
|
+
const c = byPhase.get(phase) || { started: 0, done: 0 };
|
|
1945
|
+
startedCount++;
|
|
1946
|
+
c.started++;
|
|
1947
|
+
if (e.done) {
|
|
1948
|
+
doneCount++;
|
|
1949
|
+
c.done++;
|
|
1950
|
+
} else running.push(e.label || phase || 'agent');
|
|
1951
|
+
byPhase.set(phase, c);
|
|
1952
|
+
}
|
|
1953
|
+
return { startedCount, doneCount, byPhase, running };
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1763
1956
|
// A workflow's run artifacts (journal + agent transcripts) live at
|
|
1764
1957
|
// <sessionDir>/subagents/workflows/<wfId>/. The script itself sits under
|
|
1765
1958
|
// <sessionDir>/workflows/scripts/, so the run dir is derivable from the script
|
|
@@ -1767,8 +1960,9 @@ function readWorkflowJournal(runDir) {
|
|
|
1767
1960
|
// then to scanning every project/session (the session dir can sit under a
|
|
1768
1961
|
// different projEnc than the script when a workflow runs from another cwd). wfId
|
|
1769
1962
|
// comes from the trusted script index (validated filename), so it can't
|
|
1770
|
-
// traverse.
|
|
1771
|
-
|
|
1963
|
+
// traverse. That last scan is a cold path only — `skipScan` is what keeps it out
|
|
1964
|
+
// of the polled live view, where a script with no run dir would pay it every tick.
|
|
1965
|
+
function resolveWorkflowRunDir(meta, wfId, scriptPath, skipScan = false) {
|
|
1772
1966
|
const rel = path.join('subagents', 'workflows', wfId);
|
|
1773
1967
|
if (scriptPath) {
|
|
1774
1968
|
const sessionDir = path.dirname(path.dirname(path.dirname(scriptPath)));
|
|
@@ -1779,6 +1973,7 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
|
|
|
1779
1973
|
const local = path.join(sessionDirFromMeta(meta), rel);
|
|
1780
1974
|
if (existsSync(local)) return local;
|
|
1781
1975
|
}
|
|
1976
|
+
if (skipScan) return null;
|
|
1782
1977
|
try {
|
|
1783
1978
|
for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
|
|
1784
1979
|
if (!proj.isDirectory()) continue;
|
|
@@ -1797,15 +1992,13 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
|
|
|
1797
1992
|
|
|
1798
1993
|
// API: Workflow run state — declared phases (from the script) plus the agent
|
|
1799
1994
|
// roster (type/model/output-tokens/duration/status) reconstructed from the run
|
|
1800
|
-
// dir's journal + transcripts.
|
|
1801
|
-
//
|
|
1995
|
+
// dir's journal + transcripts. The roster is flat: the journal carries each
|
|
1996
|
+
// agent's phase and label, but the modal lists agents in start order.
|
|
1802
1997
|
app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
1803
1998
|
try {
|
|
1804
1999
|
const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
|
|
1805
2000
|
if (!wf) return res.status(404).json({ error: 'Workflow not found' });
|
|
1806
|
-
|
|
1807
|
-
try { source = await fs.readFile(wf.path, 'utf8'); } catch (_) {}
|
|
1808
|
-
const parsed = parseWorkflowMeta(source);
|
|
2001
|
+
const parsed = getWorkflowMeta(wf.path);
|
|
1809
2002
|
|
|
1810
2003
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
1811
2004
|
const meta = loadSessionMetadata()[sessionId] || {};
|
|
@@ -1824,17 +2017,19 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
1824
2017
|
try {
|
|
1825
2018
|
type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
|
|
1826
2019
|
} catch (_) {}
|
|
1827
|
-
const
|
|
2020
|
+
const entry = journal.get(agentId);
|
|
1828
2021
|
const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
|
|
1829
2022
|
if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
|
|
1830
2023
|
agents.push({
|
|
1831
2024
|
agentId,
|
|
1832
2025
|
type,
|
|
2026
|
+
phase: entry?.phase || null,
|
|
2027
|
+
label: entry?.label || null,
|
|
1833
2028
|
model: stats.model || null,
|
|
1834
2029
|
outputTokens: stats.outputTokens || 0,
|
|
1835
2030
|
durationMs,
|
|
1836
2031
|
startedAt: stats.firstTs || null,
|
|
1837
|
-
status: done ? 'done' : 'running',
|
|
2032
|
+
status: entry?.done ? 'done' : 'running',
|
|
1838
2033
|
});
|
|
1839
2034
|
}
|
|
1840
2035
|
agents.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
|
|
@@ -1842,12 +2037,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
1842
2037
|
// Roster is start-ordered, so the earliest start is simply the first agent.
|
|
1843
2038
|
const startedAt = agents[0]?.startedAt || null;
|
|
1844
2039
|
|
|
1845
|
-
let startedCount =
|
|
1846
|
-
let doneCount = 0;
|
|
1847
|
-
for (const e of journal.values()) {
|
|
1848
|
-
if (e.started) startedCount++;
|
|
1849
|
-
if (e.done) doneCount++;
|
|
1850
|
-
}
|
|
2040
|
+
let { startedCount, doneCount } = summarizeWorkflowJournal(journal);
|
|
1851
2041
|
if (!startedCount) {
|
|
1852
2042
|
startedCount = agents.length;
|
|
1853
2043
|
doneCount = agents.filter((a) => a.status === 'done').length;
|
|
@@ -1870,6 +2060,58 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
1870
2060
|
}
|
|
1871
2061
|
});
|
|
1872
2062
|
|
|
2063
|
+
// A workflow has no terminal journal entry, so "still running" is every started
|
|
2064
|
+
// agent without a result plus a journal that moved recently — a run killed
|
|
2065
|
+
// mid-flight would otherwise stay live forever.
|
|
2066
|
+
const WORKFLOW_LIVE_MAX_IDLE_MS = 10 * 60 * 1000;
|
|
2067
|
+
// Only the newest few scripts can hold the live run, and each miss costs two
|
|
2068
|
+
// existsSync probes — a session that has accumulated scripts must not turn the
|
|
2069
|
+
// poll into a scan of all of them.
|
|
2070
|
+
const WORKFLOW_LIVE_MAX_SCRIPTS = 3;
|
|
2071
|
+
|
|
2072
|
+
// API: The session's running workflow, or null. The zen panel polls this, so it
|
|
2073
|
+
// reads the journal only — never the agent transcripts the /run view parses.
|
|
2074
|
+
app.get('/api/sessions/:sessionId/workflow-live', (req, res) => {
|
|
2075
|
+
try {
|
|
2076
|
+
const sessionId = resolveSessionId(req.params.sessionId);
|
|
2077
|
+
const meta = loadSessionMetadata()[sessionId] || {};
|
|
2078
|
+
for (const wf of getWorkflowScripts(req.params.sessionId).slice(0, WORKFLOW_LIVE_MAX_SCRIPTS)) {
|
|
2079
|
+
const runDir = resolveWorkflowRunDir(meta, wf.id, wf.path, true);
|
|
2080
|
+
if (!runDir) continue;
|
|
2081
|
+
let mtimeMs = 0;
|
|
2082
|
+
try { mtimeMs = statSync(path.join(runDir, 'journal.jsonl')).mtimeMs; } catch { continue; }
|
|
2083
|
+
if (Date.now() - mtimeMs > WORKFLOW_LIVE_MAX_IDLE_MS) continue;
|
|
2084
|
+
|
|
2085
|
+
const { startedCount, doneCount, byPhase, running } = summarizeWorkflowJournal(readWorkflowJournal(runDir));
|
|
2086
|
+
if (!startedCount || doneCount >= startedCount) continue;
|
|
2087
|
+
|
|
2088
|
+
const parsed = getWorkflowMeta(wf.path);
|
|
2089
|
+
const declared = parsed.phases.map((p) => p.title);
|
|
2090
|
+
const extra = [...byPhase.keys()].filter((t) => t && !declared.includes(t));
|
|
2091
|
+
const phases = [...declared, ...extra].map((title) => ({
|
|
2092
|
+
title,
|
|
2093
|
+
started: byPhase.get(title)?.started || 0,
|
|
2094
|
+
done: byPhase.get(title)?.done || 0,
|
|
2095
|
+
}));
|
|
2096
|
+
|
|
2097
|
+
return res.json({
|
|
2098
|
+
workflow: {
|
|
2099
|
+
id: wf.id,
|
|
2100
|
+
name: parsed.name || wf.name,
|
|
2101
|
+
startedCount,
|
|
2102
|
+
doneCount,
|
|
2103
|
+
phases,
|
|
2104
|
+
running: running.slice(0, 4),
|
|
2105
|
+
},
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
res.json({ workflow: null });
|
|
2109
|
+
} catch (error) {
|
|
2110
|
+
console.error('Error building live workflow view:', error);
|
|
2111
|
+
res.status(500).json({ error: 'Failed to build live workflow view' });
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
|
|
1873
2115
|
// API: Open a workflow script in VS Code
|
|
1874
2116
|
app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
|
|
1875
2117
|
try {
|
|
@@ -1945,11 +2187,6 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1945
2187
|
}
|
|
1946
2188
|
});
|
|
1947
2189
|
|
|
1948
|
-
// A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
|
|
1949
|
-
// external `scratch` CLI. cck only recognizes the shape and launches the viewer —
|
|
1950
|
-
// it never parses the manifest, so the two stay independently versioned.
|
|
1951
|
-
const SCRATCHPAD_MANIFEST = 'scratchpad.json';
|
|
1952
|
-
|
|
1953
2190
|
// Resolve a linked path to the pad the viewer wants: `<parent>/<name>` where
|
|
1954
2191
|
// `<name>/scratchpad.json` exists. Accepts either the manifest or its directory.
|
|
1955
2192
|
function resolveScratchpad(value) {
|
|
@@ -3016,9 +3253,69 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
3016
3253
|
|
|
3017
3254
|
// #region PREVIEW
|
|
3018
3255
|
// API: File preview — read file and broadcast to clients
|
|
3019
|
-
|
|
3256
|
+
// `text` files are shown as source, highlighted by extension, so the value doubles as
|
|
3257
|
+
// the hljs language name where the two differ. An allowlist rather than byte sniffing:
|
|
3258
|
+
// the client puts the whole response in the DOM, and a mislabelled binary would land
|
|
3259
|
+
// there as megabytes of mojibake.
|
|
3260
|
+
const PREVIEW_TEXT_EXTS =
|
|
3261
|
+
'txt log csv tsv json jsonl ndjson yml yaml toml ini cfg conf env js mjs cjs ts tsx jsx py rb go rs java kt cs c h cpp hpp php pl lua sh bash zsh ps1 psm1 bat cmd sql graphql css scss less xml svg patch diff';
|
|
3262
|
+
// Served as bytes by GET /api/preview/image, so the NUL sniff below does not apply.
|
|
3263
|
+
const PREVIEW_IMAGE_EXTS = 'png jpg jpeg gif webp avif bmp';
|
|
3264
|
+
const PREVIEW_KINDS = {
|
|
3265
|
+
'.md': 'markdown',
|
|
3266
|
+
'.markdown': 'markdown',
|
|
3267
|
+
'.html': 'html',
|
|
3268
|
+
'.htm': 'html',
|
|
3269
|
+
...Object.fromEntries(PREVIEW_TEXT_EXTS.split(' ').map((ext) => [`.${ext}`, 'text'])),
|
|
3270
|
+
...Object.fromEntries(PREVIEW_IMAGE_EXTS.split(' ').map((ext) => [`.${ext}`, 'image'])),
|
|
3271
|
+
};
|
|
3272
|
+
// A scratchpad collects files whose real extension is buried under a trailing one —
|
|
3273
|
+
// report.html.before, app.js.map, config.env.local, page.html~. One hop past an
|
|
3274
|
+
// extension the allowlist does not know asks the question that actually decides the
|
|
3275
|
+
// kind, "is the extension under it previewable", instead of enumerating the suffix
|
|
3276
|
+
// conventions a session might invent. Compressed suffixes stop the hop: the bytes
|
|
3277
|
+
// beneath them are binary, which is what the allowlist exists to keep out of the DOM.
|
|
3278
|
+
const PREVIEW_OPAQUE_EXTS = new Set(['.gz', '.br', '.zst', '.xz', '.bz2', '.zip', '.7z']);
|
|
3279
|
+
|
|
3280
|
+
function previewExtFor(absPath) {
|
|
3281
|
+
const trimmed = absPath.replace(/~+$/, '');
|
|
3282
|
+
const ext = path.extname(trimmed).toLowerCase();
|
|
3283
|
+
if (PREVIEW_KINDS[ext] || !ext || PREVIEW_OPAQUE_EXTS.has(ext)) return ext;
|
|
3284
|
+
return path.extname(trimmed.slice(0, -ext.length)).toLowerCase();
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
function previewKindFor(absPath) {
|
|
3288
|
+
return PREVIEW_KINDS[previewExtFor(absPath)] || null;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
// The hop makes a claim about bytes from a name, and PREVIEW_OPAQUE_EXTS can only stop
|
|
3292
|
+
// it for suffixes it already knows: report.json.gpg, notes.md.enc and secrets.yml.age
|
|
3293
|
+
// all hop to the extension underneath and would render their ciphertext. Enumerating
|
|
3294
|
+
// the encryption conventions fails open the same way, so the claim is confirmed against
|
|
3295
|
+
// the bytes instead — a NUL in the head is git's test for "not text", and encrypted or
|
|
3296
|
+
// compressed output trips it immediately.
|
|
3297
|
+
const TEXT_SNIFF_BYTES = 8192;
|
|
3298
|
+
|
|
3299
|
+
async function readHead(absPath) {
|
|
3300
|
+
const fh = await fs.open(absPath, 'r');
|
|
3301
|
+
try {
|
|
3302
|
+
const buf = Buffer.alloc(TEXT_SNIFF_BYTES);
|
|
3303
|
+
const { bytesRead } = await fh.read(buf, 0, TEXT_SNIFF_BYTES, 0);
|
|
3304
|
+
return buf.subarray(0, bytesRead);
|
|
3305
|
+
} finally {
|
|
3306
|
+
await fh.close();
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
async function confirmKind(absPath, kind) {
|
|
3311
|
+
if (!kind) return null;
|
|
3312
|
+
return (await readHead(absPath)).includes(0) ? null : kind;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3020
3315
|
// Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
|
|
3021
3316
|
const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
|
|
3317
|
+
// Source in a modal is for reading, not for scrolling a generated bundle.
|
|
3318
|
+
const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
|
|
3022
3319
|
|
|
3023
3320
|
function previewError(status, message) {
|
|
3024
3321
|
const err = new Error(message);
|
|
@@ -3033,7 +3330,8 @@ async function statFileTarget(absPath) {
|
|
|
3033
3330
|
try {
|
|
3034
3331
|
const stats = await fs.stat(absPath);
|
|
3035
3332
|
if (!stats.isFile()) throw previewError(400, 'Not a file');
|
|
3036
|
-
|
|
3333
|
+
const kind = previewKindFor(absPath);
|
|
3334
|
+
return { size: stats.size, kind: kind === 'image' ? kind : await confirmKind(absPath, kind) };
|
|
3037
3335
|
} catch (e) {
|
|
3038
3336
|
if (e.status) throw e;
|
|
3039
3337
|
if (e.code === 'ENOENT') throw previewError(404, 'File not found');
|
|
@@ -3042,22 +3340,31 @@ async function statFileTarget(absPath) {
|
|
|
3042
3340
|
}
|
|
3043
3341
|
}
|
|
3044
3342
|
|
|
3045
|
-
|
|
3343
|
+
function enforcePreviewSize(kind, size) {
|
|
3344
|
+
const max = kind === 'text' ? PREVIEW_TEXT_MAX_BYTES : PREVIEW_MAX_BYTES;
|
|
3345
|
+
if (size > max) {
|
|
3346
|
+
throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)`);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
// Checks the file is previewable without reading it whole — the broadcast path needs the
|
|
3046
3351
|
// validation (and its status codes) but never the content.
|
|
3047
3352
|
async function validatePreviewFile(absPath) {
|
|
3048
3353
|
const { kind, size } = await statFileTarget(absPath);
|
|
3049
|
-
if (!kind) throw previewError(400, '
|
|
3050
|
-
|
|
3051
|
-
throw previewError(
|
|
3052
|
-
400,
|
|
3053
|
-
`Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
|
|
3054
|
-
);
|
|
3055
|
-
}
|
|
3354
|
+
if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file');
|
|
3355
|
+
enforcePreviewSize(kind, size);
|
|
3056
3356
|
return { kind, size };
|
|
3057
3357
|
}
|
|
3058
3358
|
|
|
3059
3359
|
async function readPreviewFile(absPath) {
|
|
3060
|
-
const { kind } = await
|
|
3360
|
+
const { kind, size } = await statFileTarget(absPath);
|
|
3361
|
+
// A kind the previewer can't render is an answer, not a failure, reported the way
|
|
3362
|
+
// /api/file/resolve reports it: the caller opens the file in the editor instead. A 400
|
|
3363
|
+
// would only reach the browser console. A size refusal stays a 400 — that one is real.
|
|
3364
|
+
if (!kind) return { content: null, kind: null };
|
|
3365
|
+
enforcePreviewSize(kind, size);
|
|
3366
|
+
// Images travel as bytes through GET /api/preview/image; the client builds the URL from `path`.
|
|
3367
|
+
if (kind === 'image') return { content: null, kind };
|
|
3061
3368
|
const raw = await fs.readFile(absPath, 'utf8');
|
|
3062
3369
|
if (kind !== 'html') return { content: raw, kind };
|
|
3063
3370
|
// The client renders HTML into a `srcdoc` iframe, which has no base URL — sibling
|
|
@@ -3090,7 +3397,10 @@ function resolvePreviewPath(rawPath, base) {
|
|
|
3090
3397
|
// Every path entering the preview/link surface passes through here, so accepting
|
|
3091
3398
|
// `file://` once covers /api/preview, /api/document/link and /api/file/resolve.
|
|
3092
3399
|
const filePath = fileUrlToPath(rawPath);
|
|
3093
|
-
|
|
3400
|
+
// Normalized, not passed through: a linked document is identified by its path string,
|
|
3401
|
+
// and `C:/a/b` typed into the link editor has to reach the same string as the `C:\a\b`
|
|
3402
|
+
// a server-side `path.join` produces, or the same file is linked twice.
|
|
3403
|
+
if (path.isAbsolute(filePath)) return dropUrlFragment(path.normalize(filePath));
|
|
3094
3404
|
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
3095
3405
|
let baseDir = base;
|
|
3096
3406
|
try {
|
|
@@ -3207,27 +3517,51 @@ app.get('/api/session/pins', (_req, res) => {
|
|
|
3207
3517
|
});
|
|
3208
3518
|
|
|
3209
3519
|
app.get('/api/preview', async (req, res) => {
|
|
3520
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
3521
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3210
3522
|
try {
|
|
3211
|
-
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
3212
|
-
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3213
3523
|
const { content, kind } = await readPreviewFile(abs);
|
|
3214
|
-
res.json({ path: abs, content, kind });
|
|
3524
|
+
res.json({ path: abs, exists: true, content, kind });
|
|
3215
3525
|
} catch (error) {
|
|
3216
|
-
|
|
3526
|
+
// Reported the way /api/file/resolve reports it: a relative link inside a rendered
|
|
3527
|
+
// document is followed without knowing the target is there, so a link to a deleted
|
|
3528
|
+
// file would put a 404 in the browser console. The caller toasts it from `exists`.
|
|
3529
|
+
if (error.status === 404) return res.json({ path: abs, exists: false, content: null, kind: null });
|
|
3530
|
+
// A previewError carries the status it wants reported and is an answer, not a
|
|
3531
|
+
// failure — a missing file or one too large to render is the user's doing.
|
|
3532
|
+
if (!error.status) console.error('Error in GET /api/preview:', error);
|
|
3217
3533
|
res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
|
|
3218
3534
|
}
|
|
3219
3535
|
});
|
|
3220
3536
|
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3537
|
+
app.get('/api/preview/image', async (req, res) => {
|
|
3538
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
3539
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3540
|
+
try {
|
|
3541
|
+
const { kind } = await statFileTarget(abs);
|
|
3542
|
+
if (kind !== 'image') throw previewError(400, 'Not an image');
|
|
3543
|
+
res.type(MIME_BY_EXT[previewExtFor(abs)]);
|
|
3544
|
+
res.sendFile(abs);
|
|
3545
|
+
} catch (error) {
|
|
3546
|
+
if (!error.status) console.error('Error in GET /api/preview/image:', error);
|
|
3547
|
+
res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
|
|
3548
|
+
}
|
|
3549
|
+
});
|
|
3550
|
+
|
|
3551
|
+
// API: Resolve a hand-typed path to an absolute one, reporting whether it exists and
|
|
3552
|
+
// what it is. Deliberately not extension-restricted — a linked file the previewer can't
|
|
3553
|
+
// render (kind: null) is still linkable, the client just opens it in the editor instead.
|
|
3224
3554
|
app.get('/api/file/resolve', async (req, res) => {
|
|
3555
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
3556
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3225
3557
|
try {
|
|
3226
|
-
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
3227
|
-
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3228
3558
|
// No size cap here — an unpreviewable file of any size is still linkable.
|
|
3229
|
-
res.json({ path: abs, ...(await statFileTarget(abs)) });
|
|
3559
|
+
res.json({ path: abs, exists: true, ...(await statFileTarget(abs)) });
|
|
3230
3560
|
} catch (error) {
|
|
3561
|
+
// A path that is not there is an answer, not a transport failure: a linked-doc row
|
|
3562
|
+
// classifies its file through this endpoint, so a file since deleted would put a 404
|
|
3563
|
+
// in the browser console on every render. The link editor reports it from `exists`.
|
|
3564
|
+
if (error.status === 404) return res.json({ path: abs, exists: false, kind: null });
|
|
3231
3565
|
console.error('Error in GET /api/file/resolve:', error);
|
|
3232
3566
|
res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
|
|
3233
3567
|
}
|