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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public class IssueConversationController {

@QueryMapping public IssueConversationView issueConversation(@Argument String id) { return conversations.get(id); }

@MutationMapping public boolean deleteIssueConversation(@Argument String conversationId) {
return conversations.delete(conversationId);
}

@MutationMapping public IssueConversationView startIssueConversation(@Argument String repository, @Argument String message) {
return conversations.start(repository, message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ public IssueConversationView createIssue(String conversationId, String title, St
return view(conversation);
}

/** Lock against claims/completions; retain accounting and external GitHub work. */
@Transactional
public boolean delete(String conversationId) {
operators.requireOperator();
IssueConversation conversation = requireConversation(conversationId);
if (!conversation.getCreatedBy().equals(operators.subject())) operators.requireAdministrator();
if ("RUNNING".equals(conversation.getStatus()))
throw new IllegalStateException("Wait for the current reply to finish before deleting this conversation.");
messages.deleteByConversation_Id(conversationId);
conversations.delete(conversation);
audit.record("ISSUE_CHAT_DELETED", "ISSUE_CONVERSATION", conversationId, "history-deleted;usage-retained");
return true;
}

private IssueConversation requireConversation(String id) {
return conversations.lockByIdAndOrganizationId(id, operators.organizationId())
.orElseThrow(() -> new IllegalArgumentException("Issue conversation not found"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface IssueConversationMessageRepository extends JpaRepository<IssueConversationMessage, Long> {
void deleteByConversation_Id(String conversationId);
List<IssueConversationMessage> findByConversation_IdOrderByCreatedAtAscIdAsc(String conversationId);
}
2 changes: 1 addition & 1 deletion control-plane/src/main/resources/graphql/schema.graphqls
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
type Query { featureRun(id: ID!): FeatureRun!, featureRuns: [FeatureRun!]!, runAnalytics: RunAnalytics!, organizationPolicy: OrganizationPolicy!, harnessDefinitions: [HarnessDefinition!]!, localMcpConfigurations: [LocalMcpConfiguration!]!, repositoryConnections: [RepositoryConnection!]!, repositoryScans(repository: String!): [RepositoryScan!]!, runners(organizationId: String!): [Runner!]!, organizationMemberships(organizationId: String!): [OrganizationMembership!]!, featureRunAuditEvents(runId: ID!): [AuditLedgerEntry!]!, featureRunEvidence(runId: ID!): [VerificationEvidence!]!, featureRunReviewEvidence(runId: ID!): [ReviewEvidence!]!, featureRunArtifacts(runId: ID!): [ArtifactMetadata!]!, featureRunEvents(runId: ID!): [RunnerEvent!]!, featureRunEscalations(runId: ID!): [HumanEscalation!]!, featureRunPublication(runId: ID!): GithubPublication, currentOperator: OperatorSession!, availableRunnerTasks(runnerId: ID!, credential: String!): [Task!]! }
extend type Query { providerActivities(days: Int!, repository: String): [ProviderActivity!]! }
extend type Query { issueConversations(repository: String!): [IssueConversation!]!, issueConversation(id: ID!): IssueConversation! }
extend type Mutation { startIssueConversation(repository: String!, message: String!): IssueConversation!, sendIssueConversationMessage(conversationId: ID!, message: String!): IssueConversation!, createIssueFromConversation(conversationId: ID!, title: String!, body: String!, acceptanceCriteria: [String!]!): IssueConversation!, claimIssueChatTurn(runnerId: ID!, credential: String!): IssueChatTurnGrant, completeIssueChatTurn(conversationId: ID!, runnerId: ID!, credential: String!, input: IssueChatTurnResultInput!): IssueConversation! }
extend type Mutation { deleteIssueConversation(conversationId: ID!): Boolean!, startIssueConversation(repository: String!, message: String!): IssueConversation!, sendIssueConversationMessage(conversationId: ID!, message: String!): IssueConversation!, createIssueFromConversation(conversationId: ID!, title: String!, body: String!, acceptanceCriteria: [String!]!): IssueConversation!, claimIssueChatTurn(runnerId: ID!, credential: String!): IssueChatTurnGrant, completeIssueChatTurn(conversationId: ID!, runnerId: ID!, credential: String!, input: IssueChatTurnResultInput!): IssueConversation! }
type IssueConversation { id: ID!, repository: String!, status: String!, createdAt: String!, updatedAt: String!, failureSummary: String, draftTitle: String, draftBody: String, acceptanceCriteria: [String!]!, provider: String, model: String, inputTokens: Float!, outputTokens: Float!, estimatedCostMicros: Float!, costKnown: Boolean!, issueNumber: Int, issueUrl: String, messages: [IssueChatMessage!]! }
type IssueChatMessage { role: String!, content: String!, createdAt: String! }
type IssueChatTurnGrant { id: ID!, repository: String!, draftTitle: String, draftBody: String, acceptanceCriteria: [String!]!, messages: [IssueChatTurnMessage!]! }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,39 @@
import org.springframework.security.access.AccessDeniedException;

class IssueConversationServiceTest {
@Test void deletesPendingOwnedHistoryButRetainsExternalWorkAndUsage() {
IssueConversation conversation = new IssueConversation("org-1", "acme/app", "owner");
when(operators.organizationId()).thenReturn("org-1");
when(operators.subject()).thenReturn("owner");
when(conversations.lockByIdAndOrganizationId(conversation.getId(), "org-1")).thenReturn(Optional.of(conversation));
assertEquals(true, service.delete(conversation.getId()));
verify(messages).deleteByConversation_Id(conversation.getId());
verify(conversations).delete(conversation);
verify(operators, never()).requireAdministrator();
org.mockito.Mockito.verifyNoInteractions(github, activities);
}

@Test void refusesRunningAndCrossOrganizationDeletion() {
IssueConversation conversation = new IssueConversation("org-1", "acme/app", "owner");
conversation.claim("runner");
when(operators.organizationId()).thenReturn("org-1");
when(operators.subject()).thenReturn("owner");
when(conversations.lockByIdAndOrganizationId(conversation.getId(), "org-1")).thenReturn(Optional.of(conversation));
assertThrows(IllegalStateException.class, () -> service.delete(conversation.getId()));
when(operators.organizationId()).thenReturn("other");
assertThrows(IllegalArgumentException.class, () -> service.delete(conversation.getId()));
verify(conversations, never()).delete(any());
}

@Test void requiresAdministratorToDeleteAnotherUsersConversation() {
IssueConversation conversation = new IssueConversation("org-1", "acme/app", "owner");
when(operators.organizationId()).thenReturn("org-1");
when(operators.subject()).thenReturn("other-user");
when(conversations.lockByIdAndOrganizationId(conversation.getId(), "org-1")).thenReturn(Optional.of(conversation));
doThrow(new AccessDeniedException("administrator required")).when(operators).requireAdministrator();
assertThrows(AccessDeniedException.class, () -> service.delete(conversation.getId()));
verify(conversations, never()).delete(any());
}
private final IssueConversationRepository conversations = mock(IssueConversationRepository.class);
private final IssueConversationMessageRepository messages = mock(IssueConversationMessageRepository.class);
private final RepositoryConnectionService connections = mock(RepositoryConnectionService.class);
Expand Down
15 changes: 15 additions & 0 deletions docs/issue-chat-deletion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Conversation deletion

Open a conversation and choose Delete conversation, then confirm. Owners can
delete their own chats; administrators can delete other chats in the same
workspace. Viewers cannot delete. A locked transaction checks workspace and
ownership and deletes messages before the conversation. Pending work can be
deleted before a runner claims it; running replies must finish first.

Deletion is permanent. GitHub issues, feature runs, provider accounting and audit
records remain. No provider call is made by deletion.

Chat replies run on an enrolled customer runner, not the website. A local dev
stack needs a separately paired runner pointing to http://localhost:5173; a
runner paired to the production hostname cannot claim local database jobs.
Do not repoint a working production runner just to inspect UI changes.
16 changes: 16 additions & 0 deletions docs/settings-consolidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Consolidated console settings

Settings is the single editing location for organization spending/delivery policy,
repository budgets, assignment intake and retained run records. Repository pages
show summaries and link to Settings; they retain repository scans. Runner setup,
harnesses and local MCP configuration are in a collapsed advanced section.

The organization ceiling remains a safety boundary, not a second per-run budget.
New manual runs inherit the selected repository limit capped by the current
organization ceiling. Issue-triggered behavior is unchanged. Existing runs keep
their captured limits; no stored settings or enforcement were removed.

The getting-started banner's dismissal is persisted in localStorage per GitHub
identity and workspace. It survives reloads in that browser; clearing storage or
using another browser resets it. Blocked storage still permits session dismissal.
Getting started remains accessible in the sidebar. No secrets are stored.
5 changes: 3 additions & 2 deletions frontend/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ test('renders the persisted delivery-run console', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Evidence browser & redacted logs' })).toBeVisible();
}

await page.getByRole('button', { name: /Harness & policy/ }).click();
await expect(page.getByRole('heading', { name: 'Harness & policy' })).toBeVisible();
await page.getByRole('button', { name: /Settings/ }).click();
await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible();
await page.getByText('Runner setup & advanced configuration', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Execution policy' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Harness definitions' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'MCP routes' })).toBeVisible();
Expand Down
12 changes: 8 additions & 4 deletions frontend/e2e/mobile-runs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,21 @@ for (const width of [320, 390, 430, 768, 1440]) {
await expect(page.getByRole('heading', { name: 'Connected repositories', exact: true })).toBeVisible();
const repositoryCard = page.locator('.repository-card');
await expect(repositoryCard).toHaveCount(1);
for (const setting of ['Issue intake', 'Run budget', 'Run records', 'Repository issue scan']) {
await expect(repositoryCard.getByRole('heading', { name: setting, exact: true })).toBeVisible();
await expect(repositoryCard.getByRole('heading', { name: 'Repository issue scan', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Save budget' })).toHaveCount(0);
await repositoryCard.getByRole('button', { name: 'Manage repository settings' }).click();
for (const setting of ['Issue intake', 'Run budget', 'Run records']) {
await expect(page.getByRole('heading', { name: setting, exact: true })).toBeVisible();
}
if (width <= 760) {
expect(await page.locator('.repository-settings-grid').evaluate(element => getComputedStyle(element).gridTemplateColumns.split(' ').length)).toBe(1);
}
await fits(page);
await page.screenshot({ path: `../evidence/forgeloop-ui/repository-settings-${width}.png`, fullPage: true });
if (width <= 760) await page.getByRole('button', { name: 'Open navigation' }).click();
await page.getByRole('button', { name: 'Harness & policy', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Execution policy', exact: true })).toBeVisible();
await page.getByText('Runner setup & advanced configuration', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Harness definitions', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'MCP routes', exact: true })).toBeVisible();
await fits(page);
});
}
14 changes: 7 additions & 7 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ server {
proxy_hide_header Cache-Control;
add_header Cache-Control "public, max-age=60";
}
location /api/runner/ { client_max_body_size 12m; proxy_pass http://control-plane/api/runner/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location /api/runner/ { client_max_body_size 12m; proxy_pass http://control-plane/api/runner/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
root /usr/share/nginx/html;
location = /support { try_files /support.html =404; add_header Cache-Control "no-store"; add_header X-Robots-Tag "noindex" always; add_header Referrer-Policy "no-referrer" always; }
# Keep support abuse limits tied to this proxy's peer, not client-supplied forwarding chains.
Expand Down Expand Up @@ -82,12 +82,12 @@ server {
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location /graphql { proxy_pass http://control-plane/graphql; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header Content-Type $content_type; }
location /api/github/ { proxy_pass http://control-plane/api/github/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location /oauth2/ { proxy_pass http://control-plane/oauth2/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location /login/ { proxy_pass http://control-plane/login/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location = /logout { proxy_pass http://control-plane/logout; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location /graphql { proxy_pass http://control-plane/graphql; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header Content-Type $content_type; }
location /api/github/ { proxy_pass http://control-plane/api/github/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location /oauth2/ { proxy_pass http://control-plane/oauth2/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location /login/ { proxy_pass http://control-plane/login/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
location = /logout { proxy_pass http://control-plane/logout; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; }
}
4 changes: 2 additions & 2 deletions frontend/src/RepositoryBudgetSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function RepositoryBudgetSettings({ item, organizationLimit, edit
.catch(reason => setError(reason instanceof Error ? reason.message : 'Unable to save repository budget'))
.finally(() => setBusy(false));
}}>
<div className="repository-setting-heading"><h4>Run budget</h4><p>Keep issue-triggered work inside the organization limit.</p></div>
<div className="repository-setting-heading"><h4>Run budget</h4><p>One limit for new manual and issue-triggered runs.</p></div>
<label>Maximum run budget (USD)
<input aria-label={`Maximum run budget for ${item.repository}`} type="number" min="0.01" step="0.01"
max={organizationLimit} value={budget} onChange={event => setBudget(event.target.value)}
Expand All @@ -38,7 +38,7 @@ export default function RepositoryBudgetSettings({ item, organizationLimit, edit
}}
disabled={!editable || busy}/>
</label>
<small>Issue-triggered runs use this repository ceiling. It must not exceed the organization limit (${organizationLimit}); existing runs keep their original budget.</small>
<small>New runs inherit this limit, capped by the organization ceiling (${organizationLimit}); existing runs keep their original budget.</small>
{editable && <button type="submit" className="secondary" disabled={busy}>{busy ? 'Saving…' : 'Save budget'}</button>}
{error && <p role="alert">{error}</p>}
</form>;
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/RunConsole.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import App from './main';

afterEach(() => vi.unstubAllGlobals());

it('centralizes repository settings and inherits the configured budget for new runs', async () => {
vi.stubGlobal('fetch', controlPlane({ repositoryConnections: [{ id: 'repo', repository: 'org/repo', enabled: true, defaultBranch: 'main', issueLabel: 'forgeloop', requiredGates: ['unit'], maxBudgetUsd: 5, policyRevision: 1 }] }));
render(<App/>);
fireEvent.click(await screen.findByRole('button', { name: 'Repositories' }));
expect(screen.queryByRole('button', { name: 'Save budget' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Manage repository settings' }));
expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save budget' })).toBeInTheDocument();
expect(screen.getByLabelText('Organization per-run ceiling (USD)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Runs' }));
fireEvent.click(screen.getByRole('button', { name: '+ New Run' }));
fireEvent.change(screen.getByLabelText('Authorized repository'), { target: { value: 'org/repo' } });
expect(await screen.findByText(/Run limit: \$5. Inherited from Settings/)).toBeInTheDocument();
expect(screen.queryByLabelText('Maximum spend')).not.toBeInTheDocument();
});

it('places Account in the sidebar with a decorative icon matching the navigation', async () => {
vi.stubGlobal('fetch', controlPlane({}));
render(<App />);
Expand Down Expand Up @@ -143,7 +159,7 @@ it('shows persisted harness and runner-local MCP configuration', async () => {
vi.stubGlobal('fetch', controlPlane({ repositoryConnections: [] }));
render(<App />);
await screen.findByText('Intake queue');
fireEvent.click(screen.getByRole('button', { name: /Harness & policy/ }));
fireEvent.click(screen.getByRole('button', { name: /Settings/ }));
expect(await screen.findByText('Execution policy')).toBeInTheDocument();
expect(screen.getByText('FULL_STACK')).toBeInTheDocument();
expect(screen.getByText('No local MCP context routes configured.')).toBeInTheDocument();
Expand Down
Loading
Loading