Skip to content
3 changes: 3 additions & 0 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -776,10 +776,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
);
return;
}
// A link in a row that ran in another folder opens this screen with that folder; keep it.
const routeCwd = firstRouteParam(params.cwd);
const destination = {
environmentId: String(environmentId),
threadId: String(threadId),
path: segments,
...(routeCwd === null ? {} : { cwd: routeCwd }),
};
if (directoryPath !== null) navigation.dispatch(StackActions.push("ThreadFile", destination));
else navigation.navigate("ThreadFile", destination);
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ import {
type ThreadComposerProps,
} from "./ThreadComposer";
import { ComposerPopoverHost } from "./ComposerPopoverHost";
import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
import { ThreadFeed, type ThreadFeedHistoryControls, type ThreadFeedProps } from "./ThreadFeed";
import { WorkspaceMutationIdProvider } from "./WorkspaceMutationIdProvider";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
Expand Down Expand Up @@ -283,6 +283,8 @@ export interface ThreadDetailScreenProps {
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
readonly runs?: ThreadFeedProps["runs"];
readonly runFolders?: ThreadFeedProps["runFolders"];
readonly selectedThreadQueueCount: number;
readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
readonly dispatchingMessageId: MessageId | null;
Expand Down Expand Up @@ -1489,6 +1491,8 @@ function ThreadDetailBody(
threadId={props.selectedThread.id}
detailRef={props.selectedThreadDetailRef}
workspaceRoot={props.threadCwd}
runs={props.runs}
runFolders={props.runFolders}
feed={props.selectedThreadFeed}
pendingHandoff={props.pendingHandoff}
worktreeSetup={props.worktreeSetup}
Expand Down
140 changes: 101 additions & 39 deletions apps/mobile/src/features/threads/ThreadFeed.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,8 @@ function ThreadRouteContent(
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
runs={selectedThreadDetail?.runs}
runFolders={selectedThreadDetail?.runFolders}
selectedThreadQueueCount={composer.selectedThreadQueueCount}
queuedMessages={
selectedThreadCreation && selectedThreadCreation.outcome?.kind !== "delivered"
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/native/useMarkdownImageSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export function useMarkdownImageSource(props: {
readonly threadId: ThreadId | null;
readonly workspaceRoot: string | null;
readonly captured?: boolean;
}): MarkdownImageSourceResolver {
}): (
image: Parameters<MarkdownImageSourceResolver>[0],
/** Replaces the workspace root for one image: a feed row that ran in another folder. */
root?: string,
) => ReturnType<MarkdownImageSourceResolver> {
const { environmentId, threadId, workspaceRoot, captured } = props;
const connection = usePreparedConnection(environmentId);
const environment = useEnvironmentPresentation(environmentId);
Expand All @@ -33,10 +37,10 @@ export function useMarkdownImageSource(props: {
const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null;
const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false });
return useCallback(
async (image) => {
async (image, root) => {
const media = resolveMobileMarkdownMediaSource(image.href, {
threadId: threadId ?? undefined,
workspaceRoot,
workspaceRoot: root ?? workspaceRoot,
imageEmbed: true,
});
if (media?.access === "direct") return { uri: normalizeNativeMarkdownUrl(media.uri) };
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ describe("RPC authorization scopes", () => {
}
});

it("keeps triage, which seeds an agent that reads logs and the database, behind operate scope", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareTriage)).toBe(
AuthOrchestrationOperateScope,
);
it("keeps triage, which seeds an agent that reads logs and the database, and thread moves behind operate scope", () => {
for (const method of [WS_METHODS.serverPrepareTriage, WS_METHODS.threadsMove]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope);
}
});

it("separates passive terminal observation from operations that can change a shell", () => {
Expand Down
105 changes: 103 additions & 2 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it, vi } from "@effect/vitest";
import { CheckpointRef, CheckpointScopeId, RunId, ThreadId } from "@t3tools/contracts";
import { CheckpointRef, CheckpointScopeId, NodeId, RunId, ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand All @@ -20,6 +20,7 @@ import { CheckpointRefUnavailableError, CheckpointTurnRangeUnavailableError } fr
const threadId = ThreadId.make("thread:checkpoint-diff-v2");
const firstRunId = RunId.make("run:checkpoint-diff-v2:1");
const secondRunId = RunId.make("run:checkpoint-diff-v2:2");
const thirdRunId = RunId.make("run:checkpoint-diff-v2:3");
const firstScopeId = CheckpointScopeId.make("scope:checkpoint-diff-v2:1");
const secondScopeId = CheckpointScopeId.make("scope:checkpoint-diff-v2:2");
const secondRef = CheckpointRef.make("refs/t3/test/second");
Expand Down Expand Up @@ -51,12 +52,23 @@ function layerFor(input: {
readonly projection: Effect.Effect<ProjectionCheckpointContext, OrchestratorProjectionError>;
readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"];
readonly authoredPaths?: ReadonlySet<string>;
readonly runs?: ReadonlyArray<{
readonly id: RunId;
readonly cwd?: string;
readonly rootNodeId?: NodeId;
}>;
readonly nodes?: ReadonlyArray<{
readonly id: NodeId;
readonly checkpointScopeId: CheckpointScopeId;
}>;
}) {
return CheckpointDiffQuery.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagement.ThreadManagementService)({
getCheckpointContext: () => input.projection,
getThreadRecords: () =>
Effect.succeed({ runs: input.runs ?? [], nodes: input.nodes ?? [] } as never),
}),
Layer.mock(CheckpointStore.CheckpointStore)({
diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")),
Expand Down Expand Up @@ -107,7 +119,6 @@ it.effect("excludes ready checkpoints from rolled-back runs", () => {
});

function makeProjectionAfterStoppedRun(stoppedRunCheckpoint: boolean) {
const thirdRunId = RunId.make("run:checkpoint-diff-v2:3");
const checkpoint = (
ordinalWithinScope: number,
runId: RunId | null,
Expand Down Expand Up @@ -190,6 +201,50 @@ it.effect.each([false, true])(
},
);

it.effect("diffs a past turn in the folder its run recorded", () => {
const moved = makeProjectionAfterStoppedRun(true);
const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) =>
Effect.succeed("diff"),
);
const layer = layerFor({
projection: Effect.succeed({
...moved,
runs: moved.runs.map((run) => ({ ...run, status: "completed" as const })),
checkpointScopes: [
{ id: secondScopeId, runId: thirdRunId, kind: "root_run", cwd: "/repo-c" },
{ ...moved.checkpointScopes[0]!, cwd: "/repo-c" },
],
}),
runs: [
{ id: firstRunId },
{ id: secondRunId, cwd: "/repo-a" },
{ id: thirdRunId, cwd: "/repo-b" },
],
diffCheckpoints,
});

return Effect.gen(function* () {
const query = yield* CheckpointDiffQuery.CheckpointDiffQuery;
yield* query.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 2 });
yield* query.getTurnDiff({ threadId, fromTurnCount: 0, toTurnCount: 1 });
yield* query.getTurnDiff({ threadId, fromTurnCount: 2, toTurnCount: 3 });
for (const fromTurnCount of [0, 1]) {
assert.deepInclude(
yield* query.getTurnDiff({ threadId, fromTurnCount, toTurnCount: 3 }).pipe(Effect.flip),
{ _tag: "CheckpointRefUnavailableError", checkpoint: "from", turnCount: fromTurnCount },
);
}
assert.deepEqual(
diffCheckpoints.mock.calls.map(([input]) => input.cwd),
["/repo-a", "/repo-c", "/repo-b"],
);
assert.strictEqual(
diffCheckpoints.mock.calls[1]![0].fromCheckpointRef,
yield* checkpointRefForScopeOrdinal({ scopeId: firstScopeId, ordinalWithinScope: 0 }),
);
}).pipe(Effect.provide(layer));
});

const layerVcsProcess = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
const layerStore = CheckpointStore.layer.pipe(
Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcess))),
Expand Down Expand Up @@ -254,6 +309,7 @@ it.effect("shares the turn and full-thread diff budget across real Git batches",
const query = yield* CheckpointDiffQuery.make.pipe(
Effect.provide(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadRecords: () => Effect.succeed({ runs: [] } as never),
getCheckpointContext: () =>
Effect.succeed({
runs: [{ id: runId, ordinal: 1, status: "completed" }],
Expand Down Expand Up @@ -343,3 +399,48 @@ it.effect("preserves an exact-fit filtered patch across batches without a marker
assert.strictEqual(patchReads, 2);
}).pipe(Effect.provide(layer));
});

it.effect("retains the thread baseline when its first run failed without a checkpoint", () => {
const projection = makeProjectionAfterStoppedRun(true);
const rootNodeId = NodeId.make("node:failed-first-run");
const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) =>
Effect.succeed("diff"),
);
return Effect.gen(function* () {
const query = yield* CheckpointDiffQuery.CheckpointDiffQuery;
const result = yield* query.getFullThreadDiff({ threadId, toTurnCount: 3 });
assert.strictEqual(result.diff, "diff");
assert.strictEqual(
diffCheckpoints.mock.calls[0]![0].fromCheckpointRef,
yield* checkpointRefForScopeOrdinal({ scopeId: firstScopeId, ordinalWithinScope: 0 }),
);
}).pipe(
Effect.provide(
layerFor({
projection: Effect.succeed({
...projection,
checkpointScopes: [
{
...projection.checkpointScopes[0]!,
id: CheckpointScopeId.make("unused-scope"),
runId: firstRunId,
},
...projection.checkpointScopes,
],
runs: projection.runs.map((run) =>
run.id === firstRunId ? { ...run, status: "failed" as const } : run,
),
checkpoints: projection.checkpoints.filter(
(checkpoint) => checkpoint.runId !== firstRunId,
),
}),
runs: [
{ id: firstRunId, cwd: "/repo", rootNodeId },
{ id: thirdRunId, cwd: "/repo" },
],
nodes: [{ id: rootNodeId, checkpointScopeId: firstScopeId }],
diffCheckpoints,
}),
),
);
});
54 changes: 44 additions & 10 deletions apps/server/src/checkpointing/CheckpointDiffQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,41 @@ export const make = Effect.gen(function* () {
)
: undefined;

const firstScope =
const firstRun = projection.runs.find((run) => run.ordinal === 1);
const fromCheckpoint =
parentCheckpoint ??
readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount);
// Run 1 captured the thread baseline, and each run captures its own baseline in its folder.
const fromRunId = input.fromTurnCount === 0 ? firstRun?.id : fromCheckpoint?.runId;
const threadNotFound = () =>
new CheckpointThreadNotFoundError({ operation, threadId: input.threadId });
const { runs } = yield* threads
.getThreadRecords(input.threadId, ["runs"], {
runIds: [toCheckpoint.runId, fromRunId].flatMap((runId) =>
runId == null ? [] : [runId],
),
})
.pipe(Effect.mapError(threadNotFound));
const firstScopeId = projection.checkpoints.find(
(checkpoint) => checkpoint.status === "ready" && checkpoint.runId === firstRun?.id,
)?.scopeId;
let firstScope =
input.fromTurnCount === 0
? projection.checkpointScopes.find((scope) => scope.kind === "root_run")
? projection.checkpointScopes.find((scope) => scope.id === firstScopeId)
: undefined;
const firstRootNodeId = runs.find((run) => run.id === firstRun?.id)?.rootNodeId;
if (input.fromTurnCount === 0 && firstScope === undefined && firstRootNodeId != null) {
const { nodes } = yield* threads
.getThreadRecords(input.threadId, ["nodes"], { nodeIds: [firstRootNodeId] })
.pipe(Effect.mapError(threadNotFound));
const firstNode = nodes.find((node) => node.id === firstRootNodeId);
firstScope = projection.checkpointScopes.find(
(scope) => scope.id === firstNode?.checkpointScopeId,
);
}
firstScope ??= projection.checkpointScopes.find(
(scope) => scope.kind === "root_run" && scope.runId === firstRun?.id,
);
const fromCheckpointRef =
input.fromTurnCount === 0
? firstScope === undefined
Expand All @@ -187,13 +218,16 @@ export const make = Effect.gen(function* () {
scopeId: firstScope.id,
ordinalWithinScope: 0,
}).pipe(Effect.provideService(Digest.Digest, digest))
: (
parentCheckpoint ??
readyCheckpoints.find(
(checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount,
)
)?.ref;
if (fromCheckpointRef === undefined) {
: fromCheckpoint?.ref;
const fromScopeId = input.fromTurnCount === 0 ? firstScope?.id : fromCheckpoint?.scopeId;
const folder = (runId: typeof fromRunId, scopeId: typeof fromScopeId) =>
runs.find((run) => run.id === runId)?.cwd ??
projection.checkpointScopes.find((scope) => scope.id === scopeId)?.cwd;
const cwd = folder(toCheckpoint.runId, toCheckpoint.scopeId) ?? toScope.cwd;
if (
fromCheckpointRef === undefined ||
(input.fromTurnCount !== input.toTurnCount - 1 && folder(fromRunId, fromScopeId) !== cwd)
) {
return yield* new CheckpointRefUnavailableError({
operation,
threadId: input.threadId,
Expand All @@ -203,7 +237,7 @@ export const make = Effect.gen(function* () {
}

const comparison = {
cwd: toScope.cwd,
cwd,
fromCheckpointRef,
toCheckpointRef: toCheckpoint.ref,
fallbackFromToHead: false,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export const make = Effect.gen(function* () {
threadActiveReorder: true,
threadAutoSettleOptOut: true,
threadProjectFromScratch: true,
threadWorkingFolderMove: true,
threadTitleRegeneration: true,
commandOutputStreaming: true,
threadVisitedTracking: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/guest/GuestShareService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const layerCleanup = Layer.effect(
const fileSystem = yield* FileSystem.FileSystem;
return {
cleanupScheduledTasks: () => Effect.void,
moveScheduledTasks: () => Effect.void,
cleanupTerminals: () => Effect.void,
cleanupPreviews: () => Effect.void,
cleanupDevices: () => Effect.void,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const RPC_AGGREGATES = {
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: "orchestrationV2",
[WS_METHODS.projectsMutate]: "orchestration",
[WS_METHODS.threadsMove]: "orchestration",
[WS_METHODS.serverProbe]: "server",
[WS_METHODS.serverGetConfig]: "server",
[WS_METHODS.serverGetNpmRelease]: "server",
Expand Down
Loading
Loading