吸收数字员工产物生命周期治理
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? []);
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 : {}) });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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` 读取只读审批动作时间线,审批动作时间线已开始闭环。
|
||||||
|
|||||||
Reference in New Issue
Block a user