@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/CHANGELOG.md +8 -0
- package/cli.js +208 -38
- package/cli.js.map +4 -4
- package/index.js +199 -37
- package/index.js.map +4 -4
- package/package.json +1 -1
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
|
|
875
|
-
import { promisify as
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
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
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
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) => {
|