Skip to content
Open
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
17 changes: 13 additions & 4 deletions cmd/agentsview/pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,14 @@ func runPGServe(appCfg config.Config, basePath string) {
); err != nil {
fatal("pg serve: %v", err)
}
if err := store.DetectInsightGenerationAvailability(
ctx,
); err != nil {
fatal(
"pg serve: probing insight generation capability: %v",
err,
)
}

rtOpts := serveRuntimeOptions{
Mode: "pg-serve",
Expand All @@ -430,10 +438,11 @@ func runPGServe(appCfg config.Config, basePath string) {

opts := []server.Option{
server.WithVersion(server.VersionInfo{
Version: version,
Commit: commit,
BuildDate: buildDate,
ReadOnly: true,
Version: version,
Commit: commit,
BuildDate: buildDate,
ReadOnly: true,
InsightGenerationAvailable: store.InsightGenerationAvailable(),
}),
server.WithDataDir(appCfg.DataDir),
server.WithBaseContext(ctx),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api/generated/models/VersionInfo.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/src/lib/api/types/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface VersionInfo {
version: string;
commit: string;
build_date: string;
insight_generation_available?: boolean;
read_only?: boolean;
}

Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/components/activity/ActivityInsight.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@
router.navigate("insights");
}
const readOnly = $derived(sync.serverVersion?.read_only === true);
const insightGenerationAvailable = $derived(
sync.serverVersion?.insight_generation_available === true ||
sync.serverVersion?.read_only !== true,
);
const generationUnavailable = $derived(
sync.serverVersion === null || readOnly,
sync.serverVersion === null || !insightGenerationAvailable,
);
const unavailableTitle = $derived(
readOnly
sync.serverVersion !== null && !insightGenerationAvailable
? m.activity_insight_unavailable_read_only()
: sync.serverVersion === null
? m.activity_insight_waiting_server()
Expand Down
31 changes: 27 additions & 4 deletions frontend/src/lib/components/activity/ActivityInsight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,20 @@ const mocks = vi.hoisted(() => ({
setAgent: vi.fn(),
navigate: vi.fn(),
agent: "claude" as string,
serverVersion: { read_only: false } as { read_only: boolean } | null,
serverVersion: {
read_only: false,
} as {
read_only: boolean;
insight_generation_available?: boolean;
} | null,
}));
vi.mock("../../api/generated/index", () => ({
InsightsService: { getApiV1Insights: mocks.getInsights },
}));
vi.mock("../../api/runtime.js", () => ({ configureGeneratedClient: vi.fn() }));
vi.mock("../../api/client.js", () => ({ generateInsight: mocks.generateInsight }));
vi.mock("../../api/client.js", () => ({
generateInsight: mocks.generateInsight,
}));
vi.mock("../../stores/sync.svelte.js", () => ({
sync: {
get serverVersion() {
Expand Down Expand Up @@ -93,7 +100,10 @@ describe("ActivityInsight", () => {
});

it("generates for the current range", async () => {
mocks.generateInsight.mockReturnValue({ abort: vi.fn(), done: new Promise(() => {}) });
mocks.generateInsight.mockReturnValue({
abort: vi.fn(),
done: new Promise(() => {}),
});
render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" });
await settle();
await fireEvent.click(screen.getByRole("button", { name: /generate/i }));
Expand Down Expand Up @@ -178,7 +188,9 @@ describe("ActivityInsight", () => {
it("prefills the Insights page range and navigates", async () => {
render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" });
await settle();
const link = screen.getByRole("link", { name: /insights page|open in insights/i });
const link = screen.getByRole("link", {
name: /insights page|open in insights/i,
});
await fireEvent.click(link);
expect(mocks.setType).toHaveBeenCalledWith("daily_activity");
expect(mocks.setDateFrom).toHaveBeenCalledWith("2026-06-15");
Expand All @@ -195,6 +207,17 @@ describe("ActivityInsight", () => {
expect(btn.hasAttribute("disabled")).toBe(true);
});

it("allows Generate in read-only mode when insight generation is advertised", async () => {
mocks.serverVersion = {
read_only: true,
insight_generation_available: true,
};
render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" });
await settle();
const btn = screen.getByRole("button", { name: /generate/i });
expect(btn.hasAttribute("disabled")).toBe(false);
});

it("selects the exact-range insight over a newer nested one", async () => {
mocks.getInsights.mockResolvedValue({
insights: [
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/lib/components/insights/InsightsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@
);
const loading = $derived(analytics.loading.signals);
const error = $derived(analytics.errors.signals);
const readOnly = $derived(
sync.serverVersion?.read_only === true,
const insightGenerationAvailable = $derived(
sync.serverVersion?.insight_generation_available === true ||
sync.serverVersion?.read_only !== true,
);
const generationUnavailable = $derived(
sync.serverVersion === null || readOnly,
sync.serverVersion === null || !insightGenerationAvailable,
);
const earliestSession = $derived(sync.stats?.earliest_session ?? null);
const rangeSelection = $derived(
Expand Down Expand Up @@ -1093,7 +1094,7 @@
<button
class="generate-action"
disabled={generationUnavailable}
title={readOnly
title={sync.serverVersion !== null && !insightGenerationAvailable
? m.insights_page_generate_disabled()
: m.insights_page_generate_title()}
onclick={handleGenerateCanned}
Expand Down
54 changes: 39 additions & 15 deletions frontend/src/lib/components/insights/InsightsPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,11 @@ describe("InsightsPage sidebar filter sync", () => {
// sidebar toggle, map it to all/human, and write both fields.
const normalized = source.replace(/\s+/g, " ");
expect(source).toContain("sessions.filters.includeAutomated");
expect(normalized).toContain(
'headerIncludeAutomated ? "all" : "human"',
);
expect(normalized).toContain('headerIncludeAutomated ? "all" : "human"');
expect(source).toContain(
"analytics.includeAutomated = headerIncludeAutomated",
);
expect(source).toContain(
"analytics.automatedScope = headerAutomatedScope",
);
expect(source).toContain("analytics.automatedScope = headerAutomatedScope");
});

it("refetches when the automated scope changes", () => {
Expand Down Expand Up @@ -95,9 +91,7 @@ describe("InsightsPage date yoke controls", () => {
});

it("routes automated scope changes through the insight refresh wrapper", () => {
const handlerIndex = source.indexOf(
"function handleAutomatedScopeChange",
);
const handlerIndex = source.indexOf("function handleAutomatedScopeChange");
const nextHandlerIndex = source.indexOf(
"\n\n function handlePromptChange",
handlerIndex,
Expand Down Expand Up @@ -165,6 +159,15 @@ const state = vi.hoisted(() => {
};
});

const syncState = vi.hoisted(() => ({
serverVersion: {
read_only: false,
} as {
read_only: boolean;
insight_generation_available?: boolean;
},
}));

vi.mock("../../api/client.js", () => ({
downloadInsightExport: mocks.downloadInsightExport,
watchEvents: mocks.watchEvents,
Expand Down Expand Up @@ -195,17 +198,22 @@ vi.mock("../../stores/sessions.svelte.js", () => ({

vi.mock("../../stores/sync.svelte.js", () => ({
sync: {
serverVersion: { read_only: false },
get serverVersion() {
return syncState.serverVersion;
},
},
}));

vi.mock("../../paraglide/messages.js", () => {
const stub = new Proxy({}, {
get(_target, prop) {
if (prop === "m") return stub;
return () => String(prop);
const stub = new Proxy(
{},
{
get(_target, prop) {
if (prop === "m") return stub;
return () => String(prop);
},
},
});
);
return stub;
});

Expand All @@ -227,6 +235,7 @@ describe("InsightsPage selected insight actions", () => {
ui.activeModal = null;
ui.publishSecret = false;
ui.clearPublishTarget();
syncState.serverVersion = { read_only: false };
state.insightsStore.selectedItem = state.selectedInsight;
state.insightsStore.selectedId = state.selectedInsight.id;
state.insightsStore.items = [state.selectedInsight];
Expand Down Expand Up @@ -294,4 +303,19 @@ describe("InsightsPage selected insight actions", () => {
id: 42,
});
});

it("keeps Generate enabled for pg serve when version advertises insight writes", async () => {
syncState.serverVersion = {
read_only: true,
insight_generation_available: true,
};
component = mount(InsightsPage, { target: document.body });
await tick();

const generateButton = document.querySelector<HTMLButtonElement>(
"button.generate-action",
);
expect(generateButton).toBeDefined();
expect(generateButton!.disabled).toBe(false);
});
});
4 changes: 2 additions & 2 deletions internal/db/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,14 @@ type Store interface {
UnpinMessage(sessionID string, messageID int64) error
ListPinnedMessages(ctx context.Context, sessionID string, project string) ([]PinnedMessage, error)

// Insights (local-only; PG returns ErrReadOnly).
// Insights.
ListInsights(ctx context.Context, f InsightFilter) ([]Insight, error)
GetInsight(ctx context.Context, id int64) (*Insight, error)
GetCachedInsight(ctx context.Context, cacheKey string) (*Insight, error)
InsertInsight(s Insight) (int64, error)
DeleteInsight(id int64) error

// Session management (local-only; PG returns ErrReadOnly).
// Session management.
RenameSession(id string, displayName *string) error
SoftDeleteSession(id string) error
SoftDeleteSessions(ids []string) (int, error)
Expand Down
83 changes: 83 additions & 0 deletions internal/postgres/insights_pgtest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build pgtest

package postgres

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.kenn.io/agentsview/internal/db"
)

func TestStoreInsightCRUD(t *testing.T) {
pgURL := testPGURL(t)
ensureStoreSchema(t, pgURL)

store, err := NewStore(pgURL, testSchema, true)
require.NoError(t, err, "NewStore")
defer store.Close()

ctx := context.Background()
project := "insight-project"
cacheKey := "insight-cache-key"

firstID, err := store.InsertInsight(db.Insight{
Type: "daily_activity",
DateFrom: "2026-03-12",
DateTo: "2026-03-12",
Project: &project,
Agent: "claude",
Content: "first insight",
CacheKey: cacheKey,
CacheStatus: "fresh",
})
require.NoError(t, err, "InsertInsight first")
require.NotZero(t, firstID)

time.Sleep(10 * time.Millisecond)

secondID, err := store.InsertInsight(db.Insight{
Type: "daily_activity",
DateFrom: "2026-03-12",
DateTo: "2026-03-12",
Project: &project,
Agent: "claude",
Content: "second insight",
CacheKey: cacheKey,
CacheStatus: "hit",
})
require.NoError(t, err, "InsertInsight second")
require.NotZero(t, secondID)
assert.NotEqual(t, firstID, secondID)

listed, err := store.ListInsights(ctx, db.InsightFilter{
Type: "daily_activity",
Project: project,
})
require.NoError(t, err, "ListInsights")
require.Len(t, listed, 2)
assert.Equal(t, secondID, listed[0].ID)
assert.Equal(t, firstID, listed[1].ID)

got, err := store.GetInsight(ctx, firstID)
require.NoError(t, err, "GetInsight")
require.NotNil(t, got)
assert.Equal(t, "first insight", got.Content)
require.NotNil(t, got.Project)
assert.Equal(t, project, *got.Project)

cached, err := store.GetCachedInsight(ctx, cacheKey)
require.NoError(t, err, "GetCachedInsight")
require.NotNil(t, cached)
assert.Equal(t, secondID, cached.ID)
assert.Equal(t, "hit", cached.CacheStatus)

require.NoError(t, store.DeleteInsight(firstID), "DeleteInsight first")
got, err = store.GetInsight(ctx, firstID)
require.NoError(t, err, "GetInsight after delete")
assert.Nil(t, got)
}
Loading