Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `alphaforge` CLI for workspaces and agent tasks, with human/JSON output, live progress, timeout, cancellation, and retry.
- Agent context and tools: workspace and recent-task context in prompts, an OpenAI tool-calling loop (max 4 rounds), and read-only tools for listing/searching tasks, reading task output, and arithmetic.
- Agent artifacts: validated comparison-table and timeline outputs are generated with tasks, persisted in SQLite, and rendered on the Artifacts page with predefined React components.
- Artifact windows: validated artifacts can open in a dedicated minimal-permission WebView (`core:default`) with the payload injected as parsed JSON.

### Fixed
- Root `pnpm typecheck` now uses the pnpm workspace instead of a non-existent npm workspace.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ React component
- `agent/runtime.rs` defines `AgentRuntime`, `ProgressReporter`, and a simulated `LocalDemoRuntime` for tests/demos; `agent/openai_runtime.rs` is the production runtime — it assembles context, runs up to 4 OpenAI tool-calling rounds against a `ToolRegistry`, validates the final JSON against `ResearchOutput`, and persists canonical JSON. `agent/context.rs` supplies the workspace name and summaries of recent completed tasks; `agent/tools/` provides `list_tasks`, `search_tasks`, `get_task_output`, and `calculate`. Tool execution errors are returned to the model as `{"error": ...}` so it can self-correct. `agent/events.rs` publishes `agent-task-event` through an injected `EventPublisher` (Tauri-backed in production).
- The frontend `useAgentTaskEvents` hook subscribes to `agent-task-event` and invalidates agent queries so task status and results update live. `AgentWorkspacePanel` (rendered by `TodayPage` once a workspace is selected) combines task creation, start/cancel actions, and a detail view with the execution timeline, output, and error.
- Agent artifacts: `agent/openai_runtime.rs` accepts an optional `artifacts` array in the final JSON; `artifacts/schema.rs` validates the `comparison-table` and `timeline` schemas, and `services/artifact_service.rs` persists them (title/workspace on the row, payload in `artifacts.input`). `commands/artifacts.rs` exposes `list_artifacts` with workspace/task filters, and `features/artifacts` renders them with predefined React components in the main window.
- Artifact windows, the plugin runtime, and the remaining built-in artifact types are still scaffolding. Rendering never injects model-generated HTML; treat artifact payloads as untrusted data even after Rust validation. The intended contracts are documented in `docs/ARTIFACT_SYSTEM.md`, `docs/PLUGIN_SPEC.md`, and the manifests in `plugins/*/manifest.json` (`id`, `name`, `version`, `entry`, `inputSchema`, `permissions`, `window`).
- Artifacts can also open in a dedicated WebView via `open_artifact_window` (`windows/artifact_window.rs`). The window loads `artifact.html` / `src/artifact-main.tsx`, receives the validated payload through an initialization script as a JSON string parsed with `JSON.parse`, and runs with only `core:default` (`capabilities/artifact-window.json`). The artifact entry must not import `desktopApi` or app-shell code.
- The plugin runtime and the remaining built-in artifact types are still scaffolding. Rendering never injects model-generated HTML; treat artifact payloads as untrusted data even after Rust validation. The intended contracts are documented in `docs/ARTIFACT_SYSTEM.md`, `docs/PLUGIN_SPEC.md`, and the manifests in `plugins/*/manifest.json` (`id`, `name`, `version`, `entry`, `inputSchema`, `permissions`, `window`).
- Treat agent output as untrusted: validate it against a schema and render it with predefined React plugins. Never inject agent-generated HTML into the privileged main-window DOM.
- Artifact windows receive only `core:default` (`capabilities/artifact-window.json`); the main window additionally gets opener, shell-open, and store permissions (`capabilities/main-window.json`).
- The research, journal, portfolio, and artifact list commands (`commands/research.rs`, `journal.rs`, `portfolio.rs`, `artifacts.rs`) are stubs returning empty collections.
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/artifact.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artifact — Investment OS</title>
<script>
(function () {
try {
var stored = localStorage.getItem("investment-os-theme");
var systemDark =
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var isSystem = stored !== "light" && stored !== "dark";
var isDark = stored === "dark" || (isSystem && systemDark);
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.style.setProperty("color-scheme", isDark ? "dark" : "light");
} catch (error) {
// Theme initialization is best-effort; React applies it on mount.
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/artifact-main.tsx"></script>
</body>
</html>
15 changes: 15 additions & 0 deletions apps/desktop/src-tauri/src/commands/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ pub async fn list_artifacts(
.list(workspace_id.as_deref(), task_id.as_deref())
.await
}

#[tauri::command]
pub async fn open_artifact_window(
artifact_id: String,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), AppError> {
let artifact = state
.artifact_service
.get(&artifact_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Artifact '{}' not found", artifact_id)))?;

crate::windows::artifact_window::open_artifact_window(&app, &artifact)
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ impl ArtifactRepository {
})
}

/// Gets an artifact by ID.
pub async fn get(&self, id: &str) -> Result<Option<Artifact>, AppError> {
let row = sqlx::query_as::<_, ArtifactRow>(
r#"
SELECT id, task_id, workspace_id, artifact_type, title, input AS data,
status, created_at, updated_at
FROM artifacts
WHERE id = ?
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| AppError::Internal(format!("Failed to get artifact: {}", e)))?;

Ok(row.map(|row| row.into()))
}

/// Lists artifacts, optionally filtered by workspace and task.
pub async fn list(
&self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ mod tests {
assert_eq!(artifacts[0].data, r#"{"columns":[],"rows":[]}"#);
}

#[tokio::test]
async fn test_get_artifact() {
let repo = setup_repo().await;

let created = repo
.create(Some("t1"), Some("w1"), "timeline", "Timeline", "{}")
.await
.expect("artifact should be created");

let fetched = repo
.get(&created.id)
.await
.expect("artifact should load")
.expect("artifact should exist");
assert_eq!(fetched.title, "Timeline");
assert_eq!(fetched.artifact_type, "timeline");

assert!(repo
.get("missing")
.await
.expect("lookup should succeed")
.is_none());
}

#[tokio::test]
async fn test_list_returns_all_when_filters_are_none() {
let repo = setup_repo().await;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub fn run() {
commands::portfolio::list_portfolio_accounts,
// Artifacts commands
commands::artifacts::list_artifacts,
commands::artifacts::open_artifact_window,
])
.run(tauri::generate_context!())
.expect("failed to launch Investment OS");
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src-tauri/src/services/artifact_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ impl ArtifactService {
Ok(artifacts)
}

/// Gets an artifact by ID.
pub async fn get(&self, id: &str) -> Result<Option<Artifact>, AppError> {
self.repo.get(id).await
}

/// Lists artifacts, optionally filtered by workspace and task.
pub async fn list(
&self,
Expand Down
124 changes: 123 additions & 1 deletion apps/desktop/src-tauri/src/windows/artifact_window.rs
Original file line number Diff line number Diff line change
@@ -1 +1,123 @@
// Artifact window management. Placeholder.
// Artifact window management.
//
// Each artifact opens in its own WebView with a minimal capability set
// (`capabilities/artifact-window.json`, `core:default` only). The window loads
// a dedicated entry point that renders the payload with predefined components;
// no privileged application commands are imported by that entry.

use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};

use crate::error::AppError;
use domain::artifact::Artifact;

pub const ARTIFACT_WINDOW_PREFIX: &str = "artifact-";

/// Builds the initialization script that hands the artifact payload to the window.
///
/// The payload is embedded as a JSON string literal and parsed with `JSON.parse`,
/// so quotes, newlines, or script-like content in the artifact cannot break out
/// of the assignment.
pub fn artifact_init_script(artifact: &Artifact) -> Result<String, AppError> {
let data: serde_json::Value = serde_json::from_str(&artifact.data)
.map_err(|_| AppError::Internal("stored artifact payload is not valid JSON".to_string()))?;

let payload = serde_json::json!({
"id": artifact.id,
"title": artifact.title,
"artifact_type": artifact.artifact_type,
"data": data,
"created_at": artifact.created_at.to_rfc3339(),
});

let encoded = serde_json::to_string(&payload)
.map_err(|error| AppError::Internal(format!("failed to encode artifact: {error}")))?;
let literal = serde_json::to_string(&encoded)
.map_err(|error| AppError::Internal(format!("failed to quote artifact: {error}")))?;

Ok(format!(
"window.__ALPHAFORGE_ARTIFACT__ = JSON.parse({literal});"
))
}

/// Opens an artifact window, or focuses it when it is already open.
pub fn open_artifact_window(app: &AppHandle, artifact: &Artifact) -> Result<(), AppError> {
let label = format!("{ARTIFACT_WINDOW_PREFIX}{}", artifact.id);

if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(|error| {
AppError::Internal(format!("failed to show artifact window: {error}"))
})?;
window.set_focus().map_err(|error| {
AppError::Internal(format!("failed to focus artifact window: {error}"))
})?;
return Ok(());
}

let script = artifact_init_script(artifact)?;

WebviewWindowBuilder::new(app, label, WebviewUrl::App("artifact.html".into()))
.title(format!("Artifact — {}", artifact.title))
.inner_size(1000.0, 720.0)
.min_inner_size(640.0, 480.0)
.resizable(true)
.initialization_script(&script)
.build()
.map_err(|error| AppError::Internal(format!("failed to open artifact window: {error}")))?;

Ok(())
}

#[cfg(test)]
mod tests {
use chrono::Utc;

use super::*;
use domain::artifact::ArtifactStatus;

fn artifact(title: &str, data: &str) -> Artifact {
let now = Utc::now();
Artifact {
id: "artifact-1".to_string(),
task_id: Some("task-1".to_string()),
workspace_id: Some("ws-1".to_string()),
artifact_type: "timeline".to_string(),
title: title.to_string(),
data: data.to_string(),
status: ArtifactStatus::Completed,
created_at: now,
updated_at: now,
}
}

#[test]
fn test_init_script_escapes_quotes_and_newlines() {
let script = artifact_init_script(&artifact(
"A \"quoted\" title\nwith newline",
r#"{"events":[{"date":"2026-01-01","title":"Start"}]}"#,
))
.expect("script should build");

let literal = script
.strip_prefix("window.__ALPHAFORGE_ARTIFACT__ = JSON.parse(")
.and_then(|rest| rest.strip_suffix(");"))
.expect("script should have the expected shape");

assert!(!literal.contains('\n'));

let encoded: String =
serde_json::from_str(literal).expect("literal should be a JSON string");
let payload: serde_json::Value =
serde_json::from_str(&encoded).expect("payload should be JSON");

assert_eq!(payload["title"], "A \"quoted\" title\nwith newline");
assert_eq!(payload["artifact_type"], "timeline");
assert_eq!(payload["data"]["events"][0]["title"], "Start");
}

#[test]
fn test_init_script_rejects_invalid_payload() {
let result = artifact_init_script(&artifact("Title", "not json"));

assert!(matches!(result, Err(AppError::Internal(_))));
}
}
10 changes: 10 additions & 0 deletions apps/desktop/src/artifact-main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ArtifactWindow } from "./artifact/ArtifactWindow";
import "./styles/globals.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ArtifactWindow />
</React.StrictMode>,
);
39 changes: 39 additions & 0 deletions apps/desktop/src/artifact/ArtifactWindow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Tests for the standalone artifact window entry.

import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";

import { ArtifactWindow } from "./ArtifactWindow";

describe("ArtifactWindow", () => {
afterEach(() => {
cleanup();
delete window.__ALPHAFORGE_ARTIFACT__;
});

it("renders the injected artifact payload", () => {
window.__ALPHAFORGE_ARTIFACT__ = {
id: "artifact-1",
title: "Tesla vs Nvidia",
artifact_type: "comparison-table",
data: {
columns: [{ key: "pe", label: "P/E" }],
rows: [{ label: "Tesla", values: { pe: 28.5 } }],
},
created_at: "2026-01-01T00:00:00Z",
};

render(<ArtifactWindow />);

expect(screen.getByText("Tesla vs Nvidia")).toBeInTheDocument();
expect(screen.getByText("Tesla")).toBeInTheDocument();
expect(screen.getByText("28.5")).toBeInTheDocument();
});

it("shows a fallback when no payload was injected", () => {
render(<ArtifactWindow />);

expect(screen.getByText("No artifact data was provided.")).toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions apps/desktop/src/artifact/ArtifactWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Standalone artifact window.
//
// This entry is bundled separately from the main application and does not
// import desktopApi or app-shell code. It renders only the injected artifact
// payload with predefined components.

import type { Artifact } from "@/lib/desktop-api";
import { ArtifactRenderer } from "@/features/artifacts/components/ArtifactRenderer";

interface ArtifactWindowPayload {
id: string;
title: string;
artifact_type: string;
data: unknown;
created_at: string;
}

declare global {
interface Window {
__ALPHAFORGE_ARTIFACT__?: ArtifactWindowPayload;
}
}

export function ArtifactWindow() {
const payload = window.__ALPHAFORGE_ARTIFACT__;

if (!payload) {
return (
<main className="p-6 text-sm text-muted-foreground">No artifact data was provided.</main>
);
}

const artifact: Artifact = {
id: payload.id,
task_id: null,
workspace_id: null,
artifact_type: payload.artifact_type,
title: payload.title,
data: JSON.stringify(payload.data),
status: "completed",
created_at: payload.created_at,
updated_at: payload.created_at,
};

return (
<main className="min-h-screen bg-background p-6 text-foreground">
<header className="mb-4">
<h1 className="text-xl font-semibold">{artifact.title}</h1>
<p className="mt-1 text-xs text-muted-foreground">
Created {new Date(artifact.created_at).toLocaleString()}
</p>
</header>
<section className="rounded-lg border border-border bg-card p-4">
<ArtifactRenderer artifact={artifact} />
</section>
</main>
);
}
Loading
Loading