Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c675c08f2e | |||
| ec565b8d5d | |||
| e455cd593c | |||
| 38ae9d7d07 | |||
| abb3912530 | |||
| ced4b56dee | |||
| 17684223ab | |||
| bc959b2a85 | |||
| 784facbdb3 | |||
| c703b2ed2f |
@@ -24,8 +24,10 @@ beforeEach(() => {
|
||||
vi.mocked(api.createSession).mockResolvedValue({ authenticated: true });
|
||||
vi.mocked(api.deleteSession).mockResolvedValue(undefined);
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([]);
|
||||
vi.mocked(api.searchBranches).mockResolvedValue([]);
|
||||
vi.mocked(api.searchCommits).mockResolvedValue([]);
|
||||
vi.mocked(api.createDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
id: null,
|
||||
branch: 'master',
|
||||
status: 'queued',
|
||||
health: 'pending',
|
||||
@@ -33,7 +35,7 @@ beforeEach(() => {
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
});
|
||||
vi.mocked(api.uninstallDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
id: '1',
|
||||
branch: 'master',
|
||||
status: 'uninstalling',
|
||||
health: 'pending',
|
||||
@@ -81,11 +83,12 @@ test('submits a branch with an optional commit hash', async () => {
|
||||
test('shows health and web url, then confirms uninstall', async () => {
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([
|
||||
{
|
||||
id: 'preview-2',
|
||||
id: '42',
|
||||
branch: 'feature/demo',
|
||||
resolvedCommit: '1234567890abcdef',
|
||||
status: 'running',
|
||||
health: 'healthy',
|
||||
webPort: 8400,
|
||||
webUrl: 'http://192.168.35.82:8400',
|
||||
createdAt: 1_787_270_400,
|
||||
updatedAt: 1_787_270_460,
|
||||
@@ -94,6 +97,8 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('健康')).toBeTruthy();
|
||||
expect(screen.getByText('端口 8400')).toBeTruthy();
|
||||
expect(screen.getByText('构建 #42')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'),
|
||||
).toBe('http://192.168.35.82:8400');
|
||||
@@ -103,6 +108,89 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认卸载' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
|
||||
expect(api.uninstallDeployment).toHaveBeenCalledWith('42');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows debounced branch results and selects one with the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.searchBranches).mockResolvedValue([
|
||||
{ name: 'feature/search-one', commitHash: '111111111111' },
|
||||
{ name: 'feature/search-two', commitHash: '222222222222' },
|
||||
]);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
|
||||
await user.clear(branchInput);
|
||||
await user.type(branchInput, 'feature/search');
|
||||
|
||||
expect(
|
||||
await screen.findByRole('option', { name: /feature\/search-one/u }),
|
||||
).toBeTruthy();
|
||||
expect(api.searchBranches).toHaveBeenLastCalledWith(
|
||||
'feature/search',
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
||||
expect((branchInput as HTMLInputElement).value).toBe('feature/search-two');
|
||||
});
|
||||
|
||||
test('searches commits in the current branch and selects a result', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.searchCommits).mockResolvedValue([
|
||||
{
|
||||
commitHash: 'aa5221abcdef0123456789',
|
||||
shortHash: 'aa5221a',
|
||||
subject: '补充预览搜索',
|
||||
},
|
||||
]);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const commitInput = await screen.findByRole('combobox', {
|
||||
name: 'Commit Hash',
|
||||
});
|
||||
await user.type(commitInput, 'aa5221a');
|
||||
|
||||
const option = await screen.findByRole('option', {
|
||||
name: /aa5221a.*补充预览搜索/u,
|
||||
});
|
||||
expect(api.searchCommits).toHaveBeenCalledWith(
|
||||
'master',
|
||||
'aa5221a',
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
await user.click(option);
|
||||
expect((commitInput as HTMLInputElement).value).toBe(
|
||||
'aa5221abcdef0123456789',
|
||||
);
|
||||
});
|
||||
|
||||
test('clears commit and ignores stale commit responses when branch changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined;
|
||||
vi.mocked(api.searchCommits).mockImplementation(
|
||||
() =>
|
||||
new Promise<api.CommitRef[]>((resolve) => {
|
||||
resolveOldSearch = resolve;
|
||||
}),
|
||||
);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
|
||||
const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' });
|
||||
await user.type(commitInput, 'abcdef1');
|
||||
await waitFor(() => expect(api.searchCommits).toHaveBeenCalled());
|
||||
await user.clear(branchInput);
|
||||
await user.type(branchInput, 'feature/new');
|
||||
|
||||
expect((commitInput as HTMLInputElement).value).toBe('');
|
||||
resolveOldSearch?.([
|
||||
{
|
||||
commitHash: 'abcdef1234567890',
|
||||
shortHash: 'abcdef1',
|
||||
subject: '旧分支提交',
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('旧分支提交')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -13,15 +13,27 @@ import {
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FormEvent,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
type BranchRef,
|
||||
type CommitRef,
|
||||
createDeployment,
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
PreviewDeployerApiError,
|
||||
searchBranches,
|
||||
searchCommits,
|
||||
uninstallDeployment,
|
||||
} from './api';
|
||||
import type {
|
||||
@@ -32,6 +44,7 @@ import type {
|
||||
import { validateBranch, validateCommitHash } from './validation';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const STATUS_LABELS: Record<DeploymentStatus, string> = {
|
||||
queued: '排队中',
|
||||
@@ -66,6 +79,16 @@ export function PreviewDeployerApp() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [branchSearch, setBranchSearch] =
|
||||
useState<SearchState<BranchRef>>(emptySearchState());
|
||||
const [commitSearch, setCommitSearch] =
|
||||
useState<SearchState<CommitRef>>(emptySearchState());
|
||||
const [branchActiveIndex, setBranchActiveIndex] = useState(-1);
|
||||
const [commitActiveIndex, setCommitActiveIndex] = useState(-1);
|
||||
const [branchSearchEnabled, setBranchSearchEnabled] = useState(false);
|
||||
const [commitSearchEnabled, setCommitSearchEnabled] = useState(false);
|
||||
const branchRequestId = useRef(0);
|
||||
const commitRequestId = useRef(0);
|
||||
const [pendingUninstall, setPendingUninstall] =
|
||||
useState<PreviewDeployment | null>(null);
|
||||
const [uninstallingId, setUninstallingId] = useState('');
|
||||
@@ -129,6 +152,94 @@ export function PreviewDeployerApp() {
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refresh, sessionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = branch.trim();
|
||||
const requestId = ++branchRequestId.current;
|
||||
const controller = new AbortController();
|
||||
if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) {
|
||||
setBranchSearch(emptySearchState());
|
||||
setBranchActiveIndex(-1);
|
||||
return () => controller.abort();
|
||||
}
|
||||
setBranchSearch({ items: [], loading: true, open: true, error: '' });
|
||||
setBranchActiveIndex(-1);
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchBranches(query, controller.signal)
|
||||
.then((items) => {
|
||||
if (requestId !== branchRequestId.current) return;
|
||||
setBranchSearch({ items, loading: false, open: true, error: '' });
|
||||
})
|
||||
.catch((searchError) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
requestId !== branchRequestId.current
|
||||
)
|
||||
return;
|
||||
if (isUnauthorized(searchError)) {
|
||||
handleUnauthorized();
|
||||
return;
|
||||
}
|
||||
setBranchSearch({
|
||||
items: [],
|
||||
loading: false,
|
||||
open: true,
|
||||
error: formatError(searchError),
|
||||
});
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [branch, branchSearchEnabled, sessionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = commitHash.trim();
|
||||
const selectedBranch = branch.trim();
|
||||
const requestId = ++commitRequestId.current;
|
||||
const controller = new AbortController();
|
||||
if (
|
||||
sessionStatus !== 'authenticated' ||
|
||||
!commitSearchEnabled ||
|
||||
!query ||
|
||||
validateBranch(selectedBranch)
|
||||
) {
|
||||
setCommitSearch(emptySearchState());
|
||||
setCommitActiveIndex(-1);
|
||||
return () => controller.abort();
|
||||
}
|
||||
setCommitSearch({ items: [], loading: true, open: true, error: '' });
|
||||
setCommitActiveIndex(-1);
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchCommits(selectedBranch, query, controller.signal)
|
||||
.then((items) => {
|
||||
if (requestId !== commitRequestId.current) return;
|
||||
setCommitSearch({ items, loading: false, open: true, error: '' });
|
||||
})
|
||||
.catch((searchError) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
requestId !== commitRequestId.current
|
||||
)
|
||||
return;
|
||||
if (isUnauthorized(searchError)) {
|
||||
handleUnauthorized();
|
||||
return;
|
||||
}
|
||||
setCommitSearch({
|
||||
items: [],
|
||||
loading: false,
|
||||
open: true,
|
||||
error: formatError(searchError),
|
||||
});
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [branch, commitHash, commitSearchEnabled, sessionStatus]);
|
||||
|
||||
const runningCount = useMemo(
|
||||
() =>
|
||||
deployments.filter((deployment) => deployment.status === 'running')
|
||||
@@ -178,6 +289,9 @@ export function PreviewDeployerApp() {
|
||||
return;
|
||||
}
|
||||
const deployment = pendingUninstall;
|
||||
if (!deployment.id) {
|
||||
return;
|
||||
}
|
||||
setPendingUninstall(null);
|
||||
setUninstallingId(deployment.id);
|
||||
setError('');
|
||||
@@ -356,36 +470,166 @@ export function PreviewDeployerApp() {
|
||||
className="deploy-form"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<label>
|
||||
<span>分支名</span>
|
||||
<div className="input-shell">
|
||||
<div className="search-field">
|
||||
<label htmlFor="branch-input">分支名</label>
|
||||
<div className="input-shell" role="presentation">
|
||||
<GitBranch size={17} />
|
||||
<input
|
||||
aria-activedescendant={
|
||||
branchSearch.open && branchActiveIndex >= 0
|
||||
? `branch-option-${branchActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="branch-search-list"
|
||||
aria-expanded={branchSearch.open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="分支名"
|
||||
role="combobox"
|
||||
id="branch-input"
|
||||
autoComplete="off"
|
||||
disabled={submitting}
|
||||
maxLength={200}
|
||||
placeholder="master"
|
||||
value={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setBranchSearchEnabled(true);
|
||||
setBranch(event.target.value);
|
||||
if (commitHash) {
|
||||
setCommitHash('');
|
||||
setCommitSearchEnabled(false);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
setBranchSearchEnabled(true);
|
||||
setBranchSearch((current) => ({ ...current, open: true }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
handleSearchKeyDown(
|
||||
event,
|
||||
branchSearch,
|
||||
branchActiveIndex,
|
||||
(index) => setBranchActiveIndex(index),
|
||||
(item) => {
|
||||
setBranch(item.name);
|
||||
setCommitHash('');
|
||||
setBranchSearchEnabled(false);
|
||||
setCommitSearchEnabled(false);
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
120,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
<SearchResults
|
||||
id="branch-search-list"
|
||||
type="branch"
|
||||
state={branchSearch}
|
||||
onSelect={(item) => {
|
||||
setBranch(item.name);
|
||||
setCommitHash('');
|
||||
setBranchSearchEnabled(false);
|
||||
setCommitSearchEnabled(false);
|
||||
setBranchSearch((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
activeIndex={branchActiveIndex}
|
||||
/>
|
||||
</div>
|
||||
<div className="search-field">
|
||||
<label htmlFor="commit-input">
|
||||
Commit Hash <small>可选</small>
|
||||
</span>
|
||||
<div className="input-shell">
|
||||
</label>
|
||||
<div className="input-shell" role="presentation">
|
||||
<Hash size={17} />
|
||||
<input
|
||||
aria-activedescendant={
|
||||
commitSearch.open && commitActiveIndex >= 0
|
||||
? `commit-option-${commitActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="commit-search-list"
|
||||
aria-expanded={commitSearch.open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Commit Hash"
|
||||
role="combobox"
|
||||
id="commit-input"
|
||||
autoComplete="off"
|
||||
disabled={submitting}
|
||||
maxLength={40}
|
||||
placeholder="留空则构建分支最新提交"
|
||||
value={commitHash}
|
||||
onChange={(event) => setCommitHash(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setCommitSearchEnabled(true);
|
||||
setCommitHash(event.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setCommitSearchEnabled(true);
|
||||
setCommitSearch((current) => ({ ...current, open: true }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
handleSearchKeyDown(
|
||||
event,
|
||||
commitSearch,
|
||||
commitActiveIndex,
|
||||
(index) => setCommitActiveIndex(index),
|
||||
(item) => {
|
||||
setCommitHash(item.commitHash);
|
||||
setCommitSearchEnabled(false);
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
120,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<SearchResults
|
||||
id="commit-search-list"
|
||||
type="commit"
|
||||
state={commitSearch}
|
||||
onSelect={(item) => {
|
||||
setCommitHash(item.commitHash);
|
||||
setCommitSearchEnabled(false);
|
||||
setCommitSearch((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
activeIndex={commitActiveIndex}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
@@ -435,7 +679,10 @@ export function PreviewDeployerApp() {
|
||||
<div className="deployment-list">
|
||||
{deployments.map((deployment) => (
|
||||
<DeploymentCard
|
||||
key={deployment.id}
|
||||
key={
|
||||
deployment.id ??
|
||||
`${deployment.branch}-${deployment.createdAt}`
|
||||
}
|
||||
deployment={deployment}
|
||||
uninstalling={uninstallingId === deployment.id}
|
||||
onUninstall={() => setPendingUninstall(deployment)}
|
||||
@@ -530,6 +777,12 @@ function DeploymentCard({
|
||||
<span className="health-dot" />
|
||||
{HEALTH_LABELS[deployment.health]}
|
||||
</span>
|
||||
{deployment.webPort ? (
|
||||
<span className="badge port-badge">端口 {deployment.webPort}</span>
|
||||
) : null}
|
||||
<span className="badge record-badge">
|
||||
构建 #{deployment.id || '待分配'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -567,7 +820,7 @@ function DeploymentCard({
|
||||
构建详情 <ExternalLink size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
{canUninstall ? (
|
||||
{canUninstall && deployment.id ? (
|
||||
<button
|
||||
className="icon-danger-button"
|
||||
type="button"
|
||||
@@ -620,6 +873,121 @@ function Summary({
|
||||
);
|
||||
}
|
||||
|
||||
type SearchState<T> = {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function emptySearchState<T>(): SearchState<T> {
|
||||
return { items: [], loading: false, open: false, error: '' };
|
||||
}
|
||||
|
||||
function handleSearchKeyDown<T>(
|
||||
event: ReactKeyboardEvent<HTMLInputElement>,
|
||||
state: SearchState<T>,
|
||||
activeIndex: number,
|
||||
setActiveIndex: (index: number) => void,
|
||||
onSelect: (item: T) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
if (!state.open || (!state.items.length && event.key !== 'Escape')) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActiveIndex((activeIndex + 1) % state.items.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(activeIndex <= 0 ? state.items.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const item = state.items[activeIndex];
|
||||
if (item) onSelect(item);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(-1);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function SearchResults({
|
||||
id,
|
||||
type,
|
||||
state,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
}:
|
||||
| {
|
||||
id: string;
|
||||
type: 'branch';
|
||||
state: SearchState<BranchRef>;
|
||||
activeIndex: number;
|
||||
onSelect: (item: BranchRef) => void;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'commit';
|
||||
state: SearchState<CommitRef>;
|
||||
activeIndex: number;
|
||||
onSelect: (item: CommitRef) => void;
|
||||
}) {
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<div className="search-results" id={id} role="listbox">
|
||||
{state.loading ? (
|
||||
<div className="search-state">
|
||||
<LoaderCircle className="spin" size={15} />
|
||||
正在搜索
|
||||
</div>
|
||||
) : state.error ? (
|
||||
<div className="search-state search-state-error">{state.error}</div>
|
||||
) : state.items.length === 0 ? (
|
||||
<div className="search-state">没有匹配结果</div>
|
||||
) : type === 'branch' ? (
|
||||
state.items.map((item, index) => (
|
||||
<button
|
||||
aria-selected={index === activeIndex}
|
||||
className={`search-option${index === activeIndex ? ' active' : ''}`}
|
||||
id={`branch-option-${index}`}
|
||||
key={item.name}
|
||||
role="option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<GitBranch size={15} />
|
||||
<span>{item.name}</span>
|
||||
{item.commitHash ? (
|
||||
<small>{shortCommit(item.commitHash)}</small>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
state.items.map((item, index) => (
|
||||
<button
|
||||
aria-selected={index === activeIndex}
|
||||
className={`search-option${index === activeIndex ? ' active' : ''}`}
|
||||
id={`commit-option-${index}`}
|
||||
key={item.commitHash}
|
||||
role="option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<Hash size={15} />
|
||||
<span>
|
||||
<strong>{item.shortHash || shortCommit(item.commitHash)}</strong>
|
||||
{item.subject ? <small>{item.subject}</small> : null}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isActive(status: DeploymentStatus) {
|
||||
return ['queued', 'building', 'deploying', 'uninstalling'].includes(status);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
searchBranches,
|
||||
searchCommits,
|
||||
uninstallDeployment,
|
||||
} from './api';
|
||||
|
||||
@@ -112,3 +114,45 @@ test('submits only branch and optional commit to fixed endpoints', async () => {
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('searches branches and branch-scoped commits with encoded queries', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [{ name: 'feature/search', commitHash: 'abcdef123456' }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
commitHash: 'abcdef123456',
|
||||
shortHash: 'abcdef1',
|
||||
subject: '搜索提交',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
expect(await searchBranches('feature/search')).toHaveLength(1);
|
||||
expect(await searchCommits('feature/search', 'abc def')).toHaveLength(1);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/preview-deployer/refs/branches?q=feature%2Fsearch',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/preview-deployer/refs/commits?branch=feature%2Fsearch&q=abc%20def',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,17 @@ export interface PreviewDeployerSession {
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export interface BranchRef {
|
||||
name: string;
|
||||
commitHash?: string | null;
|
||||
}
|
||||
|
||||
export interface CommitRef {
|
||||
commitHash: string;
|
||||
shortHash: string;
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
export async function getSession(signal?: AbortSignal) {
|
||||
const session = await request<PreviewDeployerSession | null>('/session', {
|
||||
signal,
|
||||
@@ -41,6 +52,26 @@ export async function listDeployments(signal?: AbortSignal) {
|
||||
return Array.isArray(payload) ? payload : payload.deployments;
|
||||
}
|
||||
|
||||
export async function searchBranches(query: string, signal?: AbortSignal) {
|
||||
const payload = await request<BranchRef[] | { items: BranchRef[] }>(
|
||||
`/refs/branches?q=${encodeURIComponent(query)}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.items;
|
||||
}
|
||||
|
||||
export async function searchCommits(
|
||||
branch: string,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const payload = await request<CommitRef[] | { items: CommitRef[] }>(
|
||||
`/refs/commits?branch=${encodeURIComponent(branch)}&q=${encodeURIComponent(query)}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.items;
|
||||
}
|
||||
|
||||
export function createDeployment(input: CreateDeploymentInput) {
|
||||
return request<PreviewDeployment>('/deployments', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -190,14 +190,77 @@ a {
|
||||
padding: 22px;
|
||||
box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055);
|
||||
}
|
||||
.deploy-form label {
|
||||
.deploy-form label,
|
||||
.search-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #39465a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.deploy-form label small {
|
||||
.search-field {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-results {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% - 1px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #cfd7e3;
|
||||
border-radius: 0 0 9px 9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 25px rgba(28, 37, 51, 0.13);
|
||||
}
|
||||
.search-option {
|
||||
display: flex;
|
||||
min-height: 39px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
color: #344054;
|
||||
background: #fff;
|
||||
padding: 7px 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.search-option:hover,
|
||||
.search-option.active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
.search-option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-option small {
|
||||
overflow: hidden;
|
||||
color: #8792a2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-state {
|
||||
display: flex;
|
||||
min-height: 39px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #8792a2;
|
||||
padding: 0 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-state-error {
|
||||
color: #b42318;
|
||||
}
|
||||
.deploy-form label small,
|
||||
.search-field label small {
|
||||
color: #8994a5;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -381,6 +444,16 @@ a {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.port-badge {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.record-badge {
|
||||
color: #475569;
|
||||
background: #f8fafc;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.badge-running {
|
||||
color: #14734a;
|
||||
background: #e9f9f0;
|
||||
|
||||
@@ -11,12 +11,13 @@ export type DeploymentStatus =
|
||||
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface PreviewDeployment {
|
||||
id: string;
|
||||
id?: string | null;
|
||||
branch: string;
|
||||
commitHash?: string | null;
|
||||
resolvedCommit?: string | null;
|
||||
status: DeploymentStatus;
|
||||
health: DeploymentHealth;
|
||||
webPort?: number | null;
|
||||
webUrl?: string | null;
|
||||
jenkinsBuildUrl?: string | null;
|
||||
createdAt: string | number;
|
||||
|
||||
+6
@@ -1,8 +1,14 @@
|
||||
# 仅部署在本机内网 HTTP 入口 http://192.168.35.82/build/。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/
|
||||
# 页面“构建详情”链接使用局域网地址,内部请求仍使用上面的 loopback 地址。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
|
||||
# 只读源码查询固定使用本机 Gitea SSH 入口,控制服务不接受客户端传入 remote。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git
|
||||
# 使用 Jenkins 用户专用只读 deploy key 与独立 known_hosts;禁止关闭主机密钥校验。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND=ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
- [浏览器内 AI Web 工程沙箱预览](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)
|
||||
- [AI Web 工程 Runner 安全模型](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md)
|
||||
|
||||
### AI 游戏创作 Runtime
|
||||
|
||||
1. [AI 游戏创作 Agent Runtime 交互边界重构总览与实施计划](./technical/【技术方案】AI游戏创作Agent%20Runtime交互边界重构实施计划-2026-08-12.md)
|
||||
2. [AI 游戏创作 Agent Runtime 交互合同 V1(唯一规范性协议)](./technical/【技术协议】AI游戏创作Agent%20Runtime交互合同V1-2026-08-17.md)
|
||||
3. [AI 游戏创作 Agent Runtime 交互边界迁移矩阵](./technical/【迁移方案】AI游戏创作Agent%20Runtime交互边界迁移矩阵-2026-08-17.md)
|
||||
4. [AI 游戏创作 Agent Runtime 交互边界证据与决策附录](./technical/【设计依据】AI游戏创作Agent%20Runtime交互边界证据与决策附录-2026-08-17.md)
|
||||
5. [AI 游戏创作 Agent Runtime V1.1](./technical/【技术方案】AI游戏创作Agent%20Runtime%20V1.1-2026-07-12.md)
|
||||
|
||||
上述四份交互边界文档按“总览 → Contract → 迁移矩阵 → 证据附录”阅读;字段、状态机和错误语义只以 Contract 中的 `IC-*` 为准。
|
||||
|
||||
### 后端与公开数据
|
||||
|
||||
- [外部生成 Worker 化方案](./technical/【后端架构】外部生成Worker化方案-2026-06-03.md)
|
||||
|
||||
@@ -14161,3 +14161,9 @@
|
||||
- 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。
|
||||
- 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。
|
||||
- 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-08-17 预览发布记录使用 Jenkins 构建编号并有限保留
|
||||
|
||||
- 决策:内部稳定 `deploymentId` 继续绑定分支、Compose project 和端口租约;页面/API 记录 ID 在 Jenkins 分配执行器后改为构建编号,排队阶段为“待分配”。卸载通过构建编号找到内部实例,再向固定 Job 传内部 ID。
|
||||
- 清理:失败或取消且不存在可卸载实例的记录保留 7 天;成功卸载的内部审计记录保留 30 天;仍可卸载的失败记录永久保留到人工卸载。服务启动、读取列表和创建部署时执行清理并原子落盘。
|
||||
- 链接:服务内部仍通过 Jenkins loopback 轮询;只向浏览器返回由 `GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL` 构造的局域网构建详情地址,禁止回传 loopback URL。
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 部署身份与端口
|
||||
|
||||
`deploymentId` 由规范化分支名与分支名摘要确定,同一分支稳定得到同一 ID;commit 不进入 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。
|
||||
预览实例 ID(`deploymentId`)由规范化分支名与分支名摘要确定,只在控制服务内部和 Jenkins 参数中使用;commit 不进入该 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。页面发布记录的公开 ID 使用 Jenkins 构建编号:排队期间尚未分配编号,显示“待分配”,构建开始后立即显示真实编号。
|
||||
|
||||
Web 端口池固定为 `8400..8499`:
|
||||
|
||||
@@ -86,12 +86,18 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
|
||||
- `GET /api/preview-deployer/session`:查询当前会话状态。
|
||||
- `DELETE /api/preview-deployer/session`:退出。
|
||||
- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。
|
||||
- `GET /api/preview-deployer/refs/branches?q=`:按输入内容搜索固定源码仓库中的分支,最多返回 20 条 `{ name, commitHash }`。
|
||||
- `GET /api/preview-deployer/refs/commits?branch=&q=`:在已确认存在的目标分支历史中搜索 commit,最多返回 20 条 `{ commitHash, shortHash, subject }`。
|
||||
- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`。
|
||||
- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。
|
||||
- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。
|
||||
|
||||
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。
|
||||
|
||||
发布记录卡片直接展示 Jenkins 构建编号、后端校验后的 `webPort` 和由控制服务转换的 Jenkins 局域网构建详情地址;内部 loopback Jenkins 地址不得返回浏览器。失败或取消且不存在可卸载实例的记录保留 7 天,成功卸载的内部记录保留 30 天,清理会在服务启动、读取列表和创建新构建时执行。仍可卸载的失败记录不会自动清理。`stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
|
||||
|
||||
分支名和 commit 输入框采用 300ms 防抖搜索,并在输入框下方显示服务端结果;分支变化时清空已输入的 commit,避免把旧分支 commit 带入新请求。搜索结果只负责辅助填写,不作为构建授权或存在性真相。`POST /deployments` 在写入排队状态和触发 Jenkins 前必须重新查询固定远端:分支不存在时拒绝;填写 commit 时必须确认它可解析为 commit object 且是目标分支 HEAD 的祖先。远端查询失败时失败关闭,不得触发 Jenkins。Jenkins checkout 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。
|
||||
|
||||
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。
|
||||
|
||||
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
|
||||
@@ -101,6 +107,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
|
||||
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
|
||||
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。
|
||||
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
|
||||
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
|
||||
- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
|
||||
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
|
||||
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
||||
# AI 游戏创作 Agent Runtime 交互边界重构
|
||||
|
||||
> 文档角色:总览与分阶段实施计划
|
||||
> 状态:评审中;尚未允许进入 P1–P6 生产实现
|
||||
> 更新日期:`2026-08-17`
|
||||
|
||||
## 0. 阅读入口与权威顺序
|
||||
|
||||
本文只解释重构目的、系统边界和 P0–P6 实施顺序,不定义协议字段与状态机。四份配套文档的职责和权威顺序如下:
|
||||
|
||||
1. 本文:第一次理解方案和实施阶段的入口。
|
||||
2. [`【技术协议】AI游戏创作Agent Runtime交互合同V1-2026-08-17.md`](./【技术协议】AI游戏创作Agent%20Runtime交互合同V1-2026-08-17.md):唯一规范性协议;所有 `IC-*` 要求以它为准。
|
||||
3. [`【迁移方案】AI游戏创作Agent Runtime交互边界迁移矩阵-2026-08-17.md`](./【迁移方案】AI游戏创作Agent%20Runtime交互边界迁移矩阵-2026-08-17.md):把 `IC-*` 映射到当前代码、入口、阶段和验收证据。
|
||||
4. [`【设计依据】AI游戏创作Agent Runtime交互边界证据与决策附录-2026-08-17.md`](./【设计依据】AI游戏创作Agent%20Runtime交互边界证据与决策附录-2026-08-17.md):保存 `EV-*` 代码事实、`DR-*` 设计决策和 `EG-*` 证据门禁。
|
||||
|
||||
如四份文档发生冲突:
|
||||
|
||||
- 当前代码和仓库最新架构文档决定“系统现在是什么”;
|
||||
- Interaction Contract 决定“本次重构必须实现什么”;
|
||||
- 迁移矩阵和证据附录不得改变 Contract,只能暴露当前差距;
|
||||
- 实现发现 Contract 不可行时,先修改 Contract 并重新评审,不得在 Consumer 或 Adapter 中自行发明兼容语义。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要重构
|
||||
|
||||
当前 GUI、CLI、`swarm_cli`、Tauri wrapper 和测试路径分别承担了一部分 Runtime 生命周期判断:
|
||||
|
||||
- 是否启动新 Run;
|
||||
- 是否 steer 当前 Run;
|
||||
- 是否直接回复;
|
||||
- 如何处理用户输入、批准、拒绝、重试、恢复和取消;
|
||||
- 如何合并 Runtime state、event、response stream 和 conversation;
|
||||
- 如何把 Runtime 输出再次保存为聊天消息。
|
||||
|
||||
这导致同一个用户操作可能因 Consumer 不同而走不同控制流,也使前端刷新、CLI 无头运行、Runner 恢复和测试夹具难以共享同一行为边界。
|
||||
|
||||
本重构解决的不是“代码散落”本身,而是控制权归属不清:
|
||||
|
||||
> Consumer 现在既展示状态,又在部分路径中决定下一步并写入 Runtime;重构后 Consumer 只展示后端状态并表达用户意图,Supervisor Shell 统一作出交互决策。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标边界
|
||||
|
||||
### 2.1 核心目标
|
||||
|
||||
1. GUI、CLI 和公开协议测试成为同一协议的平等 Consumer。
|
||||
2. Consumer 只执行 `render(snapshot)` 与 `dispatch(command)`,不推进 Runtime 状态机。
|
||||
3. Supervisor Shell 统一处理正式用户交互决策、命令校验、幂等受理和结果读回。
|
||||
4. 现有 Runtime task、state、pending、provider、steer、finalization、conversation 和 event records 继续作为执行事实源。
|
||||
5. External Runner 存在时,由持有现有 OS project execution-owner lock 的 Runner 执行正式写入。
|
||||
6. Public 与 Developer read model 分离,正式用户协议不泄漏路径、Provider、工具参数、内部 action 或 recovery 细节。
|
||||
7. 通过 Adapter 和明确 writer cutover 分阶段迁移,不制造第二套 Runtime authority 或第二份 conversation 正文。
|
||||
|
||||
### 2.2 不在本轮
|
||||
|
||||
- Runtime `main_loop`、task queue、Provider retry、delegation/all-join 或 Agent 执行状态机内部重构;
|
||||
- LLM、Provider、提示词或工具体系调整;
|
||||
- Runner 开机自启或无人值守常驻;
|
||||
- 新增 live Session rotation、Session handoff、第二 active-session index 或 session control lease;
|
||||
- 新增全局 owner generation/lease CAS;
|
||||
- 重新设计资源上传、项目资源 lineage、Preview Registry 或 Session 管理面;
|
||||
- 把管理命令、路径操作或 Developer 调试能力伪装成五个 Public Runtime 命令。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新系统的一句话结构
|
||||
|
||||
```text
|
||||
GUI / CLI / Tests
|
||||
读取 Public Snapshot 与 Public Conversation
|
||||
提交五个公开写命令
|
||||
↓
|
||||
Supervisor Shell
|
||||
校验身份、权限、版本、目标和幂等性
|
||||
决定 direct reply / start / steer / reject / interaction required
|
||||
通过 Adapter 调用现有 Runtime 能力
|
||||
↓
|
||||
Existing Runtime
|
||||
继续维护 task / state / action / provider / finalization / conversation / event 事实
|
||||
```
|
||||
|
||||
四个角色的责任如下:
|
||||
|
||||
| 角色 | 负责 | 禁止 |
|
||||
|---|---|---|
|
||||
| Consumer | 显示 Snapshot、提交 command、读取 Conversation | 根据 phase/文案自行选择 start、steer、retry、resume;直接解释私有 Runtime records |
|
||||
| Supervisor Shell | 统一交互判断、命令受理、幂等、能力投影和错误映射 | 复制 Runtime 生命周期真相;把投影完成当成执行成功 |
|
||||
| Runtime | 实际执行、恢复、finalization 和事实持久化 | 依赖 GUI 轮询推进状态 |
|
||||
| Runner | 持 owner lock 时执行正式 Shell 写入、wake 和恢复 | 失去 owner 后继续写;依赖诊断 JSON 或本地时间接管 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 一次用户输入如何流动
|
||||
|
||||
1. Consumer 读取 `PublicSnapshot`。
|
||||
2. Snapshot 返回当前状态以及服务端生成的 command capability。
|
||||
3. 用户提交消息时,Consumer 调用 `submit_intent`,不调用 start/steer primitive。
|
||||
4. Shell 校验 schema、项目、Session、权限、requestId 和 capability target。
|
||||
5. Shell 持久化可读回的 request acceptance record。
|
||||
6. Shell 根据冻结 policy matrix 决定:
|
||||
- DirectReply;
|
||||
- Start;
|
||||
- Steer;
|
||||
- Reject;
|
||||
- InteractionRequired。
|
||||
7. 需要 Runtime 执行时,Shell 通过 Adapter 绑定现有 run/steer/action/finalization identity;不复制这些对象的生命周期。
|
||||
8. Runtime 更新 durable facts。
|
||||
9. Public projector 从 facts 重建 Snapshot;`SnapshotChanged` 只提示 Consumer 重新读取 Snapshot。
|
||||
10. 文本交付通过 Public Conversation Adapter 按 source 回读;Snapshot 和 conversation 都不能互相推导对方的权威结论。
|
||||
|
||||
### 4.1 五个 Public 写命令
|
||||
|
||||
| 命令 | 用户含义 | Shell 负责决定的内部动作 |
|
||||
|---|---|---|
|
||||
| `submit_intent` | 提交消息或受支持的内置命令 | direct reply / start / steer / reject / interaction required |
|
||||
| `answer` | 回答 UserInput | 校验 interaction revision、答案约束和 durable target |
|
||||
| `approve` | 批准、拒绝或带意见返工 | 校验 audience、policy、artifact binding 和 rework identity |
|
||||
| `cancel` | 取消精确 Supervisor Run | 校验 Session、Run 和取消矩阵;不伪造终态 |
|
||||
| `resume` | 继续、重试或受信任 reconcile | 明确区分 ContinueRun、RetryTerminalRun、ReconcileRun |
|
||||
|
||||
### 4.2 两个 read model
|
||||
|
||||
- Public Snapshot:普通 GUI、普通 CLI 和公开测试的唯一完整 Runtime 视图。
|
||||
- Developer Snapshot:受信任开发入口的独立 DTO;扩大 read,不扩大正式 Supervisor 写权限。
|
||||
|
||||
### 4.3 Conversation 是独立展示通道
|
||||
|
||||
Public Conversation 不保存第二份正文,只建立 source-to-Public 索引并从原 source 回读。V1 区分:
|
||||
|
||||
- User;
|
||||
- DirectReply;
|
||||
- RuntimeFinalReply;
|
||||
- 满足准入条件的 RuntimeStatus;
|
||||
- 满足准入条件的 PublicEvent。
|
||||
|
||||
不能稳定定位、校验或归属的 source 默认隔离,不因 GUI 当前能展示就进入永久 Public history。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键不变量
|
||||
|
||||
1. **单一执行事实源**:Runtime durable records 决定执行事实;Shell ledger 只记录 request 协调和 source binding。
|
||||
2. **单一正式 writer**:同一真实副作用和同一 conversation source identity 在任一时刻只有一个正式 writer。
|
||||
3. **服务端能力驱动**:没有 capability 就不能构造命令;有 capability 仍需 Shell 在锁内重读事实并复核。
|
||||
4. **结果未知不重放**:无法证明真实副作用是否发生时进入 outcome-unknown/reconciliation,不换 requestId 重做。
|
||||
5. **Session 不重归属**:历史 command、interaction 和 delivery 依赖已落盘 `agentId + sessionId + runId`,不依赖当前 active Session 猜测。
|
||||
6. **事件不是状态**:事件只提示重新读取 Snapshot;事件丢失、重复或乱序不能改变最终状态。
|
||||
7. **Conversation 不复制正文**:Public index 不成为正文 authority;source 不可回读时失败关闭。
|
||||
8. **Public 与 Developer 隔离**:Developer read capability 不能成为绕过同一 Shell write ingress 的通道。
|
||||
|
||||
完整规范见 Contract 中的 `IC-*` 要求。
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施计划
|
||||
|
||||
### P0:行为基线与协议验证框架
|
||||
|
||||
**人话目标**:在改变系统前,建立可以重复观察当前行为、发现重构破坏的测试入口;不是决定协议,也不实现生产协议。
|
||||
|
||||
允许:
|
||||
|
||||
- 只读 fixture、golden trace、negative fixture;
|
||||
- crash-point harness;
|
||||
- 调用图和 writer inventory;
|
||||
- 对当前 identity、Session、owner、conversation 和 event 行为的代码/运行证据记录。
|
||||
|
||||
禁止:
|
||||
|
||||
- 新生产 handler;
|
||||
- Consumer fallback;
|
||||
- 改变 Runtime 生产行为;
|
||||
- 用空 DTO、stub 或 ignored test 假装协议已实现。
|
||||
|
||||
完成条件:
|
||||
|
||||
- 迁移矩阵中的 P0 inventory 均有证据;
|
||||
- 每条 `EG-*` 能区分“现状满足”“现状必须隔离”“待后续阶段实现”;
|
||||
- 当前 master 行为基线可重复通过。
|
||||
|
||||
### P1:最小持久协议底座
|
||||
|
||||
**人话目标**:实现 Shell 以后需要的请求受理、结果读回和 source binding 基础,但不开放五个 Public 写命令。
|
||||
|
||||
实现:
|
||||
|
||||
- 统一 Contract/schema 单一来源;
|
||||
- 以专用 append-only Shell ledger 为 authority 的 command acceptance/read-back、interaction/rework mapping 与 source-binding record;
|
||||
- 可从 ledger 重建的 projection journal/index;
|
||||
- RFC 8785 canonical fingerprint/checksum、连续 ledgerVersion、内容安全过滤和损坏隔离;
|
||||
- 专用 `.agent/runtime/supervisor-shell/` record namespace(不混入 `agent.db` 或 Runtime journal);
|
||||
- `TrustedProjectContext` resolver、execution-owner guard 内的 Shell lock 与调用来源基础设施。
|
||||
|
||||
P1 的实现边界:Public DTO 始终无路径,但 Shell 只能接收宿主已解析、已核验 manifest 的 trusted project context;现有 `.agent/project.lock` 具有 PID/时间回收语义,不能作为 Shell protocol lock。`.agent/runtime/supervisor-shell/ledger.jsonl` 是唯一追加顺序 authority,sidecar/index 只是可重建缓存。P1 交付的是持久协调底座和 read-back,不把当前 Runner 内存 request cache 当作幂等证据,也不开放 Public 写入口。
|
||||
|
||||
不实现:
|
||||
|
||||
- 新 Public 写入口;
|
||||
- Consumer 迁移;
|
||||
- 第二份 task/finalization/provider/steer 生命周期。
|
||||
|
||||
### P2:Public / Developer Snapshot 与事件流
|
||||
|
||||
**人话目标**:先让 Consumer 能通过一个稳定接口看懂系统,而不改变旧写行为。
|
||||
|
||||
实现:
|
||||
|
||||
- Public Snapshot;
|
||||
- Developer Snapshot;
|
||||
- Snapshot revision/hash;
|
||||
- Public/Developer 隔离的 `(projectId, view)` Snapshot 订阅、`SnapshotChanged` 有界事件和重连;
|
||||
- read-only/shadow projection;
|
||||
- User/Developer interaction view 的只读物化;
|
||||
- 带 source identity/revision/digest witness 与正常缺失/损坏矩阵的有界 projection observation:能确定 project/view scope 而观察无法闭合时,发布无 capability、无未证实 Runtime 事实的 fail-closed invalid/reconciliation Snapshot;完全不能确定安全 outcome 时返回 read error,而不挑一份跨文件旧读结果继续。
|
||||
- 为每项协作执行持久化 opaque `collaborationId` binding;同组多 child、retry successor 与 manifest fallback replacement 不依赖动态 child identity;无 parent run 的静态 fallback 绑定 project/session/manifest digest/group,不能承载可操作 interaction。
|
||||
|
||||
旧 GUI/CLI 仍保留写路径;shadow 只能比较投影,不能执行真实副作用。P2 不能把当前跨 `runtime.json`、JSONL、stream 和 sidecar 的聚合读取结果直接序列化为 Public Snapshot。
|
||||
|
||||
### P3:五命令与统一 Interaction Loop
|
||||
|
||||
**人话目标**:让后端具备完整、真实可用的统一写协议,并将新旧正式入口收进同一 Shell ingress。
|
||||
|
||||
实现:
|
||||
|
||||
- 五个 Public 命令及 strict schema;
|
||||
- requestId 幂等、业务拒绝读回、unknown outcome;
|
||||
- submit intent policy matrix;
|
||||
- answer/approve/cancel/resume 状态机;
|
||||
- Public Conversation read adapter;
|
||||
- legacy ingress 的单 writer 收口。
|
||||
|
||||
P3 不提前迁移 GUI/CLI 的读模型和界面体验,但必须先收口真实 writer:任何仍能操作同一 Supervisor Run 的旧 Tauri/CLI/`swarm_cli` wrapper 都要转发同一 Shell handler(或在新协议启用时明确禁用),不得先在 Consumer 进程写 Runtime 再通知 Runner。P5 只迁移 Consumer 的读与交互体验。
|
||||
|
||||
### P4:Runner 自驱与安全恢复
|
||||
|
||||
**人话目标**:已受理操作不依赖 GUI 轮询推进;Runner 在现有 owner/lifecycle 门禁内完成 wake、恢复和 reconciliation。
|
||||
|
||||
实现:
|
||||
|
||||
- 已接受 operation 的 durable wake/discovery;
|
||||
- dirty projection 修复;
|
||||
- Runner 重启后的安全恢复与跨重启 project discovery registry;
|
||||
- drain、owner 冲突和 GUI-owner/CLI 启动路径的区分;
|
||||
- outcome-unknown 零自动真实副作用重放;
|
||||
- watchdog 强退视为 crash 边界,而非已完成的 drain。
|
||||
|
||||
本阶段不新增 headless lease,也不承诺无人值守常驻。
|
||||
|
||||
### P5:迁移 CLI、Tests、GUI
|
||||
|
||||
**人话目标**:只切换 Consumer,不新增协议语义。
|
||||
|
||||
顺序:
|
||||
|
||||
1. 普通 Supervisor CLI;
|
||||
2. 面向 Public Contract 的测试;
|
||||
3. GUI;
|
||||
4. Developer UI/CLI 的独立 read 边界。
|
||||
|
||||
迁移后:
|
||||
|
||||
- Consumer 只读 Snapshot/Conversation,只提交五命令;
|
||||
- GUI 不再解释 Runtime phase、合成启动决策或 autosave Runtime output;
|
||||
- CLI 不再直接调用 start/steer/resume primitive;
|
||||
- 内部 Runtime 单测和恢复测试仍可直接测试内部能力。
|
||||
|
||||
P5 前产品决策 `FD-001`:`--swarm-chat` 必须明确选择为只读 Public view 的普通 Supervisor CLI,或显式受信任的 Developer CLI;无论选择哪种,其正式写操作均不得绕过 Shell。
|
||||
|
||||
### P6:删除旧公开面并最终收口
|
||||
|
||||
**人话目标**:删除已经没有正式 Consumer 的旧公开控制协议,同时保留 Runtime 内部能力和必要回归测试。
|
||||
|
||||
删除:
|
||||
|
||||
- 正式 transport 的旧 start/steer/confirm/reject/answer/cancel/retry/resume/schedule/read 注册;
|
||||
- Consumer 旧调用点和生命周期分支;
|
||||
- GUI Runtime output 派生 autosave;
|
||||
- migration fallback;
|
||||
- Public scope 内缺少稳定 messageId 的 conversation append。
|
||||
|
||||
保留:
|
||||
|
||||
- Runtime 内部 start/steer/resume/recovery primitive;
|
||||
- 验证内部能力的单元和恢复测试;
|
||||
- 独立管理面 goal/compact/session/config;
|
||||
- 明确隔离的 Developer/local history。
|
||||
|
||||
---
|
||||
|
||||
## 7. 里程碑
|
||||
|
||||
| 里程碑 | 对应阶段 | 产出 | 允许进入下一阶段的条件 |
|
||||
|---|---|---|---|
|
||||
| M0 | P0 | 基线、调用图、证据门禁 | 现状与隔离边界可证明 |
|
||||
| M1 | P1 | 最小持久底座 | 原子性、损坏、幂等基础测试通过 |
|
||||
| M2 | P2 | 双 Snapshot 与事件 | source 缺失/损坏、fail-closed 发布、协作 lineage、订阅重连/缺口、权限和字段隔离通过 |
|
||||
| M3 | P3 | 五命令、Interaction、Conversation read | crash/read-back、单 writer、跨 transport fixture 通过 |
|
||||
| M4 | P4 | Runner wake/recovery | owner、drain、重启、unknown outcome 通过 |
|
||||
| M5 | P5 | CLI/Tests/GUI 迁移 | 三类 Consumer 行为等价且无私有字段依赖 |
|
||||
| M6 | P6 | 旧公开面删除 | 静态调用图和最终协议验收通过 |
|
||||
|
||||
阶段完成条件必须引用 Contract `IC-*`、迁移矩阵 `MX-*` 和证据门禁 `EG-*`;本文不重复字段级验收。
|
||||
|
||||
---
|
||||
|
||||
## 8. 冻结与开发准入
|
||||
|
||||
当前允许:
|
||||
|
||||
- 继续评审和收束四份文档;
|
||||
- 编写不改变生产行为的 P0 基线与 fixture 骨架;
|
||||
- 解决 `FD-001` 产品决策。
|
||||
|
||||
当前不允许:
|
||||
|
||||
- 开始 P1–P6 生产实现;
|
||||
- 因实现方便而修改 Contract 语义;
|
||||
- 根据旧评论恢复 owner generation、Session rotation、handoff 或 Consumer fallback;
|
||||
- 把 P0 证据任务解释为“以后再决定协议规则”。
|
||||
|
||||
允许进入 P1 的前提:
|
||||
|
||||
1. P0/M0 已完成:当前 master 行为基线可重复通过,正式 ingress/read/writer inventory 已形成,且每条后续 `EG-*` 已标记现状、隔离边界和责任阶段;
|
||||
2. Contract 中没有未标注的候选字段、重复定义或互相冲突的 `IC-*`;
|
||||
3. 迁移矩阵覆盖所有正式 ingress、read、conversation writer 和删除面;
|
||||
4. 附录中的冻结前 `EG-*` 有明确预期结果;
|
||||
5. P1 的 trusted project resolver、owner-guard Shell lock、RFC 8785 canonicalization、专用 Shell record namespace 已明确为单一实现边界;
|
||||
6. `FD-001` 可在 P5 开始前决定,不阻塞 Contract 核心冻结或 P0–P4;
|
||||
7. PR #168 完成针对四份文档职责和 Contract 可施工性的重新评审。
|
||||
|
||||
---
|
||||
|
||||
## 9. 最小心智模型
|
||||
|
||||
```text
|
||||
Consumer 只表达意图、读取状态;
|
||||
Shell 统一交互决策和正式写入口;
|
||||
Runtime 继续保存和推进执行事实;
|
||||
Runner 只在持有现有 owner 时写;
|
||||
Snapshot 是状态视图,事件是刷新提示;
|
||||
Conversation 从原 source 回读,不复制正文;
|
||||
不知道副作用结果时停止并 reconciliation,不重复执行。
|
||||
```
|
||||
@@ -0,0 +1,432 @@
|
||||
# AI 游戏创作 Agent Runtime 交互边界证据与决策附录
|
||||
|
||||
> 文档角色:代码事实、设计决策、反例与证据门禁
|
||||
> 状态:持续维护;不能覆盖 Interaction Contract
|
||||
> 规范来源:[`【技术协议】AI游戏创作Agent Runtime交互合同V1-2026-08-17.md`](./【技术协议】AI游戏创作Agent%20Runtime交互合同V1-2026-08-17.md)
|
||||
> 迁移入口:[`【迁移方案】AI游戏创作Agent Runtime交互边界迁移矩阵-2026-08-17.md`](./【迁移方案】AI游戏创作Agent%20Runtime交互边界迁移矩阵-2026-08-17.md)
|
||||
|
||||
## 0. 使用边界
|
||||
|
||||
本附录只保存三类内容:
|
||||
|
||||
- `EV-*`:当前代码已经能直接证明的事实;
|
||||
- `DR-*`:明确采用或拒绝的设计决策及其理由;
|
||||
- `EG-*`:证明实现满足 `IC-*` 的测试/调用图门禁。
|
||||
|
||||
代码变化可能使 `EV-*` 过期;此时必须更新 evidence 和迁移矩阵。不得为了适应过期代码事实而静默放宽 Contract。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前代码事实
|
||||
|
||||
### 1.1 Session 与 conversation
|
||||
|
||||
#### EV-SESSION-001:Session catalog 按 Agent 持久化
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs`
|
||||
- 关键对象/函数:Agent Session catalog、`read_game_creator_agent_session_catalog_at` 一类 catalog 读写函数。
|
||||
- 事实:catalog 保存 `schemaVersion + agentId + activeSessionId + sessions`;不是项目级全 Agent catalog。
|
||||
- 事实:当前没有独立 `sessionRevision`;不能把候选 digest 写回或解释为第二 revision。
|
||||
- 约束:支持 `IC-ID-002`、`IC-ID-003`;对应 `MX-ID-002`、`MX-ID-003`。
|
||||
|
||||
#### EV-SESSION-002:Live task 阻止 Session mutation
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs`
|
||||
- 关键函数:`ensure_agent_session_has_no_live_tasks`、create/fork/set-active/archive Session 路径。
|
||||
- 事实:Agent 有未终结 Runtime task 时,create/fork/archive/set-active 会被拒绝。
|
||||
- 约束:V1 不能用新协议绕过该语义,也不能默认具备 live handoff。
|
||||
|
||||
#### EV-CONV-001:Conversation 支持有 identity 和无 identity append
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/commands.rs`
|
||||
- 关键函数:Tauri command `append_local_conversation_message`。
|
||||
- 事实:`messageId: Option<String>`;有值走 idempotent append,无值走普通 append。
|
||||
- 风险:Public cutover 后无 identity append 会破坏去重、source mapping 和完整性证明。
|
||||
- 约束:支持 `IC-CONV-010`;对应 `MX-CONV-012`~`MX-CONV-014`。
|
||||
|
||||
#### EV-CONV-002:Conversation 正文已有持久 source
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs`
|
||||
- 关键函数:`append_local_conversation_message_for_session_at`、`append_local_conversation_message_for_session_idempotent_at`、带 finalization 的幂等 append。
|
||||
- 事实:已有 project conversation 与 Agent Session conversation;Public adapter 无需复制正文。
|
||||
- 约束:支持 `IC-CONV-002`。
|
||||
|
||||
### 1.2 Project owner、Runner 与 CLI
|
||||
|
||||
#### EV-OWNER-001:OS lock 是当前 project execution owner
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs`
|
||||
- 事实:`.agent/runtime/execution-owner.lock` 通过平台 OS 文件锁实现排他;Windows/Unix 分别有安全打开与文件类型校验。
|
||||
- 约束:支持 `IC-OWNER-001`;拒绝新增平行 owner authority。
|
||||
|
||||
#### EV-OWNER-002:Owner JSON 和 bootId 是诊断信息
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs`、`runner/state.rs`、`runner/protocol.rs`。
|
||||
- 事实:诊断 record 描述 owner/boot,但真正写入排他来自 lock handle。
|
||||
- 风险:按 JSON、mtime、本地时钟或 bootId generation 接管会形成第二 authority。
|
||||
|
||||
#### EV-OWNER-003:现有 project 写锁不是 execution owner
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。
|
||||
- 事实:`.agent/project.lock` 用 create-new 文件和 PID/时间/mtime stale reclaim;它服务现役项目写操作,不由 Runner execution owner guard 定义。
|
||||
- 约束:不能把它直接解释为 `IC-OWNER-002` 的 supervisor project lock;Shell 必须在真实 owner 下另行串行。
|
||||
|
||||
#### EV-OWNER-004:进程内 Runtime 当前只持局部锁
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`runner/state.rs`。
|
||||
- 事实:production execution owner 的获取在 Runner state;未启用 Runner 的恢复主要依赖 Agent task/run lock。
|
||||
- 约束:P1/P3 进程内 Shell 必须在任何 record/projection/Runtime 写之前补同一 OS owner-lock 获取,不能把局部锁当等价 owner。
|
||||
|
||||
#### EV-RUNNER-001:Runner 已有内部 Runtime RPC
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs`
|
||||
- 当前方法包括:`runtime.resume`、`runtime.continue_action`、`runtime.steer`、`runtime.interrupt_for_steer_decision`、`runtime.pause`、`runtime.cancel`、`runtime.compact`、`runtime.wake_pending`。
|
||||
- 事实:这些是现有 Runner 内部控制能力,不能因新 Shell 再作为平行 Public 协议保留。
|
||||
- 约束:支持 `IC-CMD-001`、`IC-CMD-010`;对应 `MX-ING-006`。
|
||||
|
||||
#### EV-CLI-001:CLI 可无 GUI 启动/连接受限 Runner
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/cli.rs` 与 Runner client/server 路径。
|
||||
- 事实:Runtime 写入要求显式项目外 `--config-dir` 并可启动 External Runner;普通 CLI Runner 路径不要求 GUI-owner。
|
||||
- 约束:V1 保留该终端会话能力,不新增 headless lease,也不承诺无人值守常驻。
|
||||
|
||||
#### EV-CLI-002:CLI/`swarm_cli` 当前仍直接调用内部能力
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/cli.rs`、`src-tauri/src/swarm_cli/turn_dispatch.rs`。
|
||||
- 事实:`AgentSteer` 路径调用 `steer_game_creator_agent_runtime_task_at`;`swarm_cli` 可 dispatch Runtime turn 并直接 append user/assistant conversation。
|
||||
- 约束:支持 `MX-ING-004`、`MX-ING-005`、`MX-CONV-002`。
|
||||
|
||||
#### EV-RUNNER-002:request 去重与已知项目均是进程内状态
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/runner/state.rs`、`runner/protocol.rs`、`runner/dispatch.rs`。
|
||||
- 事实:`write_request_cache` 和 `known_roots` 都在 Runner 内存;重启后 cache 无法提供 request result read-back,Runner 也不能仅凭自身发现此前项目。
|
||||
- 约束:P1 durable command record 不能复用该 cache;P4 的自驱恢复必须增加受信任的跨重启候选项目发现,且每个候选仍重取 owner、重读 durable evidence。
|
||||
|
||||
### 1.3 Runtime final reply 与 response stream
|
||||
|
||||
#### EV-FINAL-001:messageId 与 finalizationId 不是同一 identity
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs`
|
||||
- 事实:final reply `messageId` 由 Agent/Session/Run 派生;`finalizationId` 还绑定 response fingerprint、revision、request slot、steer cursor、plan 和 Goal fingerprint。
|
||||
- 约束:支持 `IC-CONV-005`;Public 去重 key 不能反推 finalization。
|
||||
|
||||
#### EV-FINAL-002:Response stream 有独立 tuple
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`
|
||||
- 事实:stream 使用 taskId/sessionId/runId/requestSlot/responseRevision/appliedSteerCursor;`streaming → ready → committed` 会合法改变 status/sequence。
|
||||
- 约束:status/sequence 不能被放入“不可变 source identity digest”。
|
||||
|
||||
#### EV-FINAL-003:Response stream 不能独自证明 committed
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream.rs`
|
||||
- 事实:部分 streaming sidecar 写错误被忽略;publisher 可在 char 上限处截断;dirty 状态不能替代写后回读。
|
||||
- 约束:支持 `IC-IDEMP-004`、`IC-CONV-005`;必须交叉验证 finalization 和 conversation lifecycle。
|
||||
|
||||
#### EV-FINAL-004:成功后 recovery sidecar 会删除
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs` 与 `runtime_protocol/finalization.rs`。
|
||||
- 事实:成功路径完成 conversation assistant、Runtime completed、response committed 后会清理 finalization/provider/tool-plan handoff recovery sidecar。
|
||||
- 约束:历史正文必须从 conversation messageId 回读,不能假定 sidecar 永久存在。
|
||||
|
||||
#### EV-PROJECTION-001:现有 Runtime 聚合读取不是原子观察点
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`、`agent/runtime_state.rs`。
|
||||
- 事实:读取会依次组合 runtime state、task/event JSONL、response stream 和 sidecar;写入跨文件,state rename 与 journal append 各有独立锁/时刻。
|
||||
- 约束:P2 不能直接把该聚合结果包装为 Public Snapshot;必须建立带 identity/revision/digest witness 的 observation,无法闭合即 fail closed。
|
||||
|
||||
### 1.4 RuntimeStatus
|
||||
|
||||
#### EV-STATUS-001:根 Supervisor status 落在 project conversation
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`
|
||||
- 关键函数:`append_game_creator_agent_runtime_public_status_message_at`。
|
||||
- 事实:messageId 由 agentId/sessionId/runId/status correlation 派生,但 append 时 `agent_id=None`、`session_id=None`。
|
||||
- 约束:进入 `(projectId, sessionId)` Public history 前必须显式保存 correlation mapping,不能从文件 scope 猜 Session。
|
||||
|
||||
#### EV-STATUS-002:专业 Agent terminal status 落在其 Session conversation
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`
|
||||
- 关键函数:`append_game_creator_agent_runtime_terminal_public_message_at`。
|
||||
- 事实:非根 Supervisor 的 terminal status 使用其 `agentId/sessionId` 幂等 append。
|
||||
- 约束:历史消息保持原 agent/session/run,不重归属到 Supervisor 当前 Session。
|
||||
|
||||
#### EV-STATUS-003:部分 Supervisor continuation 不写 Session status
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`。
|
||||
- 事实:有 parent agent/run 的 Supervisor receipt 或 isolated-join continuation 为避免重复 formal project chat,terminal path直接返回,不写第二 Session message。
|
||||
- 约束:这类 status 默认不进入 Public Conversation;不能假设每个 Run 有同构 status source。
|
||||
|
||||
#### EV-STATUS-004:Accepted start status 只覆盖特定根 Supervisor
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs`。
|
||||
- 事实:`requires_public_start_status` 只对无 parent 的根 Project Supervisor 且非 receipt/join source 生效。
|
||||
- 约束:P0 必须按类型而非泛化 “RuntimeStatus” 建 fixture。
|
||||
|
||||
### 1.5 Runtime event
|
||||
|
||||
#### EV-EVENT-001:普通 eventId 不可恢复
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`
|
||||
- 关键函数:`new_game_creator_agent_runtime_event_id`。
|
||||
- 事实:无 actionId 时使用 `pid + unixMillis + process-local sequence + eventType`;重启/重试没有稳定规范 key。
|
||||
- 约束:支持 `IC-CONV-007` 默认拒绝。
|
||||
|
||||
#### EV-EVENT-002:带 actionId 的 event 只覆盖部分路径
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`
|
||||
- 关键函数:`append_game_creator_agent_runtime_event_with_action`。
|
||||
- 事实:有 actionId 时可按 run/eventType/phase/actionId 形成较稳定 identity 和重复检查;普通 append 仍生成新 eventId。
|
||||
- 约束:即使 action event 较稳定,也必须同时满足 reader、digest、scope 和重放条件才能显式登记 Public。
|
||||
|
||||
#### EV-EVENT-003:现有 reader 吞坏行并截断最近 20 条
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`、`src-tauri/src/main.rs`。
|
||||
- 关键函数/常量:`read_recent_game_creator_agent_runtime_events_for_session`、`AGENT_RUNTIME_RECENT_EVENT_LIMIT = 20`。
|
||||
- 事实:JSON 解析失败直接跳过;成功记录只返回最后 20 条。
|
||||
- 约束:该 reader 只能支持当前 GUI recent display,不能作为 `IC-CONV-007` 的 Public source reader。
|
||||
|
||||
#### EV-EVENT-004:Event record 自带公开正文
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`。
|
||||
- 事实:allowlist event 写入 `publicText`;正文不一定存在于 conversation message。
|
||||
- 约束:若未来接入,PublicEvent 是 source-record projection 例外,不能复制成普通 assistant conversation。
|
||||
|
||||
### 1.6 GUI writer 与跨 scope 落盘
|
||||
|
||||
#### EV-GUI-001:GUI 为 Runtime event 生成第二 identity
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx`。
|
||||
- 事实:GUI 以 `game-chat-runtime-event:${eventId}` 构造聊天 message,并同时聚合 Supervisor 和直接 child events。
|
||||
- 约束:该 identity 不能进入规范 Public history。
|
||||
|
||||
#### EV-GUI-002:GUI 为 final reply 生成派生 identity
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx`。
|
||||
- 事实:GUI 使用 `game-chat-final-reply:*` 构造 final 聊天 message。
|
||||
- 约束:应归一到现有 finalization conversation `messageId`。
|
||||
|
||||
#### EV-GUI-003:派生消息会 autosave 到 project conversation
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src/App.tsx`。
|
||||
- 事实:全局 messages autosave 可用 `agentId=null` 调用 `append_local_conversation_message`;child event 的 source Session 与实际 project transcript scope 不同。
|
||||
- 约束:支持 `MX-CONV-010`、`MX-CONV-011`;历史跨 scope 项默认隔离。
|
||||
|
||||
#### EV-GUI-004:普通 Agent chat 直接 append
|
||||
|
||||
- 代码:`apps/ai-game-creator-shell/src/App.tsx`、`features/app-shell/useDeveloperAgentPanel.ts`。
|
||||
- 事实:普通 Agent user/assistant、错误回复和 Developer panel user message 存在直接 append 调用。
|
||||
- 约束:writer cutover 必须覆盖全部调用方,而不是只覆盖 Runtime output 双写。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
### DR-001:采用统一 Supervisor Shell
|
||||
|
||||
- 决定:正式 Consumer 只读状态、表达意图;Shell 统一交互决策。
|
||||
- 原因:GUI/CLI/测试当前存在重复且不一致的生命周期判断。
|
||||
- Contract:`IC-ARC-001`~`IC-ARC-004`。
|
||||
|
||||
### DR-002:拒绝平行 Runtime authority
|
||||
|
||||
- 拒绝:让 request ledger、projection ledger 或 Public Snapshot 自己决定 task/finalization/provider 成功。
|
||||
- 原因:现有 Runtime facts 跨多个 record,当前没有可复用的全局事务;平行状态会漂移。
|
||||
- Contract:`IC-ARC-004`、`IC-IDEMP-003`、`IC-READ-004`。
|
||||
|
||||
### DR-003:拒绝 Session rotation 与 handoff
|
||||
|
||||
- 拒绝:ActiveSessionIndex、live Session rotation、handoff manifest、continuation set、rotation fence、session control lease。
|
||||
- 原因:现有 catalog 明确禁止 live task 时切换;新增能力需要跨 task/conversation/finalization/interaction 的迁移和 rollback authority,超出本轮交互边界重构。
|
||||
- Contract:`IC-ID-002`、`IC-ID-003`。
|
||||
|
||||
### DR-004:拒绝全局 owner generation/lease
|
||||
|
||||
- 拒绝:用 boot generation、诊断 JSON、lease expiry 或本地时间替代 OS lock。
|
||||
- 原因:会建立第二 owner authority,并在 pause/时钟漂移/文件残留时产生双 writer。
|
||||
- Contract:`IC-OWNER-001`。
|
||||
|
||||
### DR-005:采用 Snapshot + 有范围的事件提示
|
||||
|
||||
- 决定:Snapshot 是完整 Public read;event 只提示重新读取。Public 与 Developer 事件流按 `(projectId, view)` 隔离,订阅原子取得完整初始 Snapshot。
|
||||
- 原因:Consumer 本地合并不能可靠处理缺口、重连和跨 source 更新;全局或按路径过滤的 event 会泄露/混淆多项目状态。
|
||||
- Contract:`IC-READ-001`、`IC-READ-004`、`IC-EVT-001`~`IC-EVT-003`。
|
||||
|
||||
### DR-005A:fail-closed 也是可发布状态
|
||||
|
||||
- 决定:source observation 无法闭合但仍能确定 project/view scope 时,发布无 capability、无未证实 Runtime 事实的 `failClosed` Snapshot;完全不能确定安全 outcome 时返回 read error。
|
||||
- 原因:若 invalid 状态不推进 revision/hash/event,Consumer 会永久保留一份已失效的 valid Snapshot;保留旧 capability 会绕过失败关闭。
|
||||
- Contract:`IC-READ-001`、`IC-READ-004`、`IC-CAP-002`。
|
||||
|
||||
### DR-005B:协作实体有独立持久 identity
|
||||
|
||||
- 决定:Public collaborator 使用 durable `collaborationId` binding,不从 group 或动态 child Agent identity 临时拼接;retry/successor 延续该 ID,manifest fallback 被 Runtime binding 替换。
|
||||
- 原因:同组多 child、重试与 isolated 执行都不能由单一 group/agentId 稳定代表,且 Public 不得泄露真实 child identity。
|
||||
- Contract:`IC-ID-003`、`IC-READ-001`、`IC-INT-002`。
|
||||
|
||||
### DR-006:采用五命令,不公开内部 primitive
|
||||
|
||||
- 决定:`submit_intent/answer/approve/cancel/resume` 是唯一 Public 写集合。
|
||||
- 原因:Consumer 表达用户意图,不选择 Runtime primitive。
|
||||
- Contract:`IC-CMD-001`~`IC-CMD-010`。
|
||||
|
||||
### DR-007:Conversation 不复制正文
|
||||
|
||||
- 决定:Public 只建无正文 source index,从既有 conversation/event source 回读。
|
||||
- 原因:复制会建立第二正文 authority,并放大 GUI/CLI 双写。
|
||||
- Contract:`IC-CONV-002`。
|
||||
|
||||
### DR-008:PublicEvent 默认拒绝
|
||||
|
||||
- 决定:普通现有 event 不进入永久 Public history;只有显式登记且满足全部 identity/reader/digest/scope 条件的类型才可接入。
|
||||
- 原因:现有普通 eventId 不可恢复,reader 截断且吞坏行,GUI 还会再造 identity。
|
||||
- Contract:`IC-CONV-007`。
|
||||
|
||||
### DR-009:RuntimeStatus 按具体 source 准入
|
||||
|
||||
- 决定:不把 RuntimeStatus 泛化为所有 Run 的同构 conversation source。
|
||||
- 原因:根 Supervisor、专业 Agent 和 receipt/join 的实际落盘行为不同。
|
||||
- Contract:`IC-CONV-006`。
|
||||
|
||||
### DR-010:结果未知时停止而非重放
|
||||
|
||||
- 决定:Provider/工具/Runtime 副作用可能发生但不可证明时进入 outcome-unknown/reconciliation。
|
||||
- 原因:换 requestId 或 fallback 会产生重复真实副作用。
|
||||
- Contract:`IC-IDEMP-004`、`IC-MIG-002`。
|
||||
|
||||
### DR-011:保留 CLI 无 GUI 的受限 Runner 能力
|
||||
|
||||
- 决定:不以 GUI-owner 门禁删除现有 CLI 会话期间启动/连接 Runner 的能力。
|
||||
- 原因:普通 CLI 是协议平等 Consumer,headless 能力是前端逻辑是否泄漏的重要验收。
|
||||
- Contract:`IC-OWNER-001`。
|
||||
|
||||
### DR-012:`--swarm-chat` 产品定位仍需显式决定
|
||||
|
||||
- 决策 ID:`FD-001`。
|
||||
- 可选:普通 Public Supervisor CLI;或显式受信任 Developer CLI。
|
||||
- 不可选:无 capability 时静默读取私有字段;Developer write 绕过 Shell。
|
||||
- Contract 不变量:`IC-MIG-004`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 冻结前证据门禁
|
||||
|
||||
同一个 `EG-*` 跨多个阶段时,状态按 `EG-ID@P阶段` 独立记录:某阶段 PR 只需关闭属于该阶段的子门禁,后续阶段的未实现证据不阻塞前一阶段完成;最终门禁只有在全部子门禁关闭后才整体完成。后续实现若推翻已关闭证据,必须重新打开对应子门禁。下文“阶段”按顺序对应各阶段必须提供的证据,不得以一个阶段的局部通过冒充整项关闭。
|
||||
|
||||
### EG-BASE-001:当前行为基线
|
||||
|
||||
- 对应:全部 P0。
|
||||
- 要求:确定性 Provider/进程内 Runtime 记录 submit、等待、批准、取消、恢复、终态与副作用计数;归一化随机 ID/时间。
|
||||
- 失败处理:阻塞迁移比较,不改变 Contract。
|
||||
|
||||
### EG-SCHEMA-001:Strict wire fixture
|
||||
|
||||
- 对应:`IC-WIRE-001`、`IC-WIRE-002`。
|
||||
- 要求:`@P0` 定义全部 DTO 的 schema/golden/negative 向量和预期结果;`@P1` 实现 Rust→TypeScript 生成/校验,覆盖未知/重复/错误字段、tagged union、大小、Unicode scalar/UTF-8 byte、Public 零路径/私有字段。
|
||||
- 阶段:P0(规范向量)/P1(实现与通过);`@P1` 阻塞 P2/P3。
|
||||
|
||||
### EG-ID-001:身份与 Session
|
||||
|
||||
- 对应:`IC-ID-001~005`。
|
||||
- 要求:projectId/path mismatch;opaque catalog digest;live-task create/fork/archive/set-active 拒绝;Supervisor 与 collaborator 交叉 Session mutation;历史 delivery 不重归属。
|
||||
- 阶段:P0/P2/P3。
|
||||
|
||||
### EG-OWNER-001:Owner 与执行位置
|
||||
|
||||
- 对应:`IC-OWNER-001~002`、`IC-CMD-010`。
|
||||
- 要求:External Runner 下所有正式 writer 实际在 owner Runner;进程内测试持等价 owner;双 Runner、drain、失锁、GUI-owner 丢失、CLI 启动回归。
|
||||
- 阶段:P0/P3/P4/P5。
|
||||
|
||||
### EG-STORE-001:Shell durable 底座
|
||||
|
||||
- 对应:`IC-ID-001`、`IC-OWNER-001~002`、`IC-DUR-001~003`、`IC-IDEMP-001~005`。
|
||||
- 要求:trusted project resolver 的缺失/manifest mismatch;进程内与 Runner 对同一 project owner 互斥;`.agent/project.lock` stale reclaim 不参与 Shell 互斥;RFC 8785 向量;专用 Shell ledger 的连续 ledgerVersion、tail repair/中间损坏隔离、atomic write、回读、checksum、派生 index 重建与 Runner crash 后 read-back。
|
||||
- 阶段:P1;阻塞 P2/P3。
|
||||
|
||||
### EG-PROJECTION-001:Projection observation
|
||||
|
||||
- 对应:`IC-READ-001~004`、`IC-EVT-001~003`、`IC-CAP-001~003`。
|
||||
- 要求:读 manifest/catalog/state/task/event/stream/Shell binding/projection journal 期间并发变化;source witness 变化的有界 retry;验证 normal/required absence、损坏和 conflict;验证 failClosed 完整固定向量及 manifest identity 不可证明时 read error;逐类 capability issuance/撤销;首次 revision1/sequence0。
|
||||
- 阶段:P2;阻塞 P2 完成与 P3 capability 依赖。
|
||||
|
||||
### EG-CMD-001:Request 幂等与崩溃读回
|
||||
|
||||
- 对应:`IC-IDEMP-001~005`、`IC-CMD-001~010`。
|
||||
- 要求:同 request 同/异 fingerprint、并发重复、业务拒绝重放、prepared/executing/succeeded 各 crash point、Runner 强杀、unknown outcome 零重复副作用。
|
||||
- 阶段:P1/P3/P4。
|
||||
|
||||
### EG-INT-001:Interaction 状态机
|
||||
|
||||
- 对应:`IC-INT-001~007`。
|
||||
- 要求:identity/revision/response replay;Public interaction capability 与 allowedActions 精确一致;User/Developer audience;question/option/freeform;target set;artifact digest;requestChanges 唯一 rework;Resolving crash recovery;collaboration binding 的 restart 重建、retry successor/parent lineage/source/group 漂移旧 interaction stale、manifestFallback 不可操作。
|
||||
- 阶段:P2/P3。
|
||||
|
||||
### EG-READ-001:Snapshot 与事件
|
||||
|
||||
- 对应:`IC-READ-001~004`、`IC-EVT-001~003`、`IC-CAP-001~003`。
|
||||
- 要求:Public/Developer `(projectId, view)` 路由隔离、原子 initial Snapshot、snapshot-first/no-backlog、duplicate/out-of-order/gap/reconnect;Public failClosed 固定向量、Developer source failure 统一 read error;revision/hash/排序/size canonical vectors,Public/Developer 超限均返回固定 read error 且无 partial DTO;全部 capability 正反签发与 cancel run revision/builtin Session/retry policy guards;bootstrap collaborators 为空、parentRun multi-child、retry lineage、fallback replacement、stale cancel 复核。
|
||||
- 阶段:P2。
|
||||
|
||||
### EG-CONV-001:Final reply source
|
||||
|
||||
- 对应:`IC-CONV-005`。
|
||||
- 要求:messageId/finalizationId/stream tuple 唯一性;streaming→ready→committed;publisher 写失败/截断;sidecar 清理后 conversation 回读;Provider 调用计数。
|
||||
- 阶段:P0/P3/P4。
|
||||
|
||||
### EG-CONV-002:Status source
|
||||
|
||||
- 对应:`IC-CONV-006`。
|
||||
- 要求:根 Supervisor start/terminal、专业 Agent terminal、receipt/isolated join 无 source 三类分别验证;project status correlation 缺失/冲突失败关闭。
|
||||
- 阶段:P0/P3。
|
||||
|
||||
### EG-CONV-003:Event 默认隔离与准入
|
||||
|
||||
- 对应:`IC-CONV-007`。
|
||||
- 要求:枚举规范 action identity 与普通 pid/time identity call site;普通 event 未进入 Public chain;如接入某类型,必须通过 eventId 定位、坏行/截断报告、digest、scope 和重放 fixture。
|
||||
- 阶段:P0/P1/P3;不通过只阻塞该 event type 接入,不阻塞默认隔离方案。
|
||||
|
||||
### EG-CONV-004:Writer cutover
|
||||
|
||||
- 对应:`IC-CONV-010`。
|
||||
- 要求:全部 `append_local_conversation_message` 调用方三选一;GUI final/event autosave 停止;同 source 单 writer;Public scope 无缺少 messageId append。
|
||||
- 阶段:P0/P3/P5/P6。
|
||||
|
||||
### EG-CONV-005:Cursor 与历史完整性
|
||||
|
||||
- 对应:`IC-CONV-008`、`IC-CONV-009`。
|
||||
- 要求:origin/tail、分页、永久 sequence 空洞、duplicate、非法 cursor、index/source/digest/correlation 损坏、无 partial page、session-lifetime cursor。
|
||||
- 阶段:P3/P5/P6。
|
||||
|
||||
### EG-MIG-001:跨 Consumer golden replay
|
||||
|
||||
- 对应:`IC-ARC-001`、`IC-MIG-001~004`。
|
||||
- 要求:GUI、普通 CLI、进程内测试和 Runner transport 对同一输入产生等价 request/result/Snapshot/conversation 语义和副作用计数。
|
||||
- 阶段:P3/P5/P6。
|
||||
|
||||
|
||||
### EG-INGRESS-001:P3 正式写入口与 writer cutover
|
||||
|
||||
- 对应:`IC-CMD-010`、`IC-CONV-010`、`IC-MIG-005`。
|
||||
- 要求:legacy Tauri/CLI/`swarm_cli`/helper 的正式写入口全部转发同一 Shell handler 或禁用;Public Conversation adapter 启用前所有 writer 已接管、隔离或停止;cutover watermark 后零旧正式 writer、零派生双写。
|
||||
- 阶段:P3;阻塞 P3 完成与 P5 Consumer 迁移。
|
||||
|
||||
### EG-RUNNER-001:P4 跨重启发现与安全恢复
|
||||
|
||||
- 对应:`IC-OWNER-001~002`、`IC-IDEMP-004`、`IC-MIG-006`。
|
||||
- 要求:trusted discovery registry 的注册/删除/损坏/权限 fixture;Runner 重启后重新解析 manifest、重取 owner、按 durable evidence wake/reconcile/no-op;未知真实副作用零自动重放。
|
||||
- 阶段:P4;阻塞 P4 完成。
|
||||
|
||||
### EG-DEL-001:旧公开面删除
|
||||
|
||||
- 对应:`IC-MIG-003`。
|
||||
- 要求:正式 invoke handler、transport、Consumer、Public DTO 不再引用旧协议;内部 primitive 与回归测试仍存在;Preview/resource/session 管理面未误删。
|
||||
- 阶段:P6。
|
||||
|
||||
---
|
||||
|
||||
## 4. 证据更新规则
|
||||
|
||||
1. `EV-*` 只能由代码读取、定向测试或运行证据支持;README/旧设计声明不能单独成为事实。
|
||||
2. 代码与 `EV-*` 冲突时先更新 evidence 和迁移矩阵;若冲突使 `IC-*` 不可实现,再提交 Contract 变更评审。
|
||||
3. `EG-*` 失败的默认处理是隔离、阻塞阶段或进入 reconciliation,不是增加 Consumer fallback。
|
||||
4. 每个 P0–P6 PR 必须列出所实现的 `IC-*`、受影响 `MX-*` 和关闭的 `EG-ID@P阶段`;不得把跨阶段门禁标记为提前整体完成。
|
||||
5. 本附录不保存密钥、Token、绝对本地私密路径、Provider 原文、会话记录或构建产物。
|
||||
@@ -0,0 +1,161 @@
|
||||
# AI 游戏创作 Agent Runtime 交互边界迁移矩阵
|
||||
|
||||
> 文档角色:把 Interaction Contract 映射到当前代码、阶段和验收证据
|
||||
> 状态:P0 inventory;矩阵不得修改 `IC-*` 语义
|
||||
> 总览入口:[`【技术方案】AI游戏创作Agent Runtime交互边界重构实施计划-2026-08-12.md`](./【技术方案】AI游戏创作Agent%20Runtime交互边界重构实施计划-2026-08-12.md)
|
||||
> 规范来源:[`【技术协议】AI游戏创作Agent Runtime交互合同V1-2026-08-17.md`](./【技术协议】AI游戏创作Agent%20Runtime交互合同V1-2026-08-17.md)
|
||||
> 证据来源:[`【设计依据】AI游戏创作Agent Runtime交互边界证据与决策附录-2026-08-17.md`](./【设计依据】AI游戏创作Agent%20Runtime交互边界证据与决策附录-2026-08-17.md)
|
||||
|
||||
## 0. 使用规则
|
||||
|
||||
每一行包含:
|
||||
|
||||
```text
|
||||
当前 source/入口
|
||||
→ 适用 IC 规则
|
||||
→ 当前差距
|
||||
→ 唯一迁移动作
|
||||
→ 阶段
|
||||
→ 完成证据
|
||||
```
|
||||
|
||||
状态值:
|
||||
|
||||
- `baseline`:现状能力,尚未迁移;
|
||||
- `isolate`:不满足 Public Contract,默认隔离;
|
||||
- `adapt`:复用现有事实并通过 Shell Adapter 接入;
|
||||
- `replace-consumer`:后端能力就绪后替换 Consumer;
|
||||
- `remove-public`:P6 删除公开注册/调用;
|
||||
- `decision`:需要显式产品决定,但不得改变 Contract。
|
||||
|
||||
---
|
||||
|
||||
## 1. 正式 ingress 与执行位置
|
||||
|
||||
| MX ID | 当前入口/source | 当前事实 | Contract | 目标动作 | 阶段 | 状态/证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| MX-ING-001 | GUI Supervisor chat,`apps/ai-game-creator-shell/src/App.tsx` 与 `SupervisorChatOnlyView.tsx` | GUI 仍参与 start/steer、状态合并和输出同步 | `IC-ARC-002`、`IC-CMD-003`、`IC-CMD-004` | GUI 只提交 capability 中的 `submit_intent`,不选择 disposition | P5 | `replace-consumer`;GUI 调用图无 Runtime primitive |
|
||||
| MX-ING-002 | 普通 Agent chat,`apps/ai-game-creator-shell/src/App.tsx` | user/assistant 直接 append,并调用内部 Agent 能力 | `IC-CMD-010`、`IC-CONV-010`、`IC-MIG-005` | P0 归类;正式路径在 P3 接 Shell、开发路径在 P3 隔离;P5 只清理旧 Consumer 分支 | P0/P3/P5 | 全调用图、ingress cutover 与 UI 清理 |
|
||||
| MX-ING-003 | Tauri commands,`apps/ai-game-creator-shell/src-tauri/src/commands.rs` | 暴露旧 Runtime 和 conversation write wrapper | `IC-OWNER-001`、`IC-CMD-010`、`IC-MIG-005` | P3 先转发同一 Shell endpoint;旧注册 P6 删除 | P3/P6 | transport fixture + invoke handler 静态检查 |
|
||||
| MX-ING-004 | CLI commands,`apps/ai-game-creator-shell/src-tauri/src/cli.rs` | `AgentSteer` 等路径直接调用 Runtime primitive;CLI 可启动受限 Runner | `IC-CMD-001`、`IC-CMD-010`、`IC-MIG-005` | P3 先收口为 Shell transport;P5 再迁 Public CLI read/UX;保留无 GUI 启动 Runner 能力 | P3/P5 | CLI golden replay;无直接 start/steer/resume |
|
||||
| MX-ING-005 | `--swarm-chat`,`cli.rs` 与 `swarm_cli/turn_dispatch.rs` | 读取专业 Agent 状态并直接 dispatch/append | `IC-ARC-005`、`IC-CMD-010`、`IC-MIG-004`、`IC-MIG-005` | P3 先让正式写走 Shell 或禁用;P5 按 `FD-001` 选择 Public/Developer read 呈现 | P3/P5 | Shell writer fixture;`decision`;Public/Developer DTO 零交叉 |
|
||||
| MX-ING-006 | Runner `runtime.*` RPC,`src-tauri/src/runner/dispatch.rs` | 已有 resume/steer/cancel/pause/compact 等内部 RPC,request cache 仅内存 | `IC-CMD-001`、`IC-CMD-010`、`IC-IDEMP-001~005` | 仅作为 Shell 内部实现/委托 Shell;不得把 cache 当 durable read-back | P1/P3/P6 | crash 后同 requestId read-back + dispatch 调用图 |
|
||||
| MX-ING-007 | 进程内测试 transport | 可绕过 External Runner 直接调用实现 | `IC-OWNER-001`、`IC-CMD-010` | 复用同一 Shell handler,并在任何 Shell/Runtime 写前取得同一 OS owner lock;per-Agent lock 不等价 | P1/P3 | 同 fixture 跨 Runner/进程内 replay |
|
||||
| MX-ING-008 | Runtime 内部 wake/recovery | timer/lane/schedule/owner recovery 不属于用户意图;Runner known roots 当前仅在内存 | `IC-CMD-009`、`IC-ARC-004`、`IC-MIG-006` | 保持内部 recovery intent,不导出为 Public resume;P4 建跨重启候选项目发现 | P4/P6 | 静态 Public DTO 检查、重启 discovery 与恢复测试 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Read model 与 Consumer 决策
|
||||
|
||||
| MX ID | 当前 read/source | 当前事实 | Contract | 目标动作 | 阶段 | 状态/证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| MX-READ-001 | manifest/Session catalog/Runtime state/task/Shell binding 读取 | GUI/CLI 分别解释 status/phase;缺少完整 project/session/binding witness | `IC-READ-001`、`IC-READ-004`、`IC-ARC-002` | Shell 按完整 source dependency matrix 形成 valid/failClosed/read-error,稳定投影 status/stage/waitingOn/nextStep | P1/P2/P5 | manifest/catalog/binding/journal、bootstrap、active-task 缺失、损坏、跨 Consumer Snapshot fixture |
|
||||
| MX-READ-002 | pending action、user-input、tool confirmation sidecar | 当前由不同 UI/CLI 分流;无 open interaction 可缺失 | `IC-READ-004`、`IC-CAP-003`、`IC-INT-001`~`IC-INT-007` | P2 只读物化稳定 Interaction,并生成与 interactionId/revision 一致的 response capability;required sidecar 缺失/损坏则 fail-closed,P3 接管 answer/approve | P2/P3 | identity/revision/audience/capability/required-source fixture |
|
||||
| MX-READ-003 | response stream | 是 optional Runtime final-reply 实时/恢复辅助,写错误可能被忽略 | `IC-READ-004`、`IC-CONV-005`、`IC-IDEMP-004` | 只作为短期 source evidence;不能单独证明 committed;完成证明依赖它时损坏/缺失 fail-closed | P0/P2/P3 | optional、写失败、截断、ready→committed fixture |
|
||||
| MX-READ-004 | GUI Runtime state/event merge | Consumer 自行拼接多个 source | `IC-READ-001`、`IC-EVT-001` | P5 删除 normalize/merge 决策,只渲染 Snapshot | P5 | 前端类型/调用图检查 |
|
||||
| MX-READ-005 | Tauri best-effort update event | 不提供按 project/view scope 可靠补读历史 | `IC-EVT-001`~`IC-EVT-003` | 替换为按 `(projectId, view)` 路由的 snapshot-first subscription;V1 不补历史 event,缺口/重连均重读完整 Snapshot | P2 | 首次 revision1/sequence0、Public/Developer 隔离、重复/乱序/缺口/重连 fixture |
|
||||
| MX-READ-006 | Developer Agent panel | 可读私有 Agent 状态并直接操作 | `IC-ARC-005`、`IC-READ-003`、`IC-MIG-005` | P2 建独立 Developer DTO;正式 Supervisor 写在 P3 走五命令,Developer-local 写在 P3 隔离;P5 只清理旧 UI 分支 | P2/P3/P5 | 未授权拒绝、ingress cutover、Public 字段零泄漏 |
|
||||
| MX-READ-007 | Preview/resource/session 管理面 | 独立现役合同 | `IC-ARC-002`、`IC-MIG-003` | 保持 sibling contract,不从 Snapshot nextStep 重造 | P5/P6 | 调用图证明未误删 |
|
||||
| MX-READ-008 | command capability 投影 | 当前 Consumer 由 status/phase 自行判断按钮 | `IC-CAP-001~003`、`IC-READ-004` | 按 Contract issuance matrix 从完整 witness 必签/撤销 submit、interaction、cancel、resume 与 Developer reconcile capability | P2/P3 | 每类 capability 正反状态、witness 漂移、Public/Developer audience fixture |
|
||||
|
||||
---
|
||||
|
||||
## 3. 身份、Session 与 owner
|
||||
|
||||
| MX ID | 当前 source | 当前事实 | Contract | 目标动作 | 阶段 | 状态/证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| MX-ID-001 | 项目 manifest/路径 | manifest 有 project identity;transport 大量使用绝对路径定位,尚无 projectId 反向 root registry | `IC-ID-001` | Public 只传 projectId;宿主 resolver 产出并复核 `TrustedProjectContext`,locator 留在受信任边界 | P1/P2/P3 | path-free schema + resolver/subscription mismatch fixture |
|
||||
| MX-ID-002 | Session catalog,`project/conversation.rs` | 每 Agent 一份 catalog;无独立 revision;live task 禁止变更 | `IC-ID-002`、`IC-ID-003` | 对 Project Supervisor catalog 计算 opaque digest;不写回、不扩权 | P0/P2/P3 | live-task 与跨 Agent Session fixture |
|
||||
| MX-ID-003 | collaborator/child history | 各自具有 agentId/sessionId/runId,但无稳定公开协作实体 | `IC-ID-003` | 首次 binding durable 分配 collaborationId;retry/successor 保持 ID,fallback 被 Runtime binding 替换;无 parent run 的 fallback 绑定 project/session/manifest digest/group;不使用 Supervisor 当前 Session 重新归属 | P2/P3 | parentRun multi-child、retry lineage、fallback replacement、交叉 mutation fixture |
|
||||
| MX-OWNER-001 | `.agent/runtime/execution-owner.lock` | OS 排他锁是真正 owner;现仅 Runner production path 获取 | `IC-OWNER-001`、`IC-OWNER-002` | 直接复用;进程内 Shell 也必须在写前取得同一实现,不新增 generation/lease | P0/P1/P4 | 双 Runner、进程内冲突、drain、失锁测试 |
|
||||
| MX-OWNER-002 | `.agent/project.lock` | create-new 文件锁按 PID/时间/mtime reclaim,不是 owner | `IC-OWNER-002` | 不可作为 Shell protocol lock;保留其现役业务用途 | P1 | stale reclaim 与 Shell lock 分离 fixture |
|
||||
| MX-OWNER-003 | `execution-owner.json` 与 bootId | 仅诊断/实例关联 | `IC-OWNER-001` | 保持私有诊断,不用于接管/CAS | P0/P4 | 时间/mtime/诊断冲突 negative fixture |
|
||||
| MX-OWNER-004 | GUI-owner watchdog | GUI 启动 Runner 时的生命周期门禁 | `IC-OWNER-001` | 保留 GUI-owner 路径;不扩张为 CLI control lease | P4 | GUI-owner 丢失与 drain fixture |
|
||||
| MX-OWNER-005 | CLI `--config-dir` Runner | 当前可无 GUI 启动/连接受限 Runner | `IC-OWNER-001` | 保留现有终端会话能力;不承诺常驻 | P4/P5 | CLI Runner 回归 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Command 与现有 Runtime identity
|
||||
|
||||
| MX ID | Public command | 现有内部能力/source | Contract | Adapter 要求 | 阶段 | 证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| MX-CMD-001 | `submit_intent` DirectReply | CLI Reply/Execute kernel、conversation append | `IC-CMD-003`~`IC-CMD-005`、`IC-CONV-004` | 预分配 user/assistant messageId,先 user commit 再 reply | P3 | crash-point + same request replay |
|
||||
| MX-CMD-002 | `submit_intent` Start | Runtime start/pending/task/status | `IC-CMD-004`、`IC-CMD-005` | 绑定 input envelope、现有 task/run/status identity | P3 | user→status→queued crash fixture |
|
||||
| MX-CMD-003 | `submit_intent` Steer | 现有 V1.13 steer ledger | `IC-CMD-004`、`IC-CMD-005` | prepared 时绑定 steerId/cursor;不复制 steer 生命周期 | P3 | same-run、重复和 deferred fixture |
|
||||
| MX-CMD-004 | `answer` | user-input sidecar/answer primitive | `IC-CMD-006`、`IC-INT-001~007` | 物化稳定 interaction,按 response/revision 解决 | P2/P3 | option/freeform/stale/replay fixture |
|
||||
| MX-CMD-005 | `approve` | tool/policy confirm 与 reject primitive | `IC-CMD-007`、`IC-INT-001~007` | audience/policy/target set/artifact binding 锁内复核 | P2/P3 | approve/reject/requestChanges matrix |
|
||||
| MX-CMD-006 | `cancel` | Runtime cancel primitive | `IC-CMD-008` | 精确 Session/Run/revision;唯一 cancel operation;不伪造终态 | P3 | cancel revision/state matrix |
|
||||
| MX-CMD-007 | `resume` ContinueRun | paused Run resume | `IC-CMD-009` | 同 Run + expected revision | P3 | paused/running/waiting/finalizing negative fixture |
|
||||
| MX-CMD-008 | `resume` RetryTerminalRun | terminal retry/successor lineage | `IC-CMD-009` | 唯一 successor runId;保存 predecessor/source identity;绑定 terminal revision 与 retry policy digest | P3 | policy drift、concurrent retry + crash fixture |
|
||||
| MX-CMD-009 | `resume` ReconcileRun | 受信任 reconciliation | `IC-CMD-009`、`IC-IDEMP-004` | 只读/修复已知事实,不重放未知副作用 | P3/P4 | Developer capability + provider/tool count |
|
||||
| MX-CMD-010 | Goal replacement | 现有 replacement primitive | `IC-CMD-004` | 仅显式 Goal management operation;不由普通 execute intent 触发 | P3 | frozen Goal Contract negative fixture |
|
||||
|
||||
---
|
||||
|
||||
## 5. Conversation source 与 writer cutover
|
||||
|
||||
| MX ID | Source/writer | 当前事实 | Contract | 目标动作 | 阶段 | 状态/证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| MX-CONV-001 | Session user message | 现有 conversation 正文 source | `IC-CONV-002`、`IC-CONV-004` | 复用正文;Public index 只保存 source metadata | P3 | source digest/read-back fixture |
|
||||
| MX-CONV-002 | DirectReply | `swarm_cli`/GUI 可直接 append user+assistant | `IC-CONV-004`、`IC-CONV-010` | Shell 接管稳定 identity 和写入顺序 | P3/P5 | 零重复 user/assistant |
|
||||
| MX-CONV-003 | RuntimeFinalReply | finalization + response stream + conversation | `IC-CONV-005` | 保存三层 identity binding;长期正文从 conversation 回读 | P3 | sidecar 清理后历史回读 |
|
||||
| MX-CONV-004 | 根 Supervisor start/terminal status | 稳定 messageId 但正文写 project conversation | `IC-CONV-006` | 以 task/run correlation 显式绑定 Supervisor Session | P0/P3 | correlation 缺失/冲突 fixture |
|
||||
| MX-CONV-005 | 专业 Agent terminal status | 写其 Agent Session conversation | `IC-CONV-006` | 按该 agent/session/run 回读,不重归属 | P0/P3 | session scope fixture |
|
||||
| MX-CONV-006 | receipt/isolated join status | 当前不写 Session status message | `IC-CONV-006` | 默认不进入 Public Conversation | P0 | `isolate` negative fixture |
|
||||
| MX-CONV-007 | 普通 Runtime event | eventId 依赖 pid/时间/进程计数 | `IC-CONV-007` | V1 默认隔离 | P0 | `isolate`;call site inventory |
|
||||
| MX-CONV-008 | action-identity event | 部分 event 可按 action identity 幂等 | `IC-CONV-007` | 仅在规范 reader/digest/scope 全闭合后显式登记 | P0/P1/P3 | 默认 `isolate`;event replay fixture |
|
||||
| MX-CONV-009 | recent-events reader | 静默跳过坏行,只返回最近 20 条 | `IC-CONV-007`、`IC-CONV-009` | 不作为 Public source reader;若接 event 必须补新 reader | P0/P1 | 损坏/截断/定位 fixture |
|
||||
| MX-CONV-010 | GUI final autosave | `response-stream → game-chat-final-reply:* → autosave` | `IC-CONV-005`、`IC-CONV-010` | P3 adapter 启用前停止正式写入;P5 只删除旧消费/展示分支 | P3/P5 | cutover watermark 后零派生 writer;GUI 调用图清理 |
|
||||
| MX-CONV-011 | GUI event autosave | `Runtime event → game-chat-runtime-event:* → project conversation` | `IC-CONV-007`、`IC-CONV-010` | P3 adapter 启用前停止正式写入并隔离历史跨 scope 项;P5 清理旧 UI 分支 | P0/P3/P5 | source scope inventory + cutover 后零派生 writer |
|
||||
| MX-CONV-012 | 普通 Agent chat append | `App.tsx` user/assistant 可无 messageId append | `IC-CONV-010` | P3 前将正式 Supervisor 接 Shell、Developer/local 显式隔离;P5 只清理旧 Consumer 分支 | P0/P3/P5 | writer 三选一清单 + cutover fixture |
|
||||
| MX-CONV-013 | Developer panel append | Developer user history 直接写 | `IC-ARC-005`、`IC-CONV-010` | P3 前标记 Developer-local 且永不进入 Public,或接正式 Shell;P5 清理旧调用面 | P0/P3/P5 | DTO/调用面隔离 + cutover fixture |
|
||||
| MX-CONV-014 | project pending-message autosave | 项目级 conversation writer | `IC-CONV-010` | P3 adapter 启用前接 stable source binding 或停止;P5 只删除旧 Consumer 分支 | P0/P3/P5 | writer cutover fixture |
|
||||
| MX-CONV-015 | Public Conversation cursor | 当前无统一永久 source index | `IC-CONV-002`、`IC-CONV-008`、`IC-CONV-009` | P3 建无正文 index、origin/tail/cursor chain | P3 | 分页、空洞、损坏、全量补读 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Public / Developer 字段边界
|
||||
|
||||
| MX ID | 数据 | 当前风险 | Contract | 动作 | 阶段 | 证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| MX-DATA-001 | project path / LocalConversationResult.path | GUI/CLI 可读本地路径 | `IC-ID-001`、`IC-READ-002` | Public DTO 零 path;local transport 单独返回 | P2/P5/P6 | schema/static check |
|
||||
| MX-DATA-002 | Provider、tool、observation | Developer/runtime records 含私有原文 | `IC-READ-002`、`IC-ERR-003` | Public 严格白名单;Developer 仍脱敏有界 | P2 | sensitive fixture |
|
||||
| MX-DATA-003 | dynamic child identity | GUI 可聚合专业/child Runtime | `IC-ID-003`、`IC-READ-002` | Public Snapshot/event/error/capability 只显示 durable collaborationId/组摘要;真实 child agent/session/parentRun/run/delegation identity 只留 private binding | P2/P5 | parentRun multi-child、retry、fallback replacement、Public zero-leak、权限 fixture |
|
||||
| MX-DATA-004 | interaction private prompt/policy | sidecar 可能含原始模型内容 | `IC-INT-005`、`IC-ERR-003` | 生成独立 Public presentation;不安全则 Developer/reconciliation | P2 | redaction fixture |
|
||||
| MX-DATA-005 | finalization/provider identity | 恢复和调试需要,正式 UI 不需要 | `IC-CONV-005`、`IC-READ-002` | 保留 private binding,Public message 仅 provenance allowlist | P3 | Public schema zero-leak |
|
||||
|
||||
---
|
||||
|
||||
## 7. P6 删除清单
|
||||
|
||||
| MX ID | 删除范围 | 保留范围 | Contract | 完成证据 |
|
||||
|---|---|---|---|---|
|
||||
| MX-DEL-001 | 正式 transport 旧 start/steer/confirm/reject/answer/cancel/retry/resume/schedule/read 注册 | Runtime 内部 primitive | `IC-MIG-003` | handler/route 静态检查 |
|
||||
| MX-DEL-002 | GUI/CLI 旧生命周期判断和 fallback | Public Consumer + Developer read | `IC-ARC-002`、`IC-MIG-002` | Consumer 调用图 |
|
||||
| MX-DEL-003 | GUI Runtime output 派生 autosave | 原 conversation/finalization/event source | `IC-CONV-010` | writer cutover + 零 duplicate |
|
||||
| MX-DEL-004 | Public DTO 的 path/finalization/provider/private fields | 受信任本地/Developer DTO | `IC-READ-002`、`IC-READ-003` | schema diff |
|
||||
| MX-DEL-005 | Public scope 无稳定 messageId append | 明确 Developer/local history | `IC-CONV-010` | 所有 append caller 已分类 |
|
||||
| MX-DEL-006 | migration unknown-command fallback | 内部回归测试 | `IC-MIG-002`、`IC-MIG-003` | transport fixture |
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前冻结前缺口
|
||||
|
||||
| ID | 缺口 | 性质 | 阻塞阶段 |
|
||||
|---|---|---|---|
|
||||
| FD-001 | `--swarm-chat` 是普通 Public Supervisor CLI 还是显式 Developer CLI | 产品兼容决策 | P5 产品绑定/呈现;不阻塞 Contract 核心冻结 |
|
||||
| GAP-001 | 全部 legacy Runtime ingress 的实际写入进程调用图尚未形成正式 artifact | P0 evidence | P3 |
|
||||
| GAP-002 | 全部 conversation append writer 的接管/隔离/禁用归类尚未闭合 | P0 evidence | P3/P5 |
|
||||
| GAP-003 | event type/call site identity inventory 尚未形成正式 artifact | P0 evidence | PublicEvent 接入;默认隔离不受阻 |
|
||||
| GAP-004 | 按 eventId 定位、报告坏行/截断、校验 digest 的 reader 尚不存在 | implementation gap | PublicEvent 接入;默认隔离不受阻 |
|
||||
| GAP-005 | Rust→TypeScript strict schema/golden fixture 尚未实现;冻结前只定义规范与向量 | P1 implementation | P2/P3 |
|
||||
| GAP-006 | `projectId → TrustedProjectContext` 的受信任宿主 resolver 尚未实现 | P1 implementation | P2/P3;Public DTO 始终保持无路径 |
|
||||
| GAP-007 | 现有 `.agent/project.lock` 具有 stale reclaim,不能当 Shell protocol lock | P1 implementation boundary | P1;须与 execution owner 下串行分离 |
|
||||
| GAP-008 | RFC 8785 canonicalization 尚无单一复用实现 | P1 implementation | P1;checksum/fingerprint/hash 不可各自序列化 |
|
||||
| GAP-009 | Shell record 尚无唯一 append order authority;不能由多份 sidecar 自行分配 ledgerVersion | P1 implementation | P1;建立专用 ledger,sidecar/index 只能派生 |
|
||||
| GAP-010 | Projection reader 尚无 witness、一致 observation、source absence/corruption matrix、fail-closed publication | P2 implementation | P2;不能直接公开现有聚合 read |
|
||||
| GAP-011 | Runner 的 known roots 与 request dedupe 都是内存态 | P3/P4 implementation | P3 durable read-back;P4 restart discovery |
|
||||
| GAP-012 | Snapshot subscription 尚无按 `(projectId, view)` 路由、durable sequence 与原子 initial Snapshot | P2 implementation | P2;V1 使用 snapshot-first/no-backlog,不能复用全局 best-effort event |
|
||||
| GAP-013 | collaborator/child 到 durable collaborationId 的 binding/lineage 尚不存在 | P2 implementation | P2;Public 不得临时以 agentId/组名拼接 identity |
|
||||
|
||||
这些缺口不得被解释为 Contract 规则未决定:除 `FD-001` 外,现状不满足即按 Contract 默认隔离或失败关闭。
|
||||
@@ -675,7 +675,7 @@ npm run container:down
|
||||
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||
|
||||
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。
|
||||
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。
|
||||
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
|
||||
|
||||
独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。
|
||||
|
||||
@@ -21,6 +21,10 @@ const systemd = readFileSync(
|
||||
'deploy/systemd/genarrative-preview-deployer.service',
|
||||
'utf8',
|
||||
);
|
||||
const environmentExample = readFileSync(
|
||||
'deploy/env/preview-deployer.env.example',
|
||||
'utf8',
|
||||
);
|
||||
const server = readFileSync(
|
||||
'server-rs/crates/preview-deployer-server/src/lib.rs',
|
||||
'utf8',
|
||||
@@ -162,6 +166,21 @@ assertIncludes(
|
||||
'.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))',
|
||||
'控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'FAILED_RECORD_TTL_SECS',
|
||||
'控制服务必须清理过期且不可卸载的失败/取消记录。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'STOPPED_RECORD_TTL_SECS',
|
||||
'控制服务必须为已卸载记录配置有限审计保留期。',
|
||||
);
|
||||
assertIncludes(
|
||||
environmentExample,
|
||||
'GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/',
|
||||
'构建详情必须使用局域网 Jenkins 地址而非 loopback。',
|
||||
);
|
||||
assertIncludes(
|
||||
jobConfig,
|
||||
'<scriptPath>jenkins/Jenkinsfile.preview-deployer</scriptPath>',
|
||||
|
||||
@@ -10,7 +10,7 @@ reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal", "process", "fs", "io-util"] }
|
||||
tower-http = { workspace = true, features = ["fs", "trace"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
@@ -9,8 +9,11 @@ pub struct Config {
|
||||
pub bind_address: String,
|
||||
pub jenkins_root_url: Url,
|
||||
pub jenkins_base_url: Url,
|
||||
pub jenkins_public_base_url: Url,
|
||||
pub jenkins_username: String,
|
||||
pub jenkins_api_token: String,
|
||||
pub git_remote_url: String,
|
||||
pub git_ssh_command: Option<String>,
|
||||
pub access_token: String,
|
||||
pub allowed_hosts: Vec<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
@@ -28,6 +31,7 @@ impl fmt::Debug for Config {
|
||||
.field("bind_address", &self.bind_address)
|
||||
.field("jenkins_root_url", &self.jenkins_root_url)
|
||||
.field("jenkins_base_url", &self.jenkins_base_url)
|
||||
.field("jenkins_public_base_url", &self.jenkins_public_base_url)
|
||||
.field(
|
||||
"jenkins_username_configured",
|
||||
&!self.jenkins_username.is_empty(),
|
||||
@@ -36,6 +40,11 @@ impl fmt::Debug for Config {
|
||||
"jenkins_api_token_configured",
|
||||
&!self.jenkins_api_token.is_empty(),
|
||||
)
|
||||
.field("git_remote_url", &self.git_remote_url)
|
||||
.field(
|
||||
"git_ssh_command_configured",
|
||||
&self.git_ssh_command.is_some(),
|
||||
)
|
||||
.field("access_token_configured", &!self.access_token.is_empty())
|
||||
.field("allowed_hosts", &self.allowed_hosts)
|
||||
.field("allowed_origins", &self.allowed_origins)
|
||||
@@ -72,7 +81,28 @@ impl Config {
|
||||
let jenkins_base_url = jenkins_root_url
|
||||
.join(JOB_PATH)
|
||||
.map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?;
|
||||
|
||||
let mut jenkins_public_root_url = Url::parse(&required(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL",
|
||||
)?)
|
||||
.map_err(|_| {
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL 不是有效 URL".to_string()
|
||||
})?;
|
||||
if !matches!(jenkins_public_root_url.scheme(), "http" | "https")
|
||||
|| jenkins_public_root_url.host_str().is_none()
|
||||
|| !jenkins_public_root_url.username().is_empty()
|
||||
|| jenkins_public_root_url.password().is_some()
|
||||
{
|
||||
return Err("Jenkins public base URL 必须是无凭据的 http/https 绝对 URL".to_string());
|
||||
}
|
||||
jenkins_public_root_url.set_query(None);
|
||||
jenkins_public_root_url.set_fragment(None);
|
||||
if !jenkins_public_root_url.path().ends_with('/') {
|
||||
let path = format!("{}/", jenkins_public_root_url.path());
|
||||
jenkins_public_root_url.set_path(&path);
|
||||
}
|
||||
let jenkins_public_base_url = jenkins_public_root_url
|
||||
.join(JOB_PATH)
|
||||
.map_err(|_| "无法构造固定 Jenkins 内网 Job URL".to_string())?;
|
||||
let access_token = required("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN")?;
|
||||
if access_token.len() < 24 {
|
||||
return Err("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN 至少需要 24 个字符".to_string());
|
||||
@@ -118,8 +148,16 @@ impl Config {
|
||||
bind_address,
|
||||
jenkins_root_url,
|
||||
jenkins_base_url,
|
||||
jenkins_public_base_url,
|
||||
jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?,
|
||||
jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?,
|
||||
git_remote_url: validate_git_remote_url(&required(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL",
|
||||
)?)?,
|
||||
git_ssh_command: env::var("GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND")
|
||||
.ok()
|
||||
.map(|value| validate_git_ssh_command(value.trim()))
|
||||
.transpose()?,
|
||||
access_token,
|
||||
allowed_hosts,
|
||||
allowed_origins,
|
||||
@@ -132,6 +170,31 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_git_ssh_command(value: &str) -> Result<String, String> {
|
||||
const TRUSTED_COMMAND: &str = "ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts";
|
||||
if value != TRUSTED_COMMAND {
|
||||
return Err(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND 必须使用固定只读密钥和严格主机校验参数"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_git_remote_url(value: &str) -> Result<String, String> {
|
||||
const TRUSTED_REMOTE: &str = "ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git";
|
||||
#[cfg(test)]
|
||||
if value.starts_with("test://") {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
if value != TRUSTED_REMOTE {
|
||||
return Err(format!(
|
||||
"GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL 只允许固定内网仓库 {TRUSTED_REMOTE}"
|
||||
));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_state_file(path: &std::path::Path) -> Result<(), String> {
|
||||
if !path.is_absolute() || path == std::path::Path::new("/") || path.file_name().is_none() {
|
||||
return Err(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::{process::Command, sync::Mutex};
|
||||
|
||||
const MAX_RESULTS: usize = 20;
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GitRepository {
|
||||
remote_url: String,
|
||||
ssh_command: Option<String>,
|
||||
cache_dir: PathBuf,
|
||||
lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BranchMatch {
|
||||
pub name: String,
|
||||
pub commit_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitMatch {
|
||||
pub commit_hash: String,
|
||||
pub short_hash: String,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl GitRepository {
|
||||
pub fn new(remote_url: String, ssh_command: Option<String>, cache_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
remote_url,
|
||||
ssh_command,
|
||||
cache_dir,
|
||||
lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_branches(&self, query: &str) -> Result<Vec<BranchMatch>, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
let candidates = [
|
||||
(
|
||||
"feature/preview-ui",
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
),
|
||||
("feature/busy", "89abcdef0123456789abcdef0123456789abcdef"),
|
||||
];
|
||||
return Ok(candidates
|
||||
.into_iter()
|
||||
.filter(|(name, _)| name.contains(query))
|
||||
.map(|(name, commit_hash)| BranchMatch {
|
||||
name: name.to_string(),
|
||||
commit_hash: commit_hash.to_string(),
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
let output = self
|
||||
.run_remote(&["ls-remote", "--heads", &self.remote_url])
|
||||
.await?;
|
||||
let query = query.to_ascii_lowercase();
|
||||
let mut matches: Vec<_> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (commit_hash, reference) = line.split_once('\t')?;
|
||||
let name = reference.strip_prefix("refs/heads/")?;
|
||||
(name.to_ascii_lowercase().contains(&query)
|
||||
&& super::validate_branch(name).is_ok()
|
||||
&& super::validate_commit(commit_hash).is_ok())
|
||||
.then(|| BranchMatch {
|
||||
name: name.to_string(),
|
||||
commit_hash: commit_hash.to_ascii_lowercase(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
matches.sort_by(|left, right| {
|
||||
branch_rank(&left.name, query.as_str())
|
||||
.cmp(&branch_rank(&right.name, query.as_str()))
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
matches.truncate(MAX_RESULTS);
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
pub async fn branch_exists(&self, branch: &str) -> Result<bool, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
return Ok(matches!(branch, "feature/preview-ui" | "feature/busy"));
|
||||
}
|
||||
let reference = format!("refs/heads/{branch}");
|
||||
let output = self
|
||||
.run_remote(&["ls-remote", "--heads", &self.remote_url, &reference])
|
||||
.await?;
|
||||
Ok(output.lines().any(|line| {
|
||||
line.split_once('\t')
|
||||
.is_some_and(|(_, returned)| returned == reference)
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn search_commits(
|
||||
&self,
|
||||
branch: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<CommitMatch>, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
if !self.branch_exists(branch).await? {
|
||||
return Err("test branch missing".to_string());
|
||||
}
|
||||
return Ok(vec![CommitMatch {
|
||||
commit_hash: "0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
short_hash: "0123456".to_string(),
|
||||
subject: "test preview commit".to_string(),
|
||||
}]);
|
||||
}
|
||||
let _guard = self.lock.lock().await;
|
||||
self.fetch_branch(branch).await?;
|
||||
let branch_ref = format!("refs/remotes/origin/{branch}");
|
||||
let output = self
|
||||
.run_cached(&[
|
||||
"log",
|
||||
"--format=%H%x09%h%x09%s",
|
||||
"--max-count=500",
|
||||
&branch_ref,
|
||||
])
|
||||
.await?;
|
||||
let query = query.to_ascii_lowercase();
|
||||
let mut matches: Vec<_> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.splitn(3, '\t');
|
||||
let hash = parts.next()?;
|
||||
let short_hash = parts.next()?;
|
||||
let subject = parts.next().unwrap_or_default();
|
||||
(query.is_empty()
|
||||
|| hash.to_ascii_lowercase().starts_with(&query)
|
||||
|| subject.to_ascii_lowercase().contains(&query))
|
||||
.then(|| CommitMatch {
|
||||
commit_hash: hash.to_ascii_lowercase(),
|
||||
short_hash: short_hash.to_ascii_lowercase(),
|
||||
subject: subject.chars().take(200).collect(),
|
||||
})
|
||||
})
|
||||
.take(MAX_RESULTS)
|
||||
.collect();
|
||||
matches.truncate(MAX_RESULTS);
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
pub async fn commit_belongs_to_branch(
|
||||
&self,
|
||||
branch: &str,
|
||||
commit: &str,
|
||||
) -> Result<bool, String> {
|
||||
#[cfg(test)]
|
||||
if self.remote_url == "test://preview-repository" {
|
||||
return Ok(branch == "feature/preview-ui" && commit == "abcdef1");
|
||||
}
|
||||
let _guard = self.lock.lock().await;
|
||||
self.fetch_branch(branch).await?;
|
||||
let branch_ref = format!("refs/remotes/origin/{branch}");
|
||||
let resolved = self
|
||||
.run_cached_status(&["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
|
||||
.await?;
|
||||
if !resolved {
|
||||
return Ok(false);
|
||||
}
|
||||
self.run_cached_status(&["merge-base", "--is-ancestor", commit, &branch_ref])
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_branch(&self, branch: &str) -> Result<(), String> {
|
||||
self.ensure_cache().await?;
|
||||
let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}");
|
||||
let status = self
|
||||
.command()
|
||||
.arg("-C")
|
||||
.arg(&self.cache_dir)
|
||||
.args(["fetch", "--no-tags", "--prune", "origin", &refspec])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
let status = tokio::time::timeout(COMMAND_TIMEOUT, status)
|
||||
.await
|
||||
.map_err(|_| "Git 分支同步超时".to_string())?
|
||||
.map_err(|error| format!("无法执行 Git 分支同步: {error}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("无法从固定仓库同步目标分支".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_cache(&self) -> Result<(), String> {
|
||||
if !self.cache_dir.exists() {
|
||||
tokio::fs::create_dir_all(&self.cache_dir)
|
||||
.await
|
||||
.map_err(|error| format!("无法创建 Git 查询缓存: {error}"))?;
|
||||
self.run_cached(&["init", "--bare"]).await?;
|
||||
}
|
||||
let metadata = tokio::fs::symlink_metadata(&self.cache_dir)
|
||||
.await
|
||||
.map_err(|error| format!("无法读取 Git 查询缓存: {error}"))?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err("Git 查询缓存必须是普通目录且不能是符号链接".to_string());
|
||||
}
|
||||
if !self
|
||||
.run_cached_status(&["remote", "get-url", "origin"])
|
||||
.await?
|
||||
{
|
||||
self.run_cached(&["remote", "add", "origin", &self.remote_url])
|
||||
.await?;
|
||||
} else {
|
||||
let current = self.run_cached(&["remote", "get-url", "origin"]).await?;
|
||||
if current.trim() != self.remote_url {
|
||||
return Err("Git 查询缓存的固定远端不匹配".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_remote(&self, args: &[&str]) -> Result<String, String> {
|
||||
self.run(self.command().args(args)).await
|
||||
}
|
||||
|
||||
async fn run_cached(&self, args: &[&str]) -> Result<String, String> {
|
||||
self.run(self.command().arg("-C").arg(&self.cache_dir).args(args))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_cached_status(&self, args: &[&str]) -> Result<bool, String> {
|
||||
let status = self
|
||||
.command()
|
||||
.arg("-C")
|
||||
.arg(&self.cache_dir)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
Ok(tokio::time::timeout(COMMAND_TIMEOUT, status)
|
||||
.await
|
||||
.map_err(|_| "Git 查询超时".to_string())?
|
||||
.map_err(|error| format!("无法执行 Git 查询: {error}"))?
|
||||
.success())
|
||||
}
|
||||
|
||||
fn command(&self) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1");
|
||||
if let Some(value) = &self.ssh_command {
|
||||
command.env("GIT_SSH_COMMAND", value);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
async fn run(&self, command: &mut Command) -> Result<String, String> {
|
||||
let output = tokio::time::timeout(COMMAND_TIMEOUT, command.output())
|
||||
.await
|
||||
.map_err(|_| "Git 查询超时".to_string())?
|
||||
.map_err(|error| format!("无法执行 Git 查询: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err("固定 Git 仓库查询失败".to_string());
|
||||
}
|
||||
String::from_utf8(output.stdout).map_err(|_| "Git 查询返回了无效文本".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_rank(name: &str, query: &str) -> u8 {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower == query {
|
||||
0
|
||||
} else if lower.starts_with(query) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub struct JenkinsClient {
|
||||
http: Client,
|
||||
root_url: Url,
|
||||
job_url: Url,
|
||||
public_job_url: Url,
|
||||
username: String,
|
||||
api_token: String,
|
||||
poll_interval: Duration,
|
||||
@@ -32,6 +33,11 @@ pub struct BuildReference {
|
||||
queue_url: Url,
|
||||
}
|
||||
|
||||
pub struct BuildStarted {
|
||||
pub number: u64,
|
||||
pub public_url: String,
|
||||
}
|
||||
|
||||
impl BuildReference {
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.queue_url.as_str()
|
||||
@@ -58,6 +64,7 @@ pub struct PreviewResult {
|
||||
pub health: Option<HealthStatus>,
|
||||
pub phase: Option<String>,
|
||||
pub health_status: Option<String>,
|
||||
pub web_port: Option<u16>,
|
||||
pub web_url: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
@@ -97,6 +104,7 @@ impl JenkinsClient {
|
||||
http,
|
||||
root_url: config.jenkins_root_url.clone(),
|
||||
job_url: config.jenkins_base_url.clone(),
|
||||
public_job_url: config.jenkins_public_base_url.clone(),
|
||||
username: config.jenkins_username.clone(),
|
||||
api_token: config.jenkins_api_token.clone(),
|
||||
poll_interval: config.poll_interval,
|
||||
@@ -199,7 +207,7 @@ impl JenkinsClient {
|
||||
mut on_build: F,
|
||||
) -> Result<JenkinsOutcome, String>
|
||||
where
|
||||
F: FnMut(&str) -> Fut,
|
||||
F: FnMut(BuildStarted) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let build_url = loop {
|
||||
@@ -225,7 +233,13 @@ impl JenkinsClient {
|
||||
tokio::time::sleep(self.poll_interval).await;
|
||||
};
|
||||
|
||||
on_build(build_url.as_str()).await;
|
||||
let build_number = parse_build_number(&build_url, &self.job_url)?;
|
||||
let public_url = self.public_build_url(&build_url)?;
|
||||
on_build(BuildStarted {
|
||||
number: build_number,
|
||||
public_url: public_url.to_string(),
|
||||
})
|
||||
.await;
|
||||
let successful = loop {
|
||||
let mut state_url = build_url
|
||||
.join("api/json")
|
||||
@@ -330,4 +344,35 @@ impl JenkinsClient {
|
||||
Err("Jenkins 返回了非受信源 URL".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn public_build_url(&self, internal: &Url) -> Result<Url, String> {
|
||||
self.ensure_same_origin(internal)?;
|
||||
let relative = internal
|
||||
.path()
|
||||
.strip_prefix(self.job_url.path())
|
||||
.ok_or_else(|| "Jenkins build URL 路径无效".to_string())?;
|
||||
let mut public = self
|
||||
.public_job_url
|
||||
.join(relative)
|
||||
.map_err(|_| "无法构造 Jenkins 内网构建详情地址".to_string())?;
|
||||
public.set_query(None);
|
||||
public.set_fragment(None);
|
||||
Ok(public)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_build_number(build_url: &Url, job_url: &Url) -> Result<u64, String> {
|
||||
let relative = build_url
|
||||
.path()
|
||||
.strip_prefix(job_url.path())
|
||||
.ok_or_else(|| "Jenkins build URL 路径无效".to_string())?
|
||||
.trim_end_matches('/');
|
||||
if relative.is_empty() || relative.contains('/') {
|
||||
return Err("Jenkins build URL 缺少构建编号".to_string());
|
||||
}
|
||||
relative
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|number| *number > 0)
|
||||
.ok_or_else(|| "Jenkins build 编号无效".to_string())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,7 @@ async fn mock_artifact(axum::extract::Path(build): axum::extract::Path<u64>) ->
|
||||
"resolvedCommit": "0123456789abcdef0123456789abcdef01234567",
|
||||
"phase": "RUNNING",
|
||||
"healthStatus": "HEALTHY",
|
||||
"webPort": 8400,
|
||||
"webUrl": "http://192.168.35.82:8400",
|
||||
"message": "预览实例已发布"
|
||||
}))
|
||||
@@ -156,10 +157,16 @@ fn test_config(jenkins_base_url: Url) -> Config {
|
||||
.unwrap();
|
||||
Config {
|
||||
bind_address: "127.0.0.1:0".to_string(),
|
||||
jenkins_root_url: jenkins_base_url,
|
||||
jenkins_root_url: jenkins_base_url.clone(),
|
||||
jenkins_base_url: job_url,
|
||||
jenkins_public_base_url: Url::parse(
|
||||
"http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/",
|
||||
)
|
||||
.unwrap(),
|
||||
jenkins_username: "preview-service".to_string(),
|
||||
jenkins_api_token: "server-only-jenkins-token".to_string(),
|
||||
git_remote_url: "test://preview-repository".to_string(),
|
||||
git_ssh_command: None,
|
||||
access_token: "correct horse battery staple".to_string(),
|
||||
allowed_hosts: vec![HOST.to_string()],
|
||||
allowed_origins: vec![ORIGIN.to_string()],
|
||||
@@ -364,7 +371,13 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.public
|
||||
.clone();
|
||||
assert_eq!(deployment.status, super::DeploymentStatus::Running);
|
||||
assert_eq!(deployment.id.as_deref(), Some("1"));
|
||||
assert_eq!(
|
||||
deployment.jenkins_build_url.as_deref(),
|
||||
Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/1/")
|
||||
);
|
||||
assert_eq!(deployment.health, super::HealthStatus::Healthy);
|
||||
assert_eq!(deployment.web_port, Some(8400));
|
||||
assert_eq!(
|
||||
deployment.web_url.as_deref(),
|
||||
Some("http://192.168.35.82:8400")
|
||||
@@ -377,7 +390,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
|
||||
let uninstall_request = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/preview-deployer/deployments/{id}/uninstall"))
|
||||
.uri("/api/preview-deployer/deployments/1/uninstall")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::ORIGIN, ORIGIN)
|
||||
.header(header::COOKIE, &cookie)
|
||||
@@ -421,8 +434,27 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.clone();
|
||||
assert_eq!(deployment.status, super::DeploymentStatus::Stopped);
|
||||
assert_eq!(deployment.health, super::HealthStatus::Unknown);
|
||||
assert_eq!(deployment.web_port, None);
|
||||
assert_eq!(deployment.web_url, None);
|
||||
assert!(!deployment.can_uninstall);
|
||||
|
||||
let list_request = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/deployments")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let list_response = app.clone().oneshot(list_request).await.unwrap();
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let list_body = list_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes();
|
||||
let list: Value = serde_json::from_slice(&list_body).unwrap();
|
||||
assert_eq!(list["deployments"], json!([]));
|
||||
let state_file = state.config.state_file.clone();
|
||||
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
|
||||
recovered_config.state_file = state_file.clone();
|
||||
@@ -443,6 +475,78 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
std::fs::remove_file(state_file).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_search_requires_session_and_returns_server_side_matches() {
|
||||
let (jenkins_url, _) = start_mock_jenkins().await;
|
||||
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
||||
let anonymous = app
|
||||
.clone()
|
||||
.oneshot(api_request(
|
||||
"GET",
|
||||
"/api/preview-deployer/refs/branches?q=preview",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
|
||||
let cookie = login_cookie(&app).await;
|
||||
let request = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/refs/branches?q=preview")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let value: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(value["items"][0]["name"], "feature/preview-ui");
|
||||
|
||||
let request = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/refs/commits?branch=feature%2Fpreview-ui&q=0123456")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let value: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
value["items"][0]["commitHash"],
|
||||
"0123456789abcdef0123456789abcdef01234567"
|
||||
);
|
||||
assert_eq!(value["items"][0]["shortHash"], "0123456");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deployment_rejects_missing_or_unrelated_refs_before_jenkins() {
|
||||
let (jenkins_url, mock) = start_mock_jenkins().await;
|
||||
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
||||
let cookie = login_cookie(&app).await;
|
||||
for payload in [
|
||||
r#"{"branch":"feature/missing"}"#,
|
||||
r#"{"branch":"feature/preview-ui","commitHash":"deadbee"}"#,
|
||||
] {
|
||||
let request = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/preview-deployer/deployments")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::ORIGIN, ORIGIN)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(payload))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.clone().oneshot(request).await.unwrap().status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
assert!(mock.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_invalid_build_status_is_retried_before_reading_artifact() {
|
||||
let (jenkins_url, mock) = start_mock_jenkins().await;
|
||||
@@ -493,12 +597,13 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
id.clone(),
|
||||
super::DeploymentRecord {
|
||||
public: super::Deployment {
|
||||
id,
|
||||
id: None,
|
||||
branch: "feature/busy".to_string(),
|
||||
commit_hash: None,
|
||||
resolved_commit: None,
|
||||
status: super::DeploymentStatus::Building,
|
||||
health: super::HealthStatus::Pending,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: now,
|
||||
@@ -506,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
||||
message: None,
|
||||
can_uninstall: false,
|
||||
},
|
||||
instance_id: id,
|
||||
operation: super::Operation::Deploy,
|
||||
queue_url: None,
|
||||
},
|
||||
@@ -576,3 +682,142 @@ fn branch_commit_and_web_url_validation_are_strict() {
|
||||
"192.168.35.82"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_running_state_recovers_web_port_from_validated_url() {
|
||||
let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap());
|
||||
let id = super::derive_deployment_id("feature/legacy-running");
|
||||
std::fs::write(
|
||||
&config.state_file,
|
||||
serde_json::to_vec(&json!({
|
||||
"schemaVersion": 1,
|
||||
"deployments": [{
|
||||
"public": {
|
||||
"id": id,
|
||||
"branch": "feature/legacy-running",
|
||||
"status": "running",
|
||||
"health": "healthy",
|
||||
"webUrl": "http://192.168.35.82:8407",
|
||||
"jenkinsBuildUrl": "http://127.0.0.1:18080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/",
|
||||
"createdAt": 1,
|
||||
"updatedAt": 2,
|
||||
"canUninstall": true
|
||||
},
|
||||
"operation": "deploy"
|
||||
}]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let state = AppState::new(config).unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.deployments
|
||||
.blocking_read()
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.public
|
||||
.web_port,
|
||||
Some(8407)
|
||||
);
|
||||
let deployment = state
|
||||
.deployments
|
||||
.blocking_read()
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.public
|
||||
.clone();
|
||||
assert_eq!(deployment.id.as_deref(), Some("42"));
|
||||
assert_eq!(
|
||||
deployment.jenkins_build_url.as_deref(),
|
||||
Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/")
|
||||
);
|
||||
std::fs::remove_file(&state.config.state_file).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_retained() {
|
||||
let (jenkins_url, _) = start_mock_jenkins().await;
|
||||
let state = AppState::new(test_config(jenkins_url)).unwrap();
|
||||
let app = build_router(state.clone());
|
||||
let cookie = login_cookie(&app).await;
|
||||
let old = super::unix_now().saturating_sub(super::FAILED_RECORD_TTL_SECS + 1);
|
||||
let failed_id = super::derive_deployment_id("feature/old-failure");
|
||||
let uninstallable_id = super::derive_deployment_id("feature/failed-uninstall");
|
||||
state.deployments.write().await.extend([
|
||||
(
|
||||
failed_id.clone(),
|
||||
super::DeploymentRecord {
|
||||
public: super::Deployment {
|
||||
id: Some("91".to_string()),
|
||||
branch: "feature/old-failure".to_string(),
|
||||
commit_hash: None,
|
||||
resolved_commit: None,
|
||||
status: super::DeploymentStatus::Failed,
|
||||
health: super::HealthStatus::Unknown,
|
||||
web_port: None,
|
||||
web_url: None,
|
||||
jenkins_build_url: None,
|
||||
created_at: old,
|
||||
updated_at: old,
|
||||
message: None,
|
||||
can_uninstall: false,
|
||||
},
|
||||
instance_id: failed_id.clone(),
|
||||
operation: super::Operation::Deploy,
|
||||
queue_url: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
uninstallable_id.clone(),
|
||||
super::DeploymentRecord {
|
||||
public: super::Deployment {
|
||||
id: Some("92".to_string()),
|
||||
branch: "feature/failed-uninstall".to_string(),
|
||||
commit_hash: None,
|
||||
resolved_commit: None,
|
||||
status: super::DeploymentStatus::Failed,
|
||||
health: super::HealthStatus::Unknown,
|
||||
web_port: Some(8401),
|
||||
web_url: Some("http://192.168.35.82:8401".to_string()),
|
||||
jenkins_build_url: None,
|
||||
created_at: old,
|
||||
updated_at: old,
|
||||
message: None,
|
||||
can_uninstall: true,
|
||||
},
|
||||
instance_id: uninstallable_id.clone(),
|
||||
operation: super::Operation::Uninstall,
|
||||
queue_url: None,
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/preview-deployer/deployments")
|
||||
.header(header::HOST, HOST)
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let listed: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(listed["deployments"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(listed["deployments"][0]["id"], "92");
|
||||
assert!(!state.deployments.read().await.contains_key(&failed_id));
|
||||
assert!(
|
||||
state
|
||||
.deployments
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&uninstallable_id)
|
||||
);
|
||||
std::fs::remove_file(&state.config.state_file).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user