@agentrific/server 0.4.0 → 0.5.0

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
@@ -586,11 +586,25 @@ var ConnectionsStatusSchema = z2.object({
586
586
  githubCli: z2.object({ installed: z2.boolean(), authenticated: z2.boolean() })
587
587
  });
588
588
  var UpdateSettingsSchema = z2.object({ projectsRoot: z2.string().trim().min(1).max(4096) });
589
+ var RepositoryIdentitySchema = z2.object({
590
+ key: z2.string(),
591
+ display: z2.string()
592
+ });
593
+ var ProjectGitStatusSchema = z2.object({
594
+ observedAt: z2.string().datetime(),
595
+ state: z2.enum(["available", "unavailable"]),
596
+ repository: RepositoryIdentitySchema.nullable(),
597
+ head: z2.string().nullable(),
598
+ branch: z2.string().nullable(),
599
+ trackedChanges: z2.number().int().nonnegative(),
600
+ untrackedChanges: z2.number().int().nonnegative()
601
+ });
589
602
  var ProjectSchema = z2.object({
590
603
  id: z2.string(),
591
604
  name: z2.string(),
592
605
  path: z2.string(),
593
- available: z2.boolean().default(true)
606
+ available: z2.boolean().default(true),
607
+ repository: RepositoryIdentitySchema.nullable().optional()
594
608
  });
595
609
  var TaskStatusSchema = z2.enum(["backlog", "needs_user_input", "running", "done"]);
596
610
  var TaskSchema = z2.object({
@@ -609,6 +623,7 @@ var TaskSchema = z2.object({
609
623
  deletedAt: z2.string().datetime().optional()
610
624
  });
611
625
  var CreateTaskSchema = z2.object({
626
+ clientTaskId: z2.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/).optional(),
612
627
  projectId: z2.string().min(1),
613
628
  title: z2.string().trim().min(1).max(120),
614
629
  prompt: z2.string().trim().max(2e4).default(""),
@@ -871,8 +886,8 @@ var Attachments = class extends Context.Service()(
871
886
 
872
887
  // src/index.ts
873
888
  import { randomUUID as randomUUID9 } from "node:crypto";
874
- import { execFile as execFile2 } from "node:child_process";
875
- import { promisify as promisify2 } from "node:util";
889
+ import { execFile as execFile3 } from "node:child_process";
890
+ import { promisify as promisify3 } from "node:util";
876
891
  import { realpath as realpath2 } from "node:fs/promises";
877
892
  import { homedir as homedir2 } from "node:os";
878
893
  import path9 from "node:path";
@@ -1097,7 +1112,7 @@ async function startControl(directory, security2, status, maintenance2) {
1097
1112
  // package.json
1098
1113
  var package_default = {
1099
1114
  name: "@agentrific/server",
1100
- version: "0.4.0",
1115
+ version: "0.5.0",
1101
1116
  description: "Self-hosted control plane for long-running coding agents",
1102
1117
  keywords: [
1103
1118
  "agentrific",
@@ -1608,7 +1623,7 @@ function editTask(store2, id, input, resolveAttachments) {
1608
1623
  });
1609
1624
  }
1610
1625
  function deleteTask(store2, id, cleanup = async () => {
1611
- }) {
1626
+ }, unrunUpdatedAt) {
1612
1627
  return withTaskLock(id, async () => {
1613
1628
  const task = await findTask(store2, id);
1614
1629
  const sessions = await Effect6.runPromise(store2.list());
@@ -1619,6 +1634,8 @@ function deleteTask(store2, id, cleanup = async () => {
1619
1634
  const associated = sessions.filter(
1620
1635
  (session) => session.taskId === task.id || session.id === task.sessionId
1621
1636
  );
1637
+ if (unrunUpdatedAt !== void 0 && (task.updatedAt !== unrunUpdatedAt || task.sessionId || associated.length || task.status !== "backlog"))
1638
+ throw new TaskActionError("This task changed or has already run. It cannot be moved.", 409);
1622
1639
  for (const session of associated) await cleanup(session);
1623
1640
  await Effect6.runPromise(
1624
1641
  store2.purgeTask(
@@ -2027,7 +2044,7 @@ var exec = promisify(execFile);
2027
2044
  function workflowLog(text) {
2028
2045
  return stripVTControlCharacters(text).slice(-32e3);
2029
2046
  }
2030
- async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2047
+ function workflowEnvironment() {
2031
2048
  const env = { ...process.env };
2032
2049
  for (const name of [
2033
2050
  "GIT_DIR",
@@ -2047,6 +2064,10 @@ async function workflowCommand(cwd, command, args, signal, managedPreparation =
2047
2064
  "GIT_REPLACE_REF_BASE"
2048
2065
  ])
2049
2066
  delete env[name];
2067
+ return env;
2068
+ }
2069
+ async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2070
+ const env = workflowEnvironment();
2050
2071
  if (managedPreparation) env.AGENTRIFIC_MANAGED_PREPARATION = "1";
2051
2072
  const result = await exec(command, args, {
2052
2073
  cwd,
@@ -3211,6 +3232,106 @@ function reconnectProject(root, id, input, store2) {
3211
3232
  }).pipe(projectLock(store2).withPermit);
3212
3233
  }
3213
3234
 
3235
+ // src/project-git.ts
3236
+ import { execFile as execFile2 } from "node:child_process";
3237
+ import { promisify as promisify2 } from "node:util";
3238
+ var execute = promisify2(execFile2);
3239
+ function repositoryIdentity(remote) {
3240
+ const input = remote.trim();
3241
+ if (!input || input.startsWith("/") || input.startsWith(".")) return null;
3242
+ const scp = /^(?:[^@/\s]+@)?([^/:\s]+):([^\s]+)$/.exec(input);
3243
+ let url;
3244
+ try {
3245
+ url = new URL(scp && !input.includes("://") ? `ssh://${scp[1]}/${scp[2]}` : input);
3246
+ } catch {
3247
+ return null;
3248
+ }
3249
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
3250
+ if (url.search || url.hash || url.port) return null;
3251
+ const host2 = url.hostname.toLowerCase();
3252
+ let path10 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3253
+ if (!host2 || !path10 || path10.includes("%") || path10.includes("//")) return null;
3254
+ if (["github.com", "bitbucket.org"].includes(host2)) path10 = path10.toLowerCase();
3255
+ const key = `${host2}/${path10}`;
3256
+ return { key, display: key };
3257
+ }
3258
+ async function git(directory, args) {
3259
+ const { stdout } = await execute("git", ["-C", directory, ...args], {
3260
+ timeout: 3e3,
3261
+ maxBuffer: 1024 * 1024,
3262
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }
3263
+ });
3264
+ return stdout.trim();
3265
+ }
3266
+ async function readRepositoryIdentity(directory) {
3267
+ try {
3268
+ const remotes = (await git(directory, ["remote"])).split("\n").filter(Boolean);
3269
+ const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
3270
+ if (!remote) return null;
3271
+ return repositoryIdentity(await git(directory, ["remote", "get-url", remote]));
3272
+ } catch {
3273
+ return null;
3274
+ }
3275
+ }
3276
+ async function inspect(directory) {
3277
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
3278
+ try {
3279
+ const [repository, head, branch, status] = await Promise.all([
3280
+ readRepositoryIdentity(directory),
3281
+ git(directory, ["rev-parse", "--verify", "HEAD"]),
3282
+ git(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null),
3283
+ // NUL-separated records avoid ambiguous filenames/newlines. --no-renames
3284
+ // keeps each entry one record, so we only need its two status characters.
3285
+ execute(
3286
+ "git",
3287
+ [
3288
+ "-C",
3289
+ directory,
3290
+ "status",
3291
+ "--porcelain=v1",
3292
+ "-z",
3293
+ "--no-renames",
3294
+ "--untracked-files=normal"
3295
+ ],
3296
+ {
3297
+ timeout: 3e3,
3298
+ maxBuffer: 1024 * 1024,
3299
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0" }
3300
+ }
3301
+ )
3302
+ ]);
3303
+ const records = status.stdout.split("\0").filter(Boolean);
3304
+ return {
3305
+ observedAt,
3306
+ state: "available",
3307
+ repository,
3308
+ head,
3309
+ branch,
3310
+ trackedChanges: records.filter((record) => !record.startsWith("??")).length,
3311
+ untrackedChanges: records.filter((record) => record.startsWith("??")).length
3312
+ };
3313
+ } catch {
3314
+ return {
3315
+ observedAt,
3316
+ state: "unavailable",
3317
+ repository: null,
3318
+ head: null,
3319
+ branch: null,
3320
+ trackedChanges: 0,
3321
+ untrackedChanges: 0
3322
+ };
3323
+ }
3324
+ }
3325
+ var observations = /* @__PURE__ */ new Map();
3326
+ function projectGitStatus(directory) {
3327
+ const cached = observations.get(directory);
3328
+ if (cached && cached.expires > Date.now()) return cached.value;
3329
+ if (observations.size >= 200) observations.delete(observations.keys().next().value);
3330
+ const value = inspect(directory);
3331
+ observations.set(directory, { expires: Date.now() + 15e3, value });
3332
+ return value;
3333
+ }
3334
+
3214
3335
  // src/store.ts
3215
3336
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
3216
3337
  import path7 from "node:path";
@@ -3777,7 +3898,7 @@ var defaultProjectsRoot = path9.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "
3777
3898
  var worktreesRoot = path9.resolve(
3778
3899
  process.env.AGENTRIFIC_WORKTREES_DIR ?? path9.join(homedir2(), ".agentrific", "workspaces")
3779
3900
  );
3780
- var execFileAsync = promisify2(execFile2);
3901
+ var execFileAsync = promisify3(execFile3);
3781
3902
  if (host !== "127.0.0.1" && host !== "::1")
3782
3903
  throw new Error("Bind Agentrific to loopback and expose it with Tailscale Serve or cloudflared.");
3783
3904
  var app = Fastify({
@@ -4054,7 +4175,24 @@ app.get("/api/projects", async (_request, reply) => {
4054
4175
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4055
4176
  const result = await Effect17.runPromise(discoverProjects(root, store).pipe(Effect17.result));
4056
4177
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4057
- return result.success;
4178
+ return Effect17.runPromise(
4179
+ Effect17.forEach(
4180
+ result.success,
4181
+ (project) => Effect17.promise(async () => ({
4182
+ ...project,
4183
+ repository: project.available ? await readRepositoryIdentity(project.path) : null
4184
+ })),
4185
+ { concurrency: 4 }
4186
+ )
4187
+ );
4188
+ });
4189
+ app.get("/api/projects/:id/git-status", async (request, reply) => {
4190
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4191
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
4192
+ const project = projects.find((item) => item.id === request.params.id);
4193
+ if (!project?.available)
4194
+ return reply.code(404).send({ error: "Project folder unavailable on this server." });
4195
+ return projectGitStatus(project.path);
4058
4196
  });
4059
4197
  app.get("/api/projects/:id", async (request, reply) => {
4060
4198
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
@@ -4078,39 +4216,51 @@ app.get(
4078
4216
  "/api/tasks",
4079
4217
  (request) => boardTasks(store, request.query.projectId)
4080
4218
  );
4219
+ app.get("/api/task-moves", () => ({ supported: true }));
4081
4220
  app.post("/api/tasks", async (request, reply) => {
4082
4221
  const parsed = CreateTaskSchema.safeParse(request.body);
4083
4222
  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 ?? [])
4223
+ const save = async () => {
4224
+ if (parsed.data.clientTaskId) {
4225
+ const existing = await Effect17.runPromise(store.getTask(parsed.data.clientTaskId));
4226
+ if (existing) {
4227
+ 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))
4228
+ return reply.code(409).send({ error: "This task creation request has already been used." });
4229
+ return reply.code(201).send(existing);
4230
+ }
4231
+ }
4232
+ if (parsed.data.gitFlow?.delivery === "pull_request") {
4233
+ const github = await githubCliStatus();
4234
+ if (!github.githubCli.authenticated)
4235
+ return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4236
+ }
4237
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4238
+ const projects = await Effect17.runPromise(
4239
+ discoverProjects(projectsRoot, store).pipe(Effect17.result)
4099
4240
  );
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)));
4241
+ if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4242
+ return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4243
+ let files;
4244
+ try {
4245
+ files = await Effect17.runPromise(
4246
+ attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4247
+ );
4248
+ } catch (error) {
4249
+ return reply.code(400).send({ error: String(error) });
4250
+ }
4251
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4252
+ const task = TaskSchema.parse({
4253
+ id: parsed.data.clientTaskId ?? randomUUID9(),
4254
+ ...parsed.data,
4255
+ attachments: files,
4256
+ status: "backlog",
4257
+ sessionId: null,
4258
+ createdAt: now,
4259
+ updatedAt: now
4260
+ });
4261
+ return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4262
+ };
4263
+ return parsed.data.clientTaskId ? withTaskLock(`create-task:${parsed.data.clientTaskId}`, save) : save();
4114
4264
  });
4115
4265
  app.patch("/api/tasks/:id", async (request, reply) => {
4116
4266
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -4137,6 +4287,18 @@ app.patch("/api/tasks/:id", async (request, reply) => {
4137
4287
  throw error;
4138
4288
  }
4139
4289
  });
4290
+ app.post("/api/tasks/:id/discard-unrun", async (request, reply) => {
4291
+ const parsed = z17.object({ updatedAt: z17.string().datetime() }).safeParse(request.body);
4292
+ if (!parsed.success) return reply.code(400).send({ error: "The task revision is required." });
4293
+ try {
4294
+ await deleteTask(store, request.params.id, void 0, parsed.data.updatedAt);
4295
+ return reply.code(204).send();
4296
+ } catch (error) {
4297
+ if (error instanceof TaskActionError)
4298
+ return reply.code(error.status).send({ error: error.message });
4299
+ throw error;
4300
+ }
4301
+ });
4140
4302
  app.delete("/api/tasks/:id", async (request, reply) => {
4141
4303
  try {
4142
4304
  await deleteTask(store, request.params.id, async (session) => {