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
21 changes: 12 additions & 9 deletions admin/slices/agent/file/components/agentFile/Provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,12 @@ const confirmStore = useConfirmStore();
const route = useRoute();
const router = useRouter();

// ── Agent copy hint (CLEAN-50) ─────────────────────────────────────
// While the agent is Running, this tab shows the S3 copy but the pod works on
// its own. Read from the agent store's record (docs/state.md).
// ── Agent copy hint (CLEAN-50, reworded in CLEAN-115) ──────────────
// While the agent is Running, this tab shows the S3 copy. The pod works on
// its own copy and its watcher pushes whatever it changes to S3 within about
// 30 s, so the two rarely differ for long — the pill must not claim the pod
// holds something newer. Sync forces a full push and catches what the
// watcher missed. Read from the agent store's record (docs/state.md).
const agent = computed(() => agentStore.byId(props.id));
const agentRunning = computed(() => agent.value?.status === 'running');

Expand All @@ -204,14 +207,14 @@ function formatClock(iso: string | null): string | null {
const copyPill = computed(() => {
if (!agentRunning.value) return null;
const pulled = formatClock(agent.value?.lastPullAt ?? null);
return pulled ? `Agent copy is newer (${pulled})` : 'Agent works on its own copy';
return pulled ? `Agent running since ${pulled}` : 'Agent is running';
});

const copyPillTitle = computed(() => {
const pulled = agent.value?.lastPullAt ? new Date(agent.value.lastPullAt).toLocaleString() : null;
const synced = agent.value?.lastSyncAt ? new Date(agent.value.lastSyncAt).toLocaleString() : null;
const parts = [
'This tab shows the stored (S3) copy. The running agent works on its own copy and may hold newer content — Sync brings it in.',
'This tab shows the stored (S3) copy. The running agent works on its own copy and pushes the files it changes to S3 within about 30 seconds. Sync forces a full push and picks up anything the watcher missed.',
];
if (pulled) parts.push(`Agent took its copy ${pulled}.`);
if (synced) parts.push(`Last sync ${synced}.`);
Expand Down Expand Up @@ -406,8 +409,8 @@ async function onSync() {
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)}. ` +
'edited from Ranch (console, chat tools or import) 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',
Expand Down Expand Up @@ -621,12 +624,12 @@ watch(
<button
v-if="copyPill"
type="button"
class="flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-xs text-amber-900 hover:bg-amber-500/20 dark:text-amber-200"
class="flex items-center gap-1.5 rounded-md border border-border bg-muted/60 px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
:title="copyPillTitle"
:disabled="syncing"
@click="onSync"
>
<span class="size-1.5 rounded-full bg-amber-500" />
<span class="size-1.5 rounded-full bg-emerald-500" />
{{ copyPill }}
<span class="font-medium">{{ syncing ? 'Syncing…' : 'Sync now' }}</span>
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,7 @@ export const SyncConflictDtoSchema = {
},
atRisk: {
description:
"S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally.",
"S3 files written from Ranch (console, tools, import) after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. Objects the pod uploaded itself are excluded (no Ranch origin tag).",
type: "array",
items: {
$ref: "#/components/schemas/AtRiskFileDto",
Expand Down
4 changes: 2 additions & 2 deletions admin/slices/setup/api/data/repositories/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ export type SyncConflictDto = {
*/
requiresConfirmation: boolean;
/**
* S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally.
* S3 files written from Ranch (console, tools, import) after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. Objects the pod uploaded itself are excluded (no Ranch origin tag).
*/
atRisk: Array<AtRiskFileDto>;
/**
Expand Down Expand Up @@ -3103,7 +3103,7 @@ export type FileControllerSyncData = {

export type FileControllerSyncErrors = {
/**
* S3 files newer than the pod’s working copy were found and confirm was not set. No sync was performed.
* S3 files edited from Ranch after the pod’s last pull/push were found and confirm was not set. No sync was performed. Files the pod uploaded itself are not counted.
*/
409: SyncConflictDto;
};
Expand Down
103 changes: 103 additions & 0 deletions api/src/slices/agent/file/data/file.gateway.origin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
HeadObjectCommand,
PutObjectCommand,
S3ServiceException,
} from '@aws-sdk/client-s3';
import { ISettingGateway } from '../../../setting/domain/setting.gateway';
import { RANCH_ORIGIN_METADATA, S3FileGateway } from './file.gateway';

// Every S3 write made through Ranch carries an origin tag so the sync guard
// (CLEAN-115) can tell a console edit from the pod's own watcher upload. The
// SDK client is replaced by a recording `send`; command inputs stay real.
const send = jest.fn();

jest.mock('@aws-sdk/client-s3', () => {
const actual = jest.requireActual('@aws-sdk/client-s3');
return {
...actual,
S3Client: jest.fn().mockImplementation(() => ({ send })),
};
});

const settings = {
findByKey: async (_group: string, name: string) => ({
value: name === 's3_bucket' ? 'test-bucket' : '',
}),
} as unknown as ISettingGateway;

function notFound(): S3ServiceException {
return new S3ServiceException({
name: 'NotFound',
$fault: 'client',
$metadata: { httpStatusCode: 404 },
});
}

describe('S3FileGateway origin tag', () => {
let gateway: S3FileGateway;

beforeEach(() => {
send.mockReset();
gateway = new S3FileGateway(settings);
});

it('tags text saves with the Ranch origin', async () => {
send.mockResolvedValue({});
await gateway.saveRaw('agent-1', 'notes.md', 'hello');
const put = send.mock.calls[0][0] as PutObjectCommand;
expect(put).toBeInstanceOf(PutObjectCommand);
expect(put.input.Key).toBe('agents/agent-1/notes.md');
expect(put.input.Metadata).toEqual(RANCH_ORIGIN_METADATA);
});

it('tags raw byte writes (import) with the Ranch origin', async () => {
send.mockResolvedValue({});
await gateway.putObjectRaw('agent-1', 'img/logo.png', Buffer.from('x'));
const put = send.mock.calls[0][0] as PutObjectCommand;
expect(put.input.Metadata).toEqual(RANCH_ORIGIN_METADATA);
});

it('tags skill files written from the template bundle', async () => {
send.mockResolvedValue({ Contents: [] });
await gateway.syncSkills('agent-1', [
{ name: 'greet', body: '# greet', files: [{ path: 'x.md', content: 'x' }] },
]);
const puts = send.mock.calls
.map((c) => c[0])
.filter(
(c): c is PutObjectCommand =>
c instanceof PutObjectCommand &&
!String(c.input.Key).endsWith(S3FileGateway.MANAGED_MARKER),
);
expect(puts.length).toBe(2);
for (const put of puts) {
expect(put.input.Metadata).toEqual(RANCH_ORIGIN_METADATA);
}
});

describe('wasWrittenByRanch', () => {
it('is true for an object carrying the tag', async () => {
send.mockResolvedValue({ Metadata: { ...RANCH_ORIGIN_METADATA } });
await expect(
gateway.wasWrittenByRanch('agent-1', 'SOUL.md'),
).resolves.toBe(true);
const head = send.mock.calls[0][0] as HeadObjectCommand;
expect(head).toBeInstanceOf(HeadObjectCommand);
expect(head.input.Key).toBe('agents/agent-1/SOUL.md');
});

it('is false for an untagged object (pushed by the pod)', async () => {
send.mockResolvedValue({ Metadata: {} });
await expect(
gateway.wasWrittenByRanch('agent-1', 'data/usage.json'),
).resolves.toBe(false);
});

it('is false when the object is gone', async () => {
send.mockRejectedValue(notFound());
await expect(
gateway.wasWrittenByRanch('agent-1', 'gone.md'),
).resolves.toBe(false);
});
});
});
34 changes: 34 additions & 0 deletions api/src/slices/agent/file/data/file.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ const AGENT_OWNED_ROOT_FILES = new Set([
'MEMORY.md',
]);

// Object metadata stamped on every write Ranch makes into an agent prefix
// (CLEAN-115). The pod's S3 watcher uploads its own changes with no metadata
// (usage.json on every LLM call, memory, sessions), so the sync guard reads
// this tag to keep only console/tool/import edits in the at-risk list.
// Template copies (seed/resync) stay untagged on purpose: they land before
// the pod's boot pull, so they are never newer than the guard's baseline,
// and CopyObject would need MetadataDirective=REPLACE plus a re-declared
// content type to carry the tag.
export const RANCH_ORIGIN_METADATA: Readonly<Record<string, string>> = {
origin: 'ranch',
};

@Injectable()
export class S3FileGateway extends IFileGateway {
// Sentinel file written into every template-managed skill dir. syncSkills
Expand Down Expand Up @@ -461,6 +473,7 @@ export class S3FileGateway extends IFileGateway {
Key: key,
Body: content,
ContentType: this.contentType(path),
Metadata: RANCH_ORIGIN_METADATA,
}),
);
}
Expand Down Expand Up @@ -739,6 +752,7 @@ export class S3FileGateway extends IFileGateway {
Key: base + 'SKILL.md',
Body: skill.body,
ContentType: this.contentType('SKILL.md'),
Metadata: RANCH_ORIGIN_METADATA,
}),
);
written++;
Expand All @@ -750,6 +764,7 @@ export class S3FileGateway extends IFileGateway {
Key: base + file.path,
Body: file.content,
ContentType: this.contentType(file.path),
Metadata: RANCH_ORIGIN_METADATA,
}),
);
written++;
Expand Down Expand Up @@ -916,6 +931,7 @@ export class S3FileGateway extends IFileGateway {
Key: this.prefix(agentId) + path,
Body: bytes,
ContentType: contentType ?? this.contentType(path),
Metadata: RANCH_ORIGIN_METADATA,
}),
);
}
Expand Down Expand Up @@ -1053,6 +1069,24 @@ export class S3FileGateway extends IFileGateway {
}
}

async wasWrittenByRanch(agentId: string, path: string): Promise<boolean> {
this.assertSafePath(path);
const { client, bucket } = await this.connect();
try {
const head = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: this.prefix(agentId) + path,
}),
);
// S3 lower-cases user metadata keys on the way back.
return head.Metadata?.origin === RANCH_ORIGIN_METADATA.origin;
} catch (err) {
if (this.isNotFound(err)) return false;
throw err;
}
}

private proposalKey(proposalId: string): string {
return `${PROPOSAL_PREFIX}${proposalId}/content`;
}
Expand Down
8 changes: 8 additions & 0 deletions api/src/slices/agent/file/domain/file.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export abstract class IFileGateway {
// ── Change proposals (CLEAN-112) ──────────────────────────────
/** ETag of the stored object (quotes stripped), or null when absent. */
abstract headEtag(agentId: string, path: string): Promise<string | null>;
/**
* Whether the stored object was last written through Ranch (console save,
* agent tool, import, skill sync) rather than uploaded by the pod's own
* S3 watcher (CLEAN-115). Ranch tags every write it makes; an object
* without the tag is the pod's copy and can never be at risk from a Sync.
* False when the object is absent.
*/
abstract wasWrittenByRanch(agentId: string, path: string): Promise<boolean>;
/** Proposed content lives outside every agent prefix until applied. */
abstract putProposalContent(
proposalId: string,
Expand Down
58 changes: 56 additions & 2 deletions api/src/slices/agent/file/domain/syncGuard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,22 @@ import { IFileNode } from './file.types';
const T0 = new Date('2026-08-31T10:00:00Z').getTime();
const at = (offsetSec: number) => new Date(T0 + offsetSec * 1000);

function fileStub(nodes: IFileNode[]): IFileGateway {
/**
* `ranchPaths` — objects carrying the Ranch origin tag. Defaults to every
* node, i.e. "all of these were written from the console"; pass a subset to
* model files the pod's own watcher uploaded (CLEAN-115).
*/
function fileStub(
nodes: IFileNode[],
ranchPaths: string[] = nodes.map((n) => n.path),
): IFileGateway & { wasWrittenByRanch: jest.Mock } {
const tagged = new Set(ranchPaths);
return {
list: async (): Promise<IFileNode[]> => nodes,
} as unknown as IFileGateway;
wasWrittenByRanch: jest.fn(
async (_agentId: string, path: string) => tagged.has(path),
),
} as unknown as IFileGateway & { wasWrittenByRanch: jest.Mock };
}

const node = (path: string, updatedAt: Date): IFileNode => ({
Expand Down Expand Up @@ -86,5 +98,47 @@ describe('SyncGuardService', () => {
const result = await guard.assess('agent-1', at(0), null);
expect(result.atRisk).toEqual([]);
});

// CLEAN-115: the pod's fs.watch pusher uploads its own changes (usage.json
// on every LLM call, memory, sessions). Those objects are newer than the
// baseline but were never edited from Ranch — the pod already holds them.
it('ignores newer files the pod pushed itself (no Ranch origin tag)', async () => {
const files = fileStub(
[
node('data/usage.json', at(600)), // watcher upload → pod's own copy
node('SOUL.md', at(500)), // console save → at risk
],
['SOUL.md'],
);
const guard = new SyncGuardService(files);
const result = await guard.assess('agent-1', at(-1000), at(100));
expect(result.atRisk.map((n) => n.path)).toEqual(['SOUL.md']);
});

it('looks up the origin only for files newer than the baseline', async () => {
const files = fileStub([
node('data/usage.json', at(600)),
node('notes.md', at(-500)),
]);
const guard = new SyncGuardService(files);
await guard.assess('agent-1', at(-1000), at(100));
expect(files.wasWrittenByRanch).toHaveBeenCalledTimes(1);
expect(files.wasWrittenByRanch).toHaveBeenCalledWith(
'agent-1',
'data/usage.json',
);
});

it('reports nothing when every newer file came from the pod', async () => {
const guard = new SyncGuardService(
fileStub(
[node('data/usage.json', at(600)), node('memory/today.md', at(700))],
[],
),
);
const result = await guard.assess('agent-1', at(-1000), at(100));
expect(result.baseline).toEqual(at(100));
expect(result.atRisk).toEqual([]);
});
});
});
14 changes: 13 additions & 1 deletion api/src/slices/agent/file/domain/syncGuard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export interface ISyncRiskAssessment {
* "at risk" — a Sync could overwrite or delete them if the pod's local copy
* also changed. The platform cannot see the pod's local state, so this is an
* upper bound by design (false positives possible, false negatives not).
*
* CLEAN-115: the pod also has an fs.watch pusher that uploads its own
* changes as they happen (usage.json on every LLM call, memory, sessions).
* Those objects are newer than the baseline too, but they ARE the pod's copy
* and cannot be at risk. Ranch tags every write it makes; a newer object
* without the tag is a watcher upload and is dropped from the list.
*/
@Injectable()
export class SyncGuardService {
Expand All @@ -48,9 +54,15 @@ export class SyncGuardService {
// behave exactly as before the guard existed.
if (!baseline) return { baseline: null, atRisk: [] };
const nodes = await this.files.list(agentId);
const atRisk = nodes.filter(
const newer = nodes.filter(
(n) => n.updatedAt.getTime() > baseline.getTime(),
);
// One HeadObject per newer file — usually a handful, never the whole
// workspace. Files at or below the baseline are not looked up.
const origins = await Promise.all(
newer.map((n) => this.files.wasWrittenByRanch(agentId, n.path)),
);
const atRisk = newer.filter((_, i) => origins[i]);
return { baseline, atRisk };
}
}
6 changes: 4 additions & 2 deletions api/src/slices/agent/file/dtos/syncFiles.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export class SyncConflictDto {
@ApiProperty({
type: [AtRiskFileDto],
description:
'S3 files modified after the pod last pulled/pushed. A sync MAY ' +
'overwrite or delete them if the pod also changed them locally.',
'S3 files written from Ranch (console, tools, import) after the pod ' +
'last pulled/pushed. A sync MAY overwrite or delete them if the pod ' +
'also changed them locally. Objects the pod uploaded itself are ' +
'excluded (no Ranch origin tag).',
})
atRisk!: AtRiskFileDto[];

Expand Down
Loading
Loading