Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/007-knowledge-workspaces-research"
"feature_directory": "specs/008-agent-files-sync-safety"
}
2 changes: 2 additions & 0 deletions admin/slices/agent/agent/data/agent.mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,8 @@ export class AgentMapper {
firstDeployedAt:
typeof o.firstDeployedAt === 'string' ? o.firstDeployedAt : null,
launchContext: this.toLaunchContext(o.launchContext),
lastPullAt: typeof o.lastPullAt === 'string' ? o.lastPullAt : null,
lastSyncAt: typeof o.lastSyncAt === 'string' ? o.lastSyncAt : null,
config:
o.config && typeof o.config === 'object'
? (o.config as Record<string, unknown>)
Expand Down
5 changes: 5 additions & 0 deletions admin/slices/agent/agent/domain/agent.types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,6 +68,11 @@ export interface IAgentData {
/** Null ⇒ the agent has never been deployed. */
firstDeployedAt: string | null;
launchContext: LaunchContextTypes | null;
/** When the running pod last pulled its file working copy from S3 (at
* boot). Null ⇒ not restarted since the field shipped. */
lastPullAt: string | null;
/** When the last successful Sync push completed. */
lastSyncAt: string | null;
config: Record<string, unknown>;
resources: IAgentResources;
isPublic: boolean;
Expand Down
86 changes: 80 additions & 6 deletions admin/slices/agent/file/components/agentFile/Provider.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,10 +11,12 @@ import {
IconAlertTriangle,
IconDownload,
IconFiles,
IconInfoCircle,
IconRefresh,
IconX,
} from '@tabler/icons-vue';
import { until } from '@vueuse/core';
import type { IAgentData } from '#agent/domain';
import AgentFileTree from './Tree.vue';
import AgentFileViewer from './Viewer.vue';

Expand All@@ -24,6 +26,27 @@ const store = useAgentFileStore();
const agentStore = useAgentStore();
const confirmStore = useConfirmStore();

// The two-copy model hint (CLEAN-50): while the agent is Running, this tab
// shows the S3 copy but the pod works on its own — surface that instead of
// letting the operator wonder why a chat-driven change is not visible.
const agent = ref<IAgentData | null>(null);
const showCopyHint = computed(() => agent.value?.status === 'running');

function formatMoment(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
}

const copyHintDetail = computed(() => {
const pulled = formatMoment(agent.value?.lastPullAt ?? null);
const synced = formatMoment(agent.value?.lastSyncAt ?? null);
const parts: string[] = [];
if (pulled) parts.push(`agent took its copy ${pulled}`);
if (synced) parts.push(`last sync ${synced}`);
return parts.length ? ` (${parts.join(', ')})` : '';
});

const syncing = ref(false);
const syncError = ref<string | null>(null);
const syncMessage = ref<string | null>(null);
Expand DownExpand Up@@ -60,17 +83,51 @@ const sheetOpen = ref(false);
const dirty = computed(() => content.value !== original.value);
const pendingRestart = computed(() => store.isPendingRestart(props.id));

// Compact one-line summary for the confirm dialog (renders as plain text).
function describeAtRisk(files: { path: string }[]): string {
const MAX_LISTED = 8;
const listed = files
.slice(0, MAX_LISTED)
.map((f) => f.path)
.join(', ');
const rest = files.length - MAX_LISTED;
return rest > 0 ? `${listed} and ${rest} more` : listed;
}

async function onSync() {
syncing.value = true;
syncError.value = null;
syncMessage.value = null;
let agentOnline = false;
try {
const result = await store.sync(props.id);
agentOnline = result.agentOnline;
syncMessage.value = result.agentOnline
? `Agent pushed ${result.pushed} file${result.pushed === 1 ? '' : 's'}`
: 'Agent is offline — files are still up to date in S3';
let outcome = await store.sync(props.id);
if (outcome.status === 'conflict') {
const { atRisk } = outcome.conflict;
const ok = await confirmStore.ask({
title: 'Overwrite newer files in S3?',
description:
`${atRisk.length} file${atRisk.length === 1 ? ' was' : 's were'} ` +
'edited in S3 after the running agent last took its copy: ' +
`${describeAtRisk(atRisk)}. ` +
'If the agent also changed them, Sync will overwrite the S3 ' +
'version with the agent’s copy. Files changed only in S3 are safe.',
confirmLabel: 'Sync anyway',
cancelLabel: 'Cancel',
variant: 'destructive',
});
if (!ok) {
syncing.value = false;
return;
}
outcome = await store.sync(props.id, true);
}
if (outcome.status === 'done') {
const result = outcome.result;
agentOnline = result.agentOnline;
syncMessage.value = result.agentOnline
? `Agent pushed ${result.pushed} file${result.pushed === 1 ? '' : 's'}`
: 'Agent is offline — files are still up to date in S3';
}
} catch (err) {
syncError.value = (err as Error).message || 'Sync failed';
}
Expand DownExpand Up@@ -240,7 +297,12 @@ async function onDownload() {
useAsyncData(
`admin-agent-files-${props.id}`,
async () => {
await store.fetchList(props.id);
const [agentData] = await Promise.all([
// Hint-only: a failed agent fetch must not break the file browser.
agentStore.fetchById(props.id).catch(() => null),
store.fetchList(props.id),
]);
agent.value = agentData;
return true;
},
{ lazy: true },
Expand All@@ -249,6 +311,18 @@ useAsyncData(

<template>
<div class="flex flex-col gap-3">
<div
v-if="showCopyHint"
class="flex flex-wrap items-center gap-3 rounded-md border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-xs text-sky-900 dark:text-sky-200"
>
<IconInfoCircle class="size-4 shrink-0" />
<p class="flex-1 min-w-[14rem]">
This tab shows the stored (S3) copy of the files. The running agent
works on its own copy{{ copyHintDetail }} and may hold newer content —
press Sync to bring it in.
</p>
</div>

<div
v-if="pendingRestart"
class="flex flex-wrap items-center gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200"
Expand Down
2 changes: 2 additions & 0 deletions admin/slices/agent/file/components/agentFile/Tree.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ interface FileNode {
name: string;
path: string;
size: number;
updatedAt: string;
}

type TreeNode = FolderNode | FileNode;
Expand DownExpand Up@@ -52,6 +53,7 @@ function buildTree(files: IFileNode[]): TreeNode[] {
name: segments[segments.length - 1],
path: file.path,
size: file.size,
updatedAt: file.updatedAt,
});
}
sortTree(root);
Expand Down
11 changes: 11 additions & 0 deletions admin/slices/agent/file/components/agentFile/TreeNode.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ interface FileNodeT {
name: string;
path: string;
size: number;
updatedAt: string;
}

type NodeT = FolderNodeT | FileNodeT;
Expand DownExpand Up@@ -43,6 +44,15 @@ function formatSize(n: number) {
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}

// S3-copy freshness (CLEAN-50): shown in the row tooltip so an operator can
// tell how old the stored copy is without opening the file.
function fileTitle(node: FileNodeT): string {
const d = new Date(node.updatedAt);
return Number.isNaN(d.getTime())
? node.path
: `${node.path} — last modified ${d.toLocaleString()}`;
}
</script>

<template>
Expand DownExpand Up@@ -90,6 +100,7 @@ function formatSize(n: number) {
type="button"
class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
:style="{ paddingLeft: `calc(0.5rem + ${indent})` }"
:title="fileTitle(node)"
@click="emit('select', node.path)"
>
<IconFile class="size-4 shrink-0 text-muted-foreground" />
Expand Down
34 changes: 30 additions & 4 deletions admin/slices/agent/file/data/agentFile.gateway.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,11 @@ import { BaseGateway } from '#common/data/BaseGateway';
import { unwrapEnvelope } from '#common/data/unwrapEnvelope';
import { IAgentFileGateway } from '../domain/agentFile.gateway';
import type {
IAtRiskFile,
IFileChunk,
IFileContent,
IFileNode,
ISyncResult,
ISyncOutcome,
} from '../domain/agentFile.types';
import { AgentFileMapper } from './agentFile.mapper';

Expand DownExpand Up@@ -73,10 +74,35 @@ export class AgentFileGateway extends BaseGateway implements IAgentFileGateway {
});
}

sync(agentId: string): Promise<ISyncResult> {
sync(agentId: string, confirm = false): Promise<ISyncOutcome> {
return this.execute(async () => {
const res = await FilesService.fileControllerSync({ path: { agentId } });
return this.mapper.toSyncResult(unwrapEnvelope(res.data));
const res = await FilesService.fileControllerSync({
path: { agentId },
body: { confirm },
});
// 409 = guard refused: S3 holds edits newer than the pod's last
// pull/push and confirm was not set. Not an error for the domain —
// it's the "ask the operator" branch of the sync flow.
// The generated client is the heyapi AXIOS variant: its result union is
// (AxiosResponse & {error: undefined}) | (AxiosError & {error: <409 body>}),
// so `.response` only exists after narrowing to the error member.
if (res.error !== undefined && res.response?.status === 409) {
const conflict = res.error as {
atRisk?: IAtRiskFile[];
baseline?: string;
};
return {
status: 'conflict' as const,
conflict: {
atRisk: conflict.atRisk ?? [],
baseline: conflict.baseline ?? '',
},
};
}
return {
status: 'done' as const,
result: this.mapper.toSyncResult(unwrapEnvelope(res.data)),
};
});
}

Expand Down
9 changes: 7 additions & 2 deletions admin/slices/agent/file/domain/agentFile.gateway.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import type {
IFileChunk,
IFileContent,
IFileNode,
ISyncResult,
ISyncOutcome,
} from './agentFile.types';

/**
Expand All@@ -27,7 +27,12 @@ export abstract class IAgentFileGateway {
path: string,
recursive: boolean,
): Promise<number>;
abstract sync(agentId: string): Promise<ISyncResult>;
/**
* Asks the runtime to push its working copy to S3. Without `confirm` the
* server refuses (outcome 'conflict') when S3 holds edits newer than the
* pod's last pull/push; `confirm: true` runs the sync regardless.
*/
abstract sync(agentId: string, confirm?: boolean): Promise<ISyncOutcome>;
/** Streams the agent's S3 prefix as a ZIP; the store triggers the download. */
abstract exportZip(agentId: string): Promise<Blob>;
}
6 changes: 3 additions & 3 deletions admin/slices/agent/file/domain/agentFile.service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import type {
IFileChunk,
IFileContent,
IFileNode,
ISyncResult,
ISyncOutcome,
} from './agentFile.types';

/**
Expand DownExpand Up@@ -34,8 +34,8 @@ export class AgentFileService {
return this.gateway.remove(agentId, path, recursive);
}

sync(agentId: string): Promise<ISyncResult> {
return this.gateway.sync(agentId);
sync(agentId: string, confirm?: boolean): Promise<ISyncOutcome> {
return this.gateway.sync(agentId, confirm);
}

exportZip(agentId: string): Promise<Blob> {
Expand Down
18 changes: 18 additions & 0 deletions admin/slices/agent/file/domain/agentFile.types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,3 +28,21 @@ export interface ISyncResult {
agentOnline: boolean;
pushed: number;
}

// S3 file modified after the pod's last pull/push — a sync MAY overwrite or
// delete it if the pod also changed it locally.
export interface IAtRiskFile {
path: string;
updatedAt: string;
}

export interface ISyncConflict {
atRisk: IAtRiskFile[];
baseline: string;
}

// Sync either ran ('done') or was refused with the at-risk list ('conflict');
// a conflict is resolved by calling sync again with confirm=true.
export type ISyncOutcome =
| { status: 'done'; result: ISyncResult }
| { status: 'conflict'; conflict: ISyncConflict };
4 changes: 2 additions & 2 deletions admin/slices/agent/file/stores/agentFile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,8 +119,8 @@ export const useAgentFileStore = defineStore('agentFile', () => {
return deleted;
}

function sync(agentId: string) {
return getService().sync(agentId);
function sync(agentId: string, confirm?: boolean) {
return getService().sync(agentId, confirm);
}

// Streams the agent's S3 prefix as a ZIP into a browser download.
Expand Down
Loading
Loading