吸收数字员工产物生命周期治理

This commit is contained in:
baiyanyun
2026-06-10 10:23:47 +08:00
parent 362f397e37
commit ed2f19d257
15 changed files with 739 additions and 164 deletions

View File

@@ -2176,6 +2176,8 @@
.de-artifact-source-row span { color: var(--de-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } .de-artifact-source-row span { color: var(--de-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.de-artifact-source-row code, .de-artifact-source-row em { grid-column: 1 / -1; min-width: 0; color: var(--de-text-body); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .de-artifact-source-row code, .de-artifact-source-row em { grid-column: 1 / -1; min-width: 0; color: var(--de-text-body); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.de-artifact-source-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; } .de-artifact-source-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
.de-artifact-source-meta { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.de-artifact-source-meta span { width: auto; max-width: 160px; padding: 2px 6px; border-radius: 6px; background: rgba(79,172,254,0.08); color: #315a78; text-align: left; }
.de-icon-btn { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid rgba(79,172,254,0.18); border-radius: 6px; color: #275a82; background: rgba(255,255,255,0.78); cursor: pointer; } .de-icon-btn { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid rgba(79,172,254,0.18); border-radius: 6px; color: #275a82; background: rgba(255,255,255,0.78); cursor: pointer; }
.de-icon-btn:hover:not(:disabled) { border-color: rgba(79,172,254,0.45); background: #fff; } .de-icon-btn:hover:not(:disabled) { border-color: rgba(79,172,254,0.45); background: #fff; }
.de-icon-btn:disabled { cursor: not-allowed; opacity: 0.42; } .de-icon-btn:disabled { cursor: not-allowed; opacity: 0.42; }
@@ -2183,6 +2185,12 @@
.de-artifact-preview { margin-top: 10px; border: 1px solid rgba(79,172,254,0.14); border-radius: 8px; background: rgba(255,255,255,0.78); overflow: hidden; } .de-artifact-preview { margin-top: 10px; border: 1px solid rgba(79,172,254,0.14); border-radius: 8px; background: rgba(255,255,255,0.78); overflow: hidden; }
.de-artifact-preview > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-bottom: 1px solid rgba(79,172,254,0.1); } .de-artifact-preview > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-bottom: 1px solid rgba(79,172,254,0.1); }
.de-artifact-preview pre { max-height: 220px; margin: 0; padding: 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--de-text-body); font-size: 12px; line-height: 1.55; } .de-artifact-preview pre { max-height: 220px; margin: 0; padding: 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--de-text-body); font-size: 12px; line-height: 1.55; }
.de-artifact-retention-actions { justify-content: flex-start !important; flex-wrap: wrap; }
.de-artifact-version-list { display: grid !important; gap: 6px; align-items: stretch !important; justify-content: stretch !important; border-bottom: 0 !important; }
.de-artifact-version-list div { display: grid; grid-template-columns: minmax(72px, 120px) minmax(90px, 1fr); gap: 2px 10px; align-items: center; padding: 8px; border-radius: 6px; background: rgba(244,250,255,0.78); }
.de-artifact-version-list strong { font-size: 12px; color: var(--de-text-primary); }
.de-artifact-version-list span, .de-artifact-version-list em { min-width: 0; color: var(--de-text-muted); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.de-artifact-version-list em { grid-column: 1 / -1; }
.de-decision-card { background: rgba(247,251,255,0.96); } .de-decision-card { background: rgba(247,251,255,0.96); }
.de-decision-chain-note { .de-decision-chain-note {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;

View File

@@ -739,6 +739,27 @@ export function getArtifactAccessEvents(params: Record<string, string | number |
return apiFetch(`/api/digital-employee/artifact-access-events${suffix}`); return apiFetch(`/api/digital-employee/artifact-access-events${suffix}`);
} }
export function getArtifactDetail(artifactId: string): Promise<any> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}`)
.then((data: any) => data.item ?? null);
}
export function getArtifactVersions(artifactId: string): Promise<any[]> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/versions`)
.then((data: any) => data.items ?? []);
}
export function setArtifactRetention(
artifactId: string,
action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention',
options: Record<string, string | null | undefined> = {},
): Promise<any> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/retention`, {
method: 'POST',
body: JSON.stringify({ action, ...options }),
});
}
export function getDebugConversations(): Promise<any[]> { export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations') return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []); .then((data) => data.conversations ?? []);

View File

@@ -64,6 +64,7 @@ declare global {
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>; recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>; recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>; accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
recordArtifactLifecycle?: (input: QimingclawArtifactLifecycleInput) => Promise<QimingclawArtifactLifecycleResult | null>;
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>; restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>; respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
}; };
@@ -249,6 +250,27 @@ interface QimingclawArtifactAccessResponse {
event_id?: string | null; event_id?: string | null;
} }
interface QimingclawArtifactLifecycleInput {
artifactId?: string | null;
action: string;
reason?: string | null;
retentionUntil?: string | null;
versionSummary?: Record<string, unknown> | null;
retentionSummary?: Record<string, unknown> | null;
deliverySummary?: Record<string, unknown> | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
}
interface QimingclawArtifactLifecycleResult {
eventId: string;
kind: string;
payload: Record<string, unknown>;
}
interface QimingclawManagedServiceActionResponse { interface QimingclawManagedServiceActionResponse {
ok: boolean; ok: boolean;
service_id: string; service_id: string;
@@ -680,6 +702,24 @@ export async function handleQimingclawDigitalApi<T>(
const artifactId = decodeURIComponent(artifactAccessMatch[1] || ''); const artifactId = decodeURIComponent(artifactAccessMatch[1] || '');
return await handleArtifactAccess(artifactId, parseOptionalJsonBody(options.body)) as T; return await handleArtifactAccess(artifactId, parseOptionalJsonBody(options.body)) as T;
} }
const artifactRetentionMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/retention$/);
if (method === 'POST' && artifactRetentionMatch) {
const artifactId = decodeURIComponent(artifactRetentionMatch[1] || '');
return await handleArtifactRetention(artifactId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
}
const artifactVersionsMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/versions$/);
if (method === 'GET' && artifactVersionsMatch) {
const artifactId = decodeURIComponent(artifactVersionsMatch[1] || '');
const snapshot = await readQimingclawSnapshot();
return { items: artifactVersionRows(snapshot, artifactId), source: BUSINESS_VIEW_SOURCE } as T;
}
const artifactDetailMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)$/);
if (method === 'GET' && artifactDetailMatch) {
const artifactId = decodeURIComponent(artifactDetailMatch[1] || '');
const snapshot = await readQimingclawSnapshot();
const item = artifactDetailRow(snapshot, artifactId);
return { item, source: BUSINESS_VIEW_SOURCE } as T;
}
const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/); const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/);
if (method === 'GET' && businessPlanDetailMatch) { if (method === 'GET' && businessPlanDetailMatch) {
const planId = decodeURIComponent(businessPlanDetailMatch[1] || ''); const planId = decodeURIComponent(businessPlanDetailMatch[1] || '');
@@ -1410,6 +1450,41 @@ function normalizeArtifactAccessAction(value: string): 'preview' | 'download' |
return 'preview'; return 'preview';
} }
async function handleArtifactRetention(
artifactId: string,
body: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<Record<string, unknown>> {
const bridge = window.QimingClawBridge?.digital;
const action = stringValue(body.action) || 'mark_review';
const artifact = artifactDetailRow(snapshot, artifactId);
if (!bridge?.recordArtifactLifecycle) {
return { ok: false, artifact_id: artifactId, action, status: 'rejected', error: 'bridge_unavailable' };
}
const result = await bridge.recordArtifactLifecycle({
artifactId,
action,
reason: stringValue(body.reason) || null,
retentionUntil: stringValue(body.retention_until ?? body.retentionUntil) || null,
versionSummary: artifact ? artifactVersionSummary(artifact) : null,
retentionSummary: artifact ? artifactRetentionSummaryForAction(artifact, action, body) : null,
deliverySummary: artifact ? artifactDeliverySummary(artifact) : null,
actor: stringValue(body.actor) || 'digital_employee_ui',
source: stringValue(body.source) || 'sgrobot-digital-adapter',
planId: artifact ? stringValue(artifact.plan_id) || null : null,
taskId: artifact ? stringValue(artifact.task_id) || null : null,
runId: artifact ? stringValue(artifact.run_id) || null : null,
});
return {
ok: Boolean(result && result.kind !== 'artifact_retention_rejected'),
artifact_id: artifactId,
action,
status: result?.kind === 'artifact_retention_rejected' ? 'rejected' : 'recorded',
event_id: result?.eventId ?? null,
item: artifact,
};
}
function syncLine(snapshot: QimingclawSnapshot | null): string { function syncLine(snapshot: QimingclawSnapshot | null): string {
const sync = snapshot?.sync; const sync = snapshot?.sync;
if (!sync) return '管理端同步:未连接'; if (!sync) return '管理端同步:未连接';
@@ -2343,7 +2418,8 @@ function businessEventRows(snapshot: QimingclawSnapshot | null): BusinessViewRow
function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const runtimeById = new Map(runtimeArtifacts(snapshot).map((artifact) => [artifact.id, artifact])); const runtimeById = new Map(runtimeArtifacts(snapshot).map((artifact) => [artifact.id, artifact]));
return buildArtifacts(snapshot).map((artifact) => { const artifacts = buildArtifacts(snapshot);
return artifacts.map((artifact) => {
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id); const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const runtime = runtimeById.get(artifactId); const runtime = runtimeById.get(artifactId);
const entity = businessEntityFromRefs({ const entity = businessEntityFromRefs({
@@ -2353,6 +2429,9 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView
fallback_type: 'artifact', fallback_type: 'artifact',
fallback_id: artifact.subject_id ?? artifactId, fallback_id: artifact.subject_id ?? artifactId,
}); });
const lifecycle = artifactLifecycleSummary(snapshot, artifactId);
const version = artifactVersionMeta(artifacts, artifact);
const delivery = artifactDeliverySummary(artifact);
return { return {
...artifact, ...artifact,
artifact_id: artifactId, artifact_id: artifactId,
@@ -2360,10 +2439,53 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView
entity_type: entity.entity_type, entity_type: entity.entity_type,
entity_id: entity.entity_id, entity_id: entity.entity_id,
sync_status: (runtime?.syncStatus ?? stringValue(artifact.sync_status)) || null, sync_status: (runtime?.syncStatus ?? stringValue(artifact.sync_status)) || null,
version: version.version,
version_key: version.version_key,
version_rank: version.version_rank,
retention_status: lifecycle.retention_status,
retention_until: lifecycle.retention_until,
last_retention_action: lifecycle.last_retention_action,
last_access_status: stringValue(asRecord(artifact.access)?.last_access_status) || null,
delivery_summary: delivery,
}; };
}); });
} }
function artifactDetailRow(snapshot: QimingclawSnapshot | null, artifactId: string): BusinessViewRow | null {
const row = businessArtifactRows(snapshot)
.find((artifact) => stringValue(artifact.artifact_id) === artifactId || stringValue(artifact.remote_id) === artifactId) ?? null;
if (!row) return null;
return {
...row,
versions: artifactVersionRows(snapshot, stringValue(row.artifact_id)),
lifecycle_events: artifactLifecycleRows(snapshot).filter((event) => stringValue(event.artifact_id) === stringValue(row.artifact_id)).slice(0, 20),
};
}
function artifactVersionRows(snapshot: QimingclawSnapshot | null, artifactId: string): BusinessViewRow[] {
const artifacts = buildArtifacts(snapshot);
const target = artifacts.find((artifact) => stringValue(artifact.artifact_id) === artifactId || stringValue(artifact.subject_id) === artifactId);
if (!target) return [];
const groupKey = artifactVersionGroupKey(target);
return artifacts
.filter((artifact) => artifactVersionGroupKey(artifact) === groupKey)
.sort((left, right) => stringValue(left.created_at).localeCompare(stringValue(right.created_at)))
.map((artifact, index) => {
const meta = artifactVersionMeta(artifacts, artifact);
return {
artifact_id: stringValue(artifact.artifact_id) || stringValue(artifact.subject_id),
name: stringValue(artifact.name) || null,
version: meta.version || `local-v${index + 1}`,
version_key: meta.version_key,
version_rank: index + 1,
delivery_summary: artifactDeliverySummary(artifact),
retention_status: artifactLifecycleSummary(snapshot, stringValue(artifact.artifact_id)).retention_status,
created_at: stringValue(artifact.created_at) || null,
source_label: stringValue(artifact.source_label) || null,
};
});
}
function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const runtimeById = new Map(runtimeApprovals(snapshot).map((approval) => [approval.id, approval])); const runtimeById = new Map(runtimeApprovals(snapshot).map((approval) => [approval.id, approval]));
return buildApprovals(snapshot).map((approval) => { return buildApprovals(snapshot).map((approval) => {
@@ -2464,6 +2586,117 @@ function filterArtifactAccessRows(rows: BusinessViewRow[], searchParams: URLSear
.filter((row) => !status || stringValue(row.status) === status); .filter((row) => !status || stringValue(row.status) === status);
} }
function artifactLifecycleRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return runtimeEvents(snapshot)
.filter((event) => ['artifact_retention_marked', 'artifact_retention_rejected', 'artifact_version_observed'].includes(event.kind))
.map((event) => {
const payload = asRecord(event.payload) ?? {};
return {
lifecycle_id: event.id,
event_id: event.id,
kind: event.kind,
artifact_id: stringValue(payload.artifact_id) || null,
action: stringValue(payload.action) || null,
status: stringValue(payload.status) || null,
retention_status: stringValue(payload.retention_status) || null,
retention_until: stringValue(payload.retention_until) || null,
reason: stringValue(payload.reason) || null,
actor: stringValue(payload.actor) || null,
source: stringValue(payload.source) || null,
plan_id: (event.planId ?? stringValue(payload.plan_id)) || null,
task_id: (event.taskId ?? stringValue(payload.task_id)) || null,
run_id: (event.runId ?? stringValue(payload.run_id)) || null,
message: event.message,
occurred_at: event.occurredAt,
sync_status: event.syncStatus ?? null,
payload,
};
})
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
}
function artifactLifecycleSummary(snapshot: QimingclawSnapshot | null, artifactId: string): Record<string, unknown> {
const latest = artifactLifecycleRows(snapshot)
.find((event) => stringValue(event.artifact_id) === artifactId && stringValue(event.kind) === 'artifact_retention_marked');
return {
retention_status: stringValue(latest?.retention_status) || 'unmanaged',
retention_until: stringValue(latest?.retention_until) || null,
last_retention_action: stringValue(latest?.action) || null,
last_retention_at: stringValue(latest?.occurred_at) || null,
};
}
function artifactRetentionSummaryForAction(
artifact: BusinessViewRow,
action: string,
body: Record<string, unknown>,
): Record<string, unknown> {
return {
previous_status: stringValue(artifact.retention_status) || 'unmanaged',
retention_status: retentionStatusForAction(action),
retention_until: stringValue(body.retention_until ?? body.retentionUntil) || null,
reason: stringValue(body.reason) || null,
};
}
function retentionStatusForAction(action: string): string | null {
if (action === 'mark_keep') return 'keep';
if (action === 'mark_expire') return 'expire_candidate';
if (action === 'mark_review') return 'review_required';
if (action === 'clear_retention') return 'unmanaged';
return null;
}
function artifactVersionSummary(artifact: BusinessViewRow): Record<string, unknown> {
return {
version: stringValue(artifact.version) || null,
version_key: stringValue(artifact.version_key) || null,
version_rank: numberValue(artifact.version_rank),
file_id: stringValue(artifact.file_id) || null,
workspace_id: stringValue(artifact.workspace_id) || null,
project_id: stringValue(artifact.project_id) || null,
created_at: stringValue(artifact.created_at) || null,
name: stringValue(artifact.name) || null,
};
}
function artifactDeliverySummary(artifact: Record<string, unknown>): Record<string, unknown> {
return {
delivery_status: stringValue(artifact.delivery_status) || null,
delivery_stage: stringValue(artifact.delivery_stage) || null,
workspace_id: stringValue(artifact.workspace_id) || null,
project_id: stringValue(artifact.project_id) || null,
file_id: stringValue(artifact.file_id) || null,
version: stringValue(artifact.version) || null,
file_size: numberValue(artifact.file_size),
source_tool: stringValue(artifact.source_tool) || null,
};
}
function artifactVersionMeta(artifacts: ArtifactEntry[], artifact: Record<string, unknown>): Record<string, unknown> {
const groupKey = artifactVersionGroupKey(artifact);
const group = artifacts
.filter((item) => artifactVersionGroupKey(item) === groupKey)
.sort((left, right) => stringValue(left.created_at).localeCompare(stringValue(right.created_at)));
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const index = Math.max(0, group.findIndex((item) => stringValue(item.artifact_id) === artifactId || stringValue(item.subject_id) === artifactId));
return {
version: stringValue(artifact.version) || `local-v${index + 1}`,
version_key: `${groupKey}:v${stringValue(artifact.version) || index + 1}`,
version_rank: index + 1,
};
}
function artifactVersionGroupKey(artifact: Record<string, unknown>): string {
const fileId = stringValue(artifact.file_id);
const workspaceId = stringValue(artifact.workspace_id);
const name = stringValue(artifact.name);
if (fileId) return `file:${workspaceId}:${fileId}:${name}`;
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
if (artifactId) return `artifact:${artifactId}`;
return `fallback:${workspaceId}:${stringValue(artifact.project_id)}:${name || stringValue(artifact.uri)}`;
}
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return buildArtifacts(snapshot) return buildArtifacts(snapshot)
.filter(isDailyReportArtifact) .filter(isDailyReportArtifact)
@@ -3077,18 +3310,28 @@ function buildArtifacts(snapshot: QimingclawSnapshot | null): ArtifactEntry[] {
runId: event.runId, runId: event.runId,
})), })),
]; ];
return dedupeArtifacts(artifacts).map((artifact) => withArtifactAccessSummary(artifact, snapshot)); const deduped = dedupeArtifacts(artifacts);
return deduped.map((artifact) => withArtifactAccessSummary(artifact, snapshot, deduped));
} }
function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: QimingclawSnapshot | null): ArtifactEntry { function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: QimingclawSnapshot | null, artifacts: ArtifactEntry[]): ArtifactEntry {
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id); const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const lastAccess = artifactAccessRows(snapshot).find((row) => stringValue(row.artifact_id) === artifactId) ?? null; const lastAccess = artifactAccessRows(snapshot).find((row) => stringValue(row.artifact_id) === artifactId) ?? null;
const hasLocalUri = isLocalArtifactUri(stringValue(artifact.uri)); const hasLocalUri = isLocalArtifactUri(stringValue(artifact.uri));
const hasDailyText = isDailyReportArtifact(artifact) && Boolean(stringValue(asRecord(artifact.payload)?.text_content)); const hasDailyText = isDailyReportArtifact(artifact) && Boolean(stringValue(asRecord(artifact.payload)?.text_content));
const formalRuntimeArtifact = runtimeArtifacts(snapshot).some((runtime) => runtime.id === artifactId || runtime.remoteId === artifactId); const formalRuntimeArtifact = runtimeArtifacts(snapshot).some((runtime) => runtime.id === artifactId || runtime.remoteId === artifactId);
const previewable = formalRuntimeArtifact && (hasDailyText || hasLocalUri); const previewable = formalRuntimeArtifact && (hasDailyText || hasLocalUri);
const lifecycle = artifactLifecycleSummary(snapshot, artifactId);
const version = artifactVersionMeta(artifacts, artifact);
return { return {
...artifact, ...artifact,
version: version.version,
version_key: version.version_key,
version_rank: version.version_rank,
retention_status: lifecycle.retention_status,
retention_until: lifecycle.retention_until,
last_retention_action: lifecycle.last_retention_action,
delivery_summary: artifactDeliverySummary(artifact),
access: { access: {
previewable, previewable,
downloadable: formalRuntimeArtifact && hasLocalUri, downloadable: formalRuntimeArtifact && hasLocalUri,
@@ -3733,6 +3976,11 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
project_id: stringValue(artifact.project_id) || null, project_id: stringValue(artifact.project_id) || null,
file_id: stringValue(artifact.file_id) || null, file_id: stringValue(artifact.file_id) || null,
version: stringValue(artifact.version) || null, version: stringValue(artifact.version) || null,
version_key: stringValue(artifact.version_key) || null,
version_rank: numberValue(artifact.version_rank),
retention_status: stringValue(artifact.retention_status) || null,
retention_until: stringValue(artifact.retention_until) || null,
last_retention_action: stringValue(artifact.last_retention_action) || null,
file_size: typeof artifact.file_size === 'number' ? artifact.file_size : null, file_size: typeof artifact.file_size === 'number' ? artifact.file_size : null,
source_tool: stringValue(artifact.source_tool) || null, source_tool: stringValue(artifact.source_tool) || null,
source_label: stringValue(artifact.source_label) || null, source_label: stringValue(artifact.source_label) || null,
@@ -3743,6 +3991,7 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
run_id: stringValue(artifact.run_id) || null, run_id: stringValue(artifact.run_id) || null,
created_at: stringValue(artifact.created_at) || null, created_at: stringValue(artifact.created_at) || null,
access: artifact.access, access: artifact.access,
delivery_summary: artifact.delivery_summary ?? null,
})); }));
} }

View File

@@ -1,11 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { Download, Eye, FolderOpen } from 'lucide-react'; import { Download, Eye, FolderOpen, History } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard'; import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar'; import CardTitleBar from '@/components/digital/CardTitleBar';
import { import {
getDigitalEmployeeWorkday, getDigitalEmployeeWorkday,
postDigitalEmployeeWorkdayAction, postDigitalEmployeeWorkdayAction,
accessArtifact, accessArtifact,
getArtifactDetail,
getArtifactVersions,
setArtifactRetention,
} from '@/lib/api'; } from '@/lib/api';
import type { import type {
DigitalEmployeeDailyReport, DigitalEmployeeDailyReport,
@@ -185,6 +188,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
const [busyKey, setBusyKey] = useState<string | null>(null); const [busyKey, setBusyKey] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [preview, setPreview] = useState<{ title: string; text: string } | null>(null); const [preview, setPreview] = useState<{ title: string; text: string } | null>(null);
const [detail, setDetail] = useState<{ artifact: DigitalEmployeeReportArtifactSource; item: any; versions: any[] } | null>(null);
if (artifacts.length === 0) { if (artifacts.length === 0) {
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>; return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>;
@@ -217,6 +221,47 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
} }
}; };
const loadDetail = async (artifact: DigitalEmployeeReportArtifactSource) => {
if (!artifact.id || busyKey) return;
setBusyKey(`${artifact.id}:detail`);
setNotice(null);
try {
const [item, versions] = await Promise.all([
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact, item, versions });
} catch (error) {
setNotice(error instanceof Error ? error.message : '读取产物详情失败');
} finally {
setBusyKey(null);
}
};
const runRetention = async (action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention') => {
const artifact = detail?.artifact;
if (!artifact?.id || busyKey) return;
setBusyKey(`${artifact.id}:${action}`);
setNotice(null);
try {
const result = await setArtifactRetention(artifact.id, action, { reason: 'digital_employee_artifact_governance' });
if (!result?.ok) {
setNotice(result?.error || '保留策略写入失败');
return;
}
const [item, versions] = await Promise.all([
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact: { ...artifact, retention_status: String(item?.retention_status || artifact.retention_status || 'unmanaged') }, item, versions });
setNotice('保留策略已记录');
} catch (error) {
setNotice(error instanceof Error ? error.message : '保留策略写入失败');
} finally {
setBusyKey(null);
}
};
return ( return (
<> <>
<div className="de-artifact-source-list" aria-label="日报产物来源"> <div className="de-artifact-source-list" aria-label="日报产物来源">
@@ -232,6 +277,13 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
<button type="button" className="de-icon-btn" disabled={!previewable || busyKey !== null} onClick={() => runAccess(artifact, 'preview')} title="预览" aria-label="预览"><Eye size={15} /></button> <button type="button" className="de-icon-btn" disabled={!previewable || busyKey !== null} onClick={() => runAccess(artifact, 'preview')} title="预览" aria-label="预览"><Eye size={15} /></button>
<button type="button" className="de-icon-btn" disabled={!downloadable || busyKey !== null} onClick={() => runAccess(artifact, 'download')} title="下载" aria-label="下载"><Download size={15} /></button> <button type="button" className="de-icon-btn" disabled={!downloadable || busyKey !== null} onClick={() => runAccess(artifact, 'download')} title="下载" aria-label="下载"><Download size={15} /></button>
<button type="button" className="de-icon-btn" disabled={!openable || busyKey !== null} onClick={() => runAccess(artifact, 'open_location')} title="位置" aria-label="位置"><FolderOpen size={15} /></button> <button type="button" className="de-icon-btn" disabled={!openable || busyKey !== null} onClick={() => runAccess(artifact, 'open_location')} title="位置" aria-label="位置"><FolderOpen size={15} /></button>
<button type="button" className="de-icon-btn" disabled={busyKey !== null} onClick={() => loadDetail(artifact)} title="版本" aria-label="版本"><History size={15} /></button>
</div>
<div className="de-artifact-source-meta">
<span>{artifact.version || 'local-v1'}</span>
<span>{artifact.retention_status || 'unmanaged'}</span>
{artifact.delivery_status ? <span>{artifact.delivery_status}</span> : null}
{artifact.access?.last_access_status ? <span>{artifact.access.last_access_status}</span> : null}
</div> </div>
{artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>} {artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>}
</div> </div>
@@ -245,6 +297,26 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
<pre>{preview.text}</pre> <pre>{preview.text}</pre>
</div> </div>
) : null} ) : null}
{detail ? (
<div className="de-artifact-preview">
<div><strong>{detail.artifact.name}</strong><button type="button" className="de-btn-secondary" onClick={() => setDetail(null)}></button></div>
<div className="de-artifact-retention-actions">
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_keep')}></button>
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_expire')}></button>
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_review')}></button>
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('clear_retention')}></button>
</div>
<div className="de-artifact-version-list">
{(detail.versions.length > 0 ? detail.versions : [detail.item]).filter(Boolean).map((item) => (
<div key={String(item.artifact_id || item.version_key || item.created_at)}>
<strong>{String(item.version || 'local-v1')}</strong>
<span>{String(item.delivery_summary?.delivery_status || item.source_label || '未记录')}</span>
<em>{String(item.created_at || '')}</em>
</div>
))}
</div>
</div>
) : null}
</> </>
); );
} }

View File

@@ -756,6 +756,11 @@ export interface DigitalEmployeeReportArtifactSource {
project_id?: string | null; project_id?: string | null;
file_id?: string | null; file_id?: string | null;
version?: string | null; version?: string | null;
version_key?: string | null;
version_rank?: number | null;
retention_status?: string | null;
retention_until?: string | null;
last_retention_action?: string | null;
file_size?: number | null; file_size?: number | null;
source_tool?: string | null; source_tool?: string | null;
source_label?: string | null; source_label?: string | null;
@@ -766,6 +771,7 @@ export interface DigitalEmployeeReportArtifactSource {
run_id?: string | null; run_id?: string | null;
created_at?: string | null; created_at?: string | null;
access?: ArtifactEntry['access']; access?: ArtifactEntry['access'];
delivery_summary?: Record<string, unknown> | null;
} }
export interface DigitalEmployeeDelivery { export interface DigitalEmployeeDelivery {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,8 +16,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error); console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
} }
</script> </script>
<script type="module" crossorigin src="./assets/index-XYzar59c.js"></script> <script type="module" crossorigin src="./assets/index-CtCKBhxB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4_0OumSc.css"> <link rel="stylesheet" crossorigin href="./assets/index-CeppSmWE.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -3,6 +3,7 @@ import type { HandlerContext } from "@shared/types/ipc";
const mockStateService = vi.hoisted(() => ({ const mockStateService = vi.hoisted(() => ({
recordManagedServiceAction: vi.fn(), recordManagedServiceAction: vi.fn(),
recordArtifactLifecycle: vi.fn(),
})); }));
const mockArtifactAccess = vi.hoisted(() => ({ const mockArtifactAccess = vi.hoisted(() => ({
@@ -66,6 +67,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
recordDigitalEmployeeSnapshot: vi.fn(() => ({ updatedAt: null, events: [] })), recordDigitalEmployeeSnapshot: vi.fn(() => ({ updatedAt: null, events: [] })),
recordDigitalEmployeeGovernanceCommand: vi.fn(), recordDigitalEmployeeGovernanceCommand: vi.fn(),
recordDigitalEmployeeDailyReport: vi.fn(), recordDigitalEmployeeDailyReport: vi.fn(),
recordDigitalEmployeeArtifactLifecycle: mockStateService.recordArtifactLifecycle,
recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction, recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction,
readDigitalEmployeeRuntimeRecords: vi.fn(), readDigitalEmployeeRuntimeRecords: vi.fn(),
readDigitalEmployeeState: vi.fn(), readDigitalEmployeeState: vi.fn(),
@@ -133,6 +135,7 @@ describe("digital employee managed service actions", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" }); mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" });
mockStateService.recordArtifactLifecycle.mockReturnValue({ eventId: "artifact-event-1", kind: "artifact_retention_marked", payload: {} });
mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" }); mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" });
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" }); mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
mockServiceManager.createServiceManager.mockReturnValue({ mockServiceManager.createServiceManager.mockReturnValue({
@@ -209,4 +212,20 @@ describe("digital employee managed service actions", () => {
expect(result).toMatchObject({ ok: true, artifact_id: "artifact-1", action: "preview" }); expect(result).toMatchObject({ ok: true, artifact_id: "artifact-1", action: "preview" });
expect(mockArtifactAccess.accessDigitalEmployeeArtifact).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "preview" }); expect(mockArtifactAccess.accessDigitalEmployeeArtifact).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "preview" });
}); });
it("registers artifact lifecycle commands through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const lifecycleCall = calls.find(([channel]) => channel === "digitalEmployee:recordArtifactLifecycle");
expect(lifecycleCall).toBeTruthy();
const handler = lifecycleCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>;
const result = await handler({}, { artifactId: "artifact-1", action: "mark_keep" });
expect(result).toMatchObject({ eventId: "artifact-event-1", kind: "artifact_retention_marked" });
expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" });
});
}); });

View File

@@ -21,6 +21,7 @@ import {
recordDigitalEmployeeSnapshot, recordDigitalEmployeeSnapshot,
recordDigitalEmployeeGovernanceCommand, recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeeDailyReport, recordDigitalEmployeeDailyReport,
recordDigitalEmployeeArtifactLifecycle,
recordDigitalEmployeeManagedServiceAction, recordDigitalEmployeeManagedServiceAction,
readDigitalEmployeeRuntimeRecords, readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState, readDigitalEmployeeState,
@@ -39,6 +40,7 @@ import {
type DigitalEmployeeManagedServiceActionInput, type DigitalEmployeeManagedServiceActionInput,
type DigitalEmployeeGovernanceCommandRecord, type DigitalEmployeeGovernanceCommandRecord,
type DigitalEmployeeDailyReportRecordInput, type DigitalEmployeeDailyReportRecordInput,
type DigitalEmployeeArtifactLifecycleInput,
type DigitalEmployeeMemoryUpsertInput, type DigitalEmployeeMemoryUpsertInput,
type DigitalEmployeeRouteDecisionInput, type DigitalEmployeeRouteDecisionInput,
type DigitalEmployeeServiceStatus, type DigitalEmployeeServiceStatus,
@@ -232,6 +234,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
}, },
); );
ipcMain.handle(
"digitalEmployee:recordArtifactLifecycle",
async (_, input: DigitalEmployeeArtifactLifecycleInput) => {
return recordDigitalEmployeeArtifactLifecycle(input ?? { action: "unknown_action" });
},
);
ipcMain.handle( ipcMain.handle(
"digitalEmployee:restartManagedService", "digitalEmployee:restartManagedService",
async (_, input: RestartManagedServiceRequest) => { async (_, input: RestartManagedServiceRequest) => {

View File

@@ -430,6 +430,73 @@ describe("digital employee state service", () => {
}); });
}); });
it("records artifact lifecycle events and rejects unknown retention actions", async () => {
const { recordDigitalEmployeeArtifactLifecycle } = await import("./stateService");
const marked = recordDigitalEmployeeArtifactLifecycle({
artifactId: "artifact-1",
action: "mark_keep",
reason: "operator_keep",
versionSummary: {
version: "v2",
version_key: "file:workspace:file:report:v2",
file_id: "file-1",
raw_path: "/Users/qiming/private/report.txt",
},
retentionSummary: {
previous_status: "unmanaged",
retention_status: "keep",
reason: "operator_keep",
},
deliverySummary: {
delivery_status: "completed",
file_id: "file-1",
secret_token: "should-not-be-persisted",
},
actor: "operator",
source: "test",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
occurredAt: "2026-06-07T08:35:00.000Z",
});
const rejected = recordDigitalEmployeeArtifactLifecycle({
artifactId: "artifact-1",
action: "delete_file",
occurredAt: "2026-06-07T08:36:00.000Z",
});
expect(marked?.eventId).toBe("artifact-lifecycle:artifact-1:mark_keep:2026-06-07T08-35-00-000Z");
expect(rejected?.kind).toBe("artifact_retention_rejected");
expect(mockState.db?.events.get(marked!.eventId)).toMatchObject({
kind: "artifact_retention_marked",
message: "产物保留策略已标记:保留 artifact-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
const payload = JSON.parse(mockState.db?.events.get(marked!.eventId)?.payload ?? "{}");
expect(payload).toMatchObject({
artifact_id: "artifact-1",
action: "mark_keep",
status: "recorded",
retention_status: "keep",
version_summary: { version: "v2", file_id: "file-1" },
retention_summary: { previous_status: "unmanaged", retention_status: "keep" },
delivery_summary: { delivery_status: "completed", file_id: "file-1" },
});
expect(JSON.stringify(payload)).not.toContain("/Users/qiming/private/report.txt");
expect(JSON.stringify(payload)).not.toContain("should-not-be-persisted");
expect(mockState.db?.outbox.get(`event:insert:${marked!.eventId}`)).toMatchObject({
entity_type: "event",
status: "pending",
});
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
action: "delete_file",
status: "rejected",
});
});
it("records and lists route decisions as syncable events", async () => { it("records and lists route decisions as syncable events", async () => {
const { const {
listDigitalEmployeeRouteDecisions, listDigitalEmployeeRouteDecisions,

View File

@@ -238,6 +238,31 @@ export interface DigitalEmployeeArtifactAccessAuditRecord {
payload: Record<string, unknown>; payload: Record<string, unknown>;
} }
export type DigitalEmployeeArtifactRetentionAction = "mark_keep" | "mark_expire" | "mark_review" | "clear_retention";
export interface DigitalEmployeeArtifactLifecycleInput {
artifactId?: string | null;
action: DigitalEmployeeArtifactRetentionAction | "observe_version" | string;
reason?: string | null;
retentionUntil?: string | null;
versionSummary?: Record<string, unknown> | null;
retentionSummary?: Record<string, unknown> | null;
deliverySummary?: Record<string, unknown> | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
occurredAt?: string | null;
message?: string | null;
}
export interface DigitalEmployeeArtifactLifecycleRecord {
eventId: string;
kind: "artifact_retention_marked" | "artifact_retention_rejected" | "artifact_version_observed";
payload: Record<string, unknown>;
}
export interface DigitalEmployeeRouteDecisionRecord { export interface DigitalEmployeeRouteDecisionRecord {
id: string; id: string;
route_kind: string; route_kind: string;
@@ -1082,6 +1107,97 @@ export function recordDigitalEmployeeArtifactAccess(
return { eventId, kind, status, payload }; return { eventId, kind, status, payload };
} }
export function recordDigitalEmployeeArtifactLifecycle(
input: DigitalEmployeeArtifactLifecycleInput,
): DigitalEmployeeArtifactLifecycleRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const artifactId = stringValue(input.artifactId) || "unknown-artifact";
const action = stringValue(input.action) || "unknown_action";
const validRetentionAction = isDigitalEmployeeArtifactRetentionAction(action);
const isVersionObserve = action === "observe_version";
const kind: DigitalEmployeeArtifactLifecycleRecord["kind"] = isVersionObserve
? "artifact_version_observed"
: validRetentionAction
? "artifact_retention_marked"
: "artifact_retention_rejected";
const retentionStatus = retentionStatusForAction(action);
const eventId = `artifact-lifecycle:${safeId(artifactId)}:${safeId(action)}:${safeId(now)}`;
const payload: Record<string, unknown> = {
artifact_id: artifactId,
action,
status: kind === "artifact_retention_rejected" ? "rejected" : "recorded",
reason: stringValue(input.reason) || null,
retention_status: retentionStatus,
retention_until: stringValue(input.retentionUntil) || null,
version_summary: sanitizeArtifactVersionSummary(objectRecord(input.versionSummary) ?? {}),
retention_summary: sanitizeArtifactRetentionSummary(objectRecord(input.retentionSummary) ?? {}),
delivery_summary: sanitizeArtifactDeliverySnapshot(objectRecord(input.deliverySummary) ?? {}),
actor: stringValue(input.actor) || null,
source: stringValue(input.source) || "qimingclaw-artifact-lifecycle",
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
};
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || artifactLifecycleMessage(kind, action, artifactId),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
return { eventId, kind, payload };
}
function isDigitalEmployeeArtifactRetentionAction(action: string): action is DigitalEmployeeArtifactRetentionAction {
return ["mark_keep", "mark_expire", "mark_review", "clear_retention"].includes(action);
}
function retentionStatusForAction(action: string): string | null {
if (action === "mark_keep") return "keep";
if (action === "mark_expire") return "expire_candidate";
if (action === "mark_review") return "review_required";
if (action === "clear_retention") return "unmanaged";
return null;
}
function sanitizeArtifactVersionSummary(summary: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set(["version", "version_key", "version_rank", "file_id", "workspace_id", "project_id", "created_at", "name"]);
return Object.fromEntries(
Object.entries(summary).filter(([key]) => allowedKeys.has(key)),
);
}
function sanitizeArtifactRetentionSummary(summary: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set(["retention_status", "retention_until", "previous_status", "reason"]);
return Object.fromEntries(
Object.entries(summary).filter(([key]) => allowedKeys.has(key)),
);
}
function artifactLifecycleMessage(
kind: DigitalEmployeeArtifactLifecycleRecord["kind"],
action: string,
artifactId: string,
): string {
if (kind === "artifact_version_observed") return `产物版本已观测:${artifactId}`;
if (kind === "artifact_retention_rejected") return `产物保留策略已拒绝:${artifactId}`;
const actionLabel = action === "mark_keep"
? "保留"
: action === "mark_expire"
? "到期候选"
: action === "mark_review"
? "待复核"
: "清除保留标记";
return `产物保留策略已标记:${actionLabel} ${artifactId}`;
}
function normalizeArtifactAccessAction(action: string): DigitalEmployeeArtifactAccessAction { function normalizeArtifactAccessAction(action: string): DigitalEmployeeArtifactAccessAction {
return ["preview", "download", "open_location"].includes(action) return ["preview", "download", "open_location"].includes(action)
? action as DigitalEmployeeArtifactAccessAction ? action as DigitalEmployeeArtifactAccessAction

View File

@@ -162,6 +162,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
...(typeof options === "object" && options ? options : {}), ...(typeof options === "object" && options ? options : {}),
}); });
}, },
async recordArtifactLifecycle(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordArtifactLifecycle", input);
},
async restartManagedService(serviceId: string, options?: unknown) { async restartManagedService(serviceId: string, options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) }); return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) });
}, },

View File

@@ -573,9 +573,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端审计消费和远端策略下发,把本地调用审计推进到跨端治理闭环。 - 建议:下一轮围绕管理端审计消费和远端策略下发,把本地调用审计推进到跨端治理闭环。
7. **产物交付闭环** 7. **产物交付闭环**
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件。 - 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件Artifact 版本/保留策略已开始闭环,业务产物视图可读取 version / retention 摘要,保留标记会写入 `artifact_retention_marked` / `artifact_retention_rejected` / `artifact_version_observed` 事件
- 缺口:仍缺少版本完整治理、保留策略和管理端业务产物视图的细化交互 - 缺口:仍缺少真实文件清理执行、保留策略批量治理和管理端跨设备聚合视图
- 建议:下一轮围绕 Artifact 版本、保留策略和管理端批量治理,把可操作交付视图推进到完整生命周期管理。 - 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。
8. **审批 / 人工介入增强** 8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event并可通过 embedded `/api/approval/:approvalId/timeline` 读取只读审批动作时间线,审批动作时间线已开始闭环。 - 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event并可通过 embedded `/api/approval/:approvalId/timeline` 读取只读审批动作时间线,审批动作时间线已开始闭环。