heyiam 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/export.js +21 -2
- package/dist/public/assets/index-CMyamplX.css +1 -0
- package/dist/public/assets/index-Cq04whgG.js +37 -0
- package/dist/public/index.html +2 -2
- package/dist/routes/auth.js +3 -3
- package/dist/routes/project-session-upload.js +223 -0
- package/dist/routes/publish.js +158 -250
- package/dist/source-audit.js +30 -50
- package/package.json +1 -1
- package/dist/public/assets/index-Coilyhtr.css +0 -1
- package/dist/public/assets/index-D0noVMFu.js +0 -44
package/dist/routes/publish.js
CHANGED
|
@@ -5,20 +5,19 @@ import path from 'node:path';
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { getAuthToken } from '../auth.js';
|
|
7
7
|
import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
|
|
8
|
-
import { loadEnhancedData,
|
|
8
|
+
import { loadEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
|
|
9
9
|
import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
|
|
10
10
|
import { buildPortfolioRenderData } from './portfolio-render-data.js';
|
|
11
11
|
import { buildProjectDetail } from './context.js';
|
|
12
12
|
import { captureScreenshot } from '../screenshot.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { buildAgentSummary } from './context.js';
|
|
13
|
+
import { renderProjectHtml } from '../render/index.js';
|
|
14
|
+
import { buildProjectRenderData } from '../render/build-render-data.js';
|
|
15
|
+
import { uploadSelectedSessions } from './project-session-upload.js';
|
|
17
16
|
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
18
17
|
import { startSSE } from './sse.js';
|
|
19
18
|
import { displayNameFromDir } from '../sync.js';
|
|
20
19
|
import { toSlug } from '../format-utils.js';
|
|
21
|
-
import { getProjectUuid
|
|
20
|
+
import { getProjectUuid } from '../db.js';
|
|
22
21
|
const IMAGE_KEY_PREFIX = 'images/';
|
|
23
22
|
export function createPublishRouter(ctx) {
|
|
24
23
|
const router = Router();
|
|
@@ -210,220 +209,17 @@ export function createPublishRouter(ctx) {
|
|
|
210
209
|
const proj = projects.find((p) => p.dirName === project);
|
|
211
210
|
let uploadedCount = 0;
|
|
212
211
|
const failedSessions = [];
|
|
213
|
-
|
|
212
|
+
let uploadedSessionCards = [];
|
|
214
213
|
if (proj) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
|
|
225
|
-
const includeTranscript = isTranscriptIncluded(sessionId);
|
|
226
|
-
const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
|
|
227
|
-
const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
|
|
228
|
-
const sessionNarrative = enhanced?.narrative ?? '';
|
|
229
|
-
const sessionTitle = enhanced?.title ?? session.title;
|
|
230
|
-
const sessionSkills = enhanced?.skills ?? session.skills ?? [];
|
|
231
|
-
const sessionSourceTool = session.source ?? meta.source ?? 'claude';
|
|
232
|
-
const sessionRecordedAt = session.date ? new Date(session.date).toISOString() : new Date().toISOString();
|
|
233
|
-
const renderOpts = {
|
|
234
|
-
sessionId,
|
|
235
|
-
session,
|
|
236
|
-
enhanced,
|
|
237
|
-
username: auth.username,
|
|
238
|
-
projectSlug: projectData.slug,
|
|
239
|
-
sessionSlug,
|
|
240
|
-
sourceTool: sessionSourceTool,
|
|
241
|
-
agentSummary,
|
|
242
|
-
template: selectedTemplate,
|
|
243
|
-
};
|
|
244
|
-
let sessionRenderedHtml = null;
|
|
245
|
-
try {
|
|
246
|
-
const sessionRenderData = buildSessionRenderData(renderOpts);
|
|
247
|
-
sessionRenderedHtml = renderSessionHtml(sessionRenderData, selectedTemplate);
|
|
248
|
-
}
|
|
249
|
-
catch (renderErr) {
|
|
250
|
-
console.error(`[upload] Session render failed for ${sessionId}:`, renderErr.message);
|
|
251
|
-
}
|
|
252
|
-
uploadedSessionCards.push(buildSessionCard(renderOpts));
|
|
253
|
-
const childLoc = agentSummary?.agents?.reduce((s, a) => s + (a.loc_changed ?? 0), 0) ?? 0;
|
|
254
|
-
const totalLocChanged = (session.linesOfCode ?? 0) + childLoc;
|
|
255
|
-
const totalFilesChanged = getFileCountWithChildren(ctx.db, sessionId) || session.filesChanged?.length || 0;
|
|
256
|
-
const sessionPayload = {
|
|
257
|
-
session: {
|
|
258
|
-
title: sessionTitle,
|
|
259
|
-
dev_take: devTake,
|
|
260
|
-
context: enhanced?.context ?? '',
|
|
261
|
-
duration_minutes: session.durationMinutes ?? 0,
|
|
262
|
-
turns: session.turns ?? 0,
|
|
263
|
-
files_changed: totalFilesChanged,
|
|
264
|
-
loc_changed: totalLocChanged,
|
|
265
|
-
recorded_at: sessionRecordedAt,
|
|
266
|
-
end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
|
|
267
|
-
cwd: session.cwd ?? null,
|
|
268
|
-
wall_clock_minutes: session.wallClockMinutes ?? null,
|
|
269
|
-
template: selectedTemplate,
|
|
270
|
-
language: null,
|
|
271
|
-
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
272
|
-
skills: sessionSkills,
|
|
273
|
-
narrative: sessionNarrative,
|
|
274
|
-
project_name: proj.name,
|
|
275
|
-
project_id: projectData.project_id,
|
|
276
|
-
slug: sessionSlug,
|
|
277
|
-
status: 'unlisted',
|
|
278
|
-
source_tool: sessionSourceTool,
|
|
279
|
-
agent_summary: agentSummary,
|
|
280
|
-
rendered_html: sessionRenderedHtml,
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
// Transcript-derived fields. When the user has toggled the
|
|
284
|
-
// transcript OFF for this session we omit them from the
|
|
285
|
-
// uploaded JSON entirely — see the spread below. The
|
|
286
|
-
// session's main payload (dev take, skills, Q&A, etc.) is
|
|
287
|
-
// unaffected.
|
|
288
|
-
const turnTimeline = (session.turnTimeline ?? []).map((t) => ({
|
|
289
|
-
timestamp: t.timestamp,
|
|
290
|
-
type: t.type,
|
|
291
|
-
content: (t.content ?? '').slice(0, 200),
|
|
292
|
-
tools: t.tools ?? [],
|
|
293
|
-
}));
|
|
294
|
-
const transcriptExcerpt = (session.rawLog ?? []).slice(0, 10).map((line, i) => {
|
|
295
|
-
const role = line.startsWith('> ') ? 'dev' : 'ai';
|
|
296
|
-
const text = role === 'dev' ? line.slice(2) : line;
|
|
297
|
-
return { role, id: `Turn ${i + 1}`, text, timestamp: null };
|
|
298
|
-
});
|
|
299
|
-
const sessionData = {
|
|
300
|
-
version: 1,
|
|
301
|
-
id: sessionId,
|
|
302
|
-
title: sessionTitle,
|
|
303
|
-
dev_take: devTake,
|
|
304
|
-
context: enhanced?.context ?? '',
|
|
305
|
-
duration_minutes: session.durationMinutes ?? 0,
|
|
306
|
-
turns: session.turns ?? 0,
|
|
307
|
-
files_changed: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
308
|
-
loc_changed: totalLocChanged,
|
|
309
|
-
date: sessionRecordedAt,
|
|
310
|
-
end_time: (() => {
|
|
311
|
-
if (!session.endTime || !session.date)
|
|
312
|
-
return null;
|
|
313
|
-
const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
|
|
314
|
-
const activeMs = (session.durationMinutes ?? 0) * 60_000;
|
|
315
|
-
return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
|
|
316
|
-
})(),
|
|
317
|
-
cwd: session.cwd ?? null,
|
|
318
|
-
wall_clock_minutes: session.wallClockMinutes ?? null,
|
|
319
|
-
template: selectedTemplate,
|
|
320
|
-
skills: sessionSkills,
|
|
321
|
-
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
322
|
-
source: sessionSourceTool,
|
|
323
|
-
slug: sessionSlug,
|
|
324
|
-
project_name: proj.name,
|
|
325
|
-
narrative: sessionNarrative,
|
|
326
|
-
status: 'unlisted',
|
|
327
|
-
raw_log: [],
|
|
328
|
-
execution_path: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
|
|
329
|
-
label: s.title ?? `Step ${i + 1}`,
|
|
330
|
-
description: s.description ?? s.body ?? '',
|
|
331
|
-
})),
|
|
332
|
-
qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
|
|
333
|
-
highlights: [],
|
|
334
|
-
tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
|
|
335
|
-
top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
336
|
-
...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
|
|
337
|
-
...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
|
|
338
|
-
agent_summary: agentSummary,
|
|
339
|
-
children: agentSummary?.agents?.map((a) => ({
|
|
340
|
-
sessionId: a.role,
|
|
341
|
-
role: a.role,
|
|
342
|
-
durationMinutes: a.duration_minutes,
|
|
343
|
-
linesOfCode: a.loc_changed,
|
|
344
|
-
})) ?? [],
|
|
345
|
-
};
|
|
346
|
-
const sessionCwd = session.cwd ?? undefined;
|
|
347
|
-
const redactedPayload = redactSession(sessionPayload, 'high', sessionCwd);
|
|
348
|
-
const redactedData = redactSession(sessionData, 'high', sessionCwd);
|
|
349
|
-
const payloadFindings = scanTextSync(JSON.stringify(sessionPayload));
|
|
350
|
-
if (payloadFindings.length > 0) {
|
|
351
|
-
const summary = formatFindings(payloadFindings);
|
|
352
|
-
send({ type: 'redaction', sessionId, message: summary });
|
|
353
|
-
}
|
|
354
|
-
const sessionRes = await fetch(`${API_URL}/api/sessions`, {
|
|
355
|
-
method: 'POST',
|
|
356
|
-
headers: {
|
|
357
|
-
'Content-Type': 'application/json',
|
|
358
|
-
Authorization: `Bearer ${auth.token}`,
|
|
359
|
-
},
|
|
360
|
-
body: JSON.stringify(redactedPayload),
|
|
361
|
-
});
|
|
362
|
-
if (sessionRes.ok) {
|
|
363
|
-
uploadedCount++;
|
|
364
|
-
try {
|
|
365
|
-
const sesData = await sessionRes.json();
|
|
366
|
-
if (sesData.upload_urls && includeTranscript) {
|
|
367
|
-
// Transcript-bearing S3 uploads. Skipped entirely when
|
|
368
|
-
// the user has toggled the transcript OFF for this
|
|
369
|
-
// session — the server never sees raw_log, turn
|
|
370
|
-
// timeline, or the bundled session-data JSON in that
|
|
371
|
-
// case.
|
|
372
|
-
const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
|
|
373
|
-
if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
|
|
374
|
-
try {
|
|
375
|
-
const rawText = readFileSync(meta.path, 'utf-8');
|
|
376
|
-
let redactedRaw = redactText(rawText);
|
|
377
|
-
redactedRaw = stripHomePathsInText(redactedRaw, sessionCwd);
|
|
378
|
-
await fetch(rawUrl, { method: 'PUT', body: Buffer.from(redactedRaw, 'utf-8'), headers: { 'Content-Type': 'application/octet-stream' } });
|
|
379
|
-
}
|
|
380
|
-
catch { /* S3 upload is best-effort */ }
|
|
381
|
-
}
|
|
382
|
-
if (logUrl && session.rawLog && session.rawLog.length > 0) {
|
|
383
|
-
try {
|
|
384
|
-
const redactedLog = session.rawLog.map((line) => {
|
|
385
|
-
let cleaned = redactText(line);
|
|
386
|
-
cleaned = stripHomePathsInText(cleaned, sessionCwd);
|
|
387
|
-
return cleaned;
|
|
388
|
-
});
|
|
389
|
-
await fetch(logUrl, { method: 'PUT', body: JSON.stringify(redactedLog), headers: { 'Content-Type': 'application/json' } });
|
|
390
|
-
}
|
|
391
|
-
catch { /* S3 upload is best-effort */ }
|
|
392
|
-
}
|
|
393
|
-
if (sesData.upload_urls.session) {
|
|
394
|
-
try {
|
|
395
|
-
await fetch(sesData.upload_urls.session, {
|
|
396
|
-
method: 'PUT',
|
|
397
|
-
body: JSON.stringify(redactedData),
|
|
398
|
-
headers: { 'Content-Type': 'application/json' },
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
catch { /* S3 upload is best-effort */ }
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
catch { /* Response already consumed or no upload_urls -- not fatal */ }
|
|
406
|
-
if (enhanced) {
|
|
407
|
-
saveEnhancedData(sessionId, { ...enhanced, uploaded: true });
|
|
408
|
-
}
|
|
409
|
-
send({ type: 'session', sessionId, status: 'uploaded' });
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
const sesErrBody = await sessionRes.json().catch(() => null);
|
|
413
|
-
const rawSesErr = sesErrBody && typeof sesErrBody === 'object' ? sesErrBody.error : null;
|
|
414
|
-
const errMsg = typeof rawSesErr === 'string' ? rawSesErr
|
|
415
|
-
: (rawSesErr && typeof rawSesErr === 'object' && 'message' in rawSesErr) ? rawSesErr.message
|
|
416
|
-
: `HTTP ${sessionRes.status}`;
|
|
417
|
-
failedSessions.push({ sessionId, error: errMsg });
|
|
418
|
-
send({ type: 'session', sessionId, status: 'failed', error: errMsg });
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
catch (err) {
|
|
422
|
-
const errMsg = err.message;
|
|
423
|
-
failedSessions.push({ sessionId, error: errMsg });
|
|
424
|
-
send({ type: 'session', sessionId, status: 'failed', error: errMsg });
|
|
425
|
-
}
|
|
426
|
-
}
|
|
214
|
+
const sessionResult = await uploadSelectedSessions(ctx, auth, {
|
|
215
|
+
proj,
|
|
216
|
+
projectData,
|
|
217
|
+
selectedSessionIds,
|
|
218
|
+
send,
|
|
219
|
+
});
|
|
220
|
+
uploadedCount = sessionResult.uploadedCount;
|
|
221
|
+
failedSessions.push(...sessionResult.failedSessions);
|
|
222
|
+
uploadedSessionCards = sessionResult.uploadedSessionCards;
|
|
427
223
|
}
|
|
428
224
|
// Step 3: Render project HTML using the same path as HTML export
|
|
429
225
|
if (uploadedSessionCards.length > 0) {
|
|
@@ -563,44 +359,156 @@ export function createPublishRouter(ctx) {
|
|
|
563
359
|
// Upload individual project pages for every project included in the
|
|
564
360
|
// portfolio. This ensures project detail pages exist on heyi.am even
|
|
565
361
|
// if the user never published them individually.
|
|
362
|
+
const MAX_SLUG_RETRIES = 10;
|
|
566
363
|
for (const rawProj of filteredProjects) {
|
|
567
364
|
try {
|
|
365
|
+
const allProjectsList = await ctx.getProjects();
|
|
366
|
+
const projInfo = allProjectsList.find((p) => p.dirName === rawProj.dirName);
|
|
367
|
+
if (!projInfo) {
|
|
368
|
+
console.warn(`[portfolio-upload] project not found: ${rawProj.dirName}`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
568
371
|
const detail = buildProjectDetail(ctx.db, rawProj);
|
|
569
|
-
const
|
|
570
|
-
const cache =
|
|
571
|
-
|
|
372
|
+
const enhance = detail.enhanceCache;
|
|
373
|
+
const cache = enhance ?? {
|
|
374
|
+
fingerprint: 'portfolio-upload',
|
|
375
|
+
enhancedAt: new Date().toISOString(),
|
|
376
|
+
selectedSessionIds: detail.sessions.map((s) => s.id),
|
|
377
|
+
result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] },
|
|
378
|
+
};
|
|
379
|
+
const selectedSessionIds = enhance !== null && enhance.selectedSessionIds !== undefined
|
|
380
|
+
? enhance.selectedSessionIds
|
|
381
|
+
: detail.sessions.map((s) => s.id);
|
|
382
|
+
const projRecord = detail.project;
|
|
572
383
|
const title = cache.title
|
|
573
|
-
||
|
|
574
|
-
const
|
|
384
|
+
|| projRecord.name || displayNameFromDir(rawProj.dirName);
|
|
385
|
+
const baseSlug = toSlug(title);
|
|
575
386
|
const clientProjectId = getProjectUuid(ctx.db, rawProj.dirName);
|
|
576
|
-
const
|
|
577
|
-
totalFilesChanged:
|
|
578
|
-
totalAgentDurationMinutes:
|
|
579
|
-
totalInputTokens:
|
|
580
|
-
totalOutputTokens:
|
|
387
|
+
const projectHtmlPreview = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
|
|
388
|
+
totalFilesChanged: projRecord.totalFiles,
|
|
389
|
+
totalAgentDurationMinutes: projRecord.totalAgentDuration,
|
|
390
|
+
totalInputTokens: projRecord.totalInputTokens,
|
|
391
|
+
totalOutputTokens: projRecord.totalOutputTokens,
|
|
581
392
|
});
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
393
|
+
const projectBodyBase = {
|
|
394
|
+
client_project_id: clientProjectId,
|
|
395
|
+
title,
|
|
396
|
+
narrative: cache.result?.narrative ?? '',
|
|
397
|
+
repo_url: cache.repoUrl || null,
|
|
398
|
+
project_url: cache.projectUrl || null,
|
|
399
|
+
timeline: cache.result?.timeline ?? [],
|
|
400
|
+
skills: cache.result?.skills ?? [],
|
|
401
|
+
total_sessions: projRecord.sessionCount,
|
|
402
|
+
total_loc: projRecord.totalLoc,
|
|
403
|
+
total_duration_minutes: projRecord.totalDuration,
|
|
404
|
+
total_agent_duration_minutes: projRecord.totalAgentDuration || null,
|
|
405
|
+
total_files_changed: projRecord.totalFiles,
|
|
406
|
+
total_input_tokens: projRecord.totalInputTokens,
|
|
407
|
+
total_output_tokens: projRecord.totalOutputTokens,
|
|
408
|
+
skipped_sessions: [],
|
|
409
|
+
};
|
|
410
|
+
let slug = baseSlug;
|
|
411
|
+
let projectRes = null;
|
|
412
|
+
if (selectedSessionIds.length === 0) {
|
|
413
|
+
for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
|
|
414
|
+
projectRes = await fetch(`${API_URL}/api/projects`, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: {
|
|
417
|
+
'Content-Type': 'application/json',
|
|
418
|
+
Authorization: `Bearer ${auth.token}`,
|
|
419
|
+
},
|
|
420
|
+
body: JSON.stringify({
|
|
421
|
+
project: {
|
|
422
|
+
...projectBodyBase,
|
|
423
|
+
slug,
|
|
424
|
+
rendered_html: projectHtmlPreview,
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
if (projectRes.status === 409) {
|
|
429
|
+
slug = `${baseSlug}-${attempt + 1}`;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
if (!projectRes?.ok) {
|
|
435
|
+
const errText = await projectRes?.text().catch(() => '');
|
|
436
|
+
console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
|
|
437
|
+
}
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
|
|
441
|
+
projectRes = await fetch(`${API_URL}/api/projects`, {
|
|
442
|
+
method: 'POST',
|
|
443
|
+
headers: {
|
|
444
|
+
'Content-Type': 'application/json',
|
|
445
|
+
Authorization: `Bearer ${auth.token}`,
|
|
602
446
|
},
|
|
603
|
-
|
|
447
|
+
body: JSON.stringify({
|
|
448
|
+
project: {
|
|
449
|
+
...projectBodyBase,
|
|
450
|
+
slug,
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
});
|
|
454
|
+
if (projectRes.status === 409) {
|
|
455
|
+
slug = `${baseSlug}-${attempt + 1}`;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
if (!projectRes || !projectRes.ok) {
|
|
461
|
+
const errText = await projectRes?.text().catch(() => '') ?? '';
|
|
462
|
+
console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const projectData = await projectRes.json();
|
|
466
|
+
const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
|
|
467
|
+
proj: projInfo,
|
|
468
|
+
projectData,
|
|
469
|
+
selectedSessionIds,
|
|
470
|
+
});
|
|
471
|
+
if (uploadedSessionCards.length === 0) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const detailAfter = buildProjectDetail(ctx.db, rawProj);
|
|
476
|
+
const cacheAfter = detailAfter.enhanceCache ?? cache;
|
|
477
|
+
const totalFiles = detailAfter.project.totalFiles;
|
|
478
|
+
const projectHtmlFinal = generateProjectHtmlFragment(rawProj.dirName, cacheAfter, detailAfter.sessions, auth.username, {
|
|
479
|
+
totalFilesChanged: totalFiles,
|
|
480
|
+
totalInputTokens: detailAfter.project.totalInputTokens,
|
|
481
|
+
totalOutputTokens: detailAfter.project.totalOutputTokens,
|
|
482
|
+
});
|
|
483
|
+
const renderRes = await fetch(`${API_URL}/api/projects`, {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers: {
|
|
486
|
+
'Content-Type': 'application/json',
|
|
487
|
+
Authorization: `Bearer ${auth.token}`,
|
|
488
|
+
},
|
|
489
|
+
body: JSON.stringify({
|
|
490
|
+
project: {
|
|
491
|
+
...projectBodyBase,
|
|
492
|
+
slug: projectData.slug,
|
|
493
|
+
rendered_html: projectHtmlFinal,
|
|
494
|
+
},
|
|
495
|
+
}),
|
|
496
|
+
});
|
|
497
|
+
if (!renderRes.ok) {
|
|
498
|
+
console.warn(`[portfolio-upload] project ${rawProj.dirName} rendered_html update failed:`, renderRes.status);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (renderErr) {
|
|
502
|
+
console.warn(`[portfolio-upload] project render update ${rawProj.dirName}:`, renderErr.message);
|
|
503
|
+
}
|
|
504
|
+
const uploadedSessionIds = selectedSessionIds.filter((sid) => {
|
|
505
|
+
const enhanced = loadEnhancedData(sid);
|
|
506
|
+
return enhanced?.uploaded;
|
|
507
|
+
});
|
|
508
|
+
saveUploadedState(rawProj.dirName, {
|
|
509
|
+
slug: projectData.slug,
|
|
510
|
+
projectId: projectData.project_id,
|
|
511
|
+
uploadedSessions: uploadedSessionIds,
|
|
604
512
|
});
|
|
605
513
|
}
|
|
606
514
|
catch (projErr) {
|
package/dist/source-audit.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// Source Audit — cross-references live sessions with the archive to
|
|
2
2
|
// produce per-source scan results and archive health metrics.
|
|
3
|
-
import { readdir, stat } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
5
3
|
import { SOURCE_DISPLAY_NAMES } from "./parsers/types.js";
|
|
6
|
-
import { getArchiveDir } from "./settings.js";
|
|
7
4
|
import { getDatabase, getSessionCount } from "./db.js";
|
|
8
5
|
// ── Source paths ─────────────────────────────────────────────
|
|
9
6
|
const SOURCE_PATHS = {
|
|
@@ -74,64 +71,47 @@ export async function getSourceAudit(configDir) {
|
|
|
74
71
|
return { sources };
|
|
75
72
|
}
|
|
76
73
|
/**
|
|
77
|
-
* Return archive-level statistics: total
|
|
78
|
-
* source count, last sync time
|
|
74
|
+
* Return archive-level statistics: total preserved sessions, oldest session
|
|
75
|
+
* date, source count, last sync time.
|
|
76
|
+
*
|
|
77
|
+
* Reads from the SQLite `sessions` table rather than walking the filesystem
|
|
78
|
+
* archive dir. The DB is authoritative for "what's been preserved" — the
|
|
79
|
+
* filesystem archive is a secondary copy, and the previous version reported
|
|
80
|
+
* `total: 0` whenever the archive dir was empty even though the sessions
|
|
81
|
+
* table held hundreds of indexed sessions. That made the top card diverge
|
|
82
|
+
* from the "By source" table directly beneath it, which was the bug the
|
|
83
|
+
* user called out.
|
|
79
84
|
*/
|
|
80
|
-
export async function getArchiveStats(
|
|
81
|
-
const archiveDir = getArchiveDir(configDir);
|
|
85
|
+
export async function getArchiveStats(_configDir) {
|
|
82
86
|
let total = 0;
|
|
83
|
-
let
|
|
87
|
+
let oldestIso = null;
|
|
88
|
+
let lastSyncMs = 0;
|
|
84
89
|
const sourcesFound = new Set();
|
|
85
|
-
let newestMs = 0;
|
|
86
|
-
try {
|
|
87
|
-
const projectDirs = await readdir(archiveDir, { withFileTypes: true });
|
|
88
|
-
for (const projectEntry of projectDirs) {
|
|
89
|
-
if (!projectEntry.isDirectory())
|
|
90
|
-
continue;
|
|
91
|
-
const projectPath = join(archiveDir, projectEntry.name);
|
|
92
|
-
const files = await readdir(projectPath, { withFileTypes: true }).catch(() => []);
|
|
93
|
-
for (const file of files) {
|
|
94
|
-
if (!file.name.endsWith(".jsonl") || file.isDirectory())
|
|
95
|
-
continue;
|
|
96
|
-
total++;
|
|
97
|
-
const filePath = join(projectPath, file.name);
|
|
98
|
-
try {
|
|
99
|
-
const fileStat = await stat(filePath);
|
|
100
|
-
const mtimeMs = fileStat.mtimeMs;
|
|
101
|
-
if (mtimeMs < oldestMs)
|
|
102
|
-
oldestMs = mtimeMs;
|
|
103
|
-
if (mtimeMs > newestMs)
|
|
104
|
-
newestMs = mtimeMs;
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
// stat failed — skip
|
|
108
|
-
}
|
|
109
|
-
// Detect source from file content (first line) would be expensive;
|
|
110
|
-
// instead use the parser detection on the archive path.
|
|
111
|
-
// For now, we count by project dir presence — the main signal.
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
// Archive directory doesn't exist yet
|
|
117
|
-
}
|
|
118
|
-
// Count distinct sources from SQLite (fast)
|
|
119
90
|
try {
|
|
120
91
|
const db = getDatabase();
|
|
121
|
-
const
|
|
122
|
-
|
|
92
|
+
const totalRow = db.prepare('SELECT COUNT(*) as c, MIN(start_time) as earliest FROM sessions WHERE is_subagent = 0').get();
|
|
93
|
+
total = totalRow.c;
|
|
94
|
+
oldestIso = totalRow.earliest;
|
|
95
|
+
const sourceRows = db.prepare('SELECT DISTINCT source FROM sessions WHERE is_subagent = 0').all();
|
|
96
|
+
for (const row of sourceRows)
|
|
123
97
|
sourcesFound.add(row.source);
|
|
98
|
+
// Last sync = newest indexed_at (the clock set when sync wrote the row).
|
|
99
|
+
const syncRow = db.prepare('SELECT MAX(indexed_at) as latest FROM sessions WHERE is_subagent = 0').get();
|
|
100
|
+
if (syncRow.latest) {
|
|
101
|
+
const ms = Date.parse(syncRow.latest);
|
|
102
|
+
if (!Number.isNaN(ms))
|
|
103
|
+
lastSyncMs = ms;
|
|
124
104
|
}
|
|
125
105
|
}
|
|
126
106
|
catch {
|
|
127
|
-
// DB not ready
|
|
107
|
+
// DB not ready — return empty state.
|
|
128
108
|
}
|
|
129
|
-
const oldest =
|
|
130
|
-
?
|
|
131
|
-
:
|
|
132
|
-
const lastSync =
|
|
109
|
+
const oldest = oldestIso
|
|
110
|
+
? formatMonthYear(new Date(oldestIso))
|
|
111
|
+
: "none";
|
|
112
|
+
const lastSync = lastSyncMs === 0
|
|
133
113
|
? "never"
|
|
134
|
-
: formatRelativeTime(
|
|
114
|
+
: formatRelativeTime(lastSyncMs);
|
|
135
115
|
return {
|
|
136
116
|
total,
|
|
137
117
|
oldest,
|