Skip to content

Commit f05c862

Browse files
committed
Release v0.2.52
1 parent 0b9bc78 commit f05c862

44 files changed

Lines changed: 1413 additions & 218 deletions

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"version": "0.2.51",
2+
"version": "0.2.52",
33
"license": "FSL-1.1-ALv2",
44
"baselineSha256": "7847f65a9b20b366a60d52d19ca3cd6bdd1bb09b8cb506659a270f27a21d8877",
55
"e2b": {
66
"recipeSha256": "761809ee2efa2b6c750451ace29283cbb8e55b6ce3b5b14c2bb4509df71df801",
77
"profileSha256": "414dbecee2d47de36016ea05c736e2edff3a0deb298babf923887ea184a01c10"
88
},
99
"dxd": {
10-
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.51/dxd-linux-x64",
10+
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.52/dxd-linux-x64",
1111
"sha256": "6d2409188da917eaf4fbe03e7be6c25293442b5fa2901ba07c844e16b2adff5b"
1212
},
1313
"toolchain": {

‎apps/core/migration-manifest.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@
138138
"filename": "0025_release_v0.2.51.sql",
139139
"sha256": "d3bc03db636cd0456a098bf1e34fb0334a9034c3135ec02f9fa221d3cff46171",
140140
"classification": "non-destructive"
141+
},
142+
{
143+
"filename": "0026_release_v0.2.52.sql",
144+
"sha256": "59fc1798166c844a29add8178e6ae5e3543274d72cc127241a0d259773d56d26",
145+
"classification": "non-destructive"
141146
}
142147
],
143148
"durableObjects": [
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- A Claude Code Thread never leaves the Claude Pro/Max plan it ran on, even
2+
-- when that plan is deleted. `claude_connection_id` is cleared by the
3+
-- connection's deletion (ON DELETE SET NULL), so the time the Thread took its
4+
-- plan is kept here: a Thread with a bound time and no connection has lost
5+
-- its plan and takes no other.
6+
ALTER TABLE threads ADD COLUMN claude_connection_bound_at TEXT
7+
CHECK (claude_connection_bound_at IS NULL OR length(claude_connection_bound_at) BETWEEN 20 AND 40);
8+
9+
UPDATE threads SET claude_connection_bound_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
10+
WHERE claude_connection_id IS NOT NULL;
11+
12+
-- Migration 0103 left the Threads that had already run Claude Code without
13+
-- a plan, so each took the first plan not used up at its next message, which
14+
-- could be another account than the one it ran on. The usage ledger names
15+
-- the plan of every turn: put each such Thread back on the plan of its
16+
-- latest Claude Code turn. A Thread with no Claude Code turn yet stays
17+
-- without a plan and takes one at its first.
18+
UPDATE threads SET
19+
claude_connection_id = (
20+
SELECT usage_event.connection_id FROM usage_event
21+
JOIN model_connection ON model_connection.id = usage_event.connection_id
22+
WHERE usage_event.owner_user_id = threads.owner_user_id
23+
AND usage_event.thread_id = threads.id
24+
AND usage_event.provider_id = 'claude-code'
25+
ORDER BY usage_event.occurred_at DESC, usage_event.id DESC
26+
LIMIT 1
27+
),
28+
claude_connection_bound_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
29+
WHERE claude_connection_id IS NULL
30+
AND claude_connection_bound_at IS NULL
31+
AND EXISTS (
32+
SELECT 1 FROM usage_event
33+
JOIN model_connection ON model_connection.id = usage_event.connection_id
34+
WHERE usage_event.owner_user_id = threads.owner_user_id
35+
AND usage_event.thread_id = threads.id
36+
AND usage_event.provider_id = 'claude-code'
37+
);

‎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.2.51",
4+
"version": "0.2.52",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/core/src/auth/authorize-thread.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ThreadDeletedResponse, ThreadNotFoundResponse } from "@dx/api";
22
import {
33
type Principal,
4+
projectReaderOf,
45
ThreadId,
56
type ThreadLifecycleState,
67
ThreadRepository,
@@ -44,7 +45,10 @@ export const authorizeThreadRequest = Effect.fn("authorizeThreadRequest")(
4445
);
4546
const owned = yield* Effect.gen(function* () {
4647
const repository = yield* ThreadRepository;
47-
return yield* repository.findOwnedById(threadId, principal.userId);
48+
return yield* repository.findOwnedById(
49+
threadId,
50+
projectReaderOf(principal),
51+
);
4852
}).pipe(
4953
Effect.catchTag("ThreadNotFound", () => Effect.succeed(undefined)),
5054
Effect.provide(repositoryLayer),

‎apps/core/src/projects/repository-d1.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ProjectId,
99
ProjectNameConflict,
1010
ProjectNotFound,
11+
type ProjectReader,
1112
ProjectRepository,
1213
ProjectSourceAuthority,
1314
} from "@dx/domain";
@@ -174,12 +175,17 @@ export const ProjectRepositoryD1 = Layer.effect(
174175
WHERE member.userId = ${userId}
175176
AND organization.lifecycleState = 'active')`;
176177
// A private Project of the user, or any workspace Project of the user's
177-
// active workspace. Another member's projectless scope stays private.
178-
const accessibleBy = (userId: string) => sql`
179-
((workspace_id IS NULL AND owner_user_id = ${userId})
180-
OR (workspace_id IS NOT NULL
178+
// active workspace. Another member's projectless scope stays private. A
179+
// reader held to one workspace (an External API App) reaches only that
180+
// workspace's Projects, never the user's private ones.
181+
const accessibleBy = ({ userId, workspaceId }: ProjectReader) => {
182+
const inWorkspace = sql`(workspace_id IS NOT NULL
181183
AND workspace_id = ${activeWorkspaceOf(userId)}
182-
AND (name != ${PROJECTLESS_PROJECT_NAME} OR owner_user_id = ${userId})))`;
184+
AND (name != ${PROJECTLESS_PROJECT_NAME} OR owner_user_id = ${userId}))`;
185+
return workspaceId === undefined
186+
? sql`((workspace_id IS NULL AND owner_user_id = ${userId}) OR ${inWorkspace})`
187+
: sql`(workspace_id = ${workspaceId} AND ${inWorkspace})`;
188+
};
183189
// Changing a workspace Project takes its creator or a workspace admin,
184190
// while either is still an active member.
185191
const manageableBy = (userId: string) => sql`
@@ -410,13 +416,13 @@ export const ProjectRepositoryD1 = Layer.effect(
410416
),
411417
),
412418

413-
findOwnedById: (projectId, ownerUserId) =>
419+
findOwnedById: (projectId, reader) =>
414420
observe(
415421
"project.findOwnedById",
416422
{ projectId },
417423
sql<ProjectRow>`
418424
SELECT * FROM project_read_model
419-
WHERE id = ${projectId} AND ${accessibleBy(ownerUserId)}
425+
WHERE id = ${projectId} AND ${accessibleBy(reader)}
420426
`.pipe(
421427
Effect.catchTag("SqlError", (cause) =>
422428
Effect.fail(unavailable("project.findOwnedById")(cause)),
@@ -430,7 +436,7 @@ export const ProjectRepositoryD1 = Layer.effect(
430436
),
431437
),
432438

433-
listOwned: (ownerUserId, request) =>
439+
listOwned: (reader, request) =>
434440
observe(
435441
"project.listOwned",
436442
{},
@@ -448,15 +454,15 @@ export const ProjectRepositoryD1 = Layer.effect(
448454
cursor
449455
? sql<ProjectRow>`
450456
SELECT * FROM project_read_model
451-
WHERE ${accessibleBy(ownerUserId)}
457+
WHERE ${accessibleBy(reader)}
452458
AND name != ${PROJECTLESS_PROJECT_NAME}
453459
AND (created_at, id) < (${cursorTime}, ${cursor.id})
454460
ORDER BY created_at DESC, id DESC
455461
LIMIT ${limit + 1}
456462
`
457463
: sql<ProjectRow>`
458464
SELECT * FROM project_read_model
459-
WHERE ${accessibleBy(ownerUserId)}
465+
WHERE ${accessibleBy(reader)}
460466
AND name != ${PROJECTLESS_PROJECT_NAME}
461467
ORDER BY created_at DESC, id DESC
462468
LIMIT ${limit + 1}

‎apps/core/src/projects/service.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type ProjectRepositoryIdentity,
1616
type ProjectSourceAuthority,
1717
type ProjectUpdate,
18+
projectReaderOf,
1819
} from "@dx/domain";
1920
import { Context, Effect, Layer, type Schema } from "effect";
2021
import type { ProjectCreationSnapshot } from "../settings/project-defaults/service.js";
@@ -96,10 +97,10 @@ export class ProjectService extends Context.Service<
9697
},
9798
),
9899
get: Effect.fn("ProjectService.get")((principal, projectId) =>
99-
repository.findOwnedById(projectId, principal.userId),
100+
repository.findOwnedById(projectId, projectReaderOf(principal)),
100101
),
101102
list: Effect.fn("ProjectService.list")((principal, request) =>
102-
repository.listOwned(principal.userId, request),
103+
repository.listOwned(projectReaderOf(principal), request),
103104
),
104105
update: Effect.fn("ProjectService.update")(
105106
(principal, projectId, revision, update) =>

‎apps/core/src/runtime/agent-composition.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ const resolvePromptData =
7777
);
7878
}
7979
const { code = "UNKNOWN", message } = body;
80-
// The Claude plan's limit text is what the Thread's limit card shows.
81-
// Flue keeps only a FlueError's message on the failed settlement.
80+
// Flue keeps only a FlueError's message on the failed settlement, so
81+
// the texts the Thread's error card reads are thrown as one: the
82+
// Claude plan's limit text, and the coordinator's classified failures,
83+
// whose `(dx_failure:class:CODE)` stamp also drives the retry policy.
8284
if (code === "CLAUDE_PLAN_LIMIT" && message !== undefined)
8385
throw new FlueError({
8486
type: "claude_plan_limit",
@@ -88,14 +90,18 @@ const resolvePromptData =
8890
meta: { code },
8991
});
9092
if (body.class !== undefined && message !== undefined)
91-
throw new Error(
92-
markRunFailure({
93+
throw new FlueError({
94+
type: "model_routing_failure",
95+
message: markRunFailure({
9396
class: body.class,
9497
code,
9598
message,
9699
retryable: body.class === "transient" || body.class === "platform",
97100
}),
98-
);
101+
details: "",
102+
dev: "",
103+
meta: { code, class: body.class },
104+
});
99105
if (code.startsWith("MODEL_NOT_SERVED:"))
100106
throw new Error(
101107
markRunFailure({

‎apps/core/src/settings/environment-variables/routes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
ProjectNotFound,
5050
ProjectRepository,
5151
parseEnvironmentVariableAssignments,
52+
projectReaderOf,
5253
SettingsMembershipInvariantViolation,
5354
SettingsScopeForbidden,
5455
type StoredEnvironmentVariable,
@@ -226,7 +227,7 @@ const authorizeTarget = Effect.fn("authorizeEnvironmentVariableTarget")(
226227
}
227228
const projects = yield* ProjectRepository;
228229
const project = yield* projects
229-
.findOwnedById(projectId.value, principal.userId)
230+
.findOwnedById(projectId.value, projectReaderOf(principal))
230231
.pipe(
231232
Effect.catchTag("ProjectNotFound", () =>
232233
Effect.fail(new SettingsScopeForbidden({ scope: "personal" })),

‎apps/core/src/settings/mcp-servers/routes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
PersistenceUnavailable,
4646
type Principal,
4747
ProjectRepository,
48+
projectReaderOf,
4849
SettingsMembershipInvariantViolation,
4950
SettingsScopeForbidden,
5051
WorkspacePolicyDenied,
@@ -336,7 +337,7 @@ const validateGrants = Effect.fn("validateMcpGrants")(function* (
336337
yield* Effect.all(
337338
projects.map((projectId) =>
338339
repository
339-
.findOwnedById(projectId, principal.userId)
340+
.findOwnedById(projectId, projectReaderOf(principal))
340341
.pipe(
341342
Effect.catchTag("ProjectNotFound", () =>
342343
Effect.fail(

0 commit comments

Comments
 (0)