@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/index.js CHANGED
@@ -1,27 +1,7 @@
1
- // src/warnings.ts
2
- var handlers = process.rawListeners("warning");
3
- process.removeAllListeners("warning");
4
- process.on("warning", (warning) => {
5
- if (warning.name === "ExperimentalWarning" && warning.message === "SQLite is an experimental feature and might change at any time")
6
- return;
7
- for (const handler of handlers) handler.call(process, warning);
8
- });
9
-
10
- // src/push.ts
11
- import {
12
- generateKeyPairSync,
13
- createPrivateKey,
14
- createPublicKey,
15
- sign,
16
- randomUUID
17
- } from "node:crypto";
18
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
- import path from "node:path";
20
- import { Effect } from "effect";
21
-
22
1
  // ../../packages/contracts/src/push.ts
23
2
  import { z } from "zod";
24
3
  var PUSH_API_URL = "https://api.agentrific.com";
4
+ var PUSH_SANDBOX_API_URL = "https://sandbox-api.agentrific.com";
25
5
  var PushKindSchema = z.enum([
26
6
  "started",
27
7
  "completed",
@@ -85,7 +65,26 @@ function pushSigningText(installationId, operation, request) {
85
65
  ].join("\n");
86
66
  }
87
67
 
68
+ // src/warnings.ts
69
+ var handlers = process.rawListeners("warning");
70
+ process.removeAllListeners("warning");
71
+ process.on("warning", (warning) => {
72
+ if (warning.name === "ExperimentalWarning" && warning.message === "SQLite is an experimental feature and might change at any time")
73
+ return;
74
+ for (const handler of handlers) handler.call(process, warning);
75
+ });
76
+
88
77
  // src/push.ts
78
+ import {
79
+ generateKeyPairSync,
80
+ createPrivateKey,
81
+ createPublicKey,
82
+ sign,
83
+ randomUUID
84
+ } from "node:crypto";
85
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
86
+ import path from "node:path";
87
+ import { Effect } from "effect";
89
88
  function pushKind(session, previous) {
90
89
  if (session.gitDelivery?.status === "pr_opened" && previous?.gitDelivery?.status !== "pr_opened")
91
90
  return "pr_created";
@@ -586,11 +585,25 @@ var ConnectionsStatusSchema = z2.object({
586
585
  githubCli: z2.object({ installed: z2.boolean(), authenticated: z2.boolean() })
587
586
  });
588
587
  var UpdateSettingsSchema = z2.object({ projectsRoot: z2.string().trim().min(1).max(4096) });
588
+ var RepositoryIdentitySchema = z2.object({
589
+ key: z2.string(),
590
+ display: z2.string()
591
+ });
592
+ var ProjectGitStatusSchema = z2.object({
593
+ observedAt: z2.string().datetime(),
594
+ state: z2.enum(["available", "unavailable"]),
595
+ repository: RepositoryIdentitySchema.nullable(),
596
+ head: z2.string().nullable(),
597
+ branch: z2.string().nullable(),
598
+ trackedChanges: z2.number().int().nonnegative(),
599
+ untrackedChanges: z2.number().int().nonnegative()
600
+ });
589
601
  var ProjectSchema = z2.object({
590
602
  id: z2.string(),
591
603
  name: z2.string(),
592
604
  path: z2.string(),
593
- available: z2.boolean().default(true)
605
+ available: z2.boolean().default(true),
606
+ repository: RepositoryIdentitySchema.nullable().optional()
594
607
  });
595
608
  var TaskStatusSchema = z2.enum(["backlog", "needs_user_input", "running", "done"]);
596
609
  var TaskSchema = z2.object({
@@ -609,6 +622,7 @@ var TaskSchema = z2.object({
609
622
  deletedAt: z2.string().datetime().optional()
610
623
  });
611
624
  var CreateTaskSchema = z2.object({
625
+ clientTaskId: z2.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/).optional(),
612
626
  projectId: z2.string().min(1),
613
627
  title: z2.string().trim().min(1).max(120),
614
628
  prompt: z2.string().trim().max(2e4).default(""),
@@ -871,8 +885,8 @@ var Attachments = class extends Context.Service()(
871
885
 
872
886
  // src/index.ts
873
887
  import { randomUUID as randomUUID9 } from "node:crypto";
874
- import { execFile as execFile2 } from "node:child_process";
875
- import { promisify as promisify2 } from "node:util";
888
+ import { execFile as execFile3 } from "node:child_process";
889
+ import { promisify as promisify3 } from "node:util";
876
890
  import { realpath as realpath2 } from "node:fs/promises";
877
891
  import { homedir as homedir2 } from "node:os";
878
892
  import path9 from "node:path";
@@ -1097,7 +1111,7 @@ async function startControl(directory, security2, status, maintenance2) {
1097
1111
  // package.json
1098
1112
  var package_default = {
1099
1113
  name: "@agentrific/server",
1100
- version: "0.4.0",
1114
+ version: "0.5.1",
1101
1115
  description: "Self-hosted control plane for long-running coding agents",
1102
1116
  keywords: [
1103
1117
  "agentrific",
@@ -1608,7 +1622,7 @@ function editTask(store2, id, input, resolveAttachments) {
1608
1622
  });
1609
1623
  }
1610
1624
  function deleteTask(store2, id, cleanup = async () => {
1611
- }) {
1625
+ }, unrunUpdatedAt) {
1612
1626
  return withTaskLock(id, async () => {
1613
1627
  const task = await findTask(store2, id);
1614
1628
  const sessions = await Effect6.runPromise(store2.list());
@@ -1619,6 +1633,8 @@ function deleteTask(store2, id, cleanup = async () => {
1619
1633
  const associated = sessions.filter(
1620
1634
  (session) => session.taskId === task.id || session.id === task.sessionId
1621
1635
  );
1636
+ if (unrunUpdatedAt !== void 0 && (task.updatedAt !== unrunUpdatedAt || task.sessionId || associated.length || task.status !== "backlog"))
1637
+ throw new TaskActionError("This task changed or has already run. It cannot be moved.", 409);
1622
1638
  for (const session of associated) await cleanup(session);
1623
1639
  await Effect6.runPromise(
1624
1640
  store2.purgeTask(
@@ -2027,7 +2043,7 @@ var exec = promisify(execFile);
2027
2043
  function workflowLog(text) {
2028
2044
  return stripVTControlCharacters(text).slice(-32e3);
2029
2045
  }
2030
- async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2046
+ function workflowEnvironment() {
2031
2047
  const env = { ...process.env };
2032
2048
  for (const name of [
2033
2049
  "GIT_DIR",
@@ -2047,6 +2063,10 @@ async function workflowCommand(cwd, command, args, signal, managedPreparation =
2047
2063
  "GIT_REPLACE_REF_BASE"
2048
2064
  ])
2049
2065
  delete env[name];
2066
+ return env;
2067
+ }
2068
+ async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2069
+ const env = workflowEnvironment();
2050
2070
  if (managedPreparation) env.AGENTRIFIC_MANAGED_PREPARATION = "1";
2051
2071
  const result = await exec(command, args, {
2052
2072
  cwd,
@@ -3211,6 +3231,106 @@ function reconnectProject(root, id, input, store2) {
3211
3231
  }).pipe(projectLock(store2).withPermit);
3212
3232
  }
3213
3233
 
3234
+ // src/project-git.ts
3235
+ import { execFile as execFile2 } from "node:child_process";
3236
+ import { promisify as promisify2 } from "node:util";
3237
+ var execute = promisify2(execFile2);
3238
+ function repositoryIdentity(remote) {
3239
+ const input = remote.trim();
3240
+ if (!input || input.startsWith("/") || input.startsWith(".")) return null;
3241
+ const scp = /^(?:[^@/\s]+@)?([^/:\s]+):([^\s]+)$/.exec(input);
3242
+ let url;
3243
+ try {
3244
+ url = new URL(scp && !input.includes("://") ? `ssh://${scp[1]}/${scp[2]}` : input);
3245
+ } catch {
3246
+ return null;
3247
+ }
3248
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
3249
+ if (url.search || url.hash || url.port) return null;
3250
+ const host2 = url.hostname.toLowerCase();
3251
+ let path10 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3252
+ if (!host2 || !path10 || path10.includes("%") || path10.includes("//")) return null;
3253
+ if (["github.com", "bitbucket.org"].includes(host2)) path10 = path10.toLowerCase();
3254
+ const key = `${host2}/${path10}`;
3255
+ return { key, display: key };
3256
+ }
3257
+ async function git(directory, args) {
3258
+ const { stdout } = await execute("git", ["-C", directory, ...args], {
3259
+ timeout: 3e3,
3260
+ maxBuffer: 1024 * 1024,
3261
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }
3262
+ });
3263
+ return stdout.trim();
3264
+ }
3265
+ async function readRepositoryIdentity(directory) {
3266
+ try {
3267
+ const remotes = (await git(directory, ["remote"])).split("\n").filter(Boolean);
3268
+ const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
3269
+ if (!remote) return null;
3270
+ return repositoryIdentity(await git(directory, ["remote", "get-url", remote]));
3271
+ } catch {
3272
+ return null;
3273
+ }
3274
+ }
3275
+ async function inspect(directory) {
3276
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
3277
+ try {
3278
+ const [repository, head, branch, status] = await Promise.all([
3279
+ readRepositoryIdentity(directory),
3280
+ git(directory, ["rev-parse", "--verify", "HEAD"]),
3281
+ git(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null),
3282
+ // NUL-separated records avoid ambiguous filenames/newlines. --no-renames
3283
+ // keeps each entry one record, so we only need its two status characters.
3284
+ execute(
3285
+ "git",
3286
+ [
3287
+ "-C",
3288
+ directory,
3289
+ "status",
3290
+ "--porcelain=v1",
3291
+ "-z",
3292
+ "--no-renames",
3293
+ "--untracked-files=normal"
3294
+ ],
3295
+ {
3296
+ timeout: 3e3,
3297
+ maxBuffer: 1024 * 1024,
3298
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0" }
3299
+ }
3300
+ )
3301
+ ]);
3302
+ const records = status.stdout.split("\0").filter(Boolean);
3303
+ return {
3304
+ observedAt,
3305
+ state: "available",
3306
+ repository,
3307
+ head,
3308
+ branch,
3309
+ trackedChanges: records.filter((record) => !record.startsWith("??")).length,
3310
+ untrackedChanges: records.filter((record) => record.startsWith("??")).length
3311
+ };
3312
+ } catch {
3313
+ return {
3314
+ observedAt,
3315
+ state: "unavailable",
3316
+ repository: null,
3317
+ head: null,
3318
+ branch: null,
3319
+ trackedChanges: 0,
3320
+ untrackedChanges: 0
3321
+ };
3322
+ }
3323
+ }
3324
+ var observations = /* @__PURE__ */ new Map();
3325
+ function projectGitStatus(directory) {
3326
+ const cached = observations.get(directory);
3327
+ if (cached && cached.expires > Date.now()) return cached.value;
3328
+ if (observations.size >= 200) observations.delete(observations.keys().next().value);
3329
+ const value = inspect(directory);
3330
+ observations.set(directory, { expires: Date.now() + 15e3, value });
3331
+ return value;
3332
+ }
3333
+
3214
3334
  // src/store.ts
3215
3335
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
3216
3336
  import path7 from "node:path";
@@ -3777,7 +3897,7 @@ var defaultProjectsRoot = path9.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "
3777
3897
  var worktreesRoot = path9.resolve(
3778
3898
  process.env.AGENTRIFIC_WORKTREES_DIR ?? path9.join(homedir2(), ".agentrific", "workspaces")
3779
3899
  );
3780
- var execFileAsync = promisify2(execFile2);
3900
+ var execFileAsync = promisify3(execFile3);
3781
3901
  if (host !== "127.0.0.1" && host !== "::1")
3782
3902
  throw new Error("Bind Agentrific to loopback and expose it with Tailscale Serve or cloudflared.");
3783
3903
  var app = Fastify({
@@ -3803,10 +3923,19 @@ var releaseLock = await acquireServerLock(dataRoot);
3803
3923
  var effects = ManagedRuntime.make(SessionStore.layer(path9.join(dataRoot, "sessions.sqlite")));
3804
3924
  var store = await effects.runPromise(SessionStore);
3805
3925
  var security = new ServerSecurity(store, await Effect17.runPromise(store.serverIdentity()));
3926
+ var sandboxPushUrl = process.env.AGENTRIFIC_PUSH_SANDBOX === "1" ? PUSH_SANDBOX_API_URL : void 0;
3927
+ if (sandboxPushUrl && process.env.AGENTRIFIC_TEST_MODE !== "1")
3928
+ throw new Error("Sandbox push requires explicit test mode.");
3806
3929
  var testPushUrl = process.env.AGENTRIFIC_PUSH_TEST_URL;
3807
3930
  if (testPushUrl && (process.env.AGENTRIFIC_TEST_MODE !== "1" || !new URL(testPushUrl).hostname.endsWith(".ts.net") || new URL(testPushUrl).protocol !== "https:"))
3808
3931
  throw new Error("Test relay requires explicit test mode and Tailscale HTTPS.");
3809
- var push = new ServerPush(store, security.serverId, dataRoot, fetch, testPushUrl);
3932
+ var push = new ServerPush(
3933
+ store,
3934
+ security.serverId,
3935
+ dataRoot,
3936
+ fetch,
3937
+ testPushUrl ?? sandboxPushUrl
3938
+ );
3810
3939
  var allowedOrigins = [
3811
3940
  .../* @__PURE__ */ new Set([
3812
3941
  "agentrific-desktop://app",
@@ -4054,7 +4183,24 @@ app.get("/api/projects", async (_request, reply) => {
4054
4183
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4055
4184
  const result = await Effect17.runPromise(discoverProjects(root, store).pipe(Effect17.result));
4056
4185
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4057
- return result.success;
4186
+ return Effect17.runPromise(
4187
+ Effect17.forEach(
4188
+ result.success,
4189
+ (project) => Effect17.promise(async () => ({
4190
+ ...project,
4191
+ repository: project.available ? await readRepositoryIdentity(project.path) : null
4192
+ })),
4193
+ { concurrency: 4 }
4194
+ )
4195
+ );
4196
+ });
4197
+ app.get("/api/projects/:id/git-status", async (request, reply) => {
4198
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4199
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
4200
+ const project = projects.find((item) => item.id === request.params.id);
4201
+ if (!project?.available)
4202
+ return reply.code(404).send({ error: "Project folder unavailable on this server." });
4203
+ return projectGitStatus(project.path);
4058
4204
  });
4059
4205
  app.get("/api/projects/:id", async (request, reply) => {
4060
4206
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
@@ -4078,39 +4224,51 @@ app.get(
4078
4224
  "/api/tasks",
4079
4225
  (request) => boardTasks(store, request.query.projectId)
4080
4226
  );
4227
+ app.get("/api/task-moves", () => ({ supported: true }));
4081
4228
  app.post("/api/tasks", async (request, reply) => {
4082
4229
  const parsed = CreateTaskSchema.safeParse(request.body);
4083
4230
  if (!parsed.success) return reply.code(400).send({ error: "Enter a task title." });
4084
- if (parsed.data.gitFlow?.delivery === "pull_request") {
4085
- const github = await githubCliStatus();
4086
- if (!github.githubCli.authenticated)
4087
- return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4088
- }
4089
- const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4090
- const projects = await Effect17.runPromise(
4091
- discoverProjects(projectsRoot, store).pipe(Effect17.result)
4092
- );
4093
- if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4094
- return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4095
- let files;
4096
- try {
4097
- files = await Effect17.runPromise(
4098
- attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4231
+ const save = async () => {
4232
+ if (parsed.data.clientTaskId) {
4233
+ const existing = await Effect17.runPromise(store.getTask(parsed.data.clientTaskId));
4234
+ if (existing) {
4235
+ 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))
4236
+ return reply.code(409).send({ error: "This task creation request has already been used." });
4237
+ return reply.code(201).send(existing);
4238
+ }
4239
+ }
4240
+ if (parsed.data.gitFlow?.delivery === "pull_request") {
4241
+ const github = await githubCliStatus();
4242
+ if (!github.githubCli.authenticated)
4243
+ return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4244
+ }
4245
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4246
+ const projects = await Effect17.runPromise(
4247
+ discoverProjects(projectsRoot, store).pipe(Effect17.result)
4099
4248
  );
4100
- } catch (error) {
4101
- return reply.code(400).send({ error: String(error) });
4102
- }
4103
- const now = (/* @__PURE__ */ new Date()).toISOString();
4104
- const task = TaskSchema.parse({
4105
- id: randomUUID9(),
4106
- ...parsed.data,
4107
- attachments: files,
4108
- status: "backlog",
4109
- sessionId: null,
4110
- createdAt: now,
4111
- updatedAt: now
4112
- });
4113
- return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4249
+ if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4250
+ return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4251
+ let files;
4252
+ try {
4253
+ files = await Effect17.runPromise(
4254
+ attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4255
+ );
4256
+ } catch (error) {
4257
+ return reply.code(400).send({ error: String(error) });
4258
+ }
4259
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4260
+ const task = TaskSchema.parse({
4261
+ id: parsed.data.clientTaskId ?? randomUUID9(),
4262
+ ...parsed.data,
4263
+ attachments: files,
4264
+ status: "backlog",
4265
+ sessionId: null,
4266
+ createdAt: now,
4267
+ updatedAt: now
4268
+ });
4269
+ return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4270
+ };
4271
+ return parsed.data.clientTaskId ? withTaskLock(`create-task:${parsed.data.clientTaskId}`, save) : save();
4114
4272
  });
4115
4273
  app.patch("/api/tasks/:id", async (request, reply) => {
4116
4274
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -4137,6 +4295,18 @@ app.patch("/api/tasks/:id", async (request, reply) => {
4137
4295
  throw error;
4138
4296
  }
4139
4297
  });
4298
+ app.post("/api/tasks/:id/discard-unrun", async (request, reply) => {
4299
+ const parsed = z17.object({ updatedAt: z17.string().datetime() }).safeParse(request.body);
4300
+ if (!parsed.success) return reply.code(400).send({ error: "The task revision is required." });
4301
+ try {
4302
+ await deleteTask(store, request.params.id, void 0, parsed.data.updatedAt);
4303
+ return reply.code(204).send();
4304
+ } catch (error) {
4305
+ if (error instanceof TaskActionError)
4306
+ return reply.code(error.status).send({ error: error.message });
4307
+ throw error;
4308
+ }
4309
+ });
4140
4310
  app.delete("/api/tasks/:id", async (request, reply) => {
4141
4311
  try {
4142
4312
  await deleteTask(store, request.params.id, async (session) => {