Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4a27a1b
fix(sessions_api): spawn deactivation watcher from stop_session_handler
devguyio Jun 10, 2026
a61fe11
feat(hydration): assemble member-level skills into session workspace
devguyio Jun 10, 2026
c9cf5fe
feat(hydration): implement AppCredentialWriter for session credential…
devguyio Jun 10, 2026
fb5d867
feat(hydration,chat): implement D-02 shared credential path for sessi…
devguyio Jun 10, 2026
361da99
feat(hydration): assemble project-level agents/skills and member comm…
devguyio Jun 10, 2026
4d9763c
chore(session): remove dead finalization code and misleading tests fo…
devguyio Jun 10, 2026
bc1024f
test(exploratory): fix D10 to check D-02 shared credential path for c…
devguyio Jun 10, 2026
c722798
feat(hydration): wire project_names through HydrationWorkspaceOps to …
devguyio Jun 10, 2026
230f64e
feat(session): wire bm meetings to ephemeral session lifecycle [Ref: …
devguyio Jun 10, 2026
bc73b66
feat(credentials): implement D-02 shared credential write-path for co…
devguyio Jun 10, 2026
4fccd8d
feat(credentials): implement KeyringAppTokenProvider and wire credent…
devguyio Jun 10, 2026
e348913
test(exploratory): update REPORT.md from latest exploratory test run
devguyio Jun 10, 2026
441dd5c
feat(sessions_api): implement CT-154-08 credential refresh loop [Ref:…
devguyio Jun 10, 2026
a29429d
feat(chat): implement CT-154-09 setup_launch_credentials with shared …
devguyio Jun 10, 2026
ec59139
chore(daemon): remove dead permanent-workspace endpoints for code-tas…
devguyio Jun 10, 2026
c2766dc
chore(daemon): remove dead formation legacy code for code-task 12
devguyio Jun 10, 2026
94c56d1
feat(daemon): wire credential refresh loop into daemon startup for co…
devguyio Jun 10, 2026
11190cc
fix(chat): wire credential_dir in prepare_chat_session_from_path for …
devguyio Jun 10, 2026
41b5d07
test(e2e): add gh api user verification to credential_relay_fn for co…
devguyio Jun 10, 2026
eb26972
docs(concepts): fix stale command references in session-model.md for …
devguyio Jun 10, 2026
cb528d7
chore(justfile): use direnv to auto-load e2e env vars for code-task 1…
devguyio Jun 10, 2026
6962f16
feat(web): add GET /api/teams/{team}/sessions to console API for code…
devguyio Jun 10, 2026
348c319
feat(console): add sessions page with API client, types, sidebar nav,…
devguyio Jun 10, 2026
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
45 changes: 27 additions & 18 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,28 @@ test: unit conformance e2e

# Run E2E tests ONLY (requires TESTS_GH_TOKEN, TESTS_GH_ORG, and TESTS_APP_* env vars)
e2e:
@test -n "$TESTS_GH_TOKEN" || { echo "Error: TESTS_GH_TOKEN env var must be set"; exit 1; }
@test -n "$TESTS_GH_ORG" || { echo "Error: TESTS_GH_ORG env var must be set"; exit 1; }
@test -n "$TESTS_APP_ID" || { echo "Error: TESTS_APP_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_CLIENT_ID" || { echo "Error: TESTS_APP_CLIENT_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_INSTALLATION_ID" || { echo "Error: TESTS_APP_INSTALLATION_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_PRIVATE_KEY_FILE" || { echo "Error: TESTS_APP_PRIVATE_KEY_FILE env var must be set"; exit 1; }
#!/usr/bin/env bash
set -euo pipefail
eval "$(direnv export bash 2>/dev/null)" || true
test -n "${TESTS_GH_TOKEN:-}" || { echo "Error: TESTS_GH_TOKEN env var must be set (hint: run 'direnv allow' in the workspace root)"; exit 1; }
test -n "${TESTS_GH_ORG:-}" || { echo "Error: TESTS_GH_ORG env var must be set"; exit 1; }
test -n "${TESTS_APP_ID:-}" || { echo "Error: TESTS_APP_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_CLIENT_ID:-}" || { echo "Error: TESTS_APP_CLIENT_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_INSTALLATION_ID:-}" || { echo "Error: TESTS_APP_INSTALLATION_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_PRIVATE_KEY_FILE:-}" || { echo "Error: TESTS_APP_PRIVATE_KEY_FILE env var must be set"; exit 1; }
cargo test -p bm --features e2e --test e2e -- --gh-token "$TESTS_GH_TOKEN" --gh-org "$TESTS_GH_ORG" --app-id "$TESTS_APP_ID" --app-client-id "$TESTS_APP_CLIENT_ID" --app-installation-id "$TESTS_APP_INSTALLATION_ID" --app-private-key-file "$TESTS_APP_PRIVATE_KEY_FILE" --test-threads=1

# Step through one E2E case at a time (progressive mode). SUITE is optional (e.g., scenario_fresh_start).
e2e-step SUITE="":
@test -n "$TESTS_GH_TOKEN" || { echo "Error: TESTS_GH_TOKEN env var must be set"; exit 1; }
@test -n "$TESTS_GH_ORG" || { echo "Error: TESTS_GH_ORG env var must be set"; exit 1; }
@test -n "$TESTS_APP_ID" || { echo "Error: TESTS_APP_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_CLIENT_ID" || { echo "Error: TESTS_APP_CLIENT_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_INSTALLATION_ID" || { echo "Error: TESTS_APP_INSTALLATION_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_PRIVATE_KEY_FILE" || { echo "Error: TESTS_APP_PRIVATE_KEY_FILE env var must be set"; exit 1; }
#!/usr/bin/env bash
set -euo pipefail
eval "$(direnv export bash 2>/dev/null)" || true
test -n "${TESTS_GH_TOKEN:-}" || { echo "Error: TESTS_GH_TOKEN env var must be set (hint: run 'direnv allow' in the workspace root)"; exit 1; }
test -n "${TESTS_GH_ORG:-}" || { echo "Error: TESTS_GH_ORG env var must be set"; exit 1; }
test -n "${TESTS_APP_ID:-}" || { echo "Error: TESTS_APP_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_CLIENT_ID:-}" || { echo "Error: TESTS_APP_CLIENT_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_INSTALLATION_ID:-}" || { echo "Error: TESTS_APP_INSTALLATION_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_PRIVATE_KEY_FILE:-}" || { echo "Error: TESTS_APP_PRIVATE_KEY_FILE env var must be set"; exit 1; }
cargo test -p bm --features e2e --test e2e -- --gh-token "$TESTS_GH_TOKEN" --gh-org "$TESTS_GH_ORG" --app-id "$TESTS_APP_ID" --app-client-id "$TESTS_APP_CLIENT_ID" --app-installation-id "$TESTS_APP_INSTALLATION_ID" --app-private-key-file "$TESTS_APP_PRIVATE_KEY_FILE" --progressive {{ SUITE }} --test-threads=1

# Reset progressive E2E state (clean up repos, containers, state files). SUITE is optional.
Expand All @@ -49,12 +55,15 @@ e2e-reset SUITE="":

# Run E2E tests with output visible (note: libtest-mimic does not support --nocapture, but stderr from eprintln! is always visible)
e2e-verbose:
@test -n "$TESTS_GH_TOKEN" || { echo "Error: TESTS_GH_TOKEN env var must be set"; exit 1; }
@test -n "$TESTS_GH_ORG" || { echo "Error: TESTS_GH_ORG env var must be set"; exit 1; }
@test -n "$TESTS_APP_ID" || { echo "Error: TESTS_APP_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_CLIENT_ID" || { echo "Error: TESTS_APP_CLIENT_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_INSTALLATION_ID" || { echo "Error: TESTS_APP_INSTALLATION_ID env var must be set"; exit 1; }
@test -n "$TESTS_APP_PRIVATE_KEY_FILE" || { echo "Error: TESTS_APP_PRIVATE_KEY_FILE env var must be set"; exit 1; }
#!/usr/bin/env bash
set -euo pipefail
eval "$(direnv export bash 2>/dev/null)" || true
test -n "${TESTS_GH_TOKEN:-}" || { echo "Error: TESTS_GH_TOKEN env var must be set (hint: run 'direnv allow' in the workspace root)"; exit 1; }
test -n "${TESTS_GH_ORG:-}" || { echo "Error: TESTS_GH_ORG env var must be set"; exit 1; }
test -n "${TESTS_APP_ID:-}" || { echo "Error: TESTS_APP_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_CLIENT_ID:-}" || { echo "Error: TESTS_APP_CLIENT_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_INSTALLATION_ID:-}" || { echo "Error: TESTS_APP_INSTALLATION_ID env var must be set"; exit 1; }
test -n "${TESTS_APP_PRIVATE_KEY_FILE:-}" || { echo "Error: TESTS_APP_PRIVATE_KEY_FILE env var must be set"; exit 1; }
cargo test -p bm --features e2e --test e2e -- --gh-token "$TESTS_GH_TOKEN" --gh-org "$TESTS_GH_ORG" --app-id "$TESTS_APP_ID" --app-client-id "$TESTS_APP_CLIENT_ID" --app-installation-id "$TESTS_APP_INSTALLATION_ID" --app-private-key-file "$TESTS_APP_PRIVATE_KEY_FILE" --test-threads=1

# Run exploratory tests on bm-test-user@localhost via SSH. Requires SSH access to test user, podman, gh auth.
Expand Down
27 changes: 27 additions & 0 deletions console/e2e/console-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,33 @@ test.describe('Process page', () => {
});
});

test.describe('Sessions page', () => {
test.beforeEach(async ({ page }) => {
await mockApi(page);
await page.goto(`/teams/${TEAM}/sessions`);
});

test('renders sessions heading', async ({ page }) => {
await expect(main(page).getByRole('heading', { name: 'Sessions' })).toBeVisible();
});

test('renders member names', async ({ page }) => {
const content = main(page);
await expect(content.getByText('superman-alice')).toBeVisible();
await expect(content.getByText('team-manager-mgr')).toBeVisible();
});

test('renders state badges', async ({ page }) => {
const content = main(page);
await expect(content.getByText('Active', { exact: true })).toBeVisible();
await expect(content.getByText('Completed', { exact: true })).toBeVisible();
});

test('shows session count', async ({ page }) => {
await expect(main(page).getByText('2 sessions')).toBeVisible();
});
});

test.describe('Files browser page', () => {
test.beforeEach(async ({ page }) => {
await mockApi(page);
Expand Down
26 changes: 25 additions & 1 deletion console/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import type {
ProcessData,
MemberListEntry,
MemberDetail,
TreeResponse
TreeResponse,
ConsoleSessionSummary
} from '../src/lib/types.js';

export const TEAM = 'my-team';
Expand Down Expand Up @@ -108,6 +109,25 @@ export const mockMemberDetail: MemberDetail = {
skill_dirs: ['gh', 'board-scanner']
};

export const mockSessions: ConsoleSessionSummary[] = [
{
session_id: 'session-abc-123',
member_name: 'superman-alice',
state: 'Active',
session_type: 'Loop',
created_at: '2026-01-01T10:00:00Z',
finalization_status: 'n/a'
},
{
session_id: 'session-def-456',
member_name: 'team-manager-mgr',
state: 'Completed',
session_type: 'Interactive',
created_at: '2026-01-01T09:00:00Z',
finalization_status: 'completed'
}
];

export const mockTree: TreeResponse = {
path: '',
entries: [
Expand Down Expand Up @@ -148,6 +168,10 @@ export async function mockApi(page: Page): Promise<void> {
route.fulfill({ json: mockMembers })
);

await page.route(`**/api/teams/${TEAM}/sessions`, (route) =>
route.fulfill({ json: mockSessions })
);

await page.route(`**/api/teams/${TEAM}/tree**`, (route) =>
route.fulfill({ json: mockTree })
);
Expand Down
8 changes: 7 additions & 1 deletion console/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TeamSummary, TeamOverview, ProcessData, MemberListEntry, MemberDetail, FileReadResponse, FileWriteResponse, TreeResponse, SyncResponse, ApiError } from './types.js';
import type { TeamSummary, TeamOverview, ProcessData, MemberListEntry, MemberDetail, FileReadResponse, FileWriteResponse, TreeResponse, SyncResponse, ConsoleSessionSummary, ApiError } from './types.js';

class ApiClient {
private baseUrl: string;
Expand Down Expand Up @@ -64,6 +64,12 @@ class ApiClient {
);
}

async fetchSessions(team: string): Promise<ConsoleSessionSummary[]> {
return this.request<ConsoleSessionSummary[]>(
`/api/teams/${encodeURIComponent(team)}/sessions`
);
}

async syncTeam(team: string): Promise<SyncResponse> {
return this.request<SyncResponse>(
`/api/teams/${encodeURIComponent(team)}/sync`,
Expand Down
5 changes: 5 additions & 0 deletions console/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
href: 'members',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z'
},
{
label: 'Sessions',
href: 'sessions',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2'
},
{
label: 'Files',
href: 'files',
Expand Down
9 changes: 9 additions & 0 deletions console/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ export interface SyncResponse {
changed_files: string[];
}

export interface ConsoleSessionSummary {
session_id: string;
member_name: string;
state: string;
session_type: string;
created_at: string;
finalization_status: string;
}

export interface ApiError {
error: string;
}
102 changes: 102 additions & 0 deletions console/src/routes/teams/[team]/sessions/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import type { ConsoleSessionSummary } from '$lib/types.js';
import { api } from '$lib/api.js';

const team = $derived($page.params.team ?? '');
let sessions = $state<ConsoleSessionSummary[]>([]);
let error = $state<string | null>(null);
let loading = $state(true);

onMount(async () => {
try {
sessions = await api.fetchSessions(team);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load sessions';
} finally {
loading = false;
}
});

function stateBadgeClass(state: string): string {
switch (state.toLowerCase()) {
case 'active':
return 'bg-green-100 text-green-700 border border-green-300';
case 'finalizing':
return 'bg-yellow-100 text-yellow-700 border border-yellow-300';
case 'completed':
return 'bg-gray-100 text-gray-600 border border-gray-300';
case 'failed':
return 'bg-red-100 text-red-700 border border-red-300';
case 'killed':
return 'bg-orange-100 text-orange-700 border border-orange-300';
case 'retained':
return 'bg-blue-100 text-blue-700 border border-blue-300';
default:
return 'bg-gray-100 text-gray-600 border border-gray-300';
}
}
</script>

<header class="border-b border-surface-border px-8 py-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Sessions</h1>
<p class="text-sm text-gray-500 mt-0.5">Active and recent member sessions</p>
</div>
{#if !loading && !error}
<span class="text-xs text-gray-500">{sessions.length} {sessions.length === 1 ? 'session' : 'sessions'}</span>
{/if}
</div>
</header>

{#if loading}
<div class="p-8">
<p class="text-gray-500">Loading...</p>
</div>
{:else if error}
<div class="p-8">
<div class="bg-red-500/10 border border-red-500/20 rounded-md p-4 text-red-400 text-sm">
{error}
</div>
</div>
{:else}
<div class="p-8">
{#if sessions.length === 0}
<div class="bg-surface-raised border border-surface-border rounded-lg p-8 text-center">
<p class="text-gray-500">No sessions found.</p>
<p class="text-gray-500 text-sm mt-1">Sessions appear when members are running.</p>
</div>
{:else}
<div class="bg-surface-raised border border-surface-border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-surface-border">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Member</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">State</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Finalization</th>
</tr>
</thead>
<tbody>
{#each sessions as session}
<tr class="border-b border-surface-border last:border-0 hover:bg-black/5">
<td class="px-4 py-3 font-medium text-gray-900">{session.member_name}</td>
<td class="px-4 py-3">
<span class="text-xs px-2 py-0.5 rounded font-medium {stateBadgeClass(session.state)}">
{session.state}
</span>
</td>
<td class="px-4 py-3 text-gray-600">{session.session_type}</td>
<td class="px-4 py-3 text-gray-500">{new Date(session.created_at).toLocaleString()}</td>
<td class="px-4 py-3 text-gray-500">{session.finalization_status}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
27 changes: 16 additions & 11 deletions crates/bm/src/agent_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use clap::Parser;
use bm::agent_cli::{AgentCli, AgentCommand, ClaudeCommand, ClaudeHookCommand, InboxCommand, InboxFormat, LockCommand, LoopCommand};
use bm::daemon::sessions_api::{AcquireLockResponse, ReleaseLockResponse};
use bm::brain::inbox;
use bm::daemon::{DaemonClient, StartLoopRequest};
use bm::daemon::DaemonClient;
use bm::daemon::sessions_api::StartSessionRequest;

fn main() {
let cli = AgentCli::parse();
Expand Down Expand Up @@ -73,21 +74,25 @@ fn connect_daemon() -> anyhow::Result<DaemonClient> {

fn run_loop(command: LoopCommand) -> anyhow::Result<()> {
match command {
LoopCommand::Start { prompt, member } => {
LoopCommand::Start { prompt: _, member } => {
let client = connect_daemon()?;
let req = StartLoopRequest { prompt, member };
let resp = client.start_loop(&req)?;
let member_name = member.ok_or_else(|| {
anyhow::anyhow!("--member is required in the ephemeral sessions model")
})?;
let req = StartSessionRequest {
member_name,
session_type: "Loop".to_string(),
work_item_id: None,
};
let resp = client.start_session(&req)?;

if resp.ok {
if let Some(pid) = resp.pid {
eprintln!("Loop started (PID {})", pid);
}
if let Some(ref loop_id) = resp.loop_id {
println!("{}", loop_id);
}
let session_id = resp.session_id.as_deref().unwrap_or("unknown");
eprintln!("Loop session started (ID {})", session_id);
println!("{}", session_id);
} else {
let err = resp.error.unwrap_or_else(|| "unknown error".to_string());
anyhow::bail!("Failed to start loop: {}", err);
anyhow::bail!("Failed to start loop session: {}", err);
}

Ok(())
Expand Down
Loading
Loading