feat: expose managed JSON versions and checks in goal UI

This commit is contained in:
mateaix 2026-09-14 22:14:36 +08:00
parent 8cca7159fc
commit 0899555f07
14 changed files with 420 additions and 9 deletions

View File

@ -43,6 +43,11 @@ public class GoalJsonAcceptanceController {
return R.ok(artifacts.read(goalId, artifactId, username(auth)));
}
@GetMapping("/snapshot")
public R<vip.mate.goal.service.GoalJsonBindingService.Snapshot> snapshot(@PathVariable Long goalId, Authentication auth) {
return R.ok(bindings.snapshot(goalId, username(auth)));
}
@GetMapping("/checks")
public R<java.util.List<vip.mate.goal.service.GoalJsonBindingService.State>> checks(@PathVariable Long goalId, Authentication auth) {
return R.ok(bindings.state(goalId, username(auth)));

View File

@ -30,6 +30,25 @@ public class GoalJsonBindingService {
Instant checkedAt, Instant expiresAt, boolean acceptanceEligible) { }
public record State(String criterionKey, long requirementRevision, String artifactId, Long generation,
String status, boolean acceptanceEligible) { }
public record Snapshot(boolean required, String status, int versionCount,
List<GoalJsonAcceptanceService.Requirement> requirements,
List<ManagedGoalJsonService.Slot> slots, List<State> checks) { }
@Transactional
public Snapshot snapshot(Long goalId, String username) {
return snapshotLocked(acceptance.authorizedGoal(goalId, username, true));
}
@Transactional
public Snapshot snapshotForRuntime(ChatOrigin origin) {
return snapshotLocked(artifacts.runtimeGoal(origin).goal());
}
private Snapshot snapshotLocked(GoalJsonAcceptanceService.GoalScope goal) {
int count = jdbc.queryForList("SELECT artifact_id FROM mate_goal_json_artifact WHERE goal_id=? FOR UPDATE", String.class, goal.id()).size();
return new Snapshot(goal.required(), goal.status(), count, acceptance.requirements(goal.id()), artifacts.slots(goal.id()), statesLocked(goal.id()));
}
record Stored(String artifactId, long generation, String body, String sha256, int byteLength, Instant expiresAt) { }
record Binding(long requirementRevision, long evaluationRevision, String artifactId, long generation,
String sha256, String recipeId, int recipeRevision, String status, Instant expiresAt) { }

View File

@ -55,14 +55,6 @@ public class ManagedGoalJsonService {
return rows.getFirst();
}
public record RuntimeView(List<GoalJsonAcceptanceService.Requirement> requirements, List<Slot> slots) { }
@Transactional
public RuntimeView listForRuntime(ChatOrigin origin) {
long goalId = runtimeGoal(origin).goal().id();
return new RuntimeView(acceptance.requirements(goalId), slots(goalId));
}
@Transactional
public Artifact publishForRuntime(ChatOrigin origin, String slot, PublishRequest request) {
var runtime = runtimeGoal(origin);

View File

@ -21,7 +21,7 @@ public class ManagedGoalJsonTool {
@Tool(description = "Read the current conversation goal's managed JSON artifact slots and generations. "
+ "Only user-selected slots appear. Preserve generation strings exactly. This does not check or complete the goal.")
public String getManagedGoalJsonSlots(ToolContext context) throws JsonProcessingException {
return json.writeValueAsString(artifacts.listForRuntime(ChatOrigin.from(context)));
return json.writeValueAsString(bindings.snapshotForRuntime(ChatOrigin.from(context)));
}
@Tool(description = "Publish a new immutable JSON object version to a user-selected slot of the current goal. "

View File

@ -36,3 +36,7 @@ This is an explicit per-goal managed JSON protocol with a limited scope. The bro
ReAct, Plan and persistent-goal continuations receive managed JSON instructions. Business-skill tool allowlists retain the three goal-level read, publish and check tools, while service identity checks and child-agent restrictions still apply. For selected goals, follow-up and scheduling projections cannot end on a model completion claim or segment Complete alone: the Goal must already have committed completed status. Rejected automatic completion produces a continue result with recheck guidance.
Runtime completion must also carry server-issued identity. The completeGoal tool and automatic evaluation node use runtime completion entry points that recheck the enabled account, current goal/conversation/workspace/agent and scheduled-owner leases in the same transaction as all bindings. Valid bindings do not authorize an expired owner, a different goal or a revoked identity to complete. Internal platform completion APIs retain the binding gate; they are not identity-free model or HTTP entry points.
The Goal panel's Versions and checks section lets users inspect stored JSON, paste content and explicitly publish a version, then check each requirement. It shows requirement revisions, versions, expiry, quota and snapshot load time; final completion still checks current state. Conflicts require a reload, revoked access clears old content, and terminal goals are read-only. JSON is displayed as text rather than rendered HTML.
`GET /snapshot` returns requirements, current slots, check eligibility, goal status and version count under one goal lock. The agent's `getManagedGoalJsonSlots` uses this snapshot too, avoiding a mixed view from separate requirement and artifact reads.

View File

@ -36,3 +36,7 @@
代理在 ReAct、Plan 和持久 Goal 续跑入口都会收到受管 JSON 操作指引。业务技能的工具白名单保留读取、发布和检查这三个 Goal 通用工具仍执行服务端身份校验与子代理禁用。选中模式下follow-up 和调度投影不能凭模型的“已完成”或 segment Complete 声明结束;必须先有已提交的 Goal completed 状态。自动完成被拒绝时,向运行时返回 continue 和重检指引,不暴露已接受完成的信号。
运行时完成也必须携带服务端身份completeGoal 工具与自动评估节点使用专门的 runtime 完成入口,在同一事务中复查启用账户、当前 Goal/对话/工作区/Agent 和调度 owner 租约,再检查所有绑定。即使绑定仍有效,旧租约、跨 Goal 或已撤销的身份也不能发起完成。平台内部完成 API 仍执行绑定门;它不是向模型或 HTTP 暴露的免身份入口。
在 Goal 面板的“产物版本与检查”中可以查看当前受管 JSON、粘贴正文并显式发布新版本以及逐条执行检查。界面显示条件修订、版本、有效期、已使用配额和读取时间这是读取时的快照最终完成仍复核当前状态。发生冲突后必须重新读取访问撤销会清空旧内容终态只读。正文按文本显示不渲染其中的 HTML。
`GET /snapshot` 在同一 Goal 锁内返回要求、当前槽、检查资格、Goal 状态和版本计数。代理 `getManagedGoalJsonSlots` 也使用此快照,避免分别读取要求和产物造成混合视图。

View File

@ -601,4 +601,23 @@ class GoalJsonAcceptanceIntegrationTest {
: goals.markRuntimeCompleted(goal.getId(), evaluation, origin);
}
@Test void userAndRuntimeSnapshotsShareCurrentRequirementsVersionsAndChecks() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var first = bindings.snapshot(goal.getId(), alice);
assertTrue(first.required()); assertEquals("active", first.status()); assertEquals(0, first.versionCount());
assertEquals("NO_ARTIFACT", first.checks().getFirst().status());
var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":false}"), alice);
bindings.check(goal.getId(), "r", checkRequest(1, version), alice);
var user = bindings.snapshot(goal.getId(), alice);
var runtime = bindings.snapshotForRuntime(accountOrigin(goal, alice));
assertEquals(user, runtime); assertEquals(1, user.versionCount());
assertEquals(user.requirements().getFirst().revision(), user.checks().getFirst().requirementRevision());
assertEquals(user.slots().getFirst().current().artifactId(), user.checks().getFirst().artifactId());
assertTrue(user.checks().getFirst().acceptanceEligible());
assertThrows(MateClawException.class, () -> bindings.snapshot(goal.getId(), bob));
goals.markRuntimeCompleted(goal.getId(), null, accountOrigin(goal, alice));
assertEquals("completed", bindings.snapshot(goal.getId(), alice).status());
}
}

View File

@ -11,3 +11,17 @@ it('keeps goal IDs and optimistic revisions as exact strings', () => {
expect(get).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance`)
expect(put).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance/requirements/report-fields`, data)
})
it('preserves artifact identity and exact generation strings across the managed API', () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({} as never)
const post = vi.spyOn(http, 'post').mockResolvedValue({} as never)
const goal = '9223372036854775801', generation = '9223372036854775802'
const check = { expectedRequirementRevision: '9223372036854775803', artifactId: 'artifact-id', expectedGeneration: generation }
goalJsonAcceptanceApi.snapshot(goal)
goalJsonAcceptanceApi.publish(goal, 'report', { expectedGeneration: generation, jsonContent: '{}' })
goalJsonAcceptanceApi.check(goal, 'report-fields', check)
goalJsonAcceptanceApi.version(goal, 'artifact-id')
expect(get).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance/snapshot`)
expect(get).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance/artifacts/versions/artifact-id`)
expect(post).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance/checks/report-fields`, check)
expect(post).toHaveBeenCalledWith(`/goals/${goal}/json-acceptance/artifacts/report`, { expectedGeneration: generation, jsonContent: '{}' })
})

View File

@ -9,7 +9,30 @@ export interface GoalJsonRequirement {
}
export interface GoalJsonAcceptanceView { required: boolean; requirements: GoalJsonRequirement[] }
export interface ConfigureJsonRequirement { expectedRevision: string; artifactSlot: string; requiredFields: string[] }
export interface ManagedJsonArtifact {
artifactId: string; artifactSlot: string; generation: string; sha256: string; byteLength: number
producerKind: string; createdAt: string; expiresAt: string
}
export interface ManagedJsonSlot { artifactSlot: string; generation: string; current: ManagedJsonArtifact | null }
export interface ManagedJsonCheckState {
criterionKey: string; requirementRevision: string; artifactId: string | null; generation: string | null
status: string; acceptanceEligible: boolean
}
export interface ManagedJsonSnapshot {
required: boolean; status: string; versionCount: number; requirements: GoalJsonRequirement[]
slots: ManagedJsonSlot[]; checks: ManagedJsonCheckState[]
}
export interface ManagedJsonCheckResult extends ManagedJsonCheckState {
missingFields: string[]; recipeId: string; recipeRevision: number; checkedAt: string; expiresAt: string
}
export const goalJsonAcceptanceApi = {
snapshot: (goalId: string) => http.get<never, { data: ManagedJsonSnapshot }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance/snapshot`),
publish: (goalId: string, slot: string, data: { expectedGeneration: string; jsonContent: string }) =>
http.post<never, { data: ManagedJsonArtifact }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance/artifacts/${encodeURIComponent(slot)}`, data),
version: (goalId: string, artifactId: string) =>
http.get<never, { data: { artifact: ManagedJsonArtifact; jsonContent: string } }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance/artifacts/versions/${encodeURIComponent(artifactId)}`),
check: (goalId: string, key: string, data: { expectedRequirementRevision: string; artifactId: string; expectedGeneration: string }) =>
http.post<never, { data: ManagedJsonCheckResult }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance/checks/${encodeURIComponent(key)}`, data),
get: (goalId: string) => http.get<never, { data: GoalJsonAcceptanceView }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance`),
configure: (goalId: string, key: string, data: ConfigureJsonRequirement) =>
http.put<never, { data: GoalJsonRequirement }>(`/goals/${encodeURIComponent(goalId)}/json-acceptance/requirements/${encodeURIComponent(key)}`, data),

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ManagedGoalJsonVersions from './ManagedGoalJsonVersions.vue'
import { goalJsonAcceptanceApi, type GoalJsonAcceptanceView, type GoalJsonRequirement } from '@/api/goalJsonAcceptance'
const props = defineProps<{ goalId: string; status: string }>()
@ -112,6 +113,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 })" />
<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

@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { goalJsonAcceptanceApi as api, type GoalJsonRequirement, type ManagedJsonArtifact, type ManagedJsonSnapshot } from '@/api/goalJsonAcceptance'
const props = defineProps<{ goalId: string; status: string; requirements: GoalJsonRequirement[] }>()
const emit = defineEmits<{ accessLost: [] }>()
const { t } = useI18n()
const expanded = ref(false)
const snapshot = ref<ManagedJsonSnapshot | null>(null)
const busy = ref(false)
const error = ref('')
const notice = ref('')
const loadedAt = ref('')
const conflict = ref(false)
const selectedSlot = ref('')
const draft = ref('')
const inspected = ref<{ artifact: ManagedJsonArtifact; jsonContent: string } | null>(null)
let epoch = 0
const writable = computed(() => ['active', 'paused'].includes(props.status) && !!snapshot.value && ['active', 'paused'].includes(snapshot.value.status))
const selected = computed(() => snapshot.value?.slots.find(slot => slot.artifactSlot === selectedSlot.value))
const current = (slot: string) => snapshot.value?.slots.find(item => item.artifactSlot === slot)?.current
const checkState = (key: string) => snapshot.value?.checks.find(item => item.criterionKey === key)
const states = new Set(['MATCH', 'MISSING_FIELDS', 'INVALID_JSON', 'UNKNOWN', 'NO_ARTIFACT', 'EXPIRED', 'CORRUPT', 'UNBOUND', 'REQUIREMENT_CHANGED', 'GOAL_CHANGED', 'SUPERSEDED', 'RECIPE_CHANGED'])
const stateLabel = (key: string) => { const state = checkState(key)?.status ?? 'UNKNOWN'; return t(`goalJsonArtifacts.state.${states.has(state) ? state : 'UNKNOWN'}`) }
const time = (value: string) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? t('goalJsonArtifacts.unknownTime') : date.toLocaleString() }
function clearContent() { snapshot.value = null; inspected.value = null; draft.value = ''; selectedSlot.value = ''; notice.value = ''; loadedAt.value = '' }
function failed(failure: unknown) {
const e = failure as { code?: number; response?: { status?: number } }
const code = e.response?.status ?? e.code
if ([401, 403, 404].includes(code ?? 0)) { clearContent(); error.value = 'goalJsonAcceptance.accessError'; emit('accessLost') }
else if (code === 409) { conflict.value = true; inspected.value = null; error.value = 'goalJsonArtifacts.conflict' }
else error.value = code === 400 ? 'goalJsonArtifacts.invalid' : 'goalJsonArtifacts.loadError'
}
async function fetchSnapshot(request: number, goalId: string) {
const { data } = await api.snapshot(goalId)
if (request !== epoch) return
snapshot.value = data
loadedAt.value = new Date().toISOString()
if (!data.slots.some(slot => slot.artifactSlot === selectedSlot.value)) selectedSlot.value = data.slots[0]?.artifactSlot ?? ''
}
async function reload() {
if (busy.value) return
const request = ++epoch, goalId = props.goalId
busy.value = true; error.value = ''; conflict.value = false; clearContent()
try { await fetchSnapshot(request, goalId) }
catch (failure) { if (request === epoch) failed(failure) }
finally { if (request === epoch) busy.value = false }
}
async function publish() {
if (busy.value || conflict.value || !writable.value || !selected.value || (snapshot.value?.versionCount ?? 32) >= 32) return
try {
if (new TextEncoder().encode(draft.value).length > 1_048_576) throw new Error()
const value: unknown = JSON.parse(draft.value)
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error()
} catch { error.value = 'goalJsonArtifacts.invalid'; return }
const request = epoch, goalId = props.goalId
const slot = selected.value
busy.value = true; error.value = ''; notice.value = ''; inspected.value = null
try {
await api.publish(goalId, slot.artifactSlot, { expectedGeneration: slot.generation, jsonContent: draft.value })
if (request !== epoch) return
draft.value = ''; notice.value = 'goalJsonArtifacts.published'
await fetchSnapshot(request, goalId)
} catch (failure) { if (request === epoch) failed(failure) }
finally { if (request === epoch) busy.value = false }
}
async function check(requirement: GoalJsonRequirement) {
const artifact = current(requirement.artifactSlot)
if (busy.value || conflict.value || !writable.value || !artifact) return
const request = epoch, goalId = props.goalId
busy.value = true; error.value = ''; notice.value = ''
try {
const { data } = await api.check(goalId, requirement.criterionKey, { expectedRequirementRevision: requirement.revision, artifactId: artifact.artifactId, expectedGeneration: artifact.generation })
if (request !== epoch) return
notice.value = data.acceptanceEligible ? 'goalJsonArtifacts.checked' : 'goalJsonArtifacts.notMatched'
await fetchSnapshot(request, goalId)
} catch (failure) { if (request === epoch) failed(failure) }
finally { if (request === epoch) busy.value = false }
}
async function inspect(artifact: ManagedJsonArtifact) {
if (busy.value) return
const request = epoch, goalId = props.goalId
busy.value = true; error.value = ''; inspected.value = null
try {
const { data } = await api.version(goalId, artifact.artifactId)
if (request === epoch && data.artifact.artifactId === artifact.artifactId) inspected.value = data
} catch (failure) { if (request === epoch) failed(failure) }
finally { if (request === epoch) busy.value = false }
}
function toggle() { expanded.value = !expanded.value; if (expanded.value && !snapshot.value) void reload() }
watch(() => [props.goalId, props.status, props.requirements], () => {
epoch++; busy.value = false; error.value = ''; conflict.value = false; clearContent()
if (expanded.value) void reload()
}, { deep: true })
onBeforeUnmount(() => { epoch++ })
</script>
<template>
<section class="managed-json">
<button type="button" data-json-versions-toggle :aria-expanded="expanded" @click="toggle">{{ t('goalJsonArtifacts.title') }}</button>
<div v-if="expanded" class="managed-json__content" :aria-busy="busy">
<p>{{ t('goalJsonArtifacts.scope') }}</p>
<button type="button" data-json-versions-refresh :disabled="busy" @click="reload">{{ t('goalJsonArtifacts.refresh') }}</button>
<p v-if="error" role="alert">{{ t(error) }}</p>
<p v-if="busy" role="status">{{ t('common.loading') }}</p>
<p v-if="notice" role="status">{{ t(notice) }}</p>
<template v-if="snapshot">
<p>{{ t('goalJsonArtifacts.loaded', { time: time(loadedAt) }) }}</p>
<p>{{ t('goalJsonArtifacts.quota', { count: snapshot.versionCount }) }}</p>
<p v-if="snapshot.versionCount >= 32" role="status">{{ t('goalJsonArtifacts.quotaFull') }}</p>
<article v-for="requirement in snapshot.requirements" :key="requirement.criterionKey" data-json-managed-requirement>
<strong>{{ requirement.criterionKey }}</strong>
<p>{{ t('goalJsonArtifacts.requirement', { revision: requirement.revision, fields: requirement.requiredFields.join(', ') }) }}</p>
<p data-json-binding-status :class="{ 'managed-json__matched': checkState(requirement.criterionKey)?.acceptanceEligible }">{{ stateLabel(requirement.criterionKey) }}</p>
<template v-if="current(requirement.artifactSlot)">
<p>{{ t('goalJsonArtifacts.version', { slot: requirement.artifactSlot, generation: current(requirement.artifactSlot)!.generation }) }}</p>
<p>{{ t('goalJsonArtifacts.expires', { time: time(current(requirement.artifactSlot)!.expiresAt) }) }}</p>
<div class="managed-json__actions">
<button type="button" data-json-version-inspect :disabled="busy" @click="inspect(current(requirement.artifactSlot)!)">{{ t('goalJsonArtifacts.inspect') }}</button>
<button v-if="writable" type="button" data-json-managed-check :disabled="busy || conflict" @click="check(requirement)">{{ t('goalJsonArtifacts.check') }}</button>
</div>
</template>
</article>
<div v-if="inspected" data-json-version-content>
<p>{{ t('goalJsonArtifacts.immutable') }} <code>{{ inspected.artifact.artifactId }}</code></p>
<p>SHA-256: <code>{{ inspected.artifact.sha256 }}</code></p>
<pre>{{ inspected.jsonContent }}</pre>
</div>
<form v-if="writable && snapshot.slots.length" @submit.prevent="publish">
<label>{{ t('goalJsonArtifacts.slot') }}<select v-model="selectedSlot" data-json-publish-slot :disabled="busy || conflict"><option v-for="slot in snapshot.slots" :key="slot.artifactSlot" :value="slot.artifactSlot">{{ slot.artifactSlot }}</option></select></label>
<label>{{ t('goalJsonArtifacts.content') }}<textarea v-model="draft" data-json-publish-content rows="7" maxlength="1048576" :disabled="busy || conflict" placeholder='{"summary":"…"}' /></label>
<button type="submit" data-json-publish :disabled="busy || conflict || snapshot.versionCount >= 32">{{ t('goalJsonArtifacts.publish') }}</button>
</form>
</template>
</div>
</section>
</template>
<style scoped>
.managed-json { border-top: 1px solid var(--mc-border-light); padding-top: 10px; margin-top: 10px; }
.managed-json__content, form, label { display: grid; gap: 8px; }
article { border: 1px solid var(--mc-border-light); border-radius: 7px; padding: 10px; }
p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
button, select, textarea { border: 1px solid var(--mc-border-light); border-radius: 6px; padding: 6px 9px; background: transparent; color: var(--mc-text-primary); font: inherit; }
button { cursor: pointer; justify-self: start; }
button:disabled, select:disabled, textarea:disabled { opacity: .55; }
select, textarea { width: 100%; box-sizing: border-box; }
textarea { resize: vertical; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow: auto; padding: 10px; background: var(--mc-bg-secondary); border-radius: 6px; }
code { overflow-wrap: anywhere; }
.managed-json__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.managed-json__matched { color: var(--mc-success, #27804a); }
[role="alert"] { color: var(--mc-danger, #b53535); }
button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
</style>

View File

@ -0,0 +1,98 @@
import { createApp, h, nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ManagedGoalJsonVersions from '../ManagedGoalJsonVersions.vue'
import { goalJsonAcceptanceApi as api, type ManagedJsonSnapshot } from '@/api/goalJsonAcceptance'
import en from '@/i18n/locales/en-US'
vi.mock('@/api/goalJsonAcceptance', () => ({ goalJsonAcceptanceApi: { snapshot: vi.fn(), publish: vi.fn(), check: vi.fn(), version: vi.fn() } }))
const apps: ReturnType<typeof createApp>[] = []
const goalId = '9223372036854775801', revision = '9223372036854775802', generation = '9223372036854775803'
const requirement = { criterionKey: 'report-fields', artifactSlot: 'report', revision, requiredFields: ['summary'], configuredBy: 'alice' }
const artifact = { artifactId: 'version-id', artifactSlot: 'report', generation, sha256: 'a'.repeat(64), byteLength: 16, producerKind: 'user', createdAt: '2026-09-14T14:00:00Z', expiresAt: '2026-09-15T14:00:00Z' }
function state(published = false): ManagedJsonSnapshot {
return { required: true, status: 'active', versionCount: published ? 1 : 0, requirements: [requirement],
slots: [{ artifactSlot: 'report', generation: published ? generation : '0', current: published ? artifact : null }],
checks: [{ criterionKey: requirement.criterionKey, requirementRevision: revision, artifactId: published ? artifact.artifactId : null, generation: published ? generation : null, status: published ? 'UNBOUND' : 'NO_ARTIFACT', acceptanceEligible: false }] }
}
async function flush() { for (let i = 0; i < 8; i++) await Promise.resolve(); await nextTick() }
function mount() {
const props = reactive({ goalId, status: 'active', requirements: [requirement] })
const lost = vi.fn(), host = document.createElement('div'); document.body.append(host)
const app = createApp({ render: () => h(ManagedGoalJsonVersions, { ...props, onAccessLost: lost }) })
app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })); app.mount(host); apps.push(app)
return { host, props, lost }
}
async function open(host: HTMLElement) { host.querySelector<HTMLButtonElement>('[data-json-versions-toggle]')!.click(); await flush() }
async function draft(host: HTMLElement, value: string) { const input = host.querySelector<HTMLTextAreaElement>('[data-json-publish-content]')!; input.value = value; input.dispatchEvent(new Event('input')); await flush() }
async function submit(host: HTMLElement) { host.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); await flush() }
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() })
describe('managed JSON versions and checks', () => {
it('loads only on expansion and publishes only on explicit submit', async () => {
vi.mocked(api.snapshot).mockResolvedValueOnce({ data: state() }).mockResolvedValue({ data: state(true) })
vi.mocked(api.publish).mockResolvedValue({ data: artifact })
const { host } = mount(); expect(api.snapshot).not.toHaveBeenCalled(); await open(host)
await draft(host, '{"summary":false}'); expect(api.publish).not.toHaveBeenCalled(); await submit(host)
expect(api.publish).toHaveBeenCalledWith(goalId, 'report', { expectedGeneration: '0', jsonContent: '{"summary":false}' })
expect(host.textContent).toContain('has not been checked')
expect(host.textContent).toContain('Version published')
})
it('checks the exact visible requirement revision and artifact generation', async () => {
const matched = state(true); matched.checks[0] = { ...matched.checks[0]!, status: 'MATCH', acceptanceEligible: true }
vi.mocked(api.snapshot).mockResolvedValueOnce({ data: state(true) }).mockResolvedValue({ data: matched })
vi.mocked(api.check).mockResolvedValue({ data: { ...matched.checks[0]!, missingFields: [], recipeId: 'json-required-fields', recipeRevision: 1, checkedAt: artifact.createdAt, expiresAt: artifact.expiresAt } })
const { host } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-managed-check]')!.click(); await flush()
expect(api.check).toHaveBeenCalledWith(goalId, 'report-fields', { expectedRequirementRevision: revision, artifactId: 'version-id', expectedGeneration: generation })
expect(host.querySelector('[data-json-binding-status]')?.textContent).toContain('Current binding matches')
})
it('blocks repeated writes after a generation conflict until reload', async () => {
vi.mocked(api.snapshot).mockResolvedValue({ data: state(true) }); vi.mocked(api.publish).mockRejectedValue({ code: 409 })
const { host } = mount(); await open(host); await draft(host, '{}'); await submit(host)
expect(host.querySelector<HTMLButtonElement>('[data-json-publish]')!.disabled).toBe(true)
expect(host.querySelector('[role="alert"]')?.textContent).toContain('Reload before')
await submit(host); expect(api.publish).toHaveBeenCalledTimes(1)
})
it('clears versions and drafts and notifies the parent after access revocation', async () => {
vi.mocked(api.snapshot).mockResolvedValue({ data: state(true) }); vi.mocked(api.version).mockRejectedValue({ code: 403 })
const { host, lost } = mount(); await open(host); await draft(host, '{"private":"draft"}')
host.querySelector<HTMLButtonElement>('[data-json-version-inspect]')!.click(); await flush()
expect(lost).toHaveBeenCalledOnce(); expect(host.querySelector('form')).toBeNull()
expect(host.querySelector('[data-json-managed-requirement]')).toBeNull(); expect(host.textContent).not.toContain('private')
})
it('never inserts delayed version content after changing goals', async () => {
vi.mocked(api.snapshot).mockResolvedValueOnce({ data: state(true) }).mockResolvedValue({ data: state() })
let resolve!: (value: unknown) => void
vi.mocked(api.version).mockImplementation(() => new Promise(r => { resolve = r }) as never)
const { host, props } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-version-inspect]')!.click(); await flush()
props.goalId = 'new-goal'; await flush(); resolve({ data: { artifact, jsonContent: 'old private body' } }); await flush()
expect(host.querySelector('[data-json-version-content]')).toBeNull(); expect(host.textContent).not.toContain('old private body')
})
it('does not apply a delayed publication response to another goal', async () => {
vi.mocked(api.snapshot).mockResolvedValue({ data: state() })
let resolve!: (value: unknown) => void
vi.mocked(api.publish).mockImplementation(() => new Promise(r => { resolve = r }) as never)
const { host, props } = mount(); await open(host); await draft(host, '{}'); await submit(host)
props.goalId = 'next-goal'; await flush(); resolve({ data: artifact }); await flush()
expect(host.textContent).not.toContain('Version published')
expect(api.snapshot).toHaveBeenLastCalledWith('next-goal')
})
it('renders stored content as text and blocks malformed object drafts', async () => {
vi.mocked(api.snapshot).mockResolvedValue({ data: state(true) })
vi.mocked(api.version).mockResolvedValue({ data: { artifact, jsonContent: '{"summary":"<img src=x onerror=alert(1)>"}' } })
const { host } = mount(); await open(host)
host.querySelector<HTMLButtonElement>('[data-json-version-inspect]')!.click(); await flush()
expect(host.querySelector('pre')?.textContent).toContain('<img'); expect(host.querySelector('img')).toBeNull()
await draft(host, '[]'); await submit(host); expect(api.publish).not.toHaveBeenCalled()
})
it('uses server terminal status and quota to prevent writes', async () => {
const terminal = state(true); terminal.status = 'completed'
vi.mocked(api.snapshot).mockResolvedValueOnce({ data: terminal })
const first = mount(); await open(first.host)
expect(first.host.querySelector('form')).toBeNull(); expect(first.host.querySelector('[data-json-managed-check]')).toBeNull()
const full = state(true); full.versionCount = 32; vi.mocked(api.snapshot).mockResolvedValueOnce({ data: full })
const second = mount(); await open(second.host)
expect(second.host.querySelector<HTMLButtonElement>('[data-json-publish]')!.disabled).toBe(true)
expect(second.host.textContent).toContain('version limit is reached')
})
})

View File

@ -1,4 +1,42 @@
export default {
goalJsonArtifacts: {
"title": "Versions and checks",
"scope": "Publish a JSON object, then check each requirement. Each version is immutable, at most 1 MiB, and valid for 24 hours. Publishing does not complete the goal.",
"refresh": "Reload versions and checks",
"quota": "{count} of 32 versions saved.",
"quotaFull": "The version limit is reached; existing versions are preserved.",
"requirement": "Requirement revision {revision} · Fields: {fields}",
"version": "{slot} · version {generation}",
"expires": "Expires: {time}",
"unknownTime": "Time unavailable",
"inspect": "View stored JSON",
"check": "Check this requirement",
"immutable": "Immutable version:",
"slot": "Publish to artifact",
"content": "JSON object content",
"publish": "Publish new version",
"published": "Version published. Check every requirement that uses this artifact.",
"checked": "This requirement matched. Completion rechecks all current requirements.",
"notMatched": "The JSON did not satisfy this requirement. Review the listed fields and publish a corrected version.",
"conflict": "The requirement, version or goal changed, or the version limit was reached. Reload before trying again.",
"invalid": "Enter a JSON object up to 1 MiB. Duplicate keys, trailing content and excessive nesting are rejected.",
"loadError": "Could not finish the operation. Reload current state before retrying.",
"loaded": "Snapshot loaded {time}. Completion checks current state again.",
"state": {
"MATCH": "Current binding matches.",
"MISSING_FIELDS": "Required fields are missing or null.",
"INVALID_JSON": "The JSON object is invalid.",
"UNKNOWN": "Check state is unavailable.",
"NO_ARTIFACT": "Publish a version before checking.",
"EXPIRED": "This version has expired. Publish a new version.",
"CORRUPT": "Stored content failed its integrity check.",
"UNBOUND": "This version has not been checked.",
"REQUIREMENT_CHANGED": "The requirement changed. Check it again.",
"GOAL_CHANGED": "The goal definition changed. Check again.",
"SUPERSEDED": "A newer version replaced the checked version. Check again.",
"RECIPE_CHANGED": "The checking recipe changed. Check again."
}
},
goalJsonAcceptance: {
"title": "Managed JSON acceptance",
"scope": "Require the listed top-level fields in a platform-managed JSON object, with values other than null (false, zero and empty strings are allowed). Text claims and ordinary file checks cannot satisfy this requirement.",

View File

@ -1,4 +1,42 @@
export default {
goalJsonArtifacts: {
"title": "产物版本与检查",
"scope": "先发布 JSON 对象再逐项检查。每版不可修改最多1 MiB、有效期24小时。发布本身不会完成目标。",
"refresh": "重新读取版本与检查",
"quota": "已保存 {count} / 32 个版本。",
"quotaFull": "已达到版本上限,历史版本会保留。",
"requirement": "要求修订 {revision} · 字段:{fields}",
"version": "{slot} · 版本 {generation}",
"expires": "有效期至:{time}",
"unknownTime": "时间不可用",
"inspect": "查看已存 JSON",
"check": "检查这项要求",
"immutable": "不可变版本:",
"slot": "发布到产物",
"content": "JSON 对象正文",
"publish": "发布新版本",
"published": "版本已发布,请检查使用此产物的每项要求。",
"checked": "这项要求已匹配,最终完成时仍会复核全部当前要求。",
"notMatched": "JSON 未满足这项要求,请核对列出的字段并发布修正版。",
"conflict": "要求、版本或目标已变化,或已达到版本上限。请重新读取后重试。",
"invalid": "请输入最多1 MiB的 JSON 对象。重复键、尾随内容和过深嵌套会被拒绝。",
"loadError": "未能完成操作,请重新读取当前状态后重试。",
"loaded": "读取时间:{time}。最终完成会再次检查当前状态。",
"state": {
"MATCH": "当前绑定匹配。",
"MISSING_FIELDS": "必需字段缺失或值为 null。",
"INVALID_JSON": "JSON 对象无效。",
"UNKNOWN": "检查状态不可用。",
"NO_ARTIFACT": "请先发布版本再检查。",
"EXPIRED": "版本已过期,请发布新版本。",
"CORRUPT": "已存正文未通过完整性检查。",
"UNBOUND": "当前版本尚未检查。",
"REQUIREMENT_CHANGED": "要求已修改,请重新检查。",
"GOAL_CHANGED": "目标定义已修改,请重新检查。",
"SUPERSEDED": "已有新版本,请重新检查。",
"RECIPE_CHANGED": "检查规则版本已变化,请重新检查。"
}
},
goalJsonAcceptance: {
"title": "受管 JSON 验收",
"scope": "要求平台受管 JSON 对象存在指定顶层字段且值不为 null允许 false、0 和空字符串)。文本声明和普通文件诊断不能满足这项要求。",