Skip to content

Commit 09cc4f8

Browse files
committed
Release v0.1.2
1 parent ee3b14d commit 09cc4f8

178 files changed

Lines changed: 19963 additions & 11250 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎RELEASE.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
2-
"version": "0.1.1",
2+
"version": "0.1.2",
33
"license": "FSL-1.1-ALv2",
44
"baselineSha256": "7847f65a9b20b366a60d52d19ca3cd6bdd1bb09b8cb506659a270f27a21d8877",
55
"e2b": {
6-
"recipeSha256": "87bc0f64f07c3a4db8368720c3f69742d53bf6deec41124379b5e7b59a18b0dd",
6+
"recipeSha256": "8159896206584133aec265e481f4c915ed48b6736d6b24b7569712b4672bf5d6",
77
"profileSha256": "414dbecee2d47de36016ea05c736e2edff3a0deb298babf923887ea184a01c10"
88
},
99
"dxd": {
10-
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.1.1/dxd-linux-x64",
11-
"sha256": "011bed7a79ceabf7b1552587d0c09297c51eccef881c0eaf6f7b775b67ac9eb7"
10+
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.1.2/dxd-linux-x64",
11+
"sha256": "810626730c00c4bbe3952566b2ebfdc92bf375fd87446fb61d6c0a8041e65bac"
1212
},
1313
"toolchain": {
1414
"node": ">=22.19.0",

‎alchemy.run.ts‎

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ export default Alchemy.Stack(
257257
workspaceInactivityMs: Config.option(
258258
Config.number("DX_DEPLOYMENT_WORKSPACE_INACTIVITY_MS"),
259259
),
260+
turnstileTestKeys: Config.withDefault(
261+
Config.boolean("DX_DEPLOYMENT_TURNSTILE_TEST_KEYS"),
262+
false,
263+
),
260264
e2bApiKey: Config.option(Config.redacted("E2B_API_KEY")),
261265
githubApp: Config.option(Config.redacted("DX_INTEGRATION_GITHUB_APP")),
262266
sarvamApiKey: Config.option(Config.redacted("SARVAM_API_KEY")),
@@ -269,6 +273,13 @@ export default Alchemy.Stack(
269273
deploymentZone: Config.option(
270274
Config.nonEmptyString("DX_DEPLOYMENT_ZONE"),
271275
),
276+
workersDevSubdomain: Config.option(
277+
Config.nonEmptyString("DX_DEPLOYMENT_WORKERS_DEV_SUBDOMAIN"),
278+
),
279+
daemonIngressName: Config.withDefault(
280+
Config.nonEmptyString("DX_DEPLOYMENT_DAEMON_INGRESS_NAME"),
281+
"dx-dxd",
282+
),
272283
signupEnabled: Config.withDefault(
273284
Config.boolean("DX_SIGNUP_ENABLED"),
274285
false,
@@ -357,6 +368,14 @@ export default Alchemy.Stack(
357368
} as never)
358369
: undefined;
359370

371+
if (
372+
configuration.turnstileTestKeys &&
373+
configuration.environment !== "preview"
374+
)
375+
return yield* Effect.die(
376+
"DX_DEPLOYMENT_TURNSTILE_TEST_KEYS is restricted to previews.",
377+
);
378+
360379
if (Option.isSome(configuration.workspaceInactivityMs)) {
361380
const inactivityMs = configuration.workspaceInactivityMs.value;
362381
if (configuration.environment !== "preview")
@@ -390,6 +409,15 @@ export default Alchemy.Stack(
390409
}
391410

392411
const deploymentHostname = new URL(configuration.origin).hostname;
412+
// A resumed guest's daemon reconnects through a dependency-free Worker on
413+
// the account's workers.dev subdomain. Its isolate starts in milliseconds,
414+
// where the Core bundle's cold start (about a second) otherwise sits on
415+
// every wake. Without a workers.dev subdomain Core serves ingress itself.
416+
const daemonIngressName = configuration.daemonIngressName;
417+
const daemonIngressOrigin = Option.map(
418+
configuration.workersDevSubdomain,
419+
(subdomain) => `https://${daemonIngressName}.${subdomain}.workers.dev/`,
420+
);
393421
const database = yield* Cloudflare.D1.Database("Database", {
394422
name: configuration.databaseName,
395423
migrations: `apps/core/${migrationManifest.d1Directory}`,
@@ -398,13 +426,20 @@ export default Alchemy.Stack(
398426
name: configuration.bucketName,
399427
forceDestroy: false,
400428
});
401-
const turnstile = hostedAuthentication
402-
? yield* Cloudflare.Turnstile.Widget("AccessTurnstile", {
403-
name: `dx access · ${deploymentHostname}`,
404-
domains: [deploymentHostname],
405-
mode: "managed",
406-
})
407-
: undefined;
429+
// Previews may use Cloudflare's published always-pass test keys instead of
430+
// creating a widget, because the account caps widgets at 20.
431+
const turnstile = !hostedAuthentication
432+
? undefined
433+
: configuration.turnstileTestKeys
434+
? {
435+
sitekey: "1x00000000000000000000AA",
436+
secret: "1x0000000000000000000000000000000AA",
437+
}
438+
: yield* Cloudflare.Turnstile.Widget("AccessTurnstile", {
439+
name: `dx access · ${deploymentHostname}`,
440+
domains: [deploymentHostname],
441+
mode: "managed",
442+
});
408443
const email = hostedAuthentication
409444
? yield* Cloudflare.Email.SendEmail("MagicLinkEmail", {
410445
allowedSenderAddresses: [
@@ -465,7 +500,7 @@ export default Alchemy.Stack(
465500
`${configuration.e2bTemplate}-${defaultE2BOrbProfile.templateSuffix}`,
466501
DX_E2B_TEMPLATE_BUILD_ID:
467502
selfhostProfiles?.defaultBuildId ?? configuration.e2bTemplateBuildId,
468-
DX_E2B_TIMEOUT_MS: "600000",
503+
DX_E2B_TIMEOUT_MS: "300000",
469504
...(Option.isSome(configuration.workspaceInactivityMs)
470505
? {
471506
DX_WORKSPACE_INACTIVITY_MS: String(
@@ -474,7 +509,10 @@ export default Alchemy.Stack(
474509
}
475510
: {}),
476511
DX_DEPLOYMENT_REVISION: configuration.revision,
477-
DX_DXD_PUBLIC_URL: configuration.origin,
512+
DX_DXD_PUBLIC_URL: Option.getOrElse(
513+
daemonIngressOrigin,
514+
() => configuration.origin,
515+
),
478516
DX_DXD_RELEASE_URL: configuration.dxdReleaseUrl,
479517
DX_DXD_RELEASE_SHA256: configuration.dxdChecksum,
480518
DX_MIGRATION_MANIFEST_VERSION: String(migrationManifest.version),
@@ -670,6 +708,25 @@ export default Alchemy.Stack(
670708
: {}),
671709
});
672710

711+
// The Thread execution object checks the key; this Worker only forwards.
712+
if (Option.isSome(daemonIngressOrigin))
713+
yield* Cloudflare.Worker("DaemonIngress", {
714+
name: daemonIngressName,
715+
main: "apps/core/src/threads/daemon-ingress.ts",
716+
compatibility: { date: "2026-08-20" },
717+
env: {
718+
THREAD_EXECUTION: Cloudflare.DurableObject("THREAD_EXECUTION", {
719+
className: "ThreadExecutionObject",
720+
scriptName: worker.workerName,
721+
}),
722+
},
723+
workersDev: true,
724+
observability: {
725+
enabled: true,
726+
logs: { enabled: true, invocationLogs: true },
727+
},
728+
});
729+
673730
if (!selfhost)
674731
yield* Command.Exec("Verify", {
675732
command: "node scripts/verify-alchemy-preview.mjs",

‎apps/core/migration-manifest.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"version": 1,
33
"d1Directory": "migrations",
44
"previousAcceptedD1Migration": "0000_baseline_v0.1.0.sql",
5-
"acceptedDestructiveD1Migrations": [],
5+
"acceptedDestructiveD1Migrations": ["0002_release_v0.1.2.sql"],
66
"d1": [
77
{
88
"filename": "0000_baseline_v0.1.0.sql",
@@ -13,6 +13,11 @@
1313
"filename": "0001_release_v0.1.1.sql",
1414
"sha256": "2e4fa92cfedebf3ccefaef8637a175a578318df91f3c0e7ef6d52faaa8f4ab78",
1515
"classification": "non-destructive"
16+
},
17+
{
18+
"filename": "0002_release_v0.1.2.sql",
19+
"sha256": "cf1f518a05062a8c4204694833640c5c9f6b811bb1b020826fa2a0500a308c55",
20+
"classification": "destructive"
1621
}
1722
],
1823
"durableObjects": [
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
-- Bitbucket gateway leases authorize the Thread owner's Bitbucket connection,
2+
-- not one repository: the gateway takes the repository from the request path
3+
-- and Bitbucket decides access. Rebuild the lease table without repository,
4+
-- binding, and branch columns, keeping live leases. Leases last three minutes;
5+
-- dropping the old table cascades only its equally short-lived LFS actions.
6+
CREATE TABLE bitbucket_git_lease_0059 (
7+
id_hash TEXT PRIMARY KEY,
8+
thread_id TEXT NOT NULL,
9+
actor_user_id TEXT NOT NULL,
10+
connection_id TEXT NOT NULL,
11+
authorization_epoch INTEGER NOT NULL,
12+
operation TEXT NOT NULL,
13+
expires_at TEXT NOT NULL,
14+
created_at TEXT NOT NULL,
15+
FOREIGN KEY(connection_id) REFERENCES bitbucket_connection(id)
16+
);
17+
18+
INSERT INTO bitbucket_git_lease_0059 (
19+
id_hash, thread_id, actor_user_id, connection_id, authorization_epoch,
20+
operation, expires_at, created_at
21+
)
22+
SELECT
23+
id_hash, thread_id, actor_user_id, connection_id, authorization_epoch,
24+
operation, expires_at, created_at
25+
FROM bitbucket_git_lease;
26+
27+
DROP TABLE bitbucket_git_lease;
28+
ALTER TABLE bitbucket_git_lease_0059 RENAME TO bitbucket_git_lease;
29+
30+
CREATE INDEX bitbucket_git_lease_expiry_idx
31+
ON bitbucket_git_lease(expires_at);
32+
33+
-- While DxTitleAgent generates a new Thread's title, clients show a loading
34+
-- state instead of the stored fallback title. The deadline bounds that state
35+
-- even when the background job never finishes. NULL means the title is final.
36+
ALTER TABLE threads
37+
ADD COLUMN title_pending_until TEXT
38+
CHECK (title_pending_until IS NULL OR length(title_pending_until) BETWEEN 20 AND 40);

‎apps/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@dx/core",
33
"private": true,
4-
"version": "0.1.1",
4+
"version": "0.1.2",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/core/src/app.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ app.route("/api/workload-identity", workloadIdentityRoutes);
5151
app.route("/api/triggers", pluginTriggerIngressRoutes);
5252
app.route("/api/source/bitbucket/git", bitbucketGitGatewayRoutes);
5353
app.route("/v1/integrations/github", githubWebhookRoutes);
54-
app.route("/v1/threads", threadDaemonRoutes);
54+
app.route("/v1", threadDaemonRoutes);
5555
app.use("/v1/*", authenticate);
5656
app.use("/v1/*", authorizeApiTokenScope);
5757
app.route("/v1/dictation", dictationRoutes);

‎apps/core/src/auth/daemon-api-key.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock("./requirements.js", () => ({
2222
}));
2323

2424
import {
25+
confirmThreadDaemonApiKey,
2526
mintThreadDaemonApiKey,
2627
revokeThreadDaemonApiKey,
2728
verifyThreadDaemonApiKey,
@@ -111,6 +112,23 @@ describe("Thread daemon API key", () => {
111112
expect(mocks.verifyApiKey).toHaveBeenCalledOnce();
112113
});
113114

115+
it("confirms a key row for the active Thread owner in one read", async () => {
116+
const db = database();
117+
await expect(
118+
confirmThreadDaemonApiKey({ DB: db.binding }, threadId, "daemon-key-id"),
119+
).resolves.toBe(true);
120+
expect(db.calls).toHaveLength(1);
121+
expect(db.calls[0]?.values).toEqual([
122+
"daemon-key-id",
123+
"daemon-keys",
124+
threadId,
125+
]);
126+
expect(db.calls[0]?.sql).toContain("threads.lifecycle_state = 'active'");
127+
expect(db.calls[0]?.sql).toContain(
128+
"threads.owner_user_id = apikey.referenceId",
129+
);
130+
});
131+
114132
it("revokes only a daemon key by its opaque ID", async () => {
115133
const db = database();
116134
await revokeThreadDaemonApiKey({ DB: db.binding }, "daemon-key-id");

‎apps/core/src/auth/daemon-api-key.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import { ThreadId, UserId } from "@dx/domain";
23
import { Effect, Option, Redacted, Schema } from "effect";
34
import type { Bindings } from "../http/types.js";
@@ -11,10 +12,18 @@ const DaemonKeyMetadata = Schema.Struct({
1112
threadId: ThreadId,
1213
});
1314

14-
const bearer = (header: string | null) => {
15+
export const threadDaemonBearer = (header: string | null) => {
1516
const match = /^Bearer (dxd_[A-Za-z0-9._~-]{16,1016})$/.exec(header ?? "");
1617
return match?.[1];
1718
};
19+
const bearer = threadDaemonBearer;
20+
21+
/**
22+
* SHA-256 of a daemon key, hex. The Thread execution object keeps it with the
23+
* authority so a registration is checked without D1.
24+
*/
25+
export const threadDaemonApiKeyHash = (key: string) =>
26+
createHash("sha256").update(key).digest("hex");
1827

1928
const database = (bindings: Bindings) => {
2029
if (bindings.DB === undefined) throw new Error("D1 database is unavailable.");
@@ -92,6 +101,34 @@ export const verifyThreadDaemonApiKey = async (
92101
return owner?.owned === 1 ? { keyId: verified.key.id } : undefined;
93102
};
94103

104+
/**
105+
* One read that confirms what `verifyThreadDaemonApiKey` would: the key row
106+
* still exists (not revoked), is enabled and unexpired, and belongs to the
107+
* owner of this active Thread. It runs behind a registration the Thread
108+
* execution object already admitted by the key's hash.
109+
*/
110+
export const confirmThreadDaemonApiKey = async (
111+
bindings: Bindings,
112+
threadId: ThreadId,
113+
keyId: string,
114+
) => {
115+
const row = await database(bindings)
116+
.prepare(
117+
`SELECT 1 AS owned
118+
FROM apikey
119+
JOIN threads ON threads.owner_user_id = apikey.referenceId
120+
WHERE apikey.id = ?
121+
AND apikey.configId = ?
122+
AND (apikey.enabled IS NULL OR apikey.enabled = 1)
123+
AND apikey.expiresAt IS NULL
124+
AND threads.id = ?
125+
AND threads.lifecycle_state = 'active'`,
126+
)
127+
.bind(keyId, DAEMON_KEY_CONFIG, threadId)
128+
.first<{ readonly owned: number }>();
129+
return row?.owned === 1;
130+
};
131+
95132
export const revokeThreadDaemonApiKey = async (
96133
bindings: Bindings,
97134
keyId: string | undefined,

‎apps/core/src/execution/activity.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,68 @@ describe("workspace activity coordinator", () => {
159159
}
160160
});
161161

162+
it("publishes the deadline again after a reconnect replaced it while retained", async () => {
163+
vi.useFakeTimers();
164+
try {
165+
vi.setSystemTime(0);
166+
const onIdle = vi.fn();
167+
const provider = host();
168+
const activity = createWorkspaceActivity(
169+
{ ...provider, onIdle },
170+
undefined,
171+
60_000,
172+
);
173+
// Idle resident Terminal: attached, then typed into once.
174+
let detach = activity.attachTerminal(async () => false);
175+
await vi.runAllTicks();
176+
activity.recordTerminalInput();
177+
await vi.runAllTicks();
178+
const published = provider.setDeadline.mock.calls.length;
179+
// The daemon socket drops within the input's retention window, so the
180+
// coordinator survives; the wake's E2B connect then replaces the
181+
// provider deadline and the daemon attaches again.
182+
await vi.advanceTimersByTimeAsync(20_000);
183+
detach();
184+
expect(onIdle).not.toHaveBeenCalled();
185+
activity.deadlineReplaced();
186+
detach = activity.attachTerminal(async () => false);
187+
await vi.runAllTicks();
188+
expect(provider.setDeadline.mock.calls.slice(published)).toEqual([
189+
[60_000],
190+
]);
191+
detach();
192+
} finally {
193+
vi.useRealTimers();
194+
}
195+
});
196+
197+
it("publishes a replaced deadline on a fresh coordinator but not while a lease renews", async () => {
198+
vi.useFakeTimers();
199+
try {
200+
const provider = host();
201+
const activity = createWorkspaceActivity(provider, undefined, 60_000);
202+
// A Files-only wake: the connect set the E2B timeout and no Terminal
203+
// attaches, so nothing else would publish the 60 s deadline.
204+
activity.deadlineReplaced();
205+
await vi.runAllTicks();
206+
expect(provider.setDeadline.mock.calls).toEqual([[60_000]]);
207+
// An attaching Terminal does not publish it a second time.
208+
const detach = activity.attachTerminal(async () => false);
209+
await vi.runAllTicks();
210+
expect(provider.setDeadline).toHaveBeenCalledOnce();
211+
detach();
212+
const command = activity.acquireCommand(10_000);
213+
await vi.runAllTicks();
214+
const calls = provider.setDeadline.mock.calls.length;
215+
activity.deadlineReplaced();
216+
await vi.runAllTicks();
217+
expect(provider.setDeadline).toHaveBeenCalledTimes(calls);
218+
command.release();
219+
} finally {
220+
vi.useRealTimers();
221+
}
222+
});
223+
162224
it("lets renewing command activity own the final deadline after terminal input", async () => {
163225
vi.useFakeTimers();
164226
try {

0 commit comments

Comments
 (0)