fix(goal): refresh terminal status in JSON requirements panel

This commit is contained in:
mateaix 2026-09-15 01:34:45 +08:00
parent 770f4e16d5
commit ab1f5069ad
7 changed files with 38 additions and 19 deletions

View File

@ -25,13 +25,13 @@ public class GoalJsonAcceptanceService {
public record ConfigureRequest(Long expectedRevision, String artifactSlot, List<String> requiredFields) { }
public record Requirement(String criterionKey, String artifactSlot, long revision,
List<String> requiredFields, String configuredBy) { }
public record View(boolean required, List<Requirement> requirements) { }
public record View(boolean required, String status, List<Requirement> requirements) { }
record GoalScope(long id, String conversationId, long workspaceId, long agentId, String status, boolean required) { }
@Transactional
public View get(Long goalId, String username) {
GoalScope goal = authorizedGoal(goalId, username, true);
return new View(goal.required(), requirements(goalId));
return new View(goal.required(), goal.status(), requirements(goalId));
}
@Transactional

View File

@ -4,13 +4,13 @@ Open a conversation with an existing Goal, click the Goals button in its header,
User configuration, independent managed versions, binding checks and the shared completion gate are connected. Every current requirement needs a matching valid binding before a selected goal can complete under its existing completion rules. Automatic evaluation, explicit completeGoal and retries share that gate. Unselected goals retain existing behavior.
The conversation Goals panel includes paused and terminal goals, loading 20 at a time with an option to load older records. Paused goals still allow requirement edits, publication and checks; terminal goals only expose existing requirements and content. Closing the panel, switching conversations or leaving the page clears its contents, and a failed refresh clears the old list. Reading history does not resume execution.
The conversation Goals panel includes paused and terminal goals, loading 20 at a time with an option to load older records. Paused goals still allow requirement edits, publication and checks; terminal goals only expose existing requirements and content. Closing the panel, switching conversations or leaving the page clears its contents, and a failed refresh clears the old list. Reading history does not resume execution. Reloading requirements also refreshes the Goal status, so a goal completed since the list was loaded becomes read-only in the acceptance panel.
## Managed version API
Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conversation-owner or administrator permission is required. Preserve IDs, revisions and generations as strings in clients.
- `GET /`: read required mode and requirements.
- `GET /`: read required mode, current Goal status and requirements.
- `PUT /requirements/{criterionKey}`: send `expectedRevision`, `artifactSlot` and `requiredFields`; use revision `0` for a new requirement.
- `GET /artifacts`: list required slots and current versions; an empty slot has generation `0`.
- `POST /artifacts/{slot}`: send `expectedGeneration` and `jsonContent` (a string containing the original JSON body) to append a version and atomically advance the slot.

View File

@ -4,13 +4,13 @@
当前已接通用户配置、独立受管版本、绑定检查及共享完成检查。选中模式后,所有当前要求必须具有匹配的有效绑定,才可在既有完成规则满足时完成;自动评估、显式 completeGoal 和重试均使用同一完成检查。未选中的 Goal 保持既有行为。
当前会话的“目标”面板也保留暂停、已完成等历史目标每次读取20项可加载更早记录。暂停目标仍可修订要求、发布和检查终态目标只能查看要求与已有正文。关闭面板、切换会话或离开页面后清除面板内容刷新失败时清除旧列表。读取历史不会恢复目标运行。
当前会话的“目标”面板也保留暂停、已完成等历史目标每次读取20项可加载更早记录。暂停目标仍可修订要求、发布和检查终态目标只能查看要求与已有正文。关闭面板、切换会话或离开页面后清除面板内容刷新失败时清除旧列表。读取历史不会恢复目标运行。刷新要求也会刷新 Goal 状态,因此列表加载后才完成的目标会在验收面板切换为只读。
## 受管版本接口
接口前缀 `/api/v1/goals/{goalId}/json-acceptance`需要启用账户及对话所有者或管理员权限。ID、revision 和 generation 在响应中使用字符串,客户端应原样保留。
- `GET /`:读取启用状态和要求。
- `GET /`:读取启用状态、Goal 当前状态和要求。
- `PUT /requirements/{criterionKey}`:提交 `expectedRevision`、`artifactSlot`、`requiredFields`。新要求的 revision 为 `0`
- `GET /artifacts`:列出当前要求使用的槽及当前版本。空槽 generation 为 `0`
- `POST /artifacts/{slot}`:提交 `expectedGeneration``jsonContent`(包含原始 JSON 正文的字符串),原子追加新版本并推进槽。

View File

@ -248,6 +248,8 @@ class GoalJsonHttpRuntimeIntegrationTest {
assertTrue(calls.get() >= (accepted ? 6 : 2) && calls.get() <= (accepted ? 10 : 4), "Bounded offline model calls: " + calls.get());
if (!accepted) assertEquals(recovered ? 1 : 0,
jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
JsonNode currentRequirements = request("GET", "/api/v1/goals/" + goal.getId() + "/json-acceptance", token, null);
assertEquals(accepted ? "completed" : "active", currentRequirements.path("data").path("status").asText());
verify(modelFactory, atLeastOnce()).buildFor(any(), any());
}
@ -273,7 +275,7 @@ class GoalJsonHttpRuntimeIntegrationTest {
.timeout(Duration.ofSeconds(45)).header("Content-Type", "application/json").header("X-Workspace-Id", "1");
if (token != null) builder.header("Authorization", "Bearer " + token);
var response = HttpClient.newHttpClient().send(builder.method(method,
HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))).build(), HttpResponse.BodyHandlers.ofString());
(body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode(), response.body());
return response.body();
}

View File

@ -7,7 +7,7 @@ export interface GoalJsonRequirement {
requiredFields: string[]
configuredBy: string
}
export interface GoalJsonAcceptanceView { required: boolean; requirements: GoalJsonRequirement[] }
export interface GoalJsonAcceptanceView { required: boolean; status: string; requirements: GoalJsonRequirement[] }
export interface ConfigureJsonRequirement { expectedRevision: string; artifactSlot: string; requiredFields: string[] }
export interface ManagedJsonArtifact {
artifactId: string; artifactSlot: string; generation: string; sha256: string; byteLength: number

View File

@ -17,7 +17,8 @@ const slot = ref('')
const fields = ref('')
const revision = ref('0')
let generation = 0
const editable = computed(() => ['active', 'paused'].includes(props.status))
const currentStatus = computed(() => ['active', 'paused'].includes(props.status) ? view.value?.status : props.status)
const editable = computed(() => !!view.value && ['active', 'paused'].includes(currentStatus.value ?? ''))
const busy = computed(() => loading.value || saving.value)
function resetForm() { key.value = ''; slot.value = ''; fields.value = ''; revision.value = '0'; conflict.value = false }
@ -76,7 +77,7 @@ async function save() {
expectedRevision: revision.value, artifactSlot: slot.value, requiredFields: required,
})
if (request !== generation || !view.value) return
view.value = { required: true, requirements: [...view.value.requirements.filter(r => r.criterionKey !== data.criterionKey), data]
view.value = { required: true, status: view.value.status, requirements: [...view.value.requirements.filter(r => r.criterionKey !== data.criterionKey), data]
.sort((a, b) => a.criterionKey.localeCompare(b.criterionKey)) }
resetForm()
} catch (failure) {
@ -105,6 +106,7 @@ onBeforeUnmount(() => { generation++ })
<p v-if="error" role="alert">{{ t(error) }}</p>
<p v-if="loading" role="status">{{ t('common.loading') }}</p>
<template v-if="view">
<p v-if="currentStatus" data-json-acceptance-status>{{ t('goalJsonAcceptance.historyStatus.' + currentStatus) }}</p>
<p data-json-acceptance-mode>{{ t(view.required ? 'goalJsonAcceptance.required' : 'goalJsonAcceptance.notSelected') }}</p>
<ul v-if="view.requirements.length">
<li v-for="requirement in view.requirements" :key="requirement.criterionKey" data-json-requirement>
@ -113,7 +115,7 @@ onBeforeUnmount(() => { generation++ })
<button v-if="editable" type="button" :disabled="busy" data-json-requirement-edit @click="edit(requirement)">{{ t('goalJsonAcceptance.edit') }}</button>
</li>
</ul>
<ManagedGoalJsonVersions v-if="view.required" :goal-id="goalId" :status="status" :requirements="view.requirements" @access-lost="showFailure({ code: 403 })" />
<ManagedGoalJsonVersions v-if="view.required" :goal-id="goalId" :status="currentStatus || status" :requirements="view.requirements" @access-lost="showFailure({ code: 403 })" />
<form v-if="editable" @submit.prevent="save">
<p>{{ t('goalJsonAcceptance.selectionNotice') }}</p>
<label>{{ t('goalJsonAcceptance.key') }}<input v-model="key" data-json-requirement-key required maxlength="64" :disabled="busy || revision !== '0'" placeholder="report-fields" /></label>

View File

@ -25,7 +25,7 @@ async function submit(host: HTMLElement) { host.querySelector('form')!.dispatchE
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() })
describe('user JSON acceptance requirements', () => {
it('loads without writing and requires an explicit save to opt in', async () => {
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: false, requirements: [] } })
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: false, status: 'active', requirements: [] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockResolvedValue({ data: requirement() })
const { host } = mount(); expect(goalJsonAcceptanceApi.get).not.toHaveBeenCalled(); await open(host)
expect(goalJsonAcceptanceApi.configure).not.toHaveBeenCalled()
@ -39,7 +39,7 @@ describe('user JSON acceptance requirements', () => {
})
it('preserves opaque revisions and refuses to resubmit a stale edit before reload', async () => {
const revision = '9223372036854775802'
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, requirements: [requirement(revision)] } })
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, status: 'active', requirements: [requirement(revision)] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockRejectedValue({ code: 409 })
const { host } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush()
@ -49,8 +49,23 @@ describe('user JSON acceptance requirements', () => {
expect(host.querySelector<HTMLButtonElement>('[data-json-requirement-save]')!.disabled).toBe(true)
await submit(host); expect(goalJsonAcceptanceApi.configure).toHaveBeenCalledTimes(1)
})
it('reloads a server-completed goal as read-only even while the parent status is stale', async () => {
vi.mocked(goalJsonAcceptanceApi.get)
.mockResolvedValueOnce({ data: { required: true, status: 'active', requirements: [requirement()] } })
.mockResolvedValueOnce({ data: { required: true, status: 'completed', requirements: [requirement()] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockRejectedValue({ code: 409 })
const { host, props } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush(); await submit(host)
host.querySelector<HTMLButtonElement>('[data-json-acceptance-refresh]')!.click(); await flush()
expect(props.status).toBe('active')
expect(host.querySelectorAll('[data-json-requirement]')).toHaveLength(1)
expect(host.querySelector('form')).toBeNull()
expect(host.querySelector('[data-json-requirement-edit]')).toBeNull()
expect(host.querySelector('[data-json-acceptance-status]')?.textContent).toContain('Completed')
expect(goalJsonAcceptanceApi.configure).toHaveBeenCalledTimes(1)
})
it('clears old requirements and drafts after access is revoked', async () => {
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, requirements: [requirement()] } })
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, status: 'active', requirements: [requirement()] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockRejectedValue({ code: 403 })
const { host } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush(); await submit(host)
@ -61,16 +76,16 @@ describe('user JSON acceptance requirements', () => {
it('does not show an old goal read response after selection changes', async () => {
let resolve!: (value: unknown) => void
vi.mocked(goalJsonAcceptanceApi.get).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
.mockResolvedValueOnce({ data: { required: false, requirements: [] } })
.mockResolvedValueOnce({ data: { required: false, status: 'active', requirements: [] } })
const { host, props } = mount(); await open(host); props.goalId = 'new'; await flush()
resolve({ data: { required: true, requirements: [requirement()] } }); await flush()
resolve({ data: { required: true, status: 'active', requirements: [requirement()] } }); await flush()
expect(host.querySelectorAll('[data-json-requirement]')).toHaveLength(0)
expect(host.querySelector('[data-json-acceptance-mode]')?.textContent).toContain('has not selected')
})
it('does not apply a delayed save to a different goal', async () => {
let resolve!: (value: unknown) => void
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValueOnce({ data: { required: true, requirements: [requirement()] } })
.mockResolvedValueOnce({ data: { required: false, requirements: [] } })
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValueOnce({ data: { required: true, status: 'active', requirements: [requirement()] } })
.mockResolvedValueOnce({ data: { required: false, status: 'active', requirements: [] } })
vi.mocked(goalJsonAcceptanceApi.configure).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
const { host, props } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush(); await submit(host)
@ -79,7 +94,7 @@ describe('user JSON acceptance requirements', () => {
expect(host.querySelector('[data-json-acceptance-mode]')?.textContent).toContain('has not selected')
})
it('does not submit duplicate fields and presents terminal goals as read-only', async () => {
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, requirements: [requirement()] } })
vi.mocked(goalJsonAcceptanceApi.get).mockResolvedValue({ data: { required: true, status: 'active', requirements: [requirement()] } })
const { host, props } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-requirement-edit]')!.click(); await flush()
await fill(host, '[data-json-requirement-fields]', 'summary\nsummary'); await submit(host)