opengraph stuff
This commit is contained in:
@@ -58,6 +58,7 @@ export function App() {
|
||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { EmailSummary } from 'types';
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const isThisYear = date.getFullYear() === now.getFullYear();
|
||||
if (isThisYear) return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
export const EmailList = () => {
|
||||
const client = useClient();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['email-messages'],
|
||||
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages'),
|
||||
});
|
||||
|
||||
const messages = data?.messages ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-50">
|
||||
Loading emails...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Mail className="h-8 w-8" />
|
||||
No emails found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Mail className="h-4 w-4 opacity-60" />
|
||||
<span className="text-sm font-medium">Inbox</span>
|
||||
<span className="text-xs opacity-50">{data?.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{messages.map((msg: EmailSummary) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => setSelectedId(msg.id)}
|
||||
className={`flex flex-col gap-0.5 border-b px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{msg.from}</span>
|
||||
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
<span className="truncate text-sm">{msg.subject}</span>
|
||||
<span className="truncate text-xs opacity-50">{msg.snippet}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { EmailMessage } from 'types';
|
||||
|
||||
const HtmlBody = ({ html }: { html: string }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
doc.open();
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #e0e0e0; background: transparent; }
|
||||
a { color: #60a5fa; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${html}</body>
|
||||
</html>
|
||||
`);
|
||||
doc.close();
|
||||
}, [html]);
|
||||
|
||||
return <iframe ref={iframeRef} className="h-full w-full border-0" sandbox="allow-same-origin" title="Email body" />;
|
||||
};
|
||||
|
||||
export const EmailReader = () => {
|
||||
const client = useClient();
|
||||
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
|
||||
const { data: message, isLoading } = useQuery({
|
||||
queryKey: ['email-message', selectedId],
|
||||
queryFn: () => client.get<EmailMessage>(`/email/messages/${selectedId}`),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
if (!selectedId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Mail className="h-10 w-10" />
|
||||
Select an email to read
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-50">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex flex-col gap-1 border-b px-4 py-3">
|
||||
<h2 className="text-lg font-semibold">{message.subject}</h2>
|
||||
<div className="flex flex-col gap-0.5 text-sm opacity-70">
|
||||
<div>
|
||||
<span className="font-medium">From:</span> {message.from}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">To:</span> {message.to}
|
||||
</div>
|
||||
{message.cc && (
|
||||
<div>
|
||||
<span className="font-medium">Cc:</span> {message.cc}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
|
||||
</div>
|
||||
{message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => (
|
||||
<span key={i} className="rounded bg-accent px-2 py-0.5 text-xs">
|
||||
{att.filename} ({Math.round(att.size / 1024)}KB)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{message.html ? (
|
||||
<HtmlBody html={message.html} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap p-4 text-sm">{message.text}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { EmailList } from './EmailList';
|
||||
import { EmailReader } from './EmailReader';
|
||||
|
||||
export const EmailScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'email-list': EmailList,
|
||||
'email-reader': EmailReader,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const mobilePanelId = isMobile && selectedId ? 'email-reader' : undefined;
|
||||
const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout
|
||||
layout={defaultLayout}
|
||||
onLayoutChange={() => {}}
|
||||
components={components}
|
||||
isMobile={isMobile}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onMobileBack={mobilePanelId ? onMobileBack : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'email-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'email-list', appType: null }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'email-reader', appType: null }, size: 70 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { EmailScreen } from './EmailScreen';
|
||||
@@ -110,11 +110,12 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail } from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
|
||||
+81
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type GoogleStatus = {
|
||||
connected: boolean;
|
||||
@@ -10,10 +12,18 @@ type GoogleStatus = {
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
export const GoogleAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
|
||||
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
|
||||
const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running');
|
||||
const lastJob = jobs[0];
|
||||
|
||||
const fetchStatus = () => {
|
||||
client
|
||||
@@ -37,6 +47,16 @@ export const GoogleAccount = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSync = async (year?: number) => {
|
||||
try {
|
||||
await createJob({ lane: 'google-api', type: 'gmail-sync', meta: year ? { year } : undefined });
|
||||
await refetch();
|
||||
toast.success("Gmail sync started — you'll receive an email when it's done");
|
||||
} catch {
|
||||
toast.error('Failed to start Gmail sync');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
const params = new URLSearchParams({
|
||||
token: client.token ?? '',
|
||||
@@ -69,6 +89,9 @@ export const GoogleAccount = () => {
|
||||
}
|
||||
|
||||
if (status.connected) {
|
||||
const currentStep = activeJob?.steps[activeJob.currentStep];
|
||||
const progress = currentStep?.progress;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
@@ -84,6 +107,64 @@ export const GoogleAccount = () => {
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Officer has access to your Google Calendar, Gmail, and other enabled services.
|
||||
</p>
|
||||
{activeJob && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-duck-dark/60 dark:text-foreground/60 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{activeJob.status === 'queued' ? 'Queued' : 'Syncing'}
|
||||
{progress?.label ? ` — ${progress.label}` : ''}
|
||||
</p>
|
||||
{progress && progress.total > 0 && (
|
||||
<div className="mt-1.5 h-1.5 rounded-full bg-duck-dark/10 dark:bg-foreground/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-duck-yellow transition-all duration-300"
|
||||
style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{progress && progress.total === 0 && progress.current > 0 && (
|
||||
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{progress.current.toLocaleString()} emails processed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'completed' && (
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
Last sync completed {formatTime(lastJob.completedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!!activeJob}
|
||||
onClick={() => handleSync()}
|
||||
className="flex-1 h-11 cursor-pointer gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sync Gmail Inbox
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!!activeJob}
|
||||
onClick={() => handleSync(2026)}
|
||||
className="flex-1 h-11 cursor-pointer gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Test Sync (2026)
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './ChatHistory';
|
||||
export * from './Workspaces';
|
||||
export * from './Projects';
|
||||
export * from './Terminal';
|
||||
export * from './Email';
|
||||
|
||||
@@ -3,7 +3,29 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>officer.dev</title>
|
||||
<title>Officer Dev (Alpha)</title>
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Officer Dev (Alpha)" />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta property="og:image" content="https://static.officerdev.com/og-image.jpg" />
|
||||
<meta property="og:site_name" content="Officer Dev" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Officer Dev (Alpha)" />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="twitter:image" content="https://static.officerdev.com/og-image.jpg" />
|
||||
|
||||
<!-- Favicons & App Icons -->
|
||||
<link rel="icon" type="image/x-icon" href="https://static.officerdev.com/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="https://static.officerdev.com/favicon-96x96.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://static.officerdev.com/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="https://static.officerdev.com/site.webmanifest" />
|
||||
<meta name="theme-color" content="#1F2620" />
|
||||
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import type { EmailSummary, EmailMessage } from 'types';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserEmailDir } from '@@/data-path';
|
||||
|
||||
type CacheEntry = {
|
||||
summaries: EmailSummary[];
|
||||
fileCount: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
const parseHeadersOnly = async (filePath: string, id: string): Promise<EmailSummary | null> => {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer, { skipHtmlToText: true, skipTextToHtml: true, skipImageLinks: true });
|
||||
|
||||
const text = parsed.text ?? '';
|
||||
const snippet = text.slice(0, 120).replace(/\s+/g, ' ').trim();
|
||||
|
||||
return {
|
||||
id,
|
||||
from: parsed.from?.text ?? '',
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(', ') : parsed.to.text) : '',
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const emailRouter = createRouter();
|
||||
|
||||
emailRouter.get('/messages', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dir = getUserEmailDir(email);
|
||||
|
||||
let filenames: string[];
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
filenames = entries.filter((f) => f.endsWith('.eml'));
|
||||
} catch {
|
||||
return ctx.json({ messages: [], total: 0 });
|
||||
}
|
||||
|
||||
const cached = cache.get(email);
|
||||
if (cached && cached.fileCount === filenames.length) {
|
||||
const page = Number(ctx.req.query('page') ?? '1');
|
||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||
const start = (page - 1) * limit;
|
||||
return ctx.json({ messages: cached.summaries.slice(start, start + limit), total: cached.summaries.length });
|
||||
}
|
||||
|
||||
const summaries: EmailSummary[] = [];
|
||||
for (const filename of filenames) {
|
||||
const id = filename.replace(/\.eml$/, '');
|
||||
const summary = await parseHeadersOnly(join(dir, filename), id);
|
||||
if (summary) summaries.push(summary);
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
cache.set(email, { summaries, fileCount: filenames.length });
|
||||
|
||||
const page = Number(ctx.req.query('page') ?? '1');
|
||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||
const start = (page - 1) * limit;
|
||||
return ctx.json({ messages: summaries.slice(start, start + limit), total: summaries.length });
|
||||
});
|
||||
|
||||
emailRouter.get('/messages/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
const filePath = join(getUserEmailDir(email), `${id}.eml`);
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer);
|
||||
|
||||
const attachments = (parsed.attachments ?? []).map((a) => ({
|
||||
filename: a.filename ?? 'unknown',
|
||||
size: a.size,
|
||||
contentType: a.contentType,
|
||||
}));
|
||||
|
||||
const message: EmailMessage = {
|
||||
id,
|
||||
from: parsed.from?.text ?? '',
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(', ') : parsed.to.text) : '',
|
||||
cc: parsed.cc ? (Array.isArray(parsed.cc) ? parsed.cc.map((a) => a.text).join(', ') : parsed.cc.text) : undefined,
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet: (parsed.text ?? '').slice(0, 120).replace(/\s+/g, ' ').trim(),
|
||||
html: parsed.html || undefined,
|
||||
text: parsed.text || undefined,
|
||||
attachments,
|
||||
};
|
||||
|
||||
return ctx.json(message);
|
||||
} catch {
|
||||
return ctx.text('Failed to parse email', 500);
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { join, relative } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { readSearxngConfig } from "../server-settings/searxng";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { logger } from "./logger";
|
||||
import { parseFrontmatter } from "../skills/skills";
|
||||
@@ -153,6 +154,14 @@ function buildResourcesEnv(): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function getGoogleConfigPath(): string {
|
||||
return join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
|
||||
}
|
||||
|
||||
function getGoogleTokenPath(email: string): string {
|
||||
return join(DATA_PATH, email, 'integrations', 'google.json');
|
||||
}
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -203,12 +212,19 @@ export async function spawnPi(
|
||||
|
||||
const resourcesEnv = buildResourcesEnv();
|
||||
|
||||
const googleConfigHost = getGoogleConfigPath();
|
||||
const googleTokenHost = join(DATA_PATH, sandbox.email, 'integrations');
|
||||
|
||||
const envFlags = [
|
||||
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
'-e', `OFFICER_USER_HOME=${containerHome}`,
|
||||
'-e', `OFFICER_USER_ROOT=/officer/user`,
|
||||
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
|
||||
'-e', `PI_SEARXNG_URL=${searxng.url}`,
|
||||
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
|
||||
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
|
||||
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
|
||||
];
|
||||
for (const [key, value] of Object.entries(storedKeys)) {
|
||||
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
|
||||
@@ -258,7 +274,7 @@ export async function spawnPi(
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv() },
|
||||
env: { ...process.env, ...storedKeys, HOME: getHomeDir(email), OFFICER_USER_HOME: getHomeDir(email), OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_GOOGLE_CONFIG_PATH: getGoogleConfigPath(), OFFICER_GOOGLE_TOKEN_PATH: getGoogleTokenPath(email) },
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi locally', {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { enqueue, cancelJob, readJob, listAllJobs } from '../../queue';
|
||||
import { NOT_FOUND } from '../../custom-errors';
|
||||
|
||||
export const queueRouter = createRouter();
|
||||
|
||||
queueRouter.get('/jobs', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const lane = ctx.req.query('lane');
|
||||
const type = ctx.req.query('type');
|
||||
const status = ctx.req.query('status');
|
||||
|
||||
let jobs = await listAllJobs();
|
||||
jobs = jobs.filter((j) => j.userId === user.email);
|
||||
|
||||
if (lane) jobs = jobs.filter((j) => j.lane === lane);
|
||||
if (type) jobs = jobs.filter((j) => j.type === type);
|
||||
if (status) jobs = jobs.filter((j) => j.status === status);
|
||||
|
||||
return ctx.json(jobs);
|
||||
});
|
||||
|
||||
queueRouter.get('/jobs/:id', async (ctx) => {
|
||||
const job = await readJob(ctx.req.param('id'));
|
||||
if (!job) throw NOT_FOUND('Job not found');
|
||||
return ctx.json(job);
|
||||
});
|
||||
|
||||
queueRouter.post('/jobs', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body');
|
||||
const { lane, type, meta } = body as { lane: string; type: string; meta?: Record<string, unknown> };
|
||||
|
||||
const job = await enqueue({ lane, type, userId: user.email, meta });
|
||||
return ctx.json(job, 201);
|
||||
});
|
||||
|
||||
queueRouter.delete('/jobs/:id', async (ctx) => {
|
||||
const job = await cancelJob(ctx.req.param('id'));
|
||||
if (!job) throw NOT_FOUND('Job not found');
|
||||
return ctx.json(job);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdirSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH } from '@@/data-path';
|
||||
@@ -101,9 +102,9 @@ const ensureDockerImage = () => {
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
// Check whether a container already has the officer resource mounts.
|
||||
// We test for the global skills dir as a proxy for all mounts being present.
|
||||
const containerHasResourceMounts = (dockerId: string): boolean => {
|
||||
// Check whether a container has all expected volume mounts.
|
||||
// Tests for multiple mount sources — if any is missing, the container should be recreated.
|
||||
const containerHasExpectedMounts = (dockerId: string): boolean => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId],
|
||||
@@ -111,7 +112,8 @@ const containerHasResourceMounts = (dockerId: string): boolean => {
|
||||
stderr: 'ignore',
|
||||
});
|
||||
if (result.exitCode !== 0) return false;
|
||||
return result.stdout.toString().includes(getGlobalSkillsDir());
|
||||
const mounts = result.stdout.toString();
|
||||
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('google-oauth.json');
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string, email: string): { dockerId: string } => {
|
||||
@@ -136,6 +138,11 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
|
||||
}
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
const googleConfigHost = join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
|
||||
const googleMounts: string[] = existsSync(googleConfigHost)
|
||||
? ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`]
|
||||
: [];
|
||||
|
||||
const run = Bun.spawnSync({
|
||||
cmd: [
|
||||
dockerPath,
|
||||
@@ -164,6 +171,8 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
|
||||
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
|
||||
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||
...googleMounts,
|
||||
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
||||
'-w', containerHome,
|
||||
tag,
|
||||
],
|
||||
@@ -231,13 +240,14 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
|
||||
mkdirSync(getUserSkillsDir(email), { recursive: true });
|
||||
mkdirSync(getUserToolsDir(email), { recursive: true });
|
||||
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
|
||||
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) {
|
||||
// Recreate if resource mounts are missing (e.g. first run after feature was added)
|
||||
if (!containerHasResourceMounts(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId)) {
|
||||
console.log(`[terminal] recreating container for ${email} — resource mounts missing`);
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else {
|
||||
@@ -246,7 +256,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
}
|
||||
|
||||
if (existing && dockerContainerExists(existing.dockerId)) {
|
||||
if (!containerHasResourceMounts(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId)) {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else if (dockerStart(existing.dockerId)) {
|
||||
return existing;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { syncSeedTools } from './sync-tools';
|
||||
import { syncSeedExtensions } from './sync-extensions';
|
||||
import { syncSeedResources } from './sync-resources';
|
||||
import { migrateSettingsToResources } from './migrate-resources';
|
||||
import { initQueue } from './queue';
|
||||
|
||||
mkdirSync(DATA_PATH, { recursive: true });
|
||||
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
||||
@@ -87,4 +88,8 @@ function seedPiConfig(): void {
|
||||
await syncAllUserPiConfigs().catch(err => {
|
||||
console.error('[bootstrap] Failed to sync user Pi configs:', err);
|
||||
});
|
||||
|
||||
await initQueue().catch(err => {
|
||||
console.error('[bootstrap] Failed to initialize queue:', err);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -88,3 +88,5 @@ export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode'
|
||||
join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments');
|
||||
|
||||
export const getUserDockFile = (email: string) => join(DATA_PATH, email, 'dock', 'dock.json');
|
||||
|
||||
export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');
|
||||
|
||||
@@ -21,6 +21,8 @@ import { piRestRouter } from './api/pi/rest';
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
import { queueRouter } from './api/queue/queue';
|
||||
import { emailRouter } from './api/email/email';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser } from './_middlewares';
|
||||
|
||||
@@ -64,6 +66,8 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/dev-server', devServerRouter);
|
||||
protectedRouter.route('/dock', dockRouter);
|
||||
protectedRouter.route('/integrations', integrationsRouter);
|
||||
protectedRouter.route('/queue', queueRouter);
|
||||
protectedRouter.route('/email', emailRouter);
|
||||
protectedRouter.route('/', piRestRouter);
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Job, JobProgress, EnqueueParams, StepContext } from './types';
|
||||
import { readJob, writeJob, listAllJobs } from './storage';
|
||||
import { getHandler } from './handler-registry';
|
||||
import { sendMail } from 'emailer';
|
||||
|
||||
const activeLanes = new Map<string, boolean>();
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
export async function enqueue(params: EnqueueParams): Promise<Job> {
|
||||
const handler = getHandler(params.type);
|
||||
if (!handler) throw new Error(`No handler registered for job type: ${params.type}`);
|
||||
|
||||
const job: Job = {
|
||||
id: crypto.randomUUID(),
|
||||
lane: params.lane,
|
||||
type: params.type,
|
||||
userId: params.userId,
|
||||
status: 'queued',
|
||||
steps: handler.steps.map((s) => ({ name: s.name, status: 'pending' as const })),
|
||||
currentStep: 0,
|
||||
createdAt: Date.now(),
|
||||
meta: params.meta,
|
||||
};
|
||||
|
||||
await writeJob(job);
|
||||
console.log(`[queue] Enqueued job ${job.id} (${job.type}) in lane ${job.lane}`);
|
||||
kickLane(job.lane);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function cancelJob(id: string): Promise<Job | null> {
|
||||
const job = await readJob(id);
|
||||
if (!job) return null;
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
|
||||
|
||||
job.status = 'cancelled';
|
||||
job.completedAt = Date.now();
|
||||
for (const step of job.steps) {
|
||||
if (step.status === 'pending' || step.status === 'running') {
|
||||
step.status = 'failed';
|
||||
step.error = 'Cancelled';
|
||||
}
|
||||
}
|
||||
await writeJob(job);
|
||||
console.log(`[queue] Cancelled job ${job.id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function resumeInterruptedJobs() {
|
||||
const jobs = await listAllJobs();
|
||||
const lanesToKick = new Set<string>();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === 'running') {
|
||||
job.status = 'queued';
|
||||
job.startedAt = undefined;
|
||||
for (const step of job.steps) {
|
||||
if (step.status === 'running') {
|
||||
step.status = 'pending';
|
||||
step.startedAt = undefined;
|
||||
}
|
||||
}
|
||||
await writeJob(job);
|
||||
console.log(`[queue] Reset interrupted job ${job.id} back to queued`);
|
||||
lanesToKick.add(job.lane);
|
||||
} else if (job.status === 'queued') {
|
||||
lanesToKick.add(job.lane);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lane of lanesToKick) {
|
||||
kickLane(lane);
|
||||
}
|
||||
}
|
||||
|
||||
function kickLane(lane: string) {
|
||||
if (activeLanes.get(lane)) return;
|
||||
activeLanes.set(lane, true);
|
||||
processNextInLane(lane);
|
||||
}
|
||||
|
||||
async function processNextInLane(lane: string) {
|
||||
try {
|
||||
const jobs = await listAllJobs();
|
||||
const next = jobs
|
||||
.filter((j) => j.lane === lane && j.status === 'queued')
|
||||
.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
|
||||
if (!next) {
|
||||
activeLanes.set(lane, false);
|
||||
return;
|
||||
}
|
||||
|
||||
await runJob(next);
|
||||
} catch (err) {
|
||||
console.error(`[queue] Lane ${lane} processing error:`, err);
|
||||
} finally {
|
||||
const jobs = await listAllJobs();
|
||||
const hasMore = jobs.some((j) => j.lane === lane && j.status === 'queued');
|
||||
if (hasMore) {
|
||||
processNextInLane(lane);
|
||||
} else {
|
||||
activeLanes.set(lane, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runJob(job: Job) {
|
||||
const handler = getHandler(job.type);
|
||||
if (!handler) {
|
||||
job.status = 'failed';
|
||||
job.error = `No handler for type: ${job.type}`;
|
||||
job.completedAt = Date.now();
|
||||
await writeJob(job);
|
||||
return;
|
||||
}
|
||||
|
||||
job.status = 'running';
|
||||
job.startedAt = Date.now();
|
||||
await writeJob(job);
|
||||
console.log(`[queue] Running job ${job.id} (${job.type})`);
|
||||
|
||||
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
|
||||
|
||||
for (let i = 0; i < handler.steps.length; i++) {
|
||||
const fresh = await readJob(job.id);
|
||||
if (!fresh || fresh.status === 'cancelled') {
|
||||
console.log(`[queue] Job ${job.id} was cancelled, stopping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handlerStep = handler.steps[i]!;
|
||||
const step = fresh.steps[i]!;
|
||||
|
||||
fresh.currentStep = i;
|
||||
step.status = 'running';
|
||||
step.startedAt = Date.now();
|
||||
await writeJob(fresh);
|
||||
console.log(`[queue] Job ${fresh.id} step ${i + 1}/${handler.steps.length}: "${handlerStep.name}"`);
|
||||
|
||||
let lastProgressWrite = 0;
|
||||
let pendingProgress: JobProgress | null = null;
|
||||
|
||||
const updateProgress = async (progress: JobProgress) => {
|
||||
step.progress = progress;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressWrite >= PROGRESS_THROTTLE_MS) {
|
||||
lastProgressWrite = now;
|
||||
pendingProgress = null;
|
||||
await writeJob(fresh);
|
||||
} else {
|
||||
pendingProgress = progress;
|
||||
}
|
||||
};
|
||||
|
||||
const ctx: StepContext = { job: fresh, step, updateProgress, meta: sharedMeta };
|
||||
|
||||
try {
|
||||
await handlerStep.run(ctx);
|
||||
|
||||
if (pendingProgress) {
|
||||
step.progress = pendingProgress;
|
||||
}
|
||||
step.status = 'completed';
|
||||
step.completedAt = Date.now();
|
||||
await writeJob(fresh);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
step.status = 'failed';
|
||||
step.error = errorMessage;
|
||||
step.completedAt = Date.now();
|
||||
fresh.status = 'failed';
|
||||
fresh.error = `Step "${step.name}" failed: ${errorMessage}`;
|
||||
fresh.completedAt = Date.now();
|
||||
await writeJob(fresh);
|
||||
console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage);
|
||||
await notifyFailure(fresh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const final = await readJob(job.id);
|
||||
if (final && final.status === 'running') {
|
||||
final.status = 'completed';
|
||||
final.completedAt = Date.now();
|
||||
await writeJob(final);
|
||||
console.log(`[queue] Job ${final.id} completed`);
|
||||
await notifyCompletion(final);
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyCompletion(job: Job) {
|
||||
try {
|
||||
await sendMail({
|
||||
template: 'JobCompleted',
|
||||
subject: `Job completed: ${job.type}`,
|
||||
to: job.userId,
|
||||
data: { job },
|
||||
});
|
||||
} catch {
|
||||
// SMTP might not be configured — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyFailure(job: Job) {
|
||||
try {
|
||||
await sendMail({
|
||||
template: 'JobFailed',
|
||||
subject: `Job failed: ${job.type}`,
|
||||
to: job.userId,
|
||||
data: { job },
|
||||
});
|
||||
} catch {
|
||||
// SMTP might not be configured — non-fatal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { JobHandler } from './types';
|
||||
|
||||
const handlers = new Map<string, JobHandler>();
|
||||
|
||||
export function registerHandler(handler: JobHandler) {
|
||||
handlers.set(handler.type, handler);
|
||||
}
|
||||
|
||||
export function getHandler(type: string): JobHandler | undefined {
|
||||
return handlers.get(type);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { JobHandler } from '../types';
|
||||
import { registerHandler } from '../handler-registry';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
type GoogleCredentials = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
const googleConfigPath = join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
|
||||
|
||||
async function loadCredentials(userId: string): Promise<GoogleCredentials> {
|
||||
const config = await Bun.file(googleConfigPath).json().catch(() => null);
|
||||
if (!config?.clientId || !config?.clientSecret) {
|
||||
throw new Error('Google OAuth not configured — ask your admin to set up credentials');
|
||||
}
|
||||
|
||||
const tokenPath = join(DATA_PATH, userId, 'integrations', 'google.json');
|
||||
const token = await Bun.file(tokenPath).json().catch(() => null);
|
||||
if (!token?.accessToken) {
|
||||
throw new Error('Google account not connected — connect in Settings → Integrations');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken ?? '',
|
||||
expiresAt: token.expiresAt ?? 0,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedAccessToken: string | null = null;
|
||||
let cachedExpiresAt = 0;
|
||||
|
||||
async function getValidAccessToken(creds: GoogleCredentials): Promise<string> {
|
||||
if (cachedAccessToken && cachedExpiresAt > Date.now() + 5 * 60 * 1000) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
|
||||
if (creds.expiresAt > Date.now() + 5 * 60 * 1000) {
|
||||
cachedAccessToken = creds.accessToken;
|
||||
cachedExpiresAt = creds.expiresAt;
|
||||
return creds.accessToken;
|
||||
}
|
||||
|
||||
if (!creds.refreshToken) throw new Error('Token expired and no refresh token available');
|
||||
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: creds.clientId,
|
||||
client_secret: creds.clientSecret,
|
||||
refresh_token: creds.refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text().catch(() => '');
|
||||
throw new Error(`Token refresh failed (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { access_token: string; expires_in?: number };
|
||||
cachedAccessToken = data.access_token;
|
||||
cachedExpiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
|
||||
|
||||
async function gmailGet(token: string, path: string, params?: Record<string, string>): Promise<unknown> {
|
||||
const url = new URL(`${GMAIL_BASE}${path}`);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v) url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text().catch(() => '');
|
||||
throw new Error(`Gmail API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function slugify(text: string, maxLen = 60): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, maxLen)
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
|
||||
function buildEmlFilename(id: string, internalDate: string | undefined, rawEmail: string): string {
|
||||
const subjectMatch = rawEmail.match(/^Subject:\s*(.+)$/mi);
|
||||
const subject = subjectMatch?.[1]?.trim() || 'no-subject';
|
||||
const ts = parseInt(internalDate || '0');
|
||||
const d = new Date(ts);
|
||||
const dateStr =
|
||||
ts > 0
|
||||
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
: 'unknown-date';
|
||||
return `${dateStr}_${slugify(subject)}_${id}.eml`;
|
||||
}
|
||||
|
||||
type SyncProgress = { saved: number; skipped: number; errors: number; page: number };
|
||||
type OnProgress = (progress: SyncProgress) => void;
|
||||
|
||||
async function syncInbox(
|
||||
token: string,
|
||||
outputDir: string,
|
||||
query?: string,
|
||||
onProgress?: OnProgress,
|
||||
): Promise<{ saved: number; skipped: number; errors: number }> {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const existingIds = new Set<string>();
|
||||
try {
|
||||
for (const file of readdirSync(outputDir)) {
|
||||
const match = file.match(/_([a-f0-9]+)\.eml$/i);
|
||||
if (match) existingIds.add(match[1]!);
|
||||
}
|
||||
} catch {
|
||||
/* dir might not exist yet */
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let page = 0;
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const params: Record<string, string> = { maxResults: '100' };
|
||||
if (query) params.q = query;
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
|
||||
const list = (await gmailGet(token, '/messages', params)) as {
|
||||
messages?: Array<{ id: string }>;
|
||||
nextPageToken?: string;
|
||||
};
|
||||
|
||||
const messages = list.messages ?? [];
|
||||
if (messages.length === 0) break;
|
||||
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const batch = messages.slice(i, i + 5);
|
||||
await Promise.all(
|
||||
batch.map(async ({ id }) => {
|
||||
if (existingIds.has(id)) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = (await gmailGet(token, `/messages/${id}`, { format: 'raw' })) as {
|
||||
id: string;
|
||||
internalDate?: string;
|
||||
raw: string;
|
||||
};
|
||||
const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8');
|
||||
const filename = buildEmlFilename(msg.id, msg.internalDate, rawEmail);
|
||||
writeFileSync(join(outputDir, filename), rawEmail);
|
||||
existingIds.add(id);
|
||||
saved++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
page++;
|
||||
console.log(`[gmail-sync] Page ${page}: saved ${saved}, skipped ${skipped}, errors ${errors}`);
|
||||
onProgress?.({ saved, skipped, errors, page });
|
||||
|
||||
pageToken = list.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return { saved, skipped, errors };
|
||||
}
|
||||
|
||||
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
function buildMonthRanges(year: number): Array<{ label: string; after: string; before: string }> {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getFullYear() === year ? now.getMonth() : 11;
|
||||
const ranges: Array<{ label: string; after: string; before: string }> = [];
|
||||
|
||||
for (let m = 0; m <= currentMonth; m++) {
|
||||
const after = `${year}/${m + 1}/1`;
|
||||
const before = m < 11 ? `${year}/${m + 2}/1` : `${year + 1}/1/1`;
|
||||
ranges.push({ label: `${MONTH_NAMES[m]!} ${year}`, after, before });
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const gmailSyncHandler: JobHandler = {
|
||||
type: 'gmail-sync',
|
||||
steps: [
|
||||
{
|
||||
name: 'Verify connection',
|
||||
run: async (ctx) => {
|
||||
const creds = await loadCredentials(ctx.job.userId);
|
||||
const token = await getValidAccessToken(creds);
|
||||
// Store token in shared meta for the next step
|
||||
ctx.meta.accessToken = token;
|
||||
ctx.meta.outputDir = join(DATA_PATH, ctx.job.userId, 'Gmail', 'emails');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sync emails',
|
||||
run: async (ctx) => {
|
||||
const token = ctx.meta.accessToken as string;
|
||||
const outputDir = ctx.meta.outputDir as string;
|
||||
const year = ctx.meta.year as number | undefined;
|
||||
|
||||
let totalSaved = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
if (year) {
|
||||
// Year-scoped sync: month by month with progress
|
||||
const months = buildMonthRanges(year);
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
const month = months[i]!;
|
||||
await ctx.updateProgress({ current: i, total: months.length, label: month.label });
|
||||
const query = `after:${month.after} before:${month.before}`;
|
||||
const { saved, skipped, errors } = await syncInbox(token, outputDir, query, (p) => {
|
||||
const label = `${month.label} — ${p.saved} saved`;
|
||||
ctx.updateProgress({ current: i, total: months.length, label });
|
||||
});
|
||||
totalSaved += saved;
|
||||
totalSkipped += skipped;
|
||||
totalErrors += errors;
|
||||
}
|
||||
await ctx.updateProgress({ current: months.length, total: months.length, label: 'Done' });
|
||||
} else {
|
||||
// Full sync: all emails with per-page progress
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Starting sync' });
|
||||
const { saved, skipped, errors } = await syncInbox(token, outputDir, undefined, (p) => {
|
||||
const label = `Saved ${p.saved}, skipped ${p.skipped} (page ${p.page})`;
|
||||
ctx.updateProgress({ current: p.saved + p.skipped + p.errors, total: 0, label });
|
||||
});
|
||||
totalSaved = saved;
|
||||
totalSkipped = skipped;
|
||||
totalErrors = errors;
|
||||
await ctx.updateProgress({ current: 1, total: 1, label: 'Done' });
|
||||
}
|
||||
|
||||
console.log(`[gmail-sync] Saved ${totalSaved}, skipped ${totalSkipped}, errors ${totalErrors}`);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
registerHandler(gmailSyncHandler);
|
||||
@@ -0,0 +1 @@
|
||||
import './gmail-sync';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ensureQueueDir } from './storage';
|
||||
import { resumeInterruptedJobs } from './engine';
|
||||
import './handlers';
|
||||
|
||||
export { enqueue, cancelJob } from './engine';
|
||||
export { readJob, listAllJobs } from './storage';
|
||||
export { registerHandler } from './handler-registry';
|
||||
export type { Job, JobStep, JobStatus, JobProgress, StepContext, JobHandler, JobHandlerStep, EnqueueParams } from './types';
|
||||
|
||||
export async function initQueue() {
|
||||
await ensureQueueDir();
|
||||
await resumeInterruptedJobs();
|
||||
console.log('[queue] Initialized');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Job } from './types';
|
||||
import { join } from 'node:path';
|
||||
import { readdir, mkdir, unlink } from 'node:fs/promises';
|
||||
import { DATA_PATH } from '../data-path';
|
||||
|
||||
const QUEUE_DIR = join(DATA_PATH, 'queue', 'jobs');
|
||||
|
||||
export async function ensureQueueDir() {
|
||||
await mkdir(QUEUE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function readJob(id: string): Promise<Job | null> {
|
||||
const file = Bun.file(join(QUEUE_DIR, `${id}.json`));
|
||||
if (!(await file.exists())) return null;
|
||||
try {
|
||||
return await file.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJob(job: Job): Promise<void> {
|
||||
await Bun.write(join(QUEUE_DIR, `${job.id}.json`), JSON.stringify(job, null, 2));
|
||||
}
|
||||
|
||||
export async function listAllJobs(): Promise<Job[]> {
|
||||
try {
|
||||
const entries = await readdir(QUEUE_DIR);
|
||||
const jobs: Job[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.json')) continue;
|
||||
const file = Bun.file(join(QUEUE_DIR, entry));
|
||||
try {
|
||||
jobs.push(await file.json());
|
||||
} catch {
|
||||
// corrupted file — skip
|
||||
}
|
||||
}
|
||||
return jobs.sort((a, b) => b.createdAt - a.createdAt);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteJobFile(id: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(QUEUE_DIR, `${id}.json`));
|
||||
} catch {
|
||||
// file doesn't exist — fine
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type JobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export type JobStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type JobProgress = {
|
||||
current: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type JobStep = {
|
||||
name: string;
|
||||
status: JobStepStatus;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
error?: string;
|
||||
progress?: JobProgress;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
lane: string;
|
||||
type: string;
|
||||
userId: string;
|
||||
status: JobStatus;
|
||||
steps: JobStep[];
|
||||
currentStep: number;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type StepContext = {
|
||||
job: Job;
|
||||
step: JobStep;
|
||||
updateProgress: (progress: JobProgress) => Promise<void>;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type JobHandlerStep = {
|
||||
name: string;
|
||||
run: (ctx: StepContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export type JobHandler = {
|
||||
type: string;
|
||||
steps: JobHandlerStep[];
|
||||
};
|
||||
|
||||
export type EnqueueParams = {
|
||||
lane: string;
|
||||
type: string;
|
||||
userId: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import Layout from 'emailer/emails/layouts/MainLayout.jsx';
|
||||
import { Container, Text } from '@react-email/components';
|
||||
|
||||
type JobEmailData = {
|
||||
job: { type: string; completedAt?: number };
|
||||
};
|
||||
|
||||
const Email = ({ job }: JobEmailData) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Container>
|
||||
<Text className="pt-4 text-2xl">Job Completed</Text>
|
||||
<Text>
|
||||
{job?.type === 'gmail-sync'
|
||||
? 'Your Google account is now completely synced.'
|
||||
: `Your ${job?.type ?? 'unknown'} job has finished successfully.`}
|
||||
</Text>
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Email;
|
||||
@@ -0,0 +1,20 @@
|
||||
import Layout from 'emailer/emails/layouts/MainLayout.jsx';
|
||||
import { Container, Text } from '@react-email/components';
|
||||
|
||||
type JobEmailData = {
|
||||
job: { type: string; error?: string };
|
||||
};
|
||||
|
||||
const Email = ({ job }: JobEmailData) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Container>
|
||||
<Text className="pt-4 text-2xl">Job Failed</Text>
|
||||
<Text>Your {job?.type ?? 'unknown'} job has failed.</Text>
|
||||
{job?.error ? <Text className="text-sm text-gray-600">{job.error}</Text> : null}
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Email;
|
||||
@@ -16,6 +16,7 @@ export { useChatWebSocket } from './useChatWebSocket';
|
||||
export { useDataControl } from './useDataControl';
|
||||
export { useCustomSorter } from './useCustomSorter';
|
||||
|
||||
export { useJobs, useJob } from './useJobs';
|
||||
export { useAuth } from './useAuth';
|
||||
export { useForm } from './useForm';
|
||||
export { useFullscreen } from './useFullscreen';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Job } from 'types';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from './useClient';
|
||||
|
||||
type JobFilters = {
|
||||
lane?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function buildQuery(filters?: JobFilters) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.lane) params.set('lane', filters.lane);
|
||||
if (filters?.type) params.set('type', filters.type);
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
const qs = params.toString();
|
||||
return qs ? `/queue/jobs?${qs}` : '/queue/jobs';
|
||||
}
|
||||
|
||||
export const useJobs = (filters?: JobFilters) => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: jobs = [], refetch } = useQuery<Job[]>({
|
||||
queryKey: ['jobs', filters],
|
||||
queryFn: () => client.get<Job[]>(buildQuery(filters)),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (!data) return 3000;
|
||||
const hasActive = data.some((j) => j.status === 'queued' || j.status === 'running');
|
||||
return hasActive ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (params: { lane: string; type: string; meta?: Record<string, unknown> }) =>
|
||||
client.post<Job>('/queue/jobs', params),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: string) => client.delete<Job>(`/queue/jobs/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return {
|
||||
jobs,
|
||||
refetch,
|
||||
createJob: createMutation.mutateAsync,
|
||||
cancelJob: cancelMutation.mutateAsync,
|
||||
};
|
||||
};
|
||||
|
||||
export const useJob = (id: string | null) => {
|
||||
const client = useClient();
|
||||
|
||||
const { data: job } = useQuery<Job>({
|
||||
queryKey: ['job', id],
|
||||
queryFn: () => client.get<Job>(`/queue/jobs/${id}`),
|
||||
enabled: !!id,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (!data) return 2000;
|
||||
const isActive = data.status === 'queued' || data.status === 'running';
|
||||
return isActive ? 2000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
return { job: job ?? null };
|
||||
};
|
||||
+19
-10
@@ -40,9 +40,10 @@ type PiMonoInnerProps = {
|
||||
cwd: { root?: string; path: string };
|
||||
initialModel: string | null;
|
||||
taskInfo: TaskInfo;
|
||||
sandboxed?: boolean;
|
||||
};
|
||||
|
||||
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo }: PiMonoInnerProps) => {
|
||||
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: PiMonoInnerProps) => {
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const availableModels = useVisiblePiModels();
|
||||
@@ -108,7 +109,7 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo }: PiMonoInnerP
|
||||
|
||||
const handleRun = () => {
|
||||
setPhase('running');
|
||||
chat.sendPrompt(defaultInput, undefined, undefined, cwd);
|
||||
chat.sendPrompt(defaultInput, undefined, undefined, cwd, undefined, sandboxed);
|
||||
};
|
||||
|
||||
if (phase === 'ready') {
|
||||
@@ -187,9 +188,11 @@ type TaskRunnerModalProps = {
|
||||
entryType?: 'file' | 'directory';
|
||||
cwd?: { root?: string; path: string };
|
||||
promptOverride?: string;
|
||||
description?: string;
|
||||
sandboxed?: boolean;
|
||||
};
|
||||
|
||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
|
||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride, description, sandboxed }: TaskRunnerModalProps) => {
|
||||
const { settings } = useSettings();
|
||||
const taskSettings = settings.tasks;
|
||||
const entryRef = entryFullPath ?? entryName;
|
||||
@@ -209,14 +212,19 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
style={cardStyle()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-background/60">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
|
||||
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
|
||||
<div className="shrink-0 flex flex-col border-b border-duck-dark/10 bg-background/60">
|
||||
<div className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
|
||||
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
|
||||
</div>
|
||||
<DialogPrimitive.Close className="p-1.5 rounded-md text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer">
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<DialogPrimitive.Close className="p-1.5 rounded-md text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer">
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
{description && (
|
||||
<p className="px-5 pb-3 text-xs text-duck-dark/50 dark:text-foreground/50 -mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Runner */}
|
||||
@@ -226,6 +234,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
cwd={cwd}
|
||||
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
|
||||
taskInfo={taskInfo}
|
||||
sandboxed={sandboxed}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export type EmailSummary = {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
snippet: string;
|
||||
read?: boolean;
|
||||
};
|
||||
|
||||
export type EmailMessage = EmailSummary & {
|
||||
cc?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments: Array<{ filename: string; size: number; contentType: string }>;
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
export type {} from './globals';
|
||||
export * from 'officerdb/types';
|
||||
|
||||
export type { Job, JobStep, JobStatus, JobStepStatus, JobProgress } from './queue';
|
||||
export type { EmailSummary, EmailMessage } from './email';
|
||||
|
||||
export type SelectOption = { value: number | string; label?: string; href?: string };
|
||||
|
||||
export type StateSetter<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export type JobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export type JobStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type JobProgress = {
|
||||
current: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type JobStep = {
|
||||
name: string;
|
||||
status: JobStepStatus;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
error?: string;
|
||||
progress?: JobProgress;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
lane: string;
|
||||
type: string;
|
||||
userId: string;
|
||||
status: JobStatus;
|
||||
steps: JobStep[];
|
||||
currentStep: number;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
Reference in New Issue
Block a user