@agentrific/server 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @agentrific/server
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Add a separate sandbox push service and require explicit test mode for sandbox grants so TestFlight purchases cannot unlock production notifications.
8
+
9
+ ## 0.5.0
10
+
11
+ ### Minor Changes
12
+
13
+ - fbb6281: Group matching repositories across connected servers while keeping each task and chat associated with its server. Show repository identity and Git status, support explicit checkout association for older servers, and choose a task server directly in the native composer. Allow never-run backlog tasks to move safely between compatible servers.
14
+
15
+ Preserve native navigation and floating glass tabs, center and truncate conversation titles, show compact status dots, and improve navigation performance. Fix native task editor character loss, server picker alignment, and tool-call grid sizing.
16
+
3
17
  ## 0.4.0
4
18
 
5
19
  ### Minor Changes
package/cli.js CHANGED
@@ -30,7 +30,7 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "@agentrific/server",
33
- version: "0.4.0",
33
+ version: "0.5.1",
34
34
  description: "Self-hosted control plane for long-running coding agents",
35
35
  keywords: [
36
36
  "agentrific",
@@ -246,7 +246,7 @@ function createPairingLink(payload) {
246
246
  link.searchParams.set("data", JSON.stringify(PairingPayloadSchema.parse(payload)));
247
247
  return link.toString();
248
248
  }
249
- var cronBounds, AutomationConfigSchema, AutomationStateSchema, SessionStatusSchema, AgentKindSchema, ModelReferenceSchema, ModelIdentitySchema, ModelOptionSchema, ModelCatalogSchema, GitFlowSchema, GitDeliverySchema, DeliveryRequestSchema, RunPreparationSchema, SessionSchema, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, AttachmentSchema, AttachmentIdsSchema, CreateSessionSchema, SendMessageSchema, FileChangeSchema, SessionEventSchema, RecordedEventSchema, EventHistorySchema, ServerEventSchema, ServerSettingsSchema, ConnectionsStatusSchema, UpdateSettingsSchema, ProjectSchema, TaskStatusSchema, TaskSchema, CreateTaskSchema, UpdateTaskSchema, PROTOCOL_VERSION, ServerUpdateStatusSchema, ServerHealthSchema, PairingPayloadSchema, PairingInvitationSchema, PairingRequestSchema, DeviceSchema, PairingResultSchema, HandshakeSchema, HandshakeAckSchema;
249
+ var cronBounds, AutomationConfigSchema, AutomationStateSchema, SessionStatusSchema, AgentKindSchema, ModelReferenceSchema, ModelIdentitySchema, ModelOptionSchema, ModelCatalogSchema, GitFlowSchema, GitDeliverySchema, DeliveryRequestSchema, RunPreparationSchema, SessionSchema, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, AttachmentSchema, AttachmentIdsSchema, CreateSessionSchema, SendMessageSchema, FileChangeSchema, SessionEventSchema, RecordedEventSchema, EventHistorySchema, ServerEventSchema, ServerSettingsSchema, ConnectionsStatusSchema, UpdateSettingsSchema, RepositoryIdentitySchema, ProjectGitStatusSchema, ProjectSchema, TaskStatusSchema, TaskSchema, CreateTaskSchema, UpdateTaskSchema, PROTOCOL_VERSION, ServerUpdateStatusSchema, ServerHealthSchema, PairingPayloadSchema, PairingInvitationSchema, PairingRequestSchema, DeviceSchema, PairingResultSchema, HandshakeSchema, HandshakeAckSchema;
250
250
  var init_src = __esm({
251
251
  "../../packages/contracts/src/index.ts"() {
252
252
  cronBounds = [
@@ -476,11 +476,25 @@ var init_src = __esm({
476
476
  githubCli: z2.object({ installed: z2.boolean(), authenticated: z2.boolean() })
477
477
  });
478
478
  UpdateSettingsSchema = z2.object({ projectsRoot: z2.string().trim().min(1).max(4096) });
479
+ RepositoryIdentitySchema = z2.object({
480
+ key: z2.string(),
481
+ display: z2.string()
482
+ });
483
+ ProjectGitStatusSchema = z2.object({
484
+ observedAt: z2.string().datetime(),
485
+ state: z2.enum(["available", "unavailable"]),
486
+ repository: RepositoryIdentitySchema.nullable(),
487
+ head: z2.string().nullable(),
488
+ branch: z2.string().nullable(),
489
+ trackedChanges: z2.number().int().nonnegative(),
490
+ untrackedChanges: z2.number().int().nonnegative()
491
+ });
479
492
  ProjectSchema = z2.object({
480
493
  id: z2.string(),
481
494
  name: z2.string(),
482
495
  path: z2.string(),
483
- available: z2.boolean().default(true)
496
+ available: z2.boolean().default(true),
497
+ repository: RepositoryIdentitySchema.nullable().optional()
484
498
  });
485
499
  TaskStatusSchema = z2.enum(["backlog", "needs_user_input", "running", "done"]);
486
500
  TaskSchema = z2.object({
@@ -499,6 +513,7 @@ var init_src = __esm({
499
513
  deletedAt: z2.string().datetime().optional()
500
514
  });
501
515
  CreateTaskSchema = z2.object({
516
+ clientTaskId: z2.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/).optional(),
502
517
  projectId: z2.string().min(1),
503
518
  title: z2.string().trim().min(1).max(120),
504
519
  prompt: z2.string().trim().max(2e4).default(""),
@@ -958,11 +973,12 @@ function pushSigningText(installationId, operation, request) {
958
973
  request.payload
959
974
  ].join("\n");
960
975
  }
961
- var PUSH_API_URL, PushKindSchema, PushPreferencesSchema, PushPublicKeySchema, PushInstallationSchema, PushRegistrationSchema, PushEnrollmentSchema, PushGrantSchema, PushPullRequestUrlSchema, PushEventSchema, PushSignedRequestSchema, PushEntitlementSchema, PushServerStatusSchema;
976
+ var PUSH_API_URL, PUSH_SANDBOX_API_URL, PushKindSchema, PushPreferencesSchema, PushPublicKeySchema, PushInstallationSchema, PushRegistrationSchema, PushEnrollmentSchema, PushGrantSchema, PushPullRequestUrlSchema, PushEventSchema, PushSignedRequestSchema, PushEntitlementSchema, PushServerStatusSchema;
962
977
  var init_push = __esm({
963
978
  "../../packages/contracts/src/push.ts"() {
964
979
  "use strict";
965
980
  PUSH_API_URL = "https://api.agentrific.com";
981
+ PUSH_SANDBOX_API_URL = "https://sandbox-api.agentrific.com";
966
982
  PushKindSchema = z6.enum([
967
983
  "started",
968
984
  "completed",
@@ -1871,7 +1887,7 @@ function editTask(store2, id, input, resolveAttachments) {
1871
1887
  });
1872
1888
  }
1873
1889
  function deleteTask(store2, id, cleanup = async () => {
1874
- }) {
1890
+ }, unrunUpdatedAt) {
1875
1891
  return withTaskLock(id, async () => {
1876
1892
  const task = await findTask(store2, id);
1877
1893
  const sessions = await Effect6.runPromise(store2.list());
@@ -1882,6 +1898,8 @@ function deleteTask(store2, id, cleanup = async () => {
1882
1898
  const associated = sessions.filter(
1883
1899
  (session) => session.taskId === task.id || session.id === task.sessionId
1884
1900
  );
1901
+ if (unrunUpdatedAt !== void 0 && (task.updatedAt !== unrunUpdatedAt || task.sessionId || associated.length || task.status !== "backlog"))
1902
+ throw new TaskActionError("This task changed or has already run. It cannot be moved.", 409);
1885
1903
  for (const session of associated) await cleanup(session);
1886
1904
  await Effect6.runPromise(
1887
1905
  store2.purgeTask(
@@ -2320,7 +2338,7 @@ import { promisify as promisify2, stripVTControlCharacters } from "node:util";
2320
2338
  function workflowLog(text) {
2321
2339
  return stripVTControlCharacters(text).slice(-32e3);
2322
2340
  }
2323
- async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2341
+ function workflowEnvironment() {
2324
2342
  const env = { ...process.env };
2325
2343
  for (const name of [
2326
2344
  "GIT_DIR",
@@ -2340,6 +2358,10 @@ async function workflowCommand(cwd, command, args, signal, managedPreparation =
2340
2358
  "GIT_REPLACE_REF_BASE"
2341
2359
  ])
2342
2360
  delete env[name];
2361
+ return env;
2362
+ }
2363
+ async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2364
+ const env = workflowEnvironment();
2343
2365
  if (managedPreparation) env.AGENTRIFIC_MANAGED_PREPARATION = "1";
2344
2366
  const result = await exec(command, args, {
2345
2367
  cwd,
@@ -3573,6 +3595,113 @@ var init_projects = __esm({
3573
3595
  }
3574
3596
  });
3575
3597
 
3598
+ // src/project-git.ts
3599
+ import { execFile as execFile3 } from "node:child_process";
3600
+ import { promisify as promisify3 } from "node:util";
3601
+ function repositoryIdentity(remote) {
3602
+ const input = remote.trim();
3603
+ if (!input || input.startsWith("/") || input.startsWith(".")) return null;
3604
+ const scp = /^(?:[^@/\s]+@)?([^/:\s]+):([^\s]+)$/.exec(input);
3605
+ let url;
3606
+ try {
3607
+ url = new URL(scp && !input.includes("://") ? `ssh://${scp[1]}/${scp[2]}` : input);
3608
+ } catch {
3609
+ return null;
3610
+ }
3611
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
3612
+ if (url.search || url.hash || url.port) return null;
3613
+ const host2 = url.hostname.toLowerCase();
3614
+ let path13 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3615
+ if (!host2 || !path13 || path13.includes("%") || path13.includes("//")) return null;
3616
+ if (["github.com", "bitbucket.org"].includes(host2)) path13 = path13.toLowerCase();
3617
+ const key = `${host2}/${path13}`;
3618
+ return { key, display: key };
3619
+ }
3620
+ async function git(directory, args) {
3621
+ const { stdout } = await execute3("git", ["-C", directory, ...args], {
3622
+ timeout: 3e3,
3623
+ maxBuffer: 1024 * 1024,
3624
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }
3625
+ });
3626
+ return stdout.trim();
3627
+ }
3628
+ async function readRepositoryIdentity(directory) {
3629
+ try {
3630
+ const remotes = (await git(directory, ["remote"])).split("\n").filter(Boolean);
3631
+ const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
3632
+ if (!remote) return null;
3633
+ return repositoryIdentity(await git(directory, ["remote", "get-url", remote]));
3634
+ } catch {
3635
+ return null;
3636
+ }
3637
+ }
3638
+ async function inspect(directory) {
3639
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
3640
+ try {
3641
+ const [repository, head, branch, status] = await Promise.all([
3642
+ readRepositoryIdentity(directory),
3643
+ git(directory, ["rev-parse", "--verify", "HEAD"]),
3644
+ git(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null),
3645
+ // NUL-separated records avoid ambiguous filenames/newlines. --no-renames
3646
+ // keeps each entry one record, so we only need its two status characters.
3647
+ execute3(
3648
+ "git",
3649
+ [
3650
+ "-C",
3651
+ directory,
3652
+ "status",
3653
+ "--porcelain=v1",
3654
+ "-z",
3655
+ "--no-renames",
3656
+ "--untracked-files=normal"
3657
+ ],
3658
+ {
3659
+ timeout: 3e3,
3660
+ maxBuffer: 1024 * 1024,
3661
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0" }
3662
+ }
3663
+ )
3664
+ ]);
3665
+ const records = status.stdout.split("\0").filter(Boolean);
3666
+ return {
3667
+ observedAt,
3668
+ state: "available",
3669
+ repository,
3670
+ head,
3671
+ branch,
3672
+ trackedChanges: records.filter((record) => !record.startsWith("??")).length,
3673
+ untrackedChanges: records.filter((record) => record.startsWith("??")).length
3674
+ };
3675
+ } catch {
3676
+ return {
3677
+ observedAt,
3678
+ state: "unavailable",
3679
+ repository: null,
3680
+ head: null,
3681
+ branch: null,
3682
+ trackedChanges: 0,
3683
+ untrackedChanges: 0
3684
+ };
3685
+ }
3686
+ }
3687
+ function projectGitStatus(directory) {
3688
+ const cached = observations.get(directory);
3689
+ if (cached && cached.expires > Date.now()) return cached.value;
3690
+ if (observations.size >= 200) observations.delete(observations.keys().next().value);
3691
+ const value = inspect(directory);
3692
+ observations.set(directory, { expires: Date.now() + 15e3, value });
3693
+ return value;
3694
+ }
3695
+ var execute3, observations;
3696
+ var init_project_git = __esm({
3697
+ "src/project-git.ts"() {
3698
+ "use strict";
3699
+ init_workflow_command();
3700
+ execute3 = promisify3(execFile3);
3701
+ observations = /* @__PURE__ */ new Map();
3702
+ }
3703
+ });
3704
+
3576
3705
  // src/push-schema.ts
3577
3706
  import { z as z16 } from "zod";
3578
3707
  var PushBindingSchema, PushStateSchema, PendingPushSchema;
@@ -4170,8 +4299,8 @@ import { styleText as styleText4 } from "node:util";
4170
4299
  import cors from "@fastify/cors";
4171
4300
  import multipart from "@fastify/multipart";
4172
4301
  import { randomUUID as randomUUID9 } from "node:crypto";
4173
- import { execFile as execFile3 } from "node:child_process";
4174
- import { promisify as promisify3 } from "node:util";
4302
+ import { execFile as execFile4 } from "node:child_process";
4303
+ import { promisify as promisify4 } from "node:util";
4175
4304
  import { realpath as realpath2 } from "node:fs/promises";
4176
4305
  import { homedir as homedir4 } from "node:os";
4177
4306
  import path11 from "node:path";
@@ -4384,10 +4513,11 @@ async function withSessionTaskLock(id, operation) {
4384
4513
  const lockId = session ? await sessionTaskLockId(store, session) : `session-${id}`;
4385
4514
  return withTaskLock(lockId, operation);
4386
4515
  }
4387
- var host, port, defaultProjectsRoot, worktreesRoot, execFileAsync, app, dataRoot, releaseLock, effects, store, security, testPushUrl, push, allowedOrigins, attachmentEffects, attachments, attachmentCleanup, models, delivery, preparation, agentEffects, runtime, worktreeCleanup, maxWorktreeAgeDays, pruneWorktrees, worktreeCleanupTimer, updates, maintenance, stopUpdateDiscovery, HistoryQuery, StartTaskBody, automation, automationTimer, closeControl;
4516
+ var host, port, defaultProjectsRoot, worktreesRoot, execFileAsync, app, dataRoot, releaseLock, effects, store, security, sandboxPushUrl, testPushUrl, push, allowedOrigins, attachmentEffects, attachments, attachmentCleanup, models, delivery, preparation, agentEffects, runtime, worktreeCleanup, maxWorktreeAgeDays, pruneWorktrees, worktreeCleanupTimer, updates, maintenance, stopUpdateDiscovery, HistoryQuery, StartTaskBody, automation, automationTimer, closeControl;
4388
4517
  var init_index = __esm({
4389
4518
  async "src/index.ts"() {
4390
4519
  "use strict";
4520
+ init_push();
4391
4521
  init_warnings();
4392
4522
  init_push2();
4393
4523
  init_attachments();
@@ -4406,6 +4536,7 @@ var init_index = __esm({
4406
4536
  init_models();
4407
4537
  init_pi();
4408
4538
  init_projects();
4539
+ init_project_git();
4409
4540
  init_store();
4410
4541
  init_tasks();
4411
4542
  init_worktree_cleanup();
@@ -4416,7 +4547,7 @@ var init_index = __esm({
4416
4547
  worktreesRoot = path11.resolve(
4417
4548
  process.env.AGENTRIFIC_WORKTREES_DIR ?? path11.join(homedir4(), ".agentrific", "workspaces")
4418
4549
  );
4419
- execFileAsync = promisify3(execFile3);
4550
+ execFileAsync = promisify4(execFile4);
4420
4551
  if (host !== "127.0.0.1" && host !== "::1")
4421
4552
  throw new Error("Bind Agentrific to loopback and expose it with Tailscale Serve or cloudflared.");
4422
4553
  app = Fastify({
@@ -4442,10 +4573,19 @@ var init_index = __esm({
4442
4573
  effects = ManagedRuntime.make(SessionStore.layer(path11.join(dataRoot, "sessions.sqlite")));
4443
4574
  store = await effects.runPromise(SessionStore);
4444
4575
  security = new ServerSecurity(store, await Effect17.runPromise(store.serverIdentity()));
4576
+ sandboxPushUrl = process.env.AGENTRIFIC_PUSH_SANDBOX === "1" ? PUSH_SANDBOX_API_URL : void 0;
4577
+ if (sandboxPushUrl && process.env.AGENTRIFIC_TEST_MODE !== "1")
4578
+ throw new Error("Sandbox push requires explicit test mode.");
4445
4579
  testPushUrl = process.env.AGENTRIFIC_PUSH_TEST_URL;
4446
4580
  if (testPushUrl && (process.env.AGENTRIFIC_TEST_MODE !== "1" || !new URL(testPushUrl).hostname.endsWith(".ts.net") || new URL(testPushUrl).protocol !== "https:"))
4447
4581
  throw new Error("Test relay requires explicit test mode and Tailscale HTTPS.");
4448
- push = new ServerPush(store, security.serverId, dataRoot, fetch, testPushUrl);
4582
+ push = new ServerPush(
4583
+ store,
4584
+ security.serverId,
4585
+ dataRoot,
4586
+ fetch,
4587
+ testPushUrl ?? sandboxPushUrl
4588
+ );
4449
4589
  allowedOrigins = [
4450
4590
  .../* @__PURE__ */ new Set([
4451
4591
  "agentrific-desktop://app",
@@ -4660,7 +4800,24 @@ var init_index = __esm({
4660
4800
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4661
4801
  const result = await Effect17.runPromise(discoverProjects(root, store).pipe(Effect17.result));
4662
4802
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4663
- return result.success;
4803
+ return Effect17.runPromise(
4804
+ Effect17.forEach(
4805
+ result.success,
4806
+ (project) => Effect17.promise(async () => ({
4807
+ ...project,
4808
+ repository: project.available ? await readRepositoryIdentity(project.path) : null
4809
+ })),
4810
+ { concurrency: 4 }
4811
+ )
4812
+ );
4813
+ });
4814
+ app.get("/api/projects/:id/git-status", async (request, reply) => {
4815
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4816
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
4817
+ const project = projects.find((item) => item.id === request.params.id);
4818
+ if (!project?.available)
4819
+ return reply.code(404).send({ error: "Project folder unavailable on this server." });
4820
+ return projectGitStatus(project.path);
4664
4821
  });
4665
4822
  app.get("/api/projects/:id", async (request, reply) => {
4666
4823
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
@@ -4684,39 +4841,51 @@ var init_index = __esm({
4684
4841
  "/api/tasks",
4685
4842
  (request) => boardTasks(store, request.query.projectId)
4686
4843
  );
4844
+ app.get("/api/task-moves", () => ({ supported: true }));
4687
4845
  app.post("/api/tasks", async (request, reply) => {
4688
4846
  const parsed = CreateTaskSchema.safeParse(request.body);
4689
4847
  if (!parsed.success) return reply.code(400).send({ error: "Enter a task title." });
4690
- if (parsed.data.gitFlow?.delivery === "pull_request") {
4691
- const github = await githubCliStatus();
4692
- if (!github.githubCli.authenticated)
4693
- return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4694
- }
4695
- const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4696
- const projects = await Effect17.runPromise(
4697
- discoverProjects(projectsRoot, store).pipe(Effect17.result)
4698
- );
4699
- if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4700
- return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4701
- let files;
4702
- try {
4703
- files = await Effect17.runPromise(
4704
- attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4848
+ const save = async () => {
4849
+ if (parsed.data.clientTaskId) {
4850
+ const existing = await Effect17.runPromise(store.getTask(parsed.data.clientTaskId));
4851
+ if (existing) {
4852
+ if (existing.deletedAt || existing.projectId !== parsed.data.projectId || existing.title !== parsed.data.title || existing.prompt !== parsed.data.prompt || JSON.stringify(existing.gitFlow) !== JSON.stringify(parsed.data.gitFlow) || JSON.stringify(existing.model) !== JSON.stringify(parsed.data.model))
4853
+ return reply.code(409).send({ error: "This task creation request has already been used." });
4854
+ return reply.code(201).send(existing);
4855
+ }
4856
+ }
4857
+ if (parsed.data.gitFlow?.delivery === "pull_request") {
4858
+ const github = await githubCliStatus();
4859
+ if (!github.githubCli.authenticated)
4860
+ return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4861
+ }
4862
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4863
+ const projects = await Effect17.runPromise(
4864
+ discoverProjects(projectsRoot, store).pipe(Effect17.result)
4705
4865
  );
4706
- } catch (error) {
4707
- return reply.code(400).send({ error: String(error) });
4708
- }
4709
- const now = (/* @__PURE__ */ new Date()).toISOString();
4710
- const task = TaskSchema.parse({
4711
- id: randomUUID9(),
4712
- ...parsed.data,
4713
- attachments: files,
4714
- status: "backlog",
4715
- sessionId: null,
4716
- createdAt: now,
4717
- updatedAt: now
4718
- });
4719
- return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4866
+ if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4867
+ return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4868
+ let files;
4869
+ try {
4870
+ files = await Effect17.runPromise(
4871
+ attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4872
+ );
4873
+ } catch (error) {
4874
+ return reply.code(400).send({ error: String(error) });
4875
+ }
4876
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4877
+ const task = TaskSchema.parse({
4878
+ id: parsed.data.clientTaskId ?? randomUUID9(),
4879
+ ...parsed.data,
4880
+ attachments: files,
4881
+ status: "backlog",
4882
+ sessionId: null,
4883
+ createdAt: now,
4884
+ updatedAt: now
4885
+ });
4886
+ return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4887
+ };
4888
+ return parsed.data.clientTaskId ? withTaskLock(`create-task:${parsed.data.clientTaskId}`, save) : save();
4720
4889
  });
4721
4890
  app.patch("/api/tasks/:id", async (request, reply) => {
4722
4891
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -4743,6 +4912,18 @@ var init_index = __esm({
4743
4912
  throw error;
4744
4913
  }
4745
4914
  });
4915
+ app.post("/api/tasks/:id/discard-unrun", async (request, reply) => {
4916
+ const parsed = z18.object({ updatedAt: z18.string().datetime() }).safeParse(request.body);
4917
+ if (!parsed.success) return reply.code(400).send({ error: "The task revision is required." });
4918
+ try {
4919
+ await deleteTask(store, request.params.id, void 0, parsed.data.updatedAt);
4920
+ return reply.code(204).send();
4921
+ } catch (error) {
4922
+ if (error instanceof TaskActionError)
4923
+ return reply.code(error.status).send({ error: error.message });
4924
+ throw error;
4925
+ }
4926
+ });
4746
4927
  app.delete("/api/tasks/:id", async (request, reply) => {
4747
4928
  try {
4748
4929
  await deleteTask(store, request.params.id, async (session) => {