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
7 changes: 7 additions & 0 deletions .changeset/quick-fixes-git-sync-rpc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/computer": patch
"@cloudflare/dofs": patch
"@cloudflare/computer-rpc": patch
---

Fix git diff/status/log edge cases, batch sync hash probes within Durable Object SQLite limits, and count tracked RPC targets by identity.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ packages/computer/src/backends/worker-shell/generated/
# blanket dist/ rule above; listed here for discoverability.
packages/computer-computerd-*/bin/computerd
packages/computer/dist/bin/computerd-*

# Local secrets for `wrangler dev`. Examples document putting
# Cloudflare API credentials in .dev.vars files that wrangler reads
# from the example directory; they must never be committed.
.dev.vars
**/.dev.vars
17 changes: 6 additions & 11 deletions docs/08_capnweb_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ alternative). The interface served is `WorkspaceRPC`, defined in
against the computerd's `/ws` endpoint, with `/api` available
as an HTTP-batch alternative (single POST per call) for callers that
can't hold a socket. Default port is `45678`; it will become a
build-time variable so hosts can pin a non-default port. The stale
`/rpc` comment at `packages/rpc/src/client.ts:18` is scheduled for
cleanup.
build-time variable so hosts can pin a non-default port.
- **Framing.** capnweb text frames. Binary frames are unsupported.
**(planned)** the server will fail the session loudly on the first
binary message; today the behaviour is unspecified.
Expand Down Expand Up @@ -108,9 +106,7 @@ interface SyncRPC {

// Container → DO direction of object transfer. Throws
// EUNKNOWN_HASH if any hash is unknown — callers must dedupe
// and probe first. (planned: today the code returns an empty
// payload for missing hashes; EUNKNOWN_HASH via
// createWorkspaceError is the deferred fix.)
// and probe first.
fetchObjects(hashes: Uint8Array[]):
ReadableStream<{ hash: Uint8Array; bytes: Uint8Array }>;

Expand Down Expand Up @@ -199,12 +195,12 @@ interface ShellRPC {
type ExecEvent =
| { id: string; seq: number; name: "stdout"; value: Uint8Array }
| { id: string; seq: number; name: "stderr"; value: Uint8Array }
| { id: string; seq: number; name: "exit"; value: number; result?: unknown };
| { id: string; seq: number; name: "exit"; code: number; result?: unknown };
```

The `exit` frame carries the process exit code and, for a callable
The `exit` frame carries the process exit code on `code` and, for a callable
backend that ran to a zero exit, the structured return value on
`result`. The value and the exit code settle together, so a single
`result`. The result and the exit code settle together, so a single
terminal frame carries both rather than splitting them across two
frames. Command backends never set `result`.

Expand Down Expand Up @@ -313,14 +309,13 @@ type WireErrorCode =
type WireError = {
code: WireErrorCode;
message: string;
detail?: unknown;
};
```

| Code | Meaning |
| --- | --- |
| `ENOENT` | Path does not exist on the receiver (covers ignored paths, which are invisible to `Workspace.fs`), or `getExec` / `disposeExec` referenced an unknown id. |
| `EUNKNOWN_HASH` | **(reserved, planned)** `fetchObjects` or `pushObjects` referenced a hash the receiver has no record of. Reserved in `WireErrorCode` but not raised today; `pushObjects` should throw it via `createWorkspaceError`. |
| `EUNKNOWN_HASH` | `fetchObjects` referenced a hash the receiver has no record of; raised via `createWorkspaceError`. |
| `EEXEC_BUSY` | `exec` was called with an `id` that's already in use by a live run. |
| `ELOG_TRUNCATED` | `getExec` resume point is older than the retained log. |
| `ESHUTDOWN` | **(reserved)** Server is shutting down; reconnect after the next boot. Not raised today. |
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ copies the prebuilt binary out of the public GHCR image and into a thin
Debian base:

```dockerfile
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM debian:stable-slim

Expand Down
2 changes: 1 addition & 1 deletion examples/container/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# back to the userspace shim transparently.


FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM debian:stable-slim

Expand Down
2 changes: 1 addition & 1 deletion examples/think-compare-runtimes/Dockerfile.workspace
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# that do not expose /dev/fuse.


FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM --platform=linux/amd64 node:22-trixie-slim

Expand Down
2 changes: 1 addition & 1 deletion examples/think/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# surface reads and writes.


FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM debian:stable-slim

Expand Down
2 changes: 1 addition & 1 deletion examples/tutorial/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# through the Workspace is on disk for pandoc to read, and the PDF
# pandoc writes syncs back to the durable object.

FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM debian:stable-slim

Expand Down
2 changes: 1 addition & 1 deletion packages/computer-computerd-linux-x64/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ builds the binary, stages it into `bin/computerd`, and publishes the image
instead:

```dockerfile
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd
FROM debian:stable-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
Expand Down
19 changes: 16 additions & 3 deletions packages/computer/src/git/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,7 +994,7 @@ describe("runGitCli — commit argv parsing", () => {
});

describe("runGitCli — log argv parsing", () => {
const sample = (oid: string, msg: string): CommitView => ({
const sample = (oid: string, msg: string, timezoneOffset = 0): CommitView => ({
oid,
message: msg,
tree: "",
Expand All @@ -1003,13 +1003,13 @@ describe("runGitCli — log argv parsing", () => {
name: "A",
email: "a@x",
timestamp: 1_700_000_000,
timezoneOffset: 0,
timezoneOffset,
},
committer: {
name: "A",
email: "a@x",
timestamp: 1_700_000_000,
timezoneOffset: 0,
timezoneOffset,
},
});

Expand All @@ -1036,10 +1036,23 @@ describe("runGitCli — log argv parsing", () => {
expect(res.exitCode).toBe(0);
expect(res.stdout).toContain(`commit ${"a".repeat(40)}`);
expect(res.stdout).toContain("Author: A <a@x>");
expect(res.stdout).toContain("Date: 2023-11-14 22:13:20 +0000");
expect(res.stdout).toContain(" hello");
expect(res.stdout).toContain(" world");
});

it("shifts full-form Date into the author's timezone", async () => {
const { client } = fakeClient(
{},
{
log: () => [sample("a".repeat(40), "hello", -330)],
},
);
const res = await runGitCli(client, { argv: ["log"] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toContain("Date: 2023-11-15 03:43:20 +0530");
});

it("-n forwards as depth", async () => {
const { client, calls } = fakeClient();
await runGitCli(client, { argv: ["log", "-n", "3"] });
Expand Down
10 changes: 5 additions & 5 deletions packages/computer/src/git/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,12 +826,12 @@ function formatLogFull(commits: CommitView[]): string {
}

function formatGitTimestamp(timestamp: number, timezoneOffset: number): string {
// isomorphic-git stores timezoneOffset as minutes east of UTC,
// but git's wire format is `git log`'s output uses minutes
// west (the inverse). The two are negatives of each other;
// pick the convention real git users see.
const d = new Date(timestamp * 1000);
// isomorphic-git stores timezoneOffset using Date.getTimezoneOffset's
// minutes-west-of-UTC convention. Git log renders the inverse offset
// (`+0530` for timezoneOffset -330) and shifts the wall clock into
// that zone before appending it.
const offsetMinutes = -timezoneOffset;
const d = new Date((timestamp + offsetMinutes * 60) * 1000);
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
Expand Down
58 changes: 58 additions & 0 deletions packages/computer/src/git/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ async function commitFile(path: string, content: string, message: string): Promi
return git.commit({ fs: memfs, dir: DIR, message, author: AUTHOR });
}

async function stageThenRemove(path: string): Promise<void> {
await memfs.promises.writeFile(`${DIR}/${path}`, "hello\n");
await git.add({ fs: memfs, dir: DIR, filepath: path });
await memfs.promises.unlink(`${DIR}/${path}`);
}

async function runDiff(opts: { ref?: string } = {}): Promise<string> {
return diffWith({
git: isomorphicGit,
Expand All @@ -57,6 +63,14 @@ describe("diffWith (real isomorphic-git + memfs)", () => {
expect(await runDiff()).toBe("");
});

it("returns '' for a staged addition removed from the working tree", async () => {
await init();
await commitFile("base.txt", "base\n", "init");
await stageThenRemove("added.txt");

expect(await runDiff()).toBe("");
});

it("returns '' when the working tree matches HEAD", async () => {
await init();
await commitFile("a.txt", "hello\n", "init");
Expand Down Expand Up @@ -274,6 +288,14 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => {
});
}

it("returns an empty list for a staged addition removed from the working tree", async () => {
await init();
await commitFile("base.txt", "base\n", "init");
await stageThenRemove("added.txt");

expect(await summary()).toEqual([]);
});

it("returns an empty list for a clean working tree", async () => {
await init();
await commitFile("a.txt", "hello\n", "init");
Expand All @@ -297,6 +319,14 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => {
expect(entries).toEqual([{ path: "b.txt", status: "A", insertions: 2, deletions: 0 }]);
});

it("reports an added empty file", async () => {
await init();
await commitFile("a.txt", "kept\n", "init");
await memfs.promises.writeFile(`${DIR}/empty.txt`, "");
const entries = await summary();
expect(entries).toEqual([{ path: "empty.txt", status: "A", insertions: 0, deletions: 0 }]);
});

it("reports a deleted file", async () => {
await init();
await commitFile("gone.txt", "a\nb\n", "init");
Expand All @@ -305,6 +335,14 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => {
expect(entries).toEqual([{ path: "gone.txt", status: "D", insertions: 0, deletions: 2 }]);
});

it("reports a deleted empty file", async () => {
await init();
await commitFile("empty.txt", "", "init");
await memfs.promises.unlink(`${DIR}/empty.txt`);
const entries = await summary();
expect(entries).toEqual([{ path: "empty.txt", status: "D", insertions: 0, deletions: 0 }]);
});

it("reports added and deleted files between two commits", async () => {
await init();
const first = await commitFile("keep.txt", "keep\n", "v1");
Expand All @@ -315,6 +353,26 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => {
expect(entries).toEqual([{ path: "new.txt", status: "A", insertions: 1, deletions: 0 }]);
});

it("reports empty file presence changes between two commits", async () => {
await init();
const first = await commitFile("removed.txt", "", "v1");
await memfs.promises.unlink(`${DIR}/removed.txt`);
await memfs.promises.writeFile(`${DIR}/added.txt`, "");
await git.remove({ fs: memfs, dir: DIR, filepath: "removed.txt" });
await git.add({ fs: memfs, dir: DIR, filepath: "added.txt" });
const second = await git.commit({
fs: memfs,
dir: DIR,
message: "empty changes",
author: AUTHOR,
});
const entries = await summary({ ref: first, to: second });
expect(entries).toEqual([
{ path: "added.txt", status: "A", insertions: 0, deletions: 0 },
{ path: "removed.txt", status: "D", insertions: 0, deletions: 0 },
]);
});

it("counts content lines that begin with diff header prefixes", async () => {
await init();
await commitFile("patch.txt", "-- old old\n", "v1");
Expand Down
15 changes: 8 additions & 7 deletions packages/computer/src/git/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,13 @@ async function collectDiffEntries(opts: DiffWithDeps): Promise<DiffEntry[]> {
if (workdirStatus === 1) continue;
if (!pathFilter(filepath)) continue;

const oldText =
headStatus === 1
? await readBlobAsText(opts.git, opts.fs, dir, head, filepath, opts.cache)
: "";
const newText =
workdirStatus === 2 ? await readWorkdirAsText(opts.readFile, dir, filepath) : "";
const oldExists = headStatus === 1;
const newExists = workdirStatus > 0;
const oldText = oldExists
? await readBlobAsText(opts.git, opts.fs, dir, head, filepath, opts.cache)
: "";
const newText = newExists ? await readWorkdirAsText(opts.readFile, dir, filepath) : "";
if (oldExists === newExists && oldText === newText) continue;
// headStatus 0 -> not in the base -> added. workdirStatus 0
// -> gone from the working tree -> deleted. Otherwise it's a
// content change.
Expand Down Expand Up @@ -221,7 +222,7 @@ async function collectRefToRef(
? await readBlobAsText(opts.git, opts.fs, dir, fromOid, filepath, opts.cache)
: "";
const b = inTo ? await readBlobAsText(opts.git, opts.fs, dir, toOid, filepath, opts.cache) : "";
if (a === b) continue;
if (inFrom === inTo && a === b) continue;
const status: DiffEntry["status"] = !inFrom ? "A" : !inTo ? "D" : "M";
entries.push({ path: filepath, status, oldText: a, newText: b });
}
Expand Down
11 changes: 11 additions & 0 deletions packages/computer/src/git/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ describe("statusWith — derived XY codes", () => {
]);
});

it("staged delete -> 'D '", async () => {
await init();
await commit("gone.txt", "x\n", "init");
await memfs.promises.unlink(`${DIR}/gone.txt`);
await git.remove({ fs: memfs, dir: DIR, filepath: "gone.txt" });

expect(await statusWith({ git: isogit, fs: memfs, dir: DIR })).toEqual([
{ path: "gone.txt", index: "D", worktree: " " },
]);
});

it("multiple entries are sorted lexicographically", async () => {
await init();
await commit("base.txt", "x\n", "init");
Expand Down
9 changes: 4 additions & 5 deletions packages/computer/src/git/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,11 @@ function indexCode(head: number, stage: number): StatusEntry["index"] {
}

// Derive the Y (workdir-vs-index) column.
// workdir absent, anything -> 'D' (deleted in workdir)
// not in HEAD, not in stage -> '?' (untracked)
// workdir differs from stage -> 'M'
// workdir same as stage -> ' '
// workdir absent, stage present -> 'D' (deleted in workdir)
// not in HEAD, not in stage -> '?' (untracked)
// workdir differs from stage -> 'M'
// workdir same as stage -> ' '
function worktreeCode(head: number, workdir: number, stage: number): StatusEntry["worktree"] {
if (workdir === 0 && stage === 0 && head === 1) return "D";
if (workdir === 0 && stage > 0) return "D";
if (head === 0 && stage === 0 && workdir > 0) return "?";
// workdirStatus 2 means "differs from HEAD"; stageStatus 3 means
Expand Down
Loading
Loading