dsh-live-teams 0.1.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/LICENSE +176 -0
- package/NOTICE +11 -0
- package/README.md +85 -0
- package/cordis.patch.yml +25 -0
- package/lib/binding.d.ts +18 -0
- package/lib/binding.js +42 -0
- package/lib/changed-paths.d.ts +26 -0
- package/lib/changed-paths.js +69 -0
- package/lib/client.js +6753 -0
- package/lib/command-queue.d.ts +60 -0
- package/lib/command-queue.js +185 -0
- package/lib/compatibility.js +109 -0
- package/lib/context-provider.d.ts +110 -0
- package/lib/context-provider.js +249 -0
- package/lib/dispatch.d.ts +174 -0
- package/lib/dispatch.js +624 -0
- package/lib/errors.d.ts +36 -0
- package/lib/errors.js +103 -0
- package/lib/git-artifacts.d.ts +50 -0
- package/lib/git-artifacts.js +242 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +14 -0
- package/lib/mailbox.d.ts +274 -0
- package/lib/mailbox.js +721 -0
- package/lib/member-tools.d.ts +57 -0
- package/lib/member-tools.js +1265 -0
- package/lib/migrations.d.ts +17 -0
- package/lib/migrations.js +47 -0
- package/lib/plugin.d.ts +106 -0
- package/lib/plugin.js +1003 -0
- package/lib/roles.d.ts +35 -0
- package/lib/roles.js +284 -0
- package/lib/routes.d.ts +586 -0
- package/lib/routes.js +2816 -0
- package/lib/scope.d.ts +62 -0
- package/lib/scope.js +133 -0
- package/lib/session-bridge.d.ts +76 -0
- package/lib/session-bridge.js +147 -0
- package/lib/session-title.js +35 -0
- package/lib/storage.d.ts +9 -0
- package/lib/storage.js +65 -0
- package/lib/task-store.d.ts +729 -0
- package/lib/task-store.js +2205 -0
- package/lib/team-store.d.ts +216 -0
- package/lib/team-store.js +765 -0
- package/lib/tree-snapshot.d.ts +28 -0
- package/lib/tree-snapshot.js +80 -0
- package/lib/types/client/TeamView.d.ts +26 -0
- package/lib/types/client/TeamView.dom.test.d.ts +1 -0
- package/lib/types/client/api.d.ts +522 -0
- package/lib/types/client/api.test.d.ts +1 -0
- package/lib/types/client/attention.d.ts +65 -0
- package/lib/types/client/attention.test.d.ts +1 -0
- package/lib/types/client/index.d.ts +31 -0
- package/lib/types/client/locales.d.ts +577 -0
- package/lib/types/client/member-name.d.ts +14 -0
- package/lib/types/client/member-name.test.d.ts +1 -0
- package/lib/types/client/roster.d.ts +26 -0
- package/lib/types/client/roster.test.d.ts +1 -0
- package/lib/types/client/styles.d.ts +3 -0
- package/package.json +104 -0
- package/roles/builder.md +40 -0
- package/roles/delegate.md +36 -0
- package/roles/lead.md +46 -0
- package/roles/oracle.md +36 -0
- package/roles/researcher.md +37 -0
- package/roles/reviewer.md +45 -0
- package/roles/scout.md +36 -0
- package/roles/verifier.md +36 -0
|
@@ -0,0 +1,2205 @@
|
|
|
1
|
+
import { ERROR_CODES, LiveTeamsError } from "./errors.js";
|
|
2
|
+
import { atomicWrite, atomicWriteJson, clone, fileExists, readJson, withProcessLock } from "./storage.js";
|
|
3
|
+
import { TEAM_ROOT } from "./team-store.js";
|
|
4
|
+
import { CODING_KINDS, literalPrefix, normalizeScope, normalizeScopePath, outsideScope, scopesIntersect } from "./scope.js";
|
|
5
|
+
import { diffSnapshots } from "./tree-snapshot.js";
|
|
6
|
+
import { MIGRATIONS, applyMigration, planMigration } from "./migrations.js";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { promises, readFileSync } from "node:fs";
|
|
10
|
+
//#region src/task-store.ts
|
|
11
|
+
const TASK_KINDS = [
|
|
12
|
+
"work",
|
|
13
|
+
"research",
|
|
14
|
+
"requirements",
|
|
15
|
+
"implementation",
|
|
16
|
+
"verification",
|
|
17
|
+
"review",
|
|
18
|
+
"repair",
|
|
19
|
+
"integration"
|
|
20
|
+
];
|
|
21
|
+
const TASK_STATUSES = [
|
|
22
|
+
"draft",
|
|
23
|
+
"ready",
|
|
24
|
+
"claimed",
|
|
25
|
+
"in_progress",
|
|
26
|
+
"waiting",
|
|
27
|
+
"needs_human",
|
|
28
|
+
"completed",
|
|
29
|
+
"failed",
|
|
30
|
+
"cancelled"
|
|
31
|
+
];
|
|
32
|
+
const MAX_TITLE = 200;
|
|
33
|
+
const MAX_BODY = 2e4;
|
|
34
|
+
const MAX_TASKS = 100;
|
|
35
|
+
/** A task with more dependencies than this is a plan, not a task. */
|
|
36
|
+
const MAX_DEPENDENCIES = 8;
|
|
37
|
+
const FINDING_SEVERITIES = [
|
|
38
|
+
"low",
|
|
39
|
+
"medium",
|
|
40
|
+
"high",
|
|
41
|
+
"blocker"
|
|
42
|
+
];
|
|
43
|
+
const REVIEW_VERDICTS = [
|
|
44
|
+
"pass",
|
|
45
|
+
"needs_revision",
|
|
46
|
+
"escalate"
|
|
47
|
+
];
|
|
48
|
+
function objectRecord(value) {
|
|
49
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
50
|
+
}
|
|
51
|
+
function nonEmpty(value) {
|
|
52
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
53
|
+
}
|
|
54
|
+
function chars(value) {
|
|
55
|
+
return Array.from(value).length;
|
|
56
|
+
}
|
|
57
|
+
function candidateId(value) {
|
|
58
|
+
return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
|
|
59
|
+
}
|
|
60
|
+
function fail(message) {
|
|
61
|
+
throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, message);
|
|
62
|
+
}
|
|
63
|
+
function memberById(team, id) {
|
|
64
|
+
return team.members.find((member) => member.memberId === id);
|
|
65
|
+
}
|
|
66
|
+
function isLead(team, actor) {
|
|
67
|
+
if (team.leadMemberId !== void 0) return team.leadMemberId === actor && memberById(team, actor) !== void 0;
|
|
68
|
+
const leads = team.members.filter((member) => member.role === "lead");
|
|
69
|
+
return leads.length === 1 && leads[0]?.memberId === actor;
|
|
70
|
+
}
|
|
71
|
+
/** The desktop route is the owning human's lead-level operation. */
|
|
72
|
+
function isLeadOrHuman(team, actor) {
|
|
73
|
+
return actor === "human:desktop" || actor === "local:unverified" || isLead(team, actor);
|
|
74
|
+
}
|
|
75
|
+
function leadMemberIdForMessage(team) {
|
|
76
|
+
return team.leadMemberId ?? team.members.find((member) => member.role === "lead")?.memberId ?? "the lead";
|
|
77
|
+
}
|
|
78
|
+
function validateRoute(route, team, assignee) {
|
|
79
|
+
if (objectRecord(route)?.type === "interactive") fail("the interactive route shape is not implemented: use {\"type\":\"review\",\"reviewer\":\"<member id>\"} or {\"type\":\"report\",\"to\":\"human\"}");
|
|
80
|
+
const value = objectRecord(route);
|
|
81
|
+
if (value === void 0 || typeof value.type !== "string") fail("route must be a report, review, interactive, or custom route");
|
|
82
|
+
if (value.type === "report") {
|
|
83
|
+
if (value.to !== "human" && (typeof value.to !== "string" || memberById(team, value.to) === void 0)) fail("report route must name a current member or human");
|
|
84
|
+
return {
|
|
85
|
+
type: "report",
|
|
86
|
+
to: value.to
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (value.type === "review") {
|
|
90
|
+
if (typeof value.reviewer !== "string" || memberById(team, value.reviewer) === void 0) fail("review route must name a current member");
|
|
91
|
+
if (assignee !== void 0 && value.reviewer === assignee) fail("reviewer cannot be the assignee; nobody reviews their own work");
|
|
92
|
+
return {
|
|
93
|
+
type: "review",
|
|
94
|
+
reviewer: value.reviewer
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (value.type === "interactive") {
|
|
98
|
+
if (value.then === void 0) fail("interactive route requires a follow-up route");
|
|
99
|
+
return {
|
|
100
|
+
type: "interactive",
|
|
101
|
+
then: validateRoute(value.then, team, assignee)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (value.type === "custom") {
|
|
105
|
+
if (!nonEmpty(value.description)) fail("custom route description must be non-empty");
|
|
106
|
+
return {
|
|
107
|
+
type: "custom",
|
|
108
|
+
description: value.description.trim()
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
fail(`unsupported task route: ${value.type}`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Validate the dependency list of a task contract.
|
|
115
|
+
*
|
|
116
|
+
* Checked against the tasks that exist *now*, because a dependency naming a task
|
|
117
|
+
* that does not exist is a typo that would block the work forever, and a cycle
|
|
118
|
+
* would block it just as permanently while looking legitimate.
|
|
119
|
+
*/
|
|
120
|
+
function validateDependencies(value, selfId, tasks) {
|
|
121
|
+
if (value === void 0) return void 0;
|
|
122
|
+
if (!Array.isArray(value)) fail("dependsOn must be an array of task ids");
|
|
123
|
+
if (value.length === 0) return void 0;
|
|
124
|
+
if (value.length > MAX_DEPENDENCIES) fail(`a task may depend on at most ${MAX_DEPENDENCIES} tasks`);
|
|
125
|
+
const ids = [];
|
|
126
|
+
for (const entry of value) {
|
|
127
|
+
if (typeof entry !== "string" || !candidateId(entry)) fail("dependsOn must contain task ids");
|
|
128
|
+
if (entry === selfId) fail("a task cannot depend on itself");
|
|
129
|
+
if (ids.includes(entry)) fail(`dependsOn repeats ${entry}`);
|
|
130
|
+
if (!tasks.some((task) => task.id === entry)) fail(`dependsOn names an unknown task: ${entry}`);
|
|
131
|
+
ids.push(entry);
|
|
132
|
+
}
|
|
133
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
134
|
+
const seen = /* @__PURE__ */ new Set();
|
|
135
|
+
const pending = [...ids];
|
|
136
|
+
while (pending.length > 0) {
|
|
137
|
+
const id = pending.pop();
|
|
138
|
+
if (id === selfId) fail(`dependsOn would create a cycle through ${id}`);
|
|
139
|
+
if (seen.has(id)) continue;
|
|
140
|
+
seen.add(id);
|
|
141
|
+
for (const next of byId.get(id)?.dependsOn ?? []) pending.push(next);
|
|
142
|
+
}
|
|
143
|
+
return ids;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The dependencies that still stand between a task and its claim, in the order
|
|
147
|
+
* the contract lists them. A dependency in a terminal non-completed state is
|
|
148
|
+
* included on purpose: it blocks forever and the human must see that.
|
|
149
|
+
*/
|
|
150
|
+
function blockedBy(task, tasks) {
|
|
151
|
+
const ids = task.dependsOn ?? [];
|
|
152
|
+
if (ids.length === 0) return [];
|
|
153
|
+
const byId = new Map(tasks.map((entry) => [entry.id, entry]));
|
|
154
|
+
return ids.filter((id) => byId.get(id)?.status !== "completed").map((id) => ({
|
|
155
|
+
id,
|
|
156
|
+
title: byId.get(id)?.title ?? id,
|
|
157
|
+
status: byId.get(id)?.status ?? "failed"
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
function validateTaskRecord(value, teamId, archiveDirectory) {
|
|
161
|
+
const candidate = objectRecord(value);
|
|
162
|
+
const humanContact = candidate?.humanContact;
|
|
163
|
+
const archivedPath = candidate?.archivedPath;
|
|
164
|
+
const archiveRoot = archiveDirectory === void 0 ? void 0 : path.resolve(archiveDirectory);
|
|
165
|
+
const resolvedArchive = typeof archivedPath !== "string" || archiveRoot === void 0 ? void 0 : path.isAbsolute(archivedPath) ? path.resolve(archivedPath) : path.resolve(path.dirname(archiveRoot), archivedPath);
|
|
166
|
+
const archivePathValid = archivedPath === void 0 || typeof archivedPath === "string" && archivedPath.length > 0 && resolvedArchive !== void 0 && (resolvedArchive === archiveRoot || resolvedArchive.startsWith(`${archiveRoot}${path.sep}`));
|
|
167
|
+
if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || candidate.teamId !== teamId || typeof candidate.title !== "string" || candidate.title.trim().length === 0 || !TASK_KINDS.includes(candidate.kind) || !TASK_STATUSES.includes(candidate.status) || !Array.isArray(candidate.acceptance) || !candidate.acceptance.every((entry) => typeof entry === "string") || candidate.status === "ready" && (candidate.assignee === void 0 || candidate.route === void 0 || candidate.acceptance.length === 0 || candidate.acceptance.some((entry) => entry.trim().length === 0)) || candidate.assignee !== void 0 && typeof candidate.assignee !== "string" || candidate.why !== void 0 && typeof candidate.why !== "string" || candidate.body !== void 0 && (typeof candidate.body !== "string" || chars(candidate.body) > MAX_BODY) || candidate.checks !== void 0 && (!Array.isArray(candidate.checks) || !candidate.checks.every((entry) => typeof entry === "string" && entry.trim().length > 0)) || candidate.dependsOn !== void 0 && (!Array.isArray(candidate.dependsOn) || candidate.dependsOn.length === 0 || candidate.dependsOn.length > MAX_DEPENDENCIES || !candidate.dependsOn.every((entry) => typeof entry === "string" && candidateId(entry)) || new Set(candidate.dependsOn).size !== candidate.dependsOn.length || candidate.dependsOn.includes(candidate.id)) || candidate.paths !== void 0 && normalizeScope(candidate.paths) === void 0 || candidate.contractRevision !== void 0 && (!Number.isInteger(candidate.contractRevision) || Number(candidate.contractRevision) < 1) || !archivePathValid || humanContact !== void 0 && humanContact !== "none" && humanContact !== "expected" && humanContact !== "required" || candidate.activeAttemptId !== void 0 && (typeof candidate.activeAttemptId !== "string" || !candidateId(candidate.activeAttemptId)) || !Number.isInteger(candidate.roundLimit) || Number(candidate.roundLimit) < 1 || Number(candidate.roundLimit) > 20 || typeof candidate.createdBy !== "string" || candidate.createdBy.length === 0 || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid task record");
|
|
168
|
+
return candidate;
|
|
169
|
+
}
|
|
170
|
+
function parseTasksState(value, teamId, archiveDirectory) {
|
|
171
|
+
const candidate = objectRecord(value);
|
|
172
|
+
if (candidate !== void 0 && Number.isInteger(candidate.schemaVersion) && Number(candidate.schemaVersion) !== 1) {
|
|
173
|
+
const plan = planMigration(candidate.schemaVersion, 1, "task state");
|
|
174
|
+
if (plan.kind === "future") throw plan.error;
|
|
175
|
+
}
|
|
176
|
+
if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.tasks)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid task state");
|
|
177
|
+
const tasks = candidate.tasks.map((entry) => validateTaskRecord(entry, teamId, archiveDirectory));
|
|
178
|
+
return {
|
|
179
|
+
...candidate,
|
|
180
|
+
revision: Number(candidate.revision),
|
|
181
|
+
tasks
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function validateAttempt(value, teamId) {
|
|
185
|
+
const candidate = objectRecord(value);
|
|
186
|
+
if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || typeof candidate.taskId !== "string" || !candidateId(candidate.taskId) || candidate.memberId === void 0 || typeof candidate.memberId !== "string" || candidate.memberId.length === 0 || typeof candidate.sessionId !== "string" || candidate.sessionId.length === 0 || !Number.isInteger(candidate.bindingGeneration) || Number(candidate.bindingGeneration) < 0 || !Number.isInteger(candidate.generation) || Number(candidate.generation) < 1 || candidate.status !== "active" && candidate.status !== "revoking" && candidate.status !== "revoked" && candidate.status !== "completed" && candidate.status !== "failed" || !Number.isInteger(candidate.startedAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1 || candidate.humanIntervenedAt !== void 0 && !Number.isInteger(candidate.humanIntervenedAt) || candidate.humanIntervenedContractRevision !== void 0 && !Number.isInteger(candidate.humanIntervenedContractRevision) || candidate.gitBase !== void 0 && (objectRecord(candidate.gitBase) === void 0 || typeof objectRecord(candidate.gitBase)?.ref !== "string" || typeof objectRecord(candidate.gitBase)?.baseTipSha !== "string") || candidate.stalled !== void 0 && (objectRecord(candidate.stalled) === void 0 || !Number.isInteger(objectRecord(candidate.stalled)?.at) || objectRecord(candidate.stalled)?.reason !== "unacknowledged" && objectRecord(candidate.stalled)?.reason !== "idle" || typeof objectRecord(candidate.stalled)?.detail !== "string")) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid task attempt in ${teamId}`);
|
|
187
|
+
return candidate;
|
|
188
|
+
}
|
|
189
|
+
function parseAttemptsState(value, teamId) {
|
|
190
|
+
const candidate = objectRecord(value);
|
|
191
|
+
if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.attempts)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid attempts state");
|
|
192
|
+
return {
|
|
193
|
+
...candidate,
|
|
194
|
+
revision: Number(candidate.revision),
|
|
195
|
+
attempts: candidate.attempts.map((entry) => validateAttempt(entry, teamId))
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The fence of ADR 0005, applied where it was missing: a member acting on its own task must present
|
|
200
|
+
* the Session and binding generation it is bound to *now*. `submitTask` always checked the triple;
|
|
201
|
+
* `claimTask` and `acknowledgeTask` did not, so a rebind could leave an old Session's call landing on
|
|
202
|
+
* the new one's attempt (found by an external review).
|
|
203
|
+
*/
|
|
204
|
+
function fenceMember(member, request, memberId) {
|
|
205
|
+
if (member === void 0) fail(`${memberId} is not a current team member`);
|
|
206
|
+
if (member.sessionId === void 0) return;
|
|
207
|
+
if (request.sessionId !== member.sessionId || request.bindingGeneration !== member.bindingGeneration) fail(`${memberId} is bound to Session ${member.sessionId} (generation ${member.bindingGeneration}); this call came from another binding. Re-read the task and act from the current Session`);
|
|
208
|
+
}
|
|
209
|
+
/** A readable, collision-free ref name for a pinned submission commit. */
|
|
210
|
+
function submissionGitRefSuffix(generation, headSha) {
|
|
211
|
+
return `g${generation}-${headSha.slice(0, 12)}`;
|
|
212
|
+
}
|
|
213
|
+
function validateCheck(value) {
|
|
214
|
+
const candidate = objectRecord(value);
|
|
215
|
+
const keys = candidate === void 0 ? String(value) : Object.keys(candidate).join(", ");
|
|
216
|
+
if (candidate === void 0 || typeof candidate.command !== "string" || candidate.command.trim().length === 0 || candidate.status !== "passed" && candidate.status !== "failed" || candidate.output !== void 0 && typeof candidate.output !== "string") fail(`check needs {"command":"…","status":"passed" | "failed"} and optionally {"output":"…"}; received keys: ${keys}`);
|
|
217
|
+
return {
|
|
218
|
+
command: candidate.command,
|
|
219
|
+
status: candidate.status,
|
|
220
|
+
...candidate.output === void 0 ? {} : { output: candidate.output }
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function validateSubmission(value, teamId) {
|
|
224
|
+
const candidate = objectRecord(value);
|
|
225
|
+
const artifact = objectRecord(candidate?.artifact);
|
|
226
|
+
if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || typeof candidate.taskId !== "string" || !candidateId(candidate.taskId) || typeof candidate.attemptId !== "string" || !candidateId(candidate.attemptId) || !Number.isInteger(candidate.attemptGeneration) || Number(candidate.attemptGeneration) < 1 || artifact === void 0 || artifact.type !== "report" && artifact.type !== "files" || typeof candidate.summary !== "string" || !Array.isArray(candidate.checks) || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid submission in ${teamId}`);
|
|
227
|
+
if (artifact.type === "report" && (typeof artifact.bodyRef !== "string" || typeof artifact.bodyDigest !== "string")) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid report submission artifact");
|
|
228
|
+
if (artifact.type === "files" && (!Array.isArray(artifact.paths) || !artifact.paths.every((entry) => typeof entry === "string") || typeof artifact.manifestDigest !== "string")) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid files submission artifact");
|
|
229
|
+
if (candidate.git !== void 0) {
|
|
230
|
+
const git = objectRecord(candidate.git);
|
|
231
|
+
if (git === void 0 || typeof git.baseRef !== "string" || typeof git.baseTipSha !== "string" || typeof git.headSha !== "string" || !Array.isArray(git.commitPaths) || !git.commitPaths.every((entry) => typeof entry === "string") || git.mergeBaseSha !== void 0 && typeof git.mergeBaseSha !== "string") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid Git submission artifact");
|
|
232
|
+
}
|
|
233
|
+
const scope = objectRecord(candidate.scope);
|
|
234
|
+
const normalizedScope = scope === void 0 ? void 0 : {
|
|
235
|
+
...scope,
|
|
236
|
+
state: scope.state === "collision" ? "shared-tree" : scope.state,
|
|
237
|
+
...scope.sharedTree === void 0 && Array.isArray(scope.collision) ? { sharedTree: scope.collision } : {}
|
|
238
|
+
};
|
|
239
|
+
if (normalizedScope !== void 0) delete normalizedScope.collision;
|
|
240
|
+
return {
|
|
241
|
+
...candidate,
|
|
242
|
+
...normalizedScope === void 0 ? {} : { scope: normalizedScope },
|
|
243
|
+
artifact: clone(artifact),
|
|
244
|
+
checks: candidate.checks.map(validateCheck)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function parseSubmissionsState(value, teamId) {
|
|
248
|
+
const candidate = objectRecord(value);
|
|
249
|
+
if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.submissions)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid submissions state");
|
|
250
|
+
return {
|
|
251
|
+
...candidate,
|
|
252
|
+
revision: Number(candidate.revision),
|
|
253
|
+
submissions: candidate.submissions.map((entry) => validateSubmission(entry, teamId))
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function validateFinding(value) {
|
|
257
|
+
const candidate = objectRecord(value);
|
|
258
|
+
const keys = candidate === void 0 ? String(value) : Object.keys(candidate).join(", ");
|
|
259
|
+
if (candidate === void 0 || !FINDING_SEVERITIES.includes(candidate.severity) || typeof candidate.problem !== "string" || candidate.problem.trim().length === 0 || typeof candidate.requiredFix !== "string" || candidate.requiredFix.trim().length === 0 || candidate.artifactLocation !== void 0 && typeof candidate.artifactLocation !== "string") fail(`finding needs {"severity":"low" | "medium" | "high" | "blocker","problem":"…","requiredFix":"…"}; received keys: ${keys}`);
|
|
260
|
+
return {
|
|
261
|
+
severity: candidate.severity,
|
|
262
|
+
problem: candidate.problem,
|
|
263
|
+
requiredFix: candidate.requiredFix,
|
|
264
|
+
...candidate.artifactLocation === void 0 ? {} : { artifactLocation: candidate.artifactLocation }
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function validateReview(value, teamId) {
|
|
268
|
+
const candidate = objectRecord(value);
|
|
269
|
+
if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || typeof candidate.taskId !== "string" || !candidateId(candidate.taskId) || typeof candidate.submissionId !== "string" || !candidateId(candidate.submissionId) || typeof candidate.reviewerMemberId !== "string" || candidate.reviewerMemberId.length === 0 || !Number.isInteger(candidate.round) || Number(candidate.round) < 1 || !REVIEW_VERDICTS.includes(candidate.verdict) || !Array.isArray(candidate.findings) || !Array.isArray(candidate.evidence) || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid review in ${teamId}`);
|
|
270
|
+
return {
|
|
271
|
+
...candidate,
|
|
272
|
+
findings: candidate.findings.map(validateFinding),
|
|
273
|
+
evidence: candidate.evidence.map(validateCheck)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function parseReviewsState(value, teamId) {
|
|
277
|
+
const candidate = objectRecord(value);
|
|
278
|
+
if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.rounds)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid reviews state");
|
|
279
|
+
return {
|
|
280
|
+
...candidate,
|
|
281
|
+
revision: Number(candidate.revision),
|
|
282
|
+
rounds: candidate.rounds.map((entry) => validateReview(entry, teamId))
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function routeText(route, team) {
|
|
286
|
+
if (route === void 0) return "—";
|
|
287
|
+
if (route.type === "report") {
|
|
288
|
+
if (route.to === "human") return "report → human";
|
|
289
|
+
return `report → ${memberById(team, route.to)?.displayName ?? route.to}`;
|
|
290
|
+
}
|
|
291
|
+
if (route.type === "review") return `review → ${memberById(team, route.reviewer)?.displayName ?? route.reviewer}`;
|
|
292
|
+
if (route.type === "custom") return `custom → ${route.description}`;
|
|
293
|
+
return `interactive → ${routeText(route.then, team)}`;
|
|
294
|
+
}
|
|
295
|
+
function assignmentRouteText(route, team) {
|
|
296
|
+
if (route === void 0) return "not set";
|
|
297
|
+
if (route.type === "report") return route.to === "human" ? "report to the human" : `report to ${memberById(team, route.to)?.displayName ?? route.to}`;
|
|
298
|
+
if (route.type === "review") return `review by ${memberById(team, route.reviewer)?.displayName ?? route.reviewer}, then the result goes to the lead`;
|
|
299
|
+
if (route.type === "custom") return `custom route: ${route.description}`;
|
|
300
|
+
return `interactive route, then ${assignmentRouteText(route.then, team)}`;
|
|
301
|
+
}
|
|
302
|
+
function deriveRouteContacts(task, team) {
|
|
303
|
+
if (task.assignee === void 0 || task.route === void 0) return [];
|
|
304
|
+
if (![
|
|
305
|
+
"ready",
|
|
306
|
+
"claimed",
|
|
307
|
+
"in_progress",
|
|
308
|
+
"waiting",
|
|
309
|
+
"needs_human"
|
|
310
|
+
].includes(task.status)) return [];
|
|
311
|
+
if (task.route.type === "report") return task.route.to === "human" ? ["human"] : [task.route.to];
|
|
312
|
+
if (task.route.type === "review") return [task.assignee, task.route.reviewer];
|
|
313
|
+
if (task.route.type === "interactive") return deriveRouteContacts({
|
|
314
|
+
...task,
|
|
315
|
+
route: task.route.then
|
|
316
|
+
}, team);
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
function routeAllowsContact(task, from, to, team) {
|
|
320
|
+
if (task.assignee === void 0 || task.route === void 0 || ![
|
|
321
|
+
"ready",
|
|
322
|
+
"claimed",
|
|
323
|
+
"in_progress",
|
|
324
|
+
"waiting",
|
|
325
|
+
"needs_human"
|
|
326
|
+
].includes(task.status)) return false;
|
|
327
|
+
if (task.route.type === "report") return from === task.assignee && task.route.to === to;
|
|
328
|
+
if (task.route.type === "review") return from === task.assignee && to === task.route.reviewer || from === task.route.reviewer && to === task.assignee;
|
|
329
|
+
if (task.route.type === "interactive") return routeAllowsContact({
|
|
330
|
+
...task,
|
|
331
|
+
route: task.route.then
|
|
332
|
+
}, from, to, team);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
function taskDocumentPath(workspacePath, teamId, taskId) {
|
|
336
|
+
return path.join(path.resolve(workspacePath), ".dsh-live-teams", "teams", teamId, "tasks", `${taskId}.md`);
|
|
337
|
+
}
|
|
338
|
+
function renderDocument(task, team, fields = {}) {
|
|
339
|
+
const why = fields.why ?? task.why;
|
|
340
|
+
const body = fields.body ?? task.body;
|
|
341
|
+
const taskChecks = fields.checks ?? task.checks ?? [];
|
|
342
|
+
const assignee = task.assignee === void 0 ? "—" : `${memberById(team, task.assignee)?.displayName ?? task.assignee} (${task.assignee})`;
|
|
343
|
+
const checks = taskChecks.length === 0 ? "- —" : taskChecks.map((check) => `- \`${check}\``).join("\n");
|
|
344
|
+
const acceptance = task.acceptance.length === 0 ? "- —" : task.acceptance.map((criterion) => `- ${criterion}`).join("\n");
|
|
345
|
+
const tasks = fields.tasks ?? [];
|
|
346
|
+
const dependsOn = (task.dependsOn ?? []).length === 0 ? "" : `\n\n## Depends on\n${(task.dependsOn ?? []).map((id) => {
|
|
347
|
+
const dependency = tasks.find((entry) => entry.id === id);
|
|
348
|
+
return `- ${id}${dependency === void 0 ? "" : ` — ${dependency.title} (${dependency.status})`}`;
|
|
349
|
+
}).join("\n")}`;
|
|
350
|
+
const paths = (task.paths ?? []).length === 0 ? "" : `\n\n## Paths\nStay inside these; anything else is outside this task's scope.\n${(task.paths ?? []).map((entry) => `- \`${entry}\``).join("\n")}`;
|
|
351
|
+
return `# ${task.title}\n\n- **Task:** ${task.id}\n- **Kind:** ${task.kind}\n- **Assignee:** ${assignee}\n- **Route:** ${routeText(task.route, team)}\n- **Round limit:** ${task.roundLimit}\n\n## Why\n${why === void 0 || why.trim().length === 0 ? "—" : why}\n\n## What to do\n${body === void 0 || body.length === 0 ? "—" : body}${dependsOn}${paths}\n\n## Checks\n${checks}\n\n## Acceptance\n${acceptance}\n\n## Finish\nRun the checks. Report to your lead, or submit for review, according to the route.\nA task without a route is a defect: ask your lead rather than choosing one.\n`;
|
|
352
|
+
}
|
|
353
|
+
var TaskStore = class {
|
|
354
|
+
location;
|
|
355
|
+
workspacePath;
|
|
356
|
+
appendAudit;
|
|
357
|
+
deliverEscalation;
|
|
358
|
+
deliverClosing;
|
|
359
|
+
deliverDirection;
|
|
360
|
+
interruptSession;
|
|
361
|
+
awaitQuiescence;
|
|
362
|
+
quiescenceTimeoutMs;
|
|
363
|
+
changedPaths;
|
|
364
|
+
git;
|
|
365
|
+
migrations;
|
|
366
|
+
workspaceMode;
|
|
367
|
+
snapshotTree;
|
|
368
|
+
/** `null` means "no port": the answer is cached because a store lives with one workspace. */
|
|
369
|
+
resolvedMode;
|
|
370
|
+
constructor(options) {
|
|
371
|
+
this.deliverEscalation = options.deliverEscalation;
|
|
372
|
+
this.deliverClosing = options.deliverClosing;
|
|
373
|
+
this.deliverDirection = options.deliverDirection;
|
|
374
|
+
this.interruptSession = options.interruptSession;
|
|
375
|
+
this.awaitQuiescence = options.awaitQuiescence;
|
|
376
|
+
this.quiescenceTimeoutMs = options.quiescenceTimeoutMs ?? 5e3;
|
|
377
|
+
this.changedPaths = options.changedPaths;
|
|
378
|
+
this.git = options.git;
|
|
379
|
+
this.migrations = options.migrations ?? MIGRATIONS;
|
|
380
|
+
this.workspaceMode = options.workspaceMode;
|
|
381
|
+
this.snapshotTree = options.snapshotTree;
|
|
382
|
+
const workspacePath = path.resolve(options.workspacePath);
|
|
383
|
+
this.workspacePath = workspacePath;
|
|
384
|
+
const directory = path.join(workspacePath, ".dsh-live-teams", "teams", options.teamId);
|
|
385
|
+
this.location = {
|
|
386
|
+
directory,
|
|
387
|
+
file: path.join(directory, "tasks.json"),
|
|
388
|
+
attemptsFile: path.join(directory, "attempts.json"),
|
|
389
|
+
submissionsFile: path.join(directory, "submissions.json"),
|
|
390
|
+
reviewsFile: path.join(directory, "reviews.json"),
|
|
391
|
+
teamFile: path.join(directory, "team.json"),
|
|
392
|
+
tasksDirectory: path.join(directory, "tasks"),
|
|
393
|
+
archiveDirectory: path.join(directory, "archive"),
|
|
394
|
+
snapshotsDirectory: path.join(directory, "snapshots"),
|
|
395
|
+
blobsDirectory: path.join(directory, "artifacts", "blobs")
|
|
396
|
+
};
|
|
397
|
+
this.appendAudit = options.appendAudit ?? (async () => void 0);
|
|
398
|
+
}
|
|
399
|
+
get filePath() {
|
|
400
|
+
return this.location.file;
|
|
401
|
+
}
|
|
402
|
+
get directoryPath() {
|
|
403
|
+
return this.location.directory;
|
|
404
|
+
}
|
|
405
|
+
async open() {
|
|
406
|
+
return withProcessLock(this.location.teamFile, async () => this.readOrCreateLocked());
|
|
407
|
+
}
|
|
408
|
+
async listTasks(options = {}) {
|
|
409
|
+
const includeArchived = typeof options === "boolean" ? options : options.includeArchived === true;
|
|
410
|
+
return clone(await withProcessLock(this.location.teamFile, async () => {
|
|
411
|
+
const state = await this.readOrCreateLocked();
|
|
412
|
+
const reviews = await this.readReviewsIfPresentLocked();
|
|
413
|
+
const latestRound = /* @__PURE__ */ new Map();
|
|
414
|
+
for (const round of reviews.rounds) {
|
|
415
|
+
const prior = latestRound.get(round.taskId);
|
|
416
|
+
if (prior === void 0 || round.round > prior) latestRound.set(round.taskId, round.round);
|
|
417
|
+
}
|
|
418
|
+
const visible = includeArchived ? state.tasks : state.tasks.filter((task) => task.archivedPath === void 0);
|
|
419
|
+
return (await Promise.all(visible.map(async (task) => {
|
|
420
|
+
const round = latestRound.get(task.id);
|
|
421
|
+
const withRound = round === void 0 ? task : {
|
|
422
|
+
...task,
|
|
423
|
+
round
|
|
424
|
+
};
|
|
425
|
+
const documentPath = task.archivedPath === void 0 ? this.documentPath(task.id) : this.archivedPathOf(task.archivedPath);
|
|
426
|
+
return await fileExists(documentPath) ? withRound : {
|
|
427
|
+
...withRound,
|
|
428
|
+
documentMissing: true
|
|
429
|
+
};
|
|
430
|
+
}))).sort((left, right) => right.updatedAt - left.updatedAt || right.createdAt - left.createdAt || right.revision - left.revision || left.id.localeCompare(right.id)).slice(0, MAX_TASKS);
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
async readTask(taskId) {
|
|
434
|
+
if (!candidateId(taskId)) fail("invalid task id");
|
|
435
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
436
|
+
const task = (await this.readOrCreateLocked()).tasks.find((entry) => entry.id === taskId);
|
|
437
|
+
if (task === void 0) fail(`task not found: ${taskId}`);
|
|
438
|
+
const driftOf = async (submission) => {
|
|
439
|
+
const artifact = submission.artifact;
|
|
440
|
+
const checks = artifact.type === "report" ? [{
|
|
441
|
+
path: artifact.bodyRef,
|
|
442
|
+
expected: artifact.bodyDigest
|
|
443
|
+
}] : (artifact.blobs ?? []).map((blob) => ({
|
|
444
|
+
path: blob.path,
|
|
445
|
+
expected: blob.digest
|
|
446
|
+
}));
|
|
447
|
+
const drift = [];
|
|
448
|
+
for (const entry of checks) {
|
|
449
|
+
const absolute = path.resolve(this.workspacePath, entry.path);
|
|
450
|
+
let bytes;
|
|
451
|
+
try {
|
|
452
|
+
bytes = await promises.readFile(absolute);
|
|
453
|
+
} catch {
|
|
454
|
+
bytes = void 0;
|
|
455
|
+
}
|
|
456
|
+
const actual = bytes === void 0 ? null : createHash("sha256").update(bytes).digest("hex");
|
|
457
|
+
if (actual !== entry.expected) drift.push({
|
|
458
|
+
path: entry.path,
|
|
459
|
+
expected: entry.expected,
|
|
460
|
+
actual
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return drift;
|
|
464
|
+
};
|
|
465
|
+
const documentPath = task.archivedPath === void 0 ? this.documentPath(taskId) : this.archivedPathOf(task.archivedPath);
|
|
466
|
+
let document;
|
|
467
|
+
try {
|
|
468
|
+
document = await promises.readFile(documentPath, "utf8");
|
|
469
|
+
} catch (error) {
|
|
470
|
+
throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `task ${taskId} document is missing at ${documentPath}; ask the lead to restore the task document`, { cause: error });
|
|
471
|
+
}
|
|
472
|
+
const submissions = await Promise.all((await this.readSubmissionsIfPresentLocked()).submissions.filter((entry) => entry.taskId === taskId).map(async (submission) => {
|
|
473
|
+
const drift = await driftOf(submission);
|
|
474
|
+
return drift.length === 0 ? submission : {
|
|
475
|
+
...submission,
|
|
476
|
+
drift
|
|
477
|
+
};
|
|
478
|
+
}));
|
|
479
|
+
const rounds = (await this.readReviewsIfPresentLocked()).rounds.filter((entry) => entry.taskId === taskId);
|
|
480
|
+
const attempts = (await this.readAttemptsIfPresentLocked()).attempts.filter((entry) => entry.taskId === taskId);
|
|
481
|
+
return {
|
|
482
|
+
task: clone(task),
|
|
483
|
+
document,
|
|
484
|
+
submissions: clone(submissions),
|
|
485
|
+
rounds: clone(rounds),
|
|
486
|
+
attempts: clone(attempts)
|
|
487
|
+
};
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async readAttempts() {
|
|
491
|
+
return withProcessLock(this.location.teamFile, async () => this.readAttemptsIfPresentLocked());
|
|
492
|
+
}
|
|
493
|
+
async readSubmissions() {
|
|
494
|
+
return withProcessLock(this.location.teamFile, async () => this.readSubmissionsIfPresentLocked());
|
|
495
|
+
}
|
|
496
|
+
async readReviews() {
|
|
497
|
+
return withProcessLock(this.location.teamFile, async () => this.readReviewsIfPresentLocked());
|
|
498
|
+
}
|
|
499
|
+
async activeTaskForMember(memberId) {
|
|
500
|
+
const active = (await withProcessLock(this.location.teamFile, async () => this.readOrCreateLocked())).tasks.filter((task) => task.assignee === memberId && [
|
|
501
|
+
"ready",
|
|
502
|
+
"claimed",
|
|
503
|
+
"in_progress",
|
|
504
|
+
"waiting",
|
|
505
|
+
"needs_human"
|
|
506
|
+
].includes(task.status));
|
|
507
|
+
active.sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
|
|
508
|
+
return active[0] === void 0 ? void 0 : clone(active[0]);
|
|
509
|
+
}
|
|
510
|
+
activeTaskForMemberSync(memberId) {
|
|
511
|
+
try {
|
|
512
|
+
const active = parseTasksState(JSON.parse(readFileSync(this.location.file, "utf8")), this.teamId, this.location.archiveDirectory).tasks.filter((task) => task.assignee === memberId && [
|
|
513
|
+
"ready",
|
|
514
|
+
"claimed",
|
|
515
|
+
"in_progress",
|
|
516
|
+
"waiting",
|
|
517
|
+
"needs_human"
|
|
518
|
+
].includes(task.status));
|
|
519
|
+
active.sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
|
|
520
|
+
return active[0] === void 0 ? void 0 : clone(active[0]);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error.code === "ENOENT") return void 0;
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The latest attempt of one task, read synchronously for context assembly: the member
|
|
528
|
+
* has to know that a human wrote into its Session mid-flight (B31).
|
|
529
|
+
*/
|
|
530
|
+
latestAttemptForTaskSync(taskId) {
|
|
531
|
+
try {
|
|
532
|
+
const attempts = parseAttemptsState(JSON.parse(readFileSync(this.location.attemptsFile, "utf8")), this.teamId).attempts.filter((attempt) => attempt.taskId === taskId);
|
|
533
|
+
attempts.sort((left, right) => right.generation - left.generation);
|
|
534
|
+
return attempts[0] === void 0 ? void 0 : clone(attempts[0]);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (error.code === "ENOENT") return void 0;
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Every task, read synchronously for context assembly. The authorization gate
|
|
542
|
+
* scans all tasks — a reviewer holds a path to a task it is not the assignee of —
|
|
543
|
+
* so the line a member reads about where it may send has to see the same set.
|
|
544
|
+
*/
|
|
545
|
+
listTasksSync() {
|
|
546
|
+
try {
|
|
547
|
+
const state = parseTasksState(JSON.parse(readFileSync(this.location.file, "utf8")), this.teamId, this.location.archiveDirectory);
|
|
548
|
+
return clone(state.tasks.filter((task) => task.archivedPath === void 0));
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (error.code === "ENOENT") return [];
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Close attempts whose task has already ended (F5 of the first live run).
|
|
556
|
+
*
|
|
557
|
+
* Both acceptance paths are supposed to close the attempt, but "supposed to" is how
|
|
558
|
+
* the member stayed busy forever: a stale active attempt blocks every later task for
|
|
559
|
+
* that member and keeps the intervention observer watching a finished Session. This
|
|
560
|
+
* heals the class rather than the instance — a task in a terminal status cannot have
|
|
561
|
+
* work in flight.
|
|
562
|
+
*/
|
|
563
|
+
async closeTerminalAttempts() {
|
|
564
|
+
return await withProcessLock(this.location.teamFile, async () => {
|
|
565
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
566
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
567
|
+
const terminal = new Map(tasks.tasks.filter((task) => task.status === "completed" || task.status === "cancelled" || task.status === "failed").map((task) => [task.id, task.status]));
|
|
568
|
+
const stale = attempts.attempts.filter((attempt) => attempt.status === "active" && terminal.has(attempt.taskId));
|
|
569
|
+
if (stale.length === 0) return { closed: [] };
|
|
570
|
+
const closed = stale.map((attempt) => ({
|
|
571
|
+
attemptId: attempt.id,
|
|
572
|
+
taskId: attempt.taskId,
|
|
573
|
+
memberId: attempt.memberId,
|
|
574
|
+
status: terminal.get(attempt.taskId) === "completed" ? "completed" : "failed"
|
|
575
|
+
}));
|
|
576
|
+
const byId = new Map(closed.map((entry) => [entry.attemptId, entry.status]));
|
|
577
|
+
const nextAttempts = {
|
|
578
|
+
...attempts,
|
|
579
|
+
schemaVersion: 1,
|
|
580
|
+
revision: attempts.revision + 1,
|
|
581
|
+
attempts: attempts.attempts.map((attempt) => byId.has(attempt.id) ? {
|
|
582
|
+
...attempt,
|
|
583
|
+
status: byId.get(attempt.id),
|
|
584
|
+
revision: attempt.revision + 1
|
|
585
|
+
} : attempt)
|
|
586
|
+
};
|
|
587
|
+
await atomicWriteJson(this.location.attemptsFile, nextAttempts);
|
|
588
|
+
for (const entry of closed) await this.appendAudit({
|
|
589
|
+
kind: "attempt/closed-stale",
|
|
590
|
+
taskId: entry.taskId,
|
|
591
|
+
attemptId: entry.attemptId,
|
|
592
|
+
memberId: entry.memberId,
|
|
593
|
+
status: entry.status,
|
|
594
|
+
actor: "reconciler",
|
|
595
|
+
at: Date.now(),
|
|
596
|
+
revision: nextAttempts.revision
|
|
597
|
+
});
|
|
598
|
+
return { closed };
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Record that a human wrote into this member's Session mid-attempt (B31). Idempotent:
|
|
603
|
+
* the first observation wins, and the task revision at that moment is stored so that a
|
|
604
|
+
* later formal restatement of the contract resolves the mark without a second write.
|
|
605
|
+
*/
|
|
606
|
+
async markHumanIntervention(attemptId, at) {
|
|
607
|
+
if (!candidateId(attemptId) || !Number.isInteger(at)) fail("invalid human intervention request");
|
|
608
|
+
return await withProcessLock(this.location.teamFile, async () => {
|
|
609
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
610
|
+
const index = attempts.attempts.findIndex((entry) => entry.id === attemptId);
|
|
611
|
+
if (index < 0) fail(`unknown attempt: ${attemptId}`);
|
|
612
|
+
const current = attempts.attempts[index];
|
|
613
|
+
if (current.humanIntervenedAt !== void 0) return {
|
|
614
|
+
attempt: clone(current),
|
|
615
|
+
changed: false
|
|
616
|
+
};
|
|
617
|
+
const contractRevision = await this.contractRevisionLocked(current.taskId);
|
|
618
|
+
const next = {
|
|
619
|
+
...current,
|
|
620
|
+
humanIntervenedAt: at,
|
|
621
|
+
...contractRevision === void 0 ? {} : { humanIntervenedContractRevision: contractRevision },
|
|
622
|
+
revision: current.revision + 1
|
|
623
|
+
};
|
|
624
|
+
const nextAttempts = {
|
|
625
|
+
...attempts,
|
|
626
|
+
revision: attempts.revision + 1,
|
|
627
|
+
attempts: attempts.attempts.map((entry) => entry.id === attemptId ? next : entry)
|
|
628
|
+
};
|
|
629
|
+
await atomicWriteJson(this.location.attemptsFile, nextAttempts);
|
|
630
|
+
await this.appendAudit({
|
|
631
|
+
kind: "attempt/human-intervened",
|
|
632
|
+
taskId: current.taskId,
|
|
633
|
+
attemptId,
|
|
634
|
+
memberId: current.memberId,
|
|
635
|
+
at,
|
|
636
|
+
actor: "reconciler",
|
|
637
|
+
revision: nextAttempts.revision
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
attempt: clone(next),
|
|
641
|
+
changed: true
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
async contractRevisionLocked(taskId) {
|
|
646
|
+
const task = (await this.readTasksIfPresentLocked()).tasks.find((entry) => entry.id === taskId);
|
|
647
|
+
return task === void 0 ? void 0 : task.contractRevision ?? 0;
|
|
648
|
+
}
|
|
649
|
+
async submitTask(request) {
|
|
650
|
+
if (!candidateId(request.taskId) || request.memberId.length === 0 || request.sessionId.length === 0 || !Number.isInteger(request.bindingGeneration)) fail("invalid submission request");
|
|
651
|
+
if (typeof request.summary !== "string" || request.summary.trim().length === 0) fail("submission summary must be non-empty");
|
|
652
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
653
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
654
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
655
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
656
|
+
if (task.assignee !== request.memberId) fail("only the task assignee may submit work");
|
|
657
|
+
if (task.status !== "claimed" && task.status !== "in_progress") fail(`task is not claimable for submission; current status is ${task.status}`);
|
|
658
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
659
|
+
const attempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
|
|
660
|
+
if (attempt === void 0 || attempt.memberId !== request.memberId || attempt.sessionId !== request.sessionId || attempt.bindingGeneration !== request.bindingGeneration) fail("submission requires the active attempt binding");
|
|
661
|
+
const artifact = await this.freezeArtifact(request.artifact);
|
|
662
|
+
const submissions = await this.readSubmissionsIfPresentLocked();
|
|
663
|
+
const previous = submissions.submissions.filter((entry) => entry.taskId === task.id).sort((left, right) => left.createdAt - right.createdAt).at(-1);
|
|
664
|
+
const lastRound = (await this.readReviewsIfPresentLocked()).rounds.filter((entry) => entry.taskId === task.id).sort((left, right) => left.round - right.round).at(-1);
|
|
665
|
+
if (previous !== void 0 && lastRound?.verdict === "needs_revision") {
|
|
666
|
+
if (previous.artifact.type === "report" && artifact.type === "report" ? previous.artifact.bodyDigest === artifact.bodyDigest : previous.artifact.type === "files" && artifact.type === "files" ? previous.artifact.manifestDigest === artifact.manifestDigest : false) fail("this submission is identical to the one the reviewer sent back; change something and say what, or escalate to the lead instead of spending another round");
|
|
667
|
+
}
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const scope = await this.checkScope(task, tasks.tasks, attempts.attempts, artifact, attempt);
|
|
670
|
+
let git;
|
|
671
|
+
if (this.git !== void 0 && await this.mode() === "git" && CODING_KINDS.has(task.kind)) {
|
|
672
|
+
if (attempt.gitBase === void 0) fail("the attempt has no recorded Git base; the submission cannot be frozen for review");
|
|
673
|
+
const commitPaths = artifact.type === "files" ? artifact.paths : (scope?.changed ?? []).filter((entry) => !(scope?.unexpected ?? []).includes(entry));
|
|
674
|
+
if (commitPaths.length === 0) fail("nothing changed under the declaration and the submission names no files; there is nothing to review");
|
|
675
|
+
let base = {
|
|
676
|
+
ref: attempt.gitBase.ref,
|
|
677
|
+
sha: attempt.gitBase.baseTipSha
|
|
678
|
+
};
|
|
679
|
+
let rebasedFrom;
|
|
680
|
+
const tip = await this.git.tip(this.workspacePath, base.ref).catch(() => void 0);
|
|
681
|
+
if (tip !== void 0 && tip !== base.sha) {
|
|
682
|
+
const moved = await this.git.changedBetween(this.workspacePath, base.sha, tip).catch(() => void 0);
|
|
683
|
+
if (moved === void 0 || moved.some((entry) => {
|
|
684
|
+
const path = normalizeScopePath(entry) ?? entry;
|
|
685
|
+
return commitPaths.some((target) => scopesIntersect(literalPrefix(target), path));
|
|
686
|
+
})) fail(`the base moved since this attempt started (${base.ref} is at ${tip}, the attempt began at ${base.sha}) and the commits in between touch the same paths; ask the lead to re-plan this task rather than review work that cannot land`);
|
|
687
|
+
rebasedFrom = base.sha;
|
|
688
|
+
base = {
|
|
689
|
+
ref: base.ref,
|
|
690
|
+
sha: tip
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const member = (await this.readTeamLocked()).members.find((entry) => entry.memberId === attempt.memberId);
|
|
694
|
+
const message = [
|
|
695
|
+
`${task.id}: ${task.title}`,
|
|
696
|
+
"",
|
|
697
|
+
request.summary.trim(),
|
|
698
|
+
"",
|
|
699
|
+
`Attempt: ${attempt.id} (generation ${attempt.generation})`,
|
|
700
|
+
`Member: ${attempt.memberId}`
|
|
701
|
+
].join("\n");
|
|
702
|
+
const built = await this.git.commit(this.workspacePath, base, {
|
|
703
|
+
paths: commitPaths,
|
|
704
|
+
message,
|
|
705
|
+
author: {
|
|
706
|
+
name: member?.displayName ?? attempt.memberId,
|
|
707
|
+
email: `${attempt.memberId}@live-teams.local`
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
if (built === void 0) fail("the changed paths could not be frozen into a commit; nothing was recorded");
|
|
711
|
+
git = {
|
|
712
|
+
baseRef: base.ref,
|
|
713
|
+
baseTipSha: base.sha,
|
|
714
|
+
...rebasedFrom === void 0 ? {} : { rebasedFrom },
|
|
715
|
+
headSha: built.headSha,
|
|
716
|
+
commitPaths: [...built.commitPaths],
|
|
717
|
+
...built.mergeBaseSha === void 0 ? {} : { mergeBaseSha: built.mergeBaseSha }
|
|
718
|
+
};
|
|
719
|
+
if (!await this.git.pin(this.workspacePath, `refs/live-teams/${task.id}/${submissionGitRefSuffix(attempt.generation, git.headSha)}`, git.headSha).catch(() => false)) fail("the reviewed commit could not be pinned in the repository; nothing was recorded");
|
|
720
|
+
}
|
|
721
|
+
if (git !== void 0 && scope !== void 0) scope.committed = [...git.commitPaths];
|
|
722
|
+
const submission = {
|
|
723
|
+
id: `submission-${randomUUID()}`,
|
|
724
|
+
taskId: task.id,
|
|
725
|
+
attemptId: attempt.id,
|
|
726
|
+
attemptGeneration: attempt.generation,
|
|
727
|
+
artifact,
|
|
728
|
+
...git === void 0 ? {} : { git },
|
|
729
|
+
summary: request.summary,
|
|
730
|
+
checks: request.checks.map(validateCheck),
|
|
731
|
+
...scope === void 0 ? {} : { scope },
|
|
732
|
+
createdAt: now,
|
|
733
|
+
revision: submissions.revision + 1
|
|
734
|
+
};
|
|
735
|
+
const nextSubmissions = {
|
|
736
|
+
...submissions,
|
|
737
|
+
schemaVersion: 1,
|
|
738
|
+
revision: submissions.revision + 1,
|
|
739
|
+
submissions: [...submissions.submissions, submission]
|
|
740
|
+
};
|
|
741
|
+
const nextTask = {
|
|
742
|
+
...task,
|
|
743
|
+
latestSubmissionId: submission.id,
|
|
744
|
+
...task.route?.type === "report" ? { status: "waiting" } : {},
|
|
745
|
+
updatedAt: now,
|
|
746
|
+
revision: task.revision + 1
|
|
747
|
+
};
|
|
748
|
+
const nextTasks = {
|
|
749
|
+
...tasks,
|
|
750
|
+
revision: tasks.revision + 1,
|
|
751
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
752
|
+
};
|
|
753
|
+
await atomicWriteJson(this.location.submissionsFile, nextSubmissions);
|
|
754
|
+
try {
|
|
755
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
await atomicWriteJson(this.location.submissionsFile, submissions);
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
await this.appendAudit({
|
|
761
|
+
kind: "submission/created",
|
|
762
|
+
taskId: task.id,
|
|
763
|
+
submissionId: submission.id,
|
|
764
|
+
attemptId: attempt.id,
|
|
765
|
+
actor: request.memberId,
|
|
766
|
+
at: now,
|
|
767
|
+
revision: nextSubmissions.revision
|
|
768
|
+
});
|
|
769
|
+
if (scope?.state === "violation") await this.appendAudit({
|
|
770
|
+
kind: "submission/scope-violation",
|
|
771
|
+
taskId: task.id,
|
|
772
|
+
submissionId: submission.id,
|
|
773
|
+
attemptId: attempt.id,
|
|
774
|
+
declared: scope.declared,
|
|
775
|
+
unexpected: scope.unexpected ?? [],
|
|
776
|
+
actor: request.memberId,
|
|
777
|
+
at: now,
|
|
778
|
+
revision: submissions.revision + 1
|
|
779
|
+
});
|
|
780
|
+
if (scope?.state === "shared-tree") await this.appendAudit({
|
|
781
|
+
kind: "submission/scope-shared-tree",
|
|
782
|
+
taskId: task.id,
|
|
783
|
+
submissionId: submission.id,
|
|
784
|
+
attemptId: attempt.id,
|
|
785
|
+
declared: scope.declared,
|
|
786
|
+
unexpected: scope.unexpected ?? [],
|
|
787
|
+
sharedTree: scope.sharedTree ?? [],
|
|
788
|
+
actor: request.memberId,
|
|
789
|
+
at: now,
|
|
790
|
+
revision: submissions.revision + 1
|
|
791
|
+
});
|
|
792
|
+
return { submission: clone(submission) };
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
async acceptTask(request) {
|
|
796
|
+
if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid accept request");
|
|
797
|
+
if (request.note !== void 0 && typeof request.note !== "string") fail("acceptance note must be a string");
|
|
798
|
+
let closing;
|
|
799
|
+
const result = await withProcessLock(this.location.teamFile, async () => {
|
|
800
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
801
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
802
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
803
|
+
const team = await this.readTeamLocked();
|
|
804
|
+
if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may accept tasks; ask ${leadMemberIdForMessage(team)}`);
|
|
805
|
+
if (task.status === "completed") fail("task is already completed");
|
|
806
|
+
if (task.archivedPath !== void 0) fail("task is already archived");
|
|
807
|
+
if (task.status !== "waiting" && task.status !== "needs_human") fail(`task cannot be accepted in status ${task.status}`);
|
|
808
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
809
|
+
const activeAttempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
|
|
810
|
+
if (activeAttempt === void 0) fail("task has no active attempt");
|
|
811
|
+
if (task.assignee === void 0 || activeAttempt.memberId !== task.assignee) fail("task has no assignee attempt");
|
|
812
|
+
const now = Date.now();
|
|
813
|
+
const accepted = (await this.readSubmissionsIfPresentLocked()).submissions.find((entry) => entry.id === task.latestSubmissionId);
|
|
814
|
+
const afterIntegration = accepted === void 0 ? task : await this.integrateSubmission(accepted, task);
|
|
815
|
+
const currentTask = afterIntegration.integrationPending === void 0 ? afterIntegration : {
|
|
816
|
+
...afterIntegration,
|
|
817
|
+
integrationPending: void 0
|
|
818
|
+
};
|
|
819
|
+
const archivedPath = path.join("archive", `${now}-${task.id}.md`);
|
|
820
|
+
const archiveTarget = path.join(this.location.directory, archivedPath);
|
|
821
|
+
const sourcePath = this.documentPath(task.id);
|
|
822
|
+
const document = await promises.readFile(sourcePath);
|
|
823
|
+
if (await fileExists(archiveTarget)) fail("archive target already exists");
|
|
824
|
+
const { integrationPending: _finished, ...settled } = currentTask;
|
|
825
|
+
const nextTask = {
|
|
826
|
+
...settled,
|
|
827
|
+
status: "completed",
|
|
828
|
+
...task.latestSubmissionId === void 0 ? {} : { acceptedSubmissionId: task.latestSubmissionId },
|
|
829
|
+
archivedPath,
|
|
830
|
+
contractRevision: (task.contractRevision ?? 0) + 1,
|
|
831
|
+
updatedAt: now,
|
|
832
|
+
revision: currentTask.revision + 1
|
|
833
|
+
};
|
|
834
|
+
const nextAttempts = {
|
|
835
|
+
...attempts,
|
|
836
|
+
schemaVersion: 1,
|
|
837
|
+
revision: attempts.revision + 1,
|
|
838
|
+
attempts: attempts.attempts.map((entry) => entry.id === activeAttempt.id ? {
|
|
839
|
+
...entry,
|
|
840
|
+
status: "completed",
|
|
841
|
+
revision: entry.revision + 1
|
|
842
|
+
} : entry)
|
|
843
|
+
};
|
|
844
|
+
const nextTasks = {
|
|
845
|
+
...tasks,
|
|
846
|
+
schemaVersion: 1,
|
|
847
|
+
revision: tasks.revision + 1,
|
|
848
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
849
|
+
};
|
|
850
|
+
try {
|
|
851
|
+
await atomicWrite(archiveTarget, document);
|
|
852
|
+
await atomicWriteJson(this.location.attemptsFile, nextAttempts);
|
|
853
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
854
|
+
await promises.unlink(sourcePath);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
await atomicWriteJson(this.location.file, tasks).catch(() => void 0);
|
|
857
|
+
await atomicWriteJson(this.location.attemptsFile, attempts).catch(() => void 0);
|
|
858
|
+
await promises.unlink(archiveTarget).catch(() => void 0);
|
|
859
|
+
await atomicWrite(sourcePath, document).catch(() => void 0);
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
await this.appendAudit({
|
|
863
|
+
kind: "task/accepted",
|
|
864
|
+
taskId: task.id,
|
|
865
|
+
actor: request.actor,
|
|
866
|
+
acceptedSubmissionId: nextTask.acceptedSubmissionId,
|
|
867
|
+
archivedPath,
|
|
868
|
+
...request.note === void 0 ? {} : { note: request.note },
|
|
869
|
+
at: now,
|
|
870
|
+
revision: nextTasks.revision
|
|
871
|
+
});
|
|
872
|
+
await this.appendAudit({
|
|
873
|
+
kind: "task/archived",
|
|
874
|
+
taskId: task.id,
|
|
875
|
+
actor: request.actor,
|
|
876
|
+
archivedPath,
|
|
877
|
+
at: now,
|
|
878
|
+
revision: nextTasks.revision
|
|
879
|
+
});
|
|
880
|
+
closing = {
|
|
881
|
+
assignee: activeAttempt.memberId,
|
|
882
|
+
taskId: task.id,
|
|
883
|
+
...request.note === void 0 ? {} : { note: request.note }
|
|
884
|
+
};
|
|
885
|
+
return {
|
|
886
|
+
task: clone(nextTask),
|
|
887
|
+
archivedPath
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
if (closing !== void 0) try {
|
|
891
|
+
await this.deliverClosing?.(closing);
|
|
892
|
+
} catch {}
|
|
893
|
+
return result;
|
|
894
|
+
}
|
|
895
|
+
async reviseTask(request) {
|
|
896
|
+
if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid revise request");
|
|
897
|
+
if (typeof request.instructions !== "string" || request.instructions.trim().length === 0) fail("revision instructions must be non-empty");
|
|
898
|
+
const instructions = request.instructions.trim();
|
|
899
|
+
let direction;
|
|
900
|
+
const result = await withProcessLock(this.location.teamFile, async () => {
|
|
901
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
902
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
903
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
904
|
+
const team = await this.readTeamLocked();
|
|
905
|
+
if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may revise tasks; ask ${leadMemberIdForMessage(team)}`);
|
|
906
|
+
if (task.status !== "waiting" && task.status !== "needs_human") fail(`task cannot be revised in status ${task.status}`);
|
|
907
|
+
if (task.assignee === void 0) fail("task has no assignee");
|
|
908
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
909
|
+
const activeAttempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
|
|
910
|
+
if (activeAttempt === void 0 || activeAttempt.memberId !== task.assignee) fail("task has no active attempt");
|
|
911
|
+
const now = Date.now();
|
|
912
|
+
const nextTask = {
|
|
913
|
+
...task,
|
|
914
|
+
status: "in_progress",
|
|
915
|
+
contractRevision: (task.contractRevision ?? 0) + 1,
|
|
916
|
+
updatedAt: now,
|
|
917
|
+
revision: task.revision + 1
|
|
918
|
+
};
|
|
919
|
+
const nextTasks = {
|
|
920
|
+
...tasks,
|
|
921
|
+
schemaVersion: 1,
|
|
922
|
+
revision: tasks.revision + 1,
|
|
923
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
924
|
+
};
|
|
925
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
926
|
+
await this.appendAudit({
|
|
927
|
+
kind: "task/revised",
|
|
928
|
+
taskId: task.id,
|
|
929
|
+
actor: request.actor,
|
|
930
|
+
instructions,
|
|
931
|
+
at: now,
|
|
932
|
+
revision: nextTasks.revision
|
|
933
|
+
});
|
|
934
|
+
direction = {
|
|
935
|
+
assignee: task.assignee,
|
|
936
|
+
taskId: task.id,
|
|
937
|
+
instructions
|
|
938
|
+
};
|
|
939
|
+
return { task: clone(nextTask) };
|
|
940
|
+
});
|
|
941
|
+
if (direction !== void 0) try {
|
|
942
|
+
await this.deliverDirection?.(direction);
|
|
943
|
+
} catch {}
|
|
944
|
+
return result;
|
|
945
|
+
}
|
|
946
|
+
async reviewTask(request) {
|
|
947
|
+
if (!candidateId(request.taskId) || !candidateId(request.submissionId) || request.reviewerMemberId.length === 0 || !REVIEW_VERDICTS.includes(request.verdict)) fail("invalid review request");
|
|
948
|
+
let escalation;
|
|
949
|
+
const result = await withProcessLock(this.location.teamFile, async () => {
|
|
950
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
951
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
952
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
953
|
+
if (task.status === "completed" || task.status === "waiting" || task.status === "cancelled" || task.status === "failed") fail(`task cannot be reviewed in status ${task.status}`);
|
|
954
|
+
if (task.route?.type !== "review" || task.route.reviewer !== request.reviewerMemberId) fail("only the task review-route member may review it");
|
|
955
|
+
if (task.assignee === request.reviewerMemberId) fail("the assignee cannot review their own work");
|
|
956
|
+
if (task.latestSubmissionId !== request.submissionId) fail("review must target the latest submission");
|
|
957
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
958
|
+
const submission = (await this.readSubmissionsIfPresentLocked()).submissions.find((entry) => entry.id === request.submissionId && entry.taskId === task.id);
|
|
959
|
+
if (submission === void 0) fail("submission not found for task");
|
|
960
|
+
const authoring = attempts.attempts.find((entry) => entry.id === submission.attemptId);
|
|
961
|
+
if (authoring === void 0 || authoring.generation !== submission.attemptGeneration) fail("submission was produced by a superseded attempt");
|
|
962
|
+
const reviews = await this.readReviewsIfPresentLocked();
|
|
963
|
+
if (reviews.rounds.some((entry) => entry.submissionId === submission.id)) fail("submission already has a terminal review");
|
|
964
|
+
const findings = (request.findings ?? []).map(validateFinding);
|
|
965
|
+
const evidence = (request.evidence ?? []).map(validateCheck);
|
|
966
|
+
if (request.verdict === "pass" && findings.some((finding) => finding.severity === "blocker" || finding.severity === "high")) fail("PASS is refused while blocker or high findings remain unresolved");
|
|
967
|
+
if (request.verdict === "pass" && evidence.length === 0) fail("PASS needs at least one recorded check: run the verification and record the command with its output, so the verdict can be reproduced later");
|
|
968
|
+
const priorRounds = reviews.rounds.filter((entry) => entry.taskId === task.id);
|
|
969
|
+
const roundNumber = priorRounds.length + 1;
|
|
970
|
+
if (request.verdict === "needs_revision" && priorRounds.filter((entry) => entry.verdict === "needs_revision").length >= task.roundLimit) fail(`round limit ${task.roundLimit} reached; escalate to the lead instead`);
|
|
971
|
+
const now = Date.now();
|
|
972
|
+
const round = {
|
|
973
|
+
id: `review-${randomUUID()}`,
|
|
974
|
+
taskId: task.id,
|
|
975
|
+
submissionId: submission.id,
|
|
976
|
+
reviewerMemberId: request.reviewerMemberId,
|
|
977
|
+
round: roundNumber,
|
|
978
|
+
verdict: request.verdict,
|
|
979
|
+
findings,
|
|
980
|
+
evidence,
|
|
981
|
+
createdAt: now,
|
|
982
|
+
revision: reviews.revision + 1
|
|
983
|
+
};
|
|
984
|
+
const nextReviews = {
|
|
985
|
+
...reviews,
|
|
986
|
+
schemaVersion: 1,
|
|
987
|
+
revision: reviews.revision + 1,
|
|
988
|
+
rounds: [...reviews.rounds, round]
|
|
989
|
+
};
|
|
990
|
+
const reviewedTask = request.verdict === "pass" ? await this.integrateSubmission(submission, task) : task;
|
|
991
|
+
const { integrationPending: _pendingDrop, ...reviewedSettled } = reviewedTask;
|
|
992
|
+
let nextTask = {
|
|
993
|
+
...reviewedSettled,
|
|
994
|
+
updatedAt: now,
|
|
995
|
+
revision: reviewedTask.revision + 1
|
|
996
|
+
};
|
|
997
|
+
if (request.verdict === "pass") nextTask = {
|
|
998
|
+
...nextTask,
|
|
999
|
+
acceptedSubmissionId: submission.id,
|
|
1000
|
+
status: "completed",
|
|
1001
|
+
contractRevision: (task.contractRevision ?? 0) + 1
|
|
1002
|
+
};
|
|
1003
|
+
else if (request.verdict === "escalate") nextTask = {
|
|
1004
|
+
...nextTask,
|
|
1005
|
+
status: "waiting"
|
|
1006
|
+
};
|
|
1007
|
+
let archivedPath;
|
|
1008
|
+
if (request.verdict === "pass" && task.archivedPath === void 0) {
|
|
1009
|
+
archivedPath = path.join("archive", `${now}-${task.id}.md`);
|
|
1010
|
+
const target = path.join(this.location.directory, archivedPath);
|
|
1011
|
+
if (await fileExists(target)) fail("archive target already exists");
|
|
1012
|
+
await atomicWrite(target, await promises.readFile(this.documentPath(task.id)));
|
|
1013
|
+
nextTask = {
|
|
1014
|
+
...nextTask,
|
|
1015
|
+
archivedPath
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const closedAttempts = request.verdict === "pass" && task.activeAttemptId !== void 0 ? {
|
|
1019
|
+
...attempts,
|
|
1020
|
+
schemaVersion: 1,
|
|
1021
|
+
revision: attempts.revision + 1,
|
|
1022
|
+
attempts: attempts.attempts.map((entry) => entry.id === task.activeAttemptId && entry.status === "active" ? {
|
|
1023
|
+
...entry,
|
|
1024
|
+
status: "completed",
|
|
1025
|
+
revision: entry.revision + 1
|
|
1026
|
+
} : entry)
|
|
1027
|
+
} : void 0;
|
|
1028
|
+
await atomicWriteJson(this.location.reviewsFile, nextReviews);
|
|
1029
|
+
if (closedAttempts !== void 0) await atomicWriteJson(this.location.attemptsFile, closedAttempts);
|
|
1030
|
+
try {
|
|
1031
|
+
await atomicWriteJson(this.location.file, {
|
|
1032
|
+
...tasks,
|
|
1033
|
+
revision: tasks.revision + 1,
|
|
1034
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
1035
|
+
});
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (archivedPath !== void 0) await promises.unlink(archivedPath).catch(() => void 0);
|
|
1038
|
+
await atomicWriteJson(this.location.reviewsFile, reviews);
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
if (archivedPath !== void 0) {
|
|
1042
|
+
await promises.unlink(this.documentPath(task.id)).catch(() => void 0);
|
|
1043
|
+
await this.appendAudit({
|
|
1044
|
+
kind: "task/archived",
|
|
1045
|
+
taskId: task.id,
|
|
1046
|
+
actor: request.reviewerMemberId,
|
|
1047
|
+
archivedPath,
|
|
1048
|
+
at: now,
|
|
1049
|
+
revision: tasks.revision + 1
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
await this.appendAudit({
|
|
1053
|
+
kind: "review/round",
|
|
1054
|
+
taskId: task.id,
|
|
1055
|
+
submissionId: submission.id,
|
|
1056
|
+
reviewerMemberId: request.reviewerMemberId,
|
|
1057
|
+
verdict: request.verdict,
|
|
1058
|
+
round: round.round,
|
|
1059
|
+
at: now,
|
|
1060
|
+
revision: nextReviews.revision
|
|
1061
|
+
});
|
|
1062
|
+
if (request.verdict === "pass") await this.appendAudit({
|
|
1063
|
+
kind: "task/accepted",
|
|
1064
|
+
taskId: task.id,
|
|
1065
|
+
submissionId: submission.id,
|
|
1066
|
+
actor: request.reviewerMemberId,
|
|
1067
|
+
at: now,
|
|
1068
|
+
revision: tasks.revision + 1
|
|
1069
|
+
});
|
|
1070
|
+
if (request.verdict === "escalate") {
|
|
1071
|
+
const leadMemberId = leadMemberIdForMessage(await this.readTeamLocked());
|
|
1072
|
+
const problem = findings.map((finding) => finding.problem).join("; ") || "reviewer escalated without a finding";
|
|
1073
|
+
const tried = findings.map((finding) => finding.requiredFix).join("; ") || "review evidence recorded";
|
|
1074
|
+
await this.appendAudit({
|
|
1075
|
+
kind: "review/escalated",
|
|
1076
|
+
taskId: task.id,
|
|
1077
|
+
submissionId: submission.id,
|
|
1078
|
+
reviewerMemberId: request.reviewerMemberId,
|
|
1079
|
+
leadMemberId,
|
|
1080
|
+
problem,
|
|
1081
|
+
tried,
|
|
1082
|
+
at: now,
|
|
1083
|
+
revision: nextReviews.revision
|
|
1084
|
+
});
|
|
1085
|
+
escalation = {
|
|
1086
|
+
leadMemberId,
|
|
1087
|
+
taskId: task.id,
|
|
1088
|
+
submissionId: submission.id,
|
|
1089
|
+
round,
|
|
1090
|
+
reviewerMemberId: request.reviewerMemberId,
|
|
1091
|
+
problem,
|
|
1092
|
+
tried
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
round: clone(round),
|
|
1097
|
+
task: clone(nextTask)
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
1100
|
+
if (escalation !== void 0) await this.deliverEscalation?.(escalation);
|
|
1101
|
+
return result;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* What the declaration was worth at submission time. Only a writing task that
|
|
1105
|
+
* declared paths is checked: with nothing declared there is nothing to violate, and
|
|
1106
|
+
* an unobservable workspace is reported as `unchecked` rather than as clean (B30).
|
|
1107
|
+
*/
|
|
1108
|
+
/**
|
|
1109
|
+
* Integrate a submission's commit into its base, refusing when the base moved.
|
|
1110
|
+
*
|
|
1111
|
+
* Nothing is checked out and nothing is merged: the working tree already holds the work, so
|
|
1112
|
+
* integration is one compare-and-swap ref update. `STALE_BASE` is a refusal — nothing is written,
|
|
1113
|
+
* and the decision has to be made again against the current base (ADR 0006).
|
|
1114
|
+
*/
|
|
1115
|
+
async integrateSubmission(submission, task) {
|
|
1116
|
+
const git = submission.git;
|
|
1117
|
+
if (git === void 0 || this.git === void 0) return task;
|
|
1118
|
+
const current = await this.writeIntegrationPendingLocked(task, git) ?? task;
|
|
1119
|
+
const outcome = await this.git.integrate(this.workspacePath, git.baseRef, git.headSha, git.baseTipSha, git.commitPaths).catch(() => "stale");
|
|
1120
|
+
if (outcome !== "integrated") await this.clearIntegrationPendingLocked(current);
|
|
1121
|
+
if (outcome === "index-failed") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `the base is at the reviewed commit ${String(git.headSha).slice(0, 12)} but the index could not be moved with it; retry the acceptance. If it persists, run: git reset -- ${git.commitPaths.join(" ")}`, { auditDetail: "index repair failed" });
|
|
1122
|
+
if (outcome === "stale") {
|
|
1123
|
+
const tip = await this.git.tip(this.workspacePath, git.baseRef).catch(() => void 0);
|
|
1124
|
+
throw new LiveTeamsError(ERROR_CODES.STALE_BASE, `the base moved since the review: ${git.baseRef} is at ${tip ?? "an unreadable tip"}, the reviewed base was ${git.baseTipSha}. Refresh the decision against the current base before accepting ${task.id}`, { auditDetail: "stale base" });
|
|
1125
|
+
}
|
|
1126
|
+
return current;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Record the intent to integrate, and return the task as it now stands.
|
|
1130
|
+
*
|
|
1131
|
+
* `Locked` on purpose: the callers (both acceptance paths) already hold the team lock, and taking it
|
|
1132
|
+
* again deadlocks the process — which is exactly what the git suite reported, as a thirty-second
|
|
1133
|
+
* hang rather than a failure.
|
|
1134
|
+
*/
|
|
1135
|
+
async writeIntegrationPendingLocked(task, git) {
|
|
1136
|
+
const state = await this.readOrCreateLocked();
|
|
1137
|
+
const current = state.tasks.find((entry) => entry.id === task.id);
|
|
1138
|
+
if (current === void 0 || current.integrationPending !== void 0) return current;
|
|
1139
|
+
const next = {
|
|
1140
|
+
...current,
|
|
1141
|
+
integrationPending: {
|
|
1142
|
+
baseRef: git.baseRef,
|
|
1143
|
+
headSha: git.headSha,
|
|
1144
|
+
baseTipSha: git.baseTipSha,
|
|
1145
|
+
at: Date.now()
|
|
1146
|
+
},
|
|
1147
|
+
revision: current.revision + 1
|
|
1148
|
+
};
|
|
1149
|
+
await atomicWriteJson(this.location.file, {
|
|
1150
|
+
...state,
|
|
1151
|
+
revision: state.revision + 1,
|
|
1152
|
+
tasks: state.tasks.map((entry) => entry.id === task.id ? next : entry)
|
|
1153
|
+
});
|
|
1154
|
+
return next;
|
|
1155
|
+
}
|
|
1156
|
+
/** Forget the intent: either the integration did not happen, or the caller is about to finish it. */
|
|
1157
|
+
async clearIntegrationPendingLocked(task) {
|
|
1158
|
+
if (task.integrationPending === void 0) return;
|
|
1159
|
+
const state = await this.readOrCreateLocked();
|
|
1160
|
+
const current = state.tasks.find((entry) => entry.id === task.id);
|
|
1161
|
+
if (current === void 0 || current.integrationPending === void 0) return;
|
|
1162
|
+
const { integrationPending: _dropped, ...withoutPending } = current;
|
|
1163
|
+
await atomicWriteJson(this.location.file, {
|
|
1164
|
+
...state,
|
|
1165
|
+
revision: state.revision + 1,
|
|
1166
|
+
tasks: state.tasks.map((entry) => entry.id === task.id ? {
|
|
1167
|
+
...withoutPending,
|
|
1168
|
+
revision: current.revision + 1
|
|
1169
|
+
} : entry)
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Where an archived document lives. New records store the path relative to the team directory;
|
|
1174
|
+
* records written before that change hold an absolute path and are still honoured.
|
|
1175
|
+
*/
|
|
1176
|
+
archivedPathOf(archivedPath) {
|
|
1177
|
+
return path.isAbsolute(archivedPath) ? archivedPath : path.resolve(this.location.directory, archivedPath);
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Record that an active attempt is not moving, once. Idempotent by design: a second call with the
|
|
1181
|
+
* same reason is a no-op, so the reconciler may call it on every pass without filling the journal.
|
|
1182
|
+
* Returns `true` when this call was the one that recorded it.
|
|
1183
|
+
*/
|
|
1184
|
+
async markAttemptStalled(request) {
|
|
1185
|
+
return await withProcessLock(this.location.teamFile, async () => {
|
|
1186
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
1187
|
+
const attempt = attempts.attempts.find((entry) => entry.id === request.attemptId);
|
|
1188
|
+
if (attempt === void 0 || attempt.status !== "active") return false;
|
|
1189
|
+
if (attempt.stalled !== void 0) return false;
|
|
1190
|
+
const at = Date.now();
|
|
1191
|
+
const next = {
|
|
1192
|
+
...attempts,
|
|
1193
|
+
schemaVersion: 1,
|
|
1194
|
+
revision: attempts.revision + 1,
|
|
1195
|
+
attempts: attempts.attempts.map((entry) => entry.id === request.attemptId ? {
|
|
1196
|
+
...entry,
|
|
1197
|
+
stalled: {
|
|
1198
|
+
at,
|
|
1199
|
+
reason: request.reason,
|
|
1200
|
+
detail: request.detail
|
|
1201
|
+
},
|
|
1202
|
+
revision: entry.revision + 1
|
|
1203
|
+
} : entry)
|
|
1204
|
+
};
|
|
1205
|
+
await atomicWriteJson(this.location.attemptsFile, next);
|
|
1206
|
+
await this.appendAudit({
|
|
1207
|
+
kind: "attempt/stalled",
|
|
1208
|
+
taskId: attempt.taskId,
|
|
1209
|
+
attemptId: attempt.id,
|
|
1210
|
+
memberId: attempt.memberId,
|
|
1211
|
+
reason: request.reason,
|
|
1212
|
+
detail: request.detail,
|
|
1213
|
+
actor: request.actor,
|
|
1214
|
+
at,
|
|
1215
|
+
revision: next.revision
|
|
1216
|
+
});
|
|
1217
|
+
return true;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Keep a copy of submitted bytes, addressed by their digest. Returns the path relative to the team
|
|
1222
|
+
* directory, or `undefined` when the copy could not be written — a submission is never refused for
|
|
1223
|
+
* this, but it is then read back from the workspace and the drift check says so.
|
|
1224
|
+
*/
|
|
1225
|
+
async storeBlob(digest, bytes) {
|
|
1226
|
+
const relative = path.join("artifacts", "blobs", digest);
|
|
1227
|
+
const target = path.join(this.location.directory, relative);
|
|
1228
|
+
try {
|
|
1229
|
+
if (!await fileExists(target)) {
|
|
1230
|
+
await promises.mkdir(this.location.blobsDirectory, { recursive: true });
|
|
1231
|
+
await promises.writeFile(target, bytes);
|
|
1232
|
+
}
|
|
1233
|
+
return relative;
|
|
1234
|
+
} catch {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** The frozen bytes of a submission artifact, or `undefined` when no copy was kept. */
|
|
1239
|
+
async readBlob(blobPath) {
|
|
1240
|
+
const resolved = path.resolve(this.location.directory, blobPath);
|
|
1241
|
+
const root = path.resolve(this.location.directory);
|
|
1242
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return void 0;
|
|
1243
|
+
try {
|
|
1244
|
+
return await promises.readFile(resolved);
|
|
1245
|
+
} catch {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Reconcile an integration that was interrupted: the marker is on the task, and the branch tells
|
|
1251
|
+
* the rest of the story. Reports once, and clears the marker only when nothing happened — a branch
|
|
1252
|
+
* that is already at the reviewed commit means the acceptance still has to be finished, and that is
|
|
1253
|
+
* a decision, not a cleanup.
|
|
1254
|
+
*/
|
|
1255
|
+
async reconcilePendingIntegrations(request) {
|
|
1256
|
+
return await withProcessLock(this.location.teamFile, async () => {
|
|
1257
|
+
const state = await this.readOrCreateLocked();
|
|
1258
|
+
const pending = state.tasks.filter((task) => task.integrationPending !== void 0 && task.integrationPending.reportedAt === void 0);
|
|
1259
|
+
if (pending.length === 0) return [];
|
|
1260
|
+
const reported = [];
|
|
1261
|
+
const now = Date.now();
|
|
1262
|
+
let tasks = state.tasks;
|
|
1263
|
+
for (const task of pending) {
|
|
1264
|
+
const marker = task.integrationPending;
|
|
1265
|
+
const tip = await request.tipOf(marker.baseRef).catch(() => void 0);
|
|
1266
|
+
const outcome = tip === marker.headSha ? "moved" : tip === marker.baseTipSha ? "untouched" : "elsewhere";
|
|
1267
|
+
const detail = outcome === "moved" ? `the branch ${marker.baseRef} is already at the reviewed commit ${marker.headSha.slice(0, 12)}, but the acceptance never finished; retry it` : outcome === "untouched" ? `the branch ${marker.baseRef} never moved; the acceptance can simply be retried` : `the branch ${marker.baseRef} is at ${tip ?? "an unreadable tip"}, which is neither the reviewed base nor the reviewed commit; a human has to decide`;
|
|
1268
|
+
const next = outcome === "untouched" ? (() => {
|
|
1269
|
+
const { integrationPending: _cleared, ...rest } = task;
|
|
1270
|
+
return {
|
|
1271
|
+
...rest,
|
|
1272
|
+
revision: task.revision + 1
|
|
1273
|
+
};
|
|
1274
|
+
})() : {
|
|
1275
|
+
...task,
|
|
1276
|
+
integrationPending: {
|
|
1277
|
+
...marker,
|
|
1278
|
+
reportedAt: now
|
|
1279
|
+
},
|
|
1280
|
+
revision: task.revision + 1
|
|
1281
|
+
};
|
|
1282
|
+
tasks = tasks.map((entry) => entry.id === task.id ? next : entry);
|
|
1283
|
+
await this.appendAudit({
|
|
1284
|
+
kind: "integration/pending",
|
|
1285
|
+
taskId: task.id,
|
|
1286
|
+
state: outcome,
|
|
1287
|
+
detail,
|
|
1288
|
+
actor: request.actor,
|
|
1289
|
+
at: now,
|
|
1290
|
+
revision: state.revision + 1
|
|
1291
|
+
});
|
|
1292
|
+
reported.push({
|
|
1293
|
+
taskId: task.id,
|
|
1294
|
+
state: outcome,
|
|
1295
|
+
detail
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
await atomicWriteJson(this.location.file, {
|
|
1299
|
+
...state,
|
|
1300
|
+
revision: state.revision + 1,
|
|
1301
|
+
tasks
|
|
1302
|
+
});
|
|
1303
|
+
return reported;
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Bring every state file this store owns forward to the current schema version.
|
|
1308
|
+
*
|
|
1309
|
+
* Reading tolerates an older version, but nothing brought it forward: a field added later stayed
|
|
1310
|
+
* missing until something happened to write the file again, and no record said the state had been
|
|
1311
|
+
* read at an older version. A state from the future is refused with both versions named.
|
|
1312
|
+
*
|
|
1313
|
+
* The file is copied before it is rewritten, and the migration is recorded in the journal: a
|
|
1314
|
+
* migration is the one operation that changes history, and it should be possible to say when it
|
|
1315
|
+
* happened and to go back.
|
|
1316
|
+
*/
|
|
1317
|
+
async migrate() {
|
|
1318
|
+
const migrations = this.migrations;
|
|
1319
|
+
const files = [];
|
|
1320
|
+
const targets = [
|
|
1321
|
+
{
|
|
1322
|
+
file: this.location.file,
|
|
1323
|
+
what: "task state",
|
|
1324
|
+
stamp: (state) => ({
|
|
1325
|
+
...state,
|
|
1326
|
+
schemaVersion: 1
|
|
1327
|
+
})
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
file: this.location.attemptsFile,
|
|
1331
|
+
what: "attempts state",
|
|
1332
|
+
stamp: (state) => ({
|
|
1333
|
+
...state,
|
|
1334
|
+
schemaVersion: 1
|
|
1335
|
+
})
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
file: this.location.submissionsFile,
|
|
1339
|
+
what: "submissions state",
|
|
1340
|
+
stamp: (state) => ({
|
|
1341
|
+
...state,
|
|
1342
|
+
schemaVersion: 1
|
|
1343
|
+
})
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
file: this.location.reviewsFile,
|
|
1347
|
+
what: "reviews state",
|
|
1348
|
+
stamp: (state) => ({
|
|
1349
|
+
...state,
|
|
1350
|
+
schemaVersion: 1
|
|
1351
|
+
})
|
|
1352
|
+
}
|
|
1353
|
+
];
|
|
1354
|
+
return await withProcessLock(this.location.teamFile, async () => {
|
|
1355
|
+
for (const target of targets) {
|
|
1356
|
+
let raw;
|
|
1357
|
+
try {
|
|
1358
|
+
raw = JSON.parse(await promises.readFile(target.file, "utf8"));
|
|
1359
|
+
} catch {
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
const state = objectRecord(raw);
|
|
1363
|
+
if (state === void 0) continue;
|
|
1364
|
+
const plan = planMigration(state.schemaVersion, 1, target.what, migrations);
|
|
1365
|
+
if (plan.kind === "future") throw plan.error;
|
|
1366
|
+
if (plan.kind === "current") continue;
|
|
1367
|
+
const backup = `${target.file}.v${plan.from}.bak`;
|
|
1368
|
+
await promises.copyFile(target.file, backup);
|
|
1369
|
+
const migrated = target.stamp(objectRecord(applyMigration(state, plan, migrations)) ?? state);
|
|
1370
|
+
await atomicWriteJson(target.file, migrated);
|
|
1371
|
+
await this.appendAudit({
|
|
1372
|
+
kind: "state/migrated",
|
|
1373
|
+
file: path.basename(target.file),
|
|
1374
|
+
from: plan.from,
|
|
1375
|
+
to: plan.to,
|
|
1376
|
+
backup: path.basename(backup),
|
|
1377
|
+
actor: "reconciler",
|
|
1378
|
+
at: Date.now()
|
|
1379
|
+
});
|
|
1380
|
+
files.push({
|
|
1381
|
+
file: path.basename(target.file),
|
|
1382
|
+
from: plan.from,
|
|
1383
|
+
to: plan.to,
|
|
1384
|
+
backup: path.basename(backup)
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
return { files };
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
/** B32: resolved once per store; the answer cannot change while a store lives. */
|
|
1391
|
+
async mode() {
|
|
1392
|
+
if (this.resolvedMode === void 0) {
|
|
1393
|
+
if (this.workspaceMode === void 0) this.resolvedMode = this.changedPaths === void 0 ? null : "git";
|
|
1394
|
+
else this.resolvedMode = await this.workspaceMode(this.workspacePath).catch(() => void 0) ?? null;
|
|
1395
|
+
}
|
|
1396
|
+
return this.resolvedMode ?? void 0;
|
|
1397
|
+
}
|
|
1398
|
+
async snapshotPathFor(attemptId) {
|
|
1399
|
+
return path.join(this.location.snapshotsDirectory, `${attemptId}.json`);
|
|
1400
|
+
}
|
|
1401
|
+
/** Take the fingerprint a `files`-mode submission will be compared against. */
|
|
1402
|
+
async takeSnapshot(attemptId) {
|
|
1403
|
+
if (await this.mode() !== "files" || this.snapshotTree === void 0) return;
|
|
1404
|
+
const snapshot = await this.snapshotTree(this.workspacePath).catch(() => void 0);
|
|
1405
|
+
if (snapshot === void 0) return;
|
|
1406
|
+
await promises.mkdir(this.location.snapshotsDirectory, { recursive: true });
|
|
1407
|
+
await atomicWriteJson(await this.snapshotPathFor(attemptId), snapshot).catch(() => void 0);
|
|
1408
|
+
}
|
|
1409
|
+
/** Delete fingerprints whose attempt is no longer running: garbage, not evidence. */
|
|
1410
|
+
async pruneSnapshots() {
|
|
1411
|
+
let names;
|
|
1412
|
+
try {
|
|
1413
|
+
names = await promises.readdir(this.location.snapshotsDirectory);
|
|
1414
|
+
} catch {
|
|
1415
|
+
return 0;
|
|
1416
|
+
}
|
|
1417
|
+
if (names.length === 0) return 0;
|
|
1418
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
1419
|
+
const live = new Set(attempts.attempts.filter((attempt) => attempt.status === "active").map((attempt) => attempt.id));
|
|
1420
|
+
let removed = 0;
|
|
1421
|
+
for (const name of names) {
|
|
1422
|
+
if (!name.endsWith(".json")) continue;
|
|
1423
|
+
const attemptId = name.slice(0, -5);
|
|
1424
|
+
if (live.has(attemptId)) continue;
|
|
1425
|
+
await promises.unlink(path.join(this.location.snapshotsDirectory, name)).catch(() => void 0);
|
|
1426
|
+
removed += 1;
|
|
1427
|
+
}
|
|
1428
|
+
return removed;
|
|
1429
|
+
}
|
|
1430
|
+
async checkScope(task, tasks, attempts, artifact, attempt) {
|
|
1431
|
+
const declared = task.paths;
|
|
1432
|
+
if (declared === void 0 || declared.length === 0 || !CODING_KINDS.has(task.kind)) return void 0;
|
|
1433
|
+
const mode = await this.mode();
|
|
1434
|
+
if (mode === void 0) return {
|
|
1435
|
+
declared,
|
|
1436
|
+
state: "unchecked",
|
|
1437
|
+
reason: "unreadable"
|
|
1438
|
+
};
|
|
1439
|
+
let changed;
|
|
1440
|
+
if (mode === "git") {
|
|
1441
|
+
if (this.changedPaths === void 0) return {
|
|
1442
|
+
declared,
|
|
1443
|
+
state: "unchecked",
|
|
1444
|
+
reason: "unreadable",
|
|
1445
|
+
mode
|
|
1446
|
+
};
|
|
1447
|
+
try {
|
|
1448
|
+
changed = await this.changedPaths(this.workspacePath);
|
|
1449
|
+
} catch {
|
|
1450
|
+
changed = void 0;
|
|
1451
|
+
}
|
|
1452
|
+
if (changed === void 0) return {
|
|
1453
|
+
declared,
|
|
1454
|
+
state: "unchecked",
|
|
1455
|
+
reason: "unreadable",
|
|
1456
|
+
mode
|
|
1457
|
+
};
|
|
1458
|
+
} else {
|
|
1459
|
+
if (this.snapshotTree === void 0) return {
|
|
1460
|
+
declared,
|
|
1461
|
+
state: "unchecked",
|
|
1462
|
+
reason: "unreadable",
|
|
1463
|
+
mode
|
|
1464
|
+
};
|
|
1465
|
+
let before;
|
|
1466
|
+
try {
|
|
1467
|
+
before = JSON.parse(await promises.readFile(await this.snapshotPathFor(attempt.id), "utf8"));
|
|
1468
|
+
} catch {
|
|
1469
|
+
before = void 0;
|
|
1470
|
+
}
|
|
1471
|
+
if (before === void 0) return {
|
|
1472
|
+
declared,
|
|
1473
|
+
state: "unchecked",
|
|
1474
|
+
reason: "no-snapshot",
|
|
1475
|
+
mode
|
|
1476
|
+
};
|
|
1477
|
+
const after = await this.snapshotTree(this.workspacePath).catch(() => void 0);
|
|
1478
|
+
if (after === void 0) return {
|
|
1479
|
+
declared,
|
|
1480
|
+
state: "unchecked",
|
|
1481
|
+
reason: "too-many-files",
|
|
1482
|
+
mode
|
|
1483
|
+
};
|
|
1484
|
+
changed = diffSnapshots(before, after);
|
|
1485
|
+
}
|
|
1486
|
+
const ignored = [TEAM_ROOT, ...artifact.type === "report" ? [artifact.bodyRef] : []];
|
|
1487
|
+
const normalizedChanged = (changed ?? []).map((entry) => normalizeScopePath(entry)).filter((entry) => entry !== void 0).filter((entry) => !ignored.some((skip) => entry === skip || entry.startsWith(`${skip}/`)));
|
|
1488
|
+
const unexpected = outsideScope(normalizedChanged, declared);
|
|
1489
|
+
if (unexpected.length === 0) return {
|
|
1490
|
+
declared,
|
|
1491
|
+
changed: normalizedChanged,
|
|
1492
|
+
state: "clean",
|
|
1493
|
+
mode
|
|
1494
|
+
};
|
|
1495
|
+
const sharedTree = unexpected.filter((candidate) => attempts.some((other) => other.status === "active" && other.taskId !== task.id && (tasks.find((entry) => entry.id === other.taskId)?.paths ?? []).some((entry) => scopesIntersect(literalPrefix(entry), candidate))));
|
|
1496
|
+
return {
|
|
1497
|
+
declared,
|
|
1498
|
+
changed: normalizedChanged,
|
|
1499
|
+
unexpected,
|
|
1500
|
+
...sharedTree.length === 0 ? {} : { sharedTree },
|
|
1501
|
+
state: sharedTree.length === 0 ? "violation" : "shared-tree",
|
|
1502
|
+
mode
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
async freezeArtifact(artifact) {
|
|
1506
|
+
if (artifact.type === "report") {
|
|
1507
|
+
if (typeof artifact.bodyRef !== "string" || artifact.bodyRef.length === 0) fail("report bodyRef must be non-empty");
|
|
1508
|
+
const reportPath = path.resolve(this.workspacePath, artifact.bodyRef);
|
|
1509
|
+
if (reportPath !== this.workspacePath && !reportPath.startsWith(`${this.workspacePath}${path.sep}`)) fail("report bodyRef must stay inside the workspace");
|
|
1510
|
+
const body = await promises.readFile(reportPath);
|
|
1511
|
+
const bodyDigest = createHash("sha256").update(body).digest("hex");
|
|
1512
|
+
const blobPath = await this.storeBlob(bodyDigest, body);
|
|
1513
|
+
return {
|
|
1514
|
+
type: "report",
|
|
1515
|
+
bodyRef: artifact.bodyRef,
|
|
1516
|
+
bodyDigest,
|
|
1517
|
+
...blobPath === void 0 ? {} : { blobPath }
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
if (!Array.isArray(artifact.paths) || artifact.paths.length === 0 || !artifact.paths.every((entry) => typeof entry === "string" && entry.length > 0)) fail("files artifact paths must be non-empty");
|
|
1521
|
+
const normalized = artifact.paths.map((file) => {
|
|
1522
|
+
const absolute = path.resolve(this.workspacePath, file);
|
|
1523
|
+
if (absolute !== this.workspacePath && !absolute.startsWith(`${this.workspacePath}${path.sep}`)) fail("file artifact paths must stay inside the workspace");
|
|
1524
|
+
return {
|
|
1525
|
+
input: file,
|
|
1526
|
+
absolute,
|
|
1527
|
+
relative: path.relative(this.workspacePath, absolute)
|
|
1528
|
+
};
|
|
1529
|
+
});
|
|
1530
|
+
if (new Set(normalized.map((entry) => entry.relative)).size !== normalized.length) fail("file artifact paths must be unique");
|
|
1531
|
+
const entries = [];
|
|
1532
|
+
const blobs = [];
|
|
1533
|
+
for (const file of normalized) {
|
|
1534
|
+
let bytes;
|
|
1535
|
+
try {
|
|
1536
|
+
bytes = await promises.readFile(file.absolute);
|
|
1537
|
+
} catch {
|
|
1538
|
+
fail(`the file artifact names ${file.relative}, which is not on disk; a deletion is submitted as a report`);
|
|
1539
|
+
}
|
|
1540
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
1541
|
+
entries.push({
|
|
1542
|
+
path: file.relative,
|
|
1543
|
+
digest
|
|
1544
|
+
});
|
|
1545
|
+
const blobPath = await this.storeBlob(digest, bytes);
|
|
1546
|
+
if (blobPath !== void 0) blobs.push({
|
|
1547
|
+
path: file.relative,
|
|
1548
|
+
digest,
|
|
1549
|
+
blobPath
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
1553
|
+
return {
|
|
1554
|
+
type: "files",
|
|
1555
|
+
paths: entries.map((entry) => entry.path),
|
|
1556
|
+
manifestDigest: createHash("sha256").update(JSON.stringify(entries)).digest("hex"),
|
|
1557
|
+
...blobs.length === entries.length ? { blobs } : {}
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Edit the contract of one task (B27).
|
|
1562
|
+
*
|
|
1563
|
+
* Only while nobody is working on it: an attempt is bound to the contract its
|
|
1564
|
+
* assignee is working from, and a human decision owns every later transition.
|
|
1565
|
+
*/
|
|
1566
|
+
async updateTask(request) {
|
|
1567
|
+
if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid update request");
|
|
1568
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
1569
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
1570
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
1571
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
1572
|
+
const team = await this.readTeamLocked();
|
|
1573
|
+
if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may update tasks; ask ${leadMemberIdForMessage(team)}`);
|
|
1574
|
+
if (request.expectedRevision !== void 0 && request.expectedRevision !== tasks.revision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${tasks.revision}`);
|
|
1575
|
+
if (task.status !== "draft" && task.status !== "ready") fail(`task cannot be updated in status ${task.status}; ${task.status === "waiting" || task.status === "needs_human" ? "a human revision owns that transition" : "only work nobody has started can be edited"}`);
|
|
1576
|
+
if ([
|
|
1577
|
+
"title",
|
|
1578
|
+
"kind",
|
|
1579
|
+
"assignee",
|
|
1580
|
+
"route",
|
|
1581
|
+
"acceptance",
|
|
1582
|
+
"checks",
|
|
1583
|
+
"why",
|
|
1584
|
+
"body",
|
|
1585
|
+
"roundLimit",
|
|
1586
|
+
"humanContact",
|
|
1587
|
+
"dependsOn",
|
|
1588
|
+
"paths"
|
|
1589
|
+
].filter((field) => request[field] !== void 0).length === 0) fail("nothing to change");
|
|
1590
|
+
const merged = {
|
|
1591
|
+
title: request.title ?? task.title,
|
|
1592
|
+
kind: request.kind ?? task.kind,
|
|
1593
|
+
assignee: request.assignee === void 0 ? task.assignee : request.assignee ?? void 0,
|
|
1594
|
+
route: request.route === void 0 ? task.route : request.route ?? void 0,
|
|
1595
|
+
humanContact: request.humanContact ?? task.humanContact ?? "none",
|
|
1596
|
+
acceptance: request.acceptance ?? task.acceptance,
|
|
1597
|
+
checks: request.checks ?? task.checks,
|
|
1598
|
+
why: request.why ?? task.why,
|
|
1599
|
+
body: request.body ?? task.body,
|
|
1600
|
+
roundLimit: request.roundLimit ?? task.roundLimit,
|
|
1601
|
+
dependsOn: request.dependsOn === void 0 ? task.dependsOn : request.dependsOn ?? void 0,
|
|
1602
|
+
paths: request.paths === void 0 ? task.paths : request.paths ?? void 0
|
|
1603
|
+
};
|
|
1604
|
+
const normalized = this.validateContract(merged, team, tasks.tasks, task.id);
|
|
1605
|
+
const comparable = (field, value) => JSON.stringify(field === "checks" ? value ?? [] : value ?? null);
|
|
1606
|
+
const changed = [
|
|
1607
|
+
"title",
|
|
1608
|
+
"kind",
|
|
1609
|
+
"assignee",
|
|
1610
|
+
"route",
|
|
1611
|
+
"humanContact",
|
|
1612
|
+
"acceptance",
|
|
1613
|
+
"checks",
|
|
1614
|
+
"why",
|
|
1615
|
+
"body",
|
|
1616
|
+
"roundLimit",
|
|
1617
|
+
"dependsOn",
|
|
1618
|
+
"paths"
|
|
1619
|
+
].filter((field) => comparable(field, task[field]) !== comparable(field, normalized[field]));
|
|
1620
|
+
if (changed.length === 0) fail("nothing to change; the contract already matches");
|
|
1621
|
+
const now = Date.now();
|
|
1622
|
+
const nextTask = {
|
|
1623
|
+
...task,
|
|
1624
|
+
title: normalized.title,
|
|
1625
|
+
kind: normalized.kind,
|
|
1626
|
+
status: normalized.status,
|
|
1627
|
+
acceptance: normalized.acceptance,
|
|
1628
|
+
roundLimit: normalized.roundLimit,
|
|
1629
|
+
humanContact: normalized.humanContact,
|
|
1630
|
+
contractRevision: (task.contractRevision ?? 0) + 1,
|
|
1631
|
+
updatedAt: now,
|
|
1632
|
+
revision: task.revision + 1
|
|
1633
|
+
};
|
|
1634
|
+
const optional = [
|
|
1635
|
+
["assignee", normalized.assignee],
|
|
1636
|
+
["route", normalized.route],
|
|
1637
|
+
["dependsOn", normalized.dependsOn],
|
|
1638
|
+
["why", normalized.why],
|
|
1639
|
+
["body", normalized.body],
|
|
1640
|
+
["checks", normalized.checks === void 0 || normalized.checks.length === 0 ? void 0 : normalized.checks],
|
|
1641
|
+
["paths", normalized.paths]
|
|
1642
|
+
];
|
|
1643
|
+
for (const [field, value] of optional) if (value === void 0) delete nextTask[field];
|
|
1644
|
+
else Object.assign(nextTask, { [field]: value });
|
|
1645
|
+
const nextTasks = {
|
|
1646
|
+
...tasks,
|
|
1647
|
+
schemaVersion: 1,
|
|
1648
|
+
revision: tasks.revision + 1,
|
|
1649
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
1650
|
+
};
|
|
1651
|
+
const document = renderDocument(nextTask, team, { tasks: tasks.tasks });
|
|
1652
|
+
await atomicWrite(this.documentPath(task.id), document);
|
|
1653
|
+
try {
|
|
1654
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
await atomicWrite(this.documentPath(task.id), renderDocument(task, team, { tasks: tasks.tasks })).catch(() => void 0);
|
|
1657
|
+
throw error;
|
|
1658
|
+
}
|
|
1659
|
+
await this.appendAudit({
|
|
1660
|
+
kind: "task/updated",
|
|
1661
|
+
taskId: task.id,
|
|
1662
|
+
actor: request.actor,
|
|
1663
|
+
fields: changed,
|
|
1664
|
+
at: now,
|
|
1665
|
+
revision: nextTasks.revision
|
|
1666
|
+
});
|
|
1667
|
+
return {
|
|
1668
|
+
task: clone(nextTask),
|
|
1669
|
+
document,
|
|
1670
|
+
changed
|
|
1671
|
+
};
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
async claimTask(request) {
|
|
1675
|
+
if (!candidateId(request.taskId) || request.memberId.length === 0 || request.sessionId.length === 0 || !Number.isInteger(request.bindingGeneration)) fail("invalid claim request");
|
|
1676
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
1677
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
1678
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
1679
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
1680
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
1681
|
+
const existing = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId);
|
|
1682
|
+
if (existing?.status === "active" && existing.memberId === request.memberId) return {
|
|
1683
|
+
task: clone(task),
|
|
1684
|
+
attempt: clone(existing)
|
|
1685
|
+
};
|
|
1686
|
+
if (task.assignee !== request.memberId) fail("only the task assignee may claim it");
|
|
1687
|
+
fenceMember((await this.readTeamLocked()).members.find((entry) => entry.memberId === request.memberId), request, request.memberId);
|
|
1688
|
+
if (task.status !== "ready") fail(`task is not ready; current status is ${task.status}`);
|
|
1689
|
+
const blockers = blockedBy(task, tasks.tasks);
|
|
1690
|
+
if (blockers.length > 0) {
|
|
1691
|
+
const first = blockers[0];
|
|
1692
|
+
fail(`task is blocked by ${first.id} "${first.title}" (${first.status})${blockers.length > 1 ? ` and ${blockers.length - 1} more` : ""}`);
|
|
1693
|
+
}
|
|
1694
|
+
if (existing?.status === "active") fail("task already has an active attempt owned by another member");
|
|
1695
|
+
if (attempts.attempts.some((entry) => entry.memberId === request.memberId && entry.status === "active")) fail("member already has an active attempt");
|
|
1696
|
+
const now = Date.now();
|
|
1697
|
+
const priorGenerations = attempts.attempts.filter((entry) => entry.taskId === task.id).map((entry) => entry.generation);
|
|
1698
|
+
const generation = (priorGenerations.length === 0 ? 0 : Math.max(...priorGenerations)) + 1;
|
|
1699
|
+
const attempt = {
|
|
1700
|
+
id: `attempt-${randomUUID()}`,
|
|
1701
|
+
taskId: task.id,
|
|
1702
|
+
memberId: request.memberId,
|
|
1703
|
+
sessionId: request.sessionId,
|
|
1704
|
+
bindingGeneration: request.bindingGeneration,
|
|
1705
|
+
generation,
|
|
1706
|
+
status: "active",
|
|
1707
|
+
startedAt: now,
|
|
1708
|
+
revision: attempts.revision + 1
|
|
1709
|
+
};
|
|
1710
|
+
const nextTask = {
|
|
1711
|
+
...task,
|
|
1712
|
+
status: "claimed",
|
|
1713
|
+
activeAttemptId: attempt.id,
|
|
1714
|
+
updatedAt: now,
|
|
1715
|
+
revision: task.revision + 1
|
|
1716
|
+
};
|
|
1717
|
+
const nextTasks = {
|
|
1718
|
+
...tasks,
|
|
1719
|
+
revision: tasks.revision + 1,
|
|
1720
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
1721
|
+
};
|
|
1722
|
+
const nextAttempts = {
|
|
1723
|
+
...attempts,
|
|
1724
|
+
revision: attempts.revision + 1,
|
|
1725
|
+
attempts: [...attempts.attempts, attempt]
|
|
1726
|
+
};
|
|
1727
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
1728
|
+
try {
|
|
1729
|
+
await atomicWriteJson(this.location.attemptsFile, nextAttempts);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
await atomicWriteJson(this.location.file, tasks);
|
|
1732
|
+
throw error;
|
|
1733
|
+
}
|
|
1734
|
+
await this.takeSnapshot(attempt.id);
|
|
1735
|
+
if (this.git !== void 0 && await this.mode() === "git") {
|
|
1736
|
+
const base = await this.git.base(this.workspacePath).catch(() => void 0);
|
|
1737
|
+
if (base !== void 0) {
|
|
1738
|
+
const current = await this.readAttemptsIfPresentLocked();
|
|
1739
|
+
const withBase = {
|
|
1740
|
+
...current,
|
|
1741
|
+
schemaVersion: 1,
|
|
1742
|
+
revision: current.revision + 1,
|
|
1743
|
+
attempts: current.attempts.map((entry) => entry.id === attempt.id ? {
|
|
1744
|
+
...entry,
|
|
1745
|
+
gitBase: {
|
|
1746
|
+
ref: base.ref,
|
|
1747
|
+
baseTipSha: base.sha
|
|
1748
|
+
},
|
|
1749
|
+
revision: entry.revision + 1
|
|
1750
|
+
} : entry)
|
|
1751
|
+
};
|
|
1752
|
+
await atomicWriteJson(this.location.attemptsFile, withBase);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
await this.appendAudit({
|
|
1756
|
+
kind: "task/claimed",
|
|
1757
|
+
taskId: task.id,
|
|
1758
|
+
memberId: request.memberId,
|
|
1759
|
+
attemptId: attempt.id,
|
|
1760
|
+
actor: request.memberId,
|
|
1761
|
+
at: now,
|
|
1762
|
+
revision: nextTasks.revision
|
|
1763
|
+
});
|
|
1764
|
+
await this.appendAudit({
|
|
1765
|
+
kind: "attempt/started",
|
|
1766
|
+
taskId: task.id,
|
|
1767
|
+
memberId: request.memberId,
|
|
1768
|
+
attemptId: attempt.id,
|
|
1769
|
+
actor: request.memberId,
|
|
1770
|
+
at: now,
|
|
1771
|
+
revision: nextAttempts.revision
|
|
1772
|
+
});
|
|
1773
|
+
return {
|
|
1774
|
+
task: clone(nextTask),
|
|
1775
|
+
attempt: clone(attempt)
|
|
1776
|
+
};
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Take work away from its current attempt (B28, ADR 0005).
|
|
1781
|
+
*
|
|
1782
|
+
* The order matters and is the contract: the capability is invalidated and made
|
|
1783
|
+
* durable *before* the session is asked to stop, so a session that keeps running
|
|
1784
|
+
* cannot write anything; and `revoked` is only ever reported once quiescence has
|
|
1785
|
+
* actually been observed. When it has not, the attempt stays `revoking` and the
|
|
1786
|
+
* task goes to the human rather than claiming a stop that did not happen.
|
|
1787
|
+
*/
|
|
1788
|
+
async revokeTask(request) {
|
|
1789
|
+
if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid revoke request");
|
|
1790
|
+
if (typeof request.reason !== "string" || request.reason.trim().length === 0) fail("revocation reason must be non-empty");
|
|
1791
|
+
const reason = request.reason.trim();
|
|
1792
|
+
const { task, attempt } = await withProcessLock(this.location.teamFile, async () => {
|
|
1793
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
1794
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
1795
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
1796
|
+
const team = await this.readTeamLocked();
|
|
1797
|
+
if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may revoke tasks; ask ${leadMemberIdForMessage(team)}`);
|
|
1798
|
+
if (request.expectedRevision !== void 0 && request.expectedRevision !== tasks.revision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${tasks.revision}`);
|
|
1799
|
+
if (task.status !== "claimed" && task.status !== "in_progress") fail(`task cannot be revoked in status ${task.status}`);
|
|
1800
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
1801
|
+
const bound = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId);
|
|
1802
|
+
const attempt = bound?.status === "active" || bound?.status === "revoking" ? bound : void 0;
|
|
1803
|
+
if (attempt === void 0) fail("task has no active attempt to revoke");
|
|
1804
|
+
if (request.nextAssignee !== void 0) {
|
|
1805
|
+
if (memberById(team, request.nextAssignee) === void 0) fail(`assignee is not a current team member: ${request.nextAssignee}`);
|
|
1806
|
+
if (task.route !== void 0) validateRoute(task.route, team, request.nextAssignee);
|
|
1807
|
+
}
|
|
1808
|
+
const now = Date.now();
|
|
1809
|
+
const revoking = {
|
|
1810
|
+
...attempt,
|
|
1811
|
+
status: "revoking",
|
|
1812
|
+
revision: attempt.revision + 1
|
|
1813
|
+
};
|
|
1814
|
+
const nextAttempts = {
|
|
1815
|
+
...attempts,
|
|
1816
|
+
schemaVersion: 1,
|
|
1817
|
+
revision: attempts.revision + 1,
|
|
1818
|
+
attempts: attempts.attempts.map((entry) => entry.id === attempt.id ? revoking : entry)
|
|
1819
|
+
};
|
|
1820
|
+
await atomicWriteJson(this.location.attemptsFile, nextAttempts);
|
|
1821
|
+
if (attempt.status === "active") await this.appendAudit({
|
|
1822
|
+
kind: "attempt/revoking",
|
|
1823
|
+
taskId: task.id,
|
|
1824
|
+
attemptId: attempt.id,
|
|
1825
|
+
memberId: attempt.memberId,
|
|
1826
|
+
reason,
|
|
1827
|
+
actor: request.actor,
|
|
1828
|
+
at: now,
|
|
1829
|
+
revision: nextAttempts.revision
|
|
1830
|
+
});
|
|
1831
|
+
return {
|
|
1832
|
+
task,
|
|
1833
|
+
attempt: revoking
|
|
1834
|
+
};
|
|
1835
|
+
});
|
|
1836
|
+
if (this.interruptSession === void 0 || this.awaitQuiescence === void 0) return this.settleRevocation(task, attempt, false, request, reason);
|
|
1837
|
+
try {
|
|
1838
|
+
await this.interruptSession(attempt.sessionId, reason);
|
|
1839
|
+
} catch {}
|
|
1840
|
+
const quiesced = await this.awaitQuiescence(attempt.sessionId, this.quiescenceTimeoutMs);
|
|
1841
|
+
return this.settleRevocation(task, attempt, quiesced, request, reason);
|
|
1842
|
+
}
|
|
1843
|
+
/** Mark the settled outcome: revoked and re-owned, or escalated to the human. */
|
|
1844
|
+
async settleRevocation(task, attempt, quiesced, request, reason) {
|
|
1845
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
1846
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
1847
|
+
const current = tasks.tasks.find((entry) => entry.id === task.id);
|
|
1848
|
+
if (current === void 0) fail(`task not found: ${task.id}`);
|
|
1849
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
1850
|
+
const stored = attempts.attempts.find((entry) => entry.id === attempt.id);
|
|
1851
|
+
if (stored === void 0) fail(`attempt not found: ${attempt.id}`);
|
|
1852
|
+
const team = await this.readTeamLocked();
|
|
1853
|
+
const now = Date.now();
|
|
1854
|
+
if (!quiesced) {
|
|
1855
|
+
const escalated = {
|
|
1856
|
+
...current,
|
|
1857
|
+
status: "needs_human",
|
|
1858
|
+
updatedAt: now,
|
|
1859
|
+
revision: current.revision + 1
|
|
1860
|
+
};
|
|
1861
|
+
const nextTasks = {
|
|
1862
|
+
...tasks,
|
|
1863
|
+
schemaVersion: 1,
|
|
1864
|
+
revision: tasks.revision + 1,
|
|
1865
|
+
tasks: tasks.tasks.map((entry) => entry.id === current.id ? escalated : entry)
|
|
1866
|
+
};
|
|
1867
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
1868
|
+
await this.appendAudit({
|
|
1869
|
+
kind: "attempt/revoke-timeout",
|
|
1870
|
+
taskId: current.id,
|
|
1871
|
+
attemptId: attempt.id,
|
|
1872
|
+
memberId: attempt.memberId,
|
|
1873
|
+
reason,
|
|
1874
|
+
actor: request.actor,
|
|
1875
|
+
at: now,
|
|
1876
|
+
revision: nextTasks.revision
|
|
1877
|
+
});
|
|
1878
|
+
return {
|
|
1879
|
+
task: clone(escalated),
|
|
1880
|
+
attempt: clone(stored),
|
|
1881
|
+
quiesced: false,
|
|
1882
|
+
reassigned: false
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
const revoked = {
|
|
1886
|
+
...stored,
|
|
1887
|
+
status: "revoked",
|
|
1888
|
+
revision: stored.revision + 1
|
|
1889
|
+
};
|
|
1890
|
+
const nextAttempts = {
|
|
1891
|
+
...attempts,
|
|
1892
|
+
schemaVersion: 1,
|
|
1893
|
+
revision: attempts.revision + 1,
|
|
1894
|
+
attempts: attempts.attempts.map((entry) => entry.id === stored.id ? revoked : entry)
|
|
1895
|
+
};
|
|
1896
|
+
const candidate = request.nextAssignee ?? current.assignee;
|
|
1897
|
+
const nextAssignee = candidate !== void 0 && memberById(team, candidate) !== void 0 ? candidate : void 0;
|
|
1898
|
+
const reassigned = nextAssignee !== void 0 && nextAssignee !== current.assignee;
|
|
1899
|
+
const ready = {
|
|
1900
|
+
...current,
|
|
1901
|
+
status: nextAssignee === void 0 || current.route === void 0 || current.acceptance.length === 0 ? "draft" : "ready",
|
|
1902
|
+
updatedAt: now,
|
|
1903
|
+
revision: current.revision + 1
|
|
1904
|
+
};
|
|
1905
|
+
if (nextAssignee === void 0) delete ready.assignee;
|
|
1906
|
+
else ready.assignee = nextAssignee;
|
|
1907
|
+
delete ready.activeAttemptId;
|
|
1908
|
+
const nextTasks = {
|
|
1909
|
+
...tasks,
|
|
1910
|
+
schemaVersion: 1,
|
|
1911
|
+
revision: tasks.revision + 1,
|
|
1912
|
+
tasks: tasks.tasks.map((entry) => entry.id === current.id ? ready : entry)
|
|
1913
|
+
};
|
|
1914
|
+
await atomicWriteJson(this.location.attemptsFile, nextAttempts);
|
|
1915
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
1916
|
+
await this.appendAudit({
|
|
1917
|
+
kind: "attempt/revoked",
|
|
1918
|
+
taskId: current.id,
|
|
1919
|
+
attemptId: stored.id,
|
|
1920
|
+
memberId: stored.memberId,
|
|
1921
|
+
reason,
|
|
1922
|
+
quiesced,
|
|
1923
|
+
actor: request.actor,
|
|
1924
|
+
at: now,
|
|
1925
|
+
revision: nextAttempts.revision
|
|
1926
|
+
});
|
|
1927
|
+
if (reassigned) await this.appendAudit({
|
|
1928
|
+
kind: "task/reassigned",
|
|
1929
|
+
taskId: current.id,
|
|
1930
|
+
from: current.assignee,
|
|
1931
|
+
to: nextAssignee,
|
|
1932
|
+
actor: request.actor,
|
|
1933
|
+
at: now,
|
|
1934
|
+
revision: nextTasks.revision
|
|
1935
|
+
});
|
|
1936
|
+
return {
|
|
1937
|
+
task: clone(ready),
|
|
1938
|
+
attempt: clone(revoked),
|
|
1939
|
+
quiesced: true,
|
|
1940
|
+
reassigned
|
|
1941
|
+
};
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
async acknowledgeTask(request) {
|
|
1945
|
+
if (!candidateId(request.taskId) || request.memberId.length === 0) fail("invalid acknowledge request");
|
|
1946
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
1947
|
+
const tasks = await this.readTasksIfPresentLocked();
|
|
1948
|
+
const task = tasks.tasks.find((entry) => entry.id === request.taskId);
|
|
1949
|
+
if (task === void 0) fail(`task not found: ${request.taskId}`);
|
|
1950
|
+
if (task.assignee !== request.memberId) fail("only the task assignee may acknowledge it");
|
|
1951
|
+
fenceMember((await this.readTeamLocked()).members.find((entry) => entry.memberId === request.memberId), request, request.memberId);
|
|
1952
|
+
if (task.status === "in_progress") return { task: clone(task) };
|
|
1953
|
+
if (task.status !== "claimed") fail(`task is not claimed; current status is ${task.status}`);
|
|
1954
|
+
const attempts = await this.readAttemptsIfPresentLocked();
|
|
1955
|
+
const attempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
|
|
1956
|
+
if (attempt === void 0 || attempt.memberId !== request.memberId) fail("task has no active attempt for this member");
|
|
1957
|
+
const now = Date.now();
|
|
1958
|
+
const nextTask = {
|
|
1959
|
+
...task,
|
|
1960
|
+
status: "in_progress",
|
|
1961
|
+
updatedAt: now,
|
|
1962
|
+
revision: task.revision + 1
|
|
1963
|
+
};
|
|
1964
|
+
const nextTasks = {
|
|
1965
|
+
...tasks,
|
|
1966
|
+
revision: tasks.revision + 1,
|
|
1967
|
+
tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
|
|
1968
|
+
};
|
|
1969
|
+
await atomicWriteJson(this.location.file, nextTasks);
|
|
1970
|
+
await this.appendAudit({
|
|
1971
|
+
kind: "task/acknowledged",
|
|
1972
|
+
taskId: task.id,
|
|
1973
|
+
memberId: request.memberId,
|
|
1974
|
+
attemptId: attempt.id,
|
|
1975
|
+
actor: request.memberId,
|
|
1976
|
+
at: now,
|
|
1977
|
+
revision: nextTasks.revision
|
|
1978
|
+
});
|
|
1979
|
+
return { task: clone(nextTask) };
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
async createTask(request) {
|
|
1983
|
+
return withProcessLock(this.location.teamFile, async () => {
|
|
1984
|
+
const team = await this.readTeamLocked();
|
|
1985
|
+
if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may create tasks; ask ${leadMemberIdForMessage(team)}`);
|
|
1986
|
+
const current = await this.readTasksIfPresentLocked();
|
|
1987
|
+
if (request.expectedRevision !== void 0 && request.expectedRevision !== current.revision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${current.revision}`);
|
|
1988
|
+
const normalized = this.validateCreate(request, team, current.tasks);
|
|
1989
|
+
const now = Date.now();
|
|
1990
|
+
const id = `task-${randomUUID()}`;
|
|
1991
|
+
const task = {
|
|
1992
|
+
id,
|
|
1993
|
+
teamId: this.teamId,
|
|
1994
|
+
title: normalized.title,
|
|
1995
|
+
kind: normalized.kind,
|
|
1996
|
+
status: normalized.status,
|
|
1997
|
+
...normalized.assignee === void 0 ? {} : { assignee: normalized.assignee },
|
|
1998
|
+
...normalized.route === void 0 ? {} : { route: normalized.route },
|
|
1999
|
+
humanContact: normalized.humanContact,
|
|
2000
|
+
...normalized.dependsOn === void 0 ? {} : { dependsOn: normalized.dependsOn },
|
|
2001
|
+
...normalized.why === void 0 ? {} : { why: normalized.why },
|
|
2002
|
+
...normalized.body === void 0 ? {} : { body: normalized.body },
|
|
2003
|
+
...normalized.checks.length === 0 ? {} : { checks: normalized.checks },
|
|
2004
|
+
...normalized.paths === void 0 ? {} : { paths: normalized.paths },
|
|
2005
|
+
acceptance: normalized.acceptance,
|
|
2006
|
+
roundLimit: normalized.roundLimit,
|
|
2007
|
+
contractRevision: 1,
|
|
2008
|
+
createdBy: request.actor,
|
|
2009
|
+
createdAt: now,
|
|
2010
|
+
updatedAt: now,
|
|
2011
|
+
revision: current.revision + 1
|
|
2012
|
+
};
|
|
2013
|
+
const documentPath = this.documentPath(id);
|
|
2014
|
+
const document = renderDocument(task, team, { tasks: current.tasks });
|
|
2015
|
+
await atomicWrite(documentPath, document);
|
|
2016
|
+
const next = {
|
|
2017
|
+
...current,
|
|
2018
|
+
schemaVersion: 1,
|
|
2019
|
+
revision: current.revision + 1,
|
|
2020
|
+
tasks: [...current.tasks, task]
|
|
2021
|
+
};
|
|
2022
|
+
try {
|
|
2023
|
+
await atomicWriteJson(this.location.file, next);
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
await promises.unlink(documentPath).catch(() => void 0);
|
|
2026
|
+
throw error;
|
|
2027
|
+
}
|
|
2028
|
+
const auditAt = Date.now();
|
|
2029
|
+
await this.appendAudit({
|
|
2030
|
+
kind: "task/created",
|
|
2031
|
+
id,
|
|
2032
|
+
taskId: id,
|
|
2033
|
+
title: task.title,
|
|
2034
|
+
assignee: task.assignee,
|
|
2035
|
+
routeType: task.route?.type,
|
|
2036
|
+
actor: request.actor,
|
|
2037
|
+
at: auditAt,
|
|
2038
|
+
revision: next.revision
|
|
2039
|
+
});
|
|
2040
|
+
await this.appendAudit({
|
|
2041
|
+
kind: "task/document",
|
|
2042
|
+
id,
|
|
2043
|
+
taskId: id,
|
|
2044
|
+
path: documentPath,
|
|
2045
|
+
actor: request.actor,
|
|
2046
|
+
at: Date.now()
|
|
2047
|
+
});
|
|
2048
|
+
return {
|
|
2049
|
+
task: clone(task),
|
|
2050
|
+
documentPath
|
|
2051
|
+
};
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
get teamId() {
|
|
2055
|
+
return path.basename(this.location.directory);
|
|
2056
|
+
}
|
|
2057
|
+
documentPath(taskId) {
|
|
2058
|
+
return path.join(this.location.tasksDirectory, `${taskId}.md`);
|
|
2059
|
+
}
|
|
2060
|
+
async readOrCreateLocked() {
|
|
2061
|
+
await promises.mkdir(this.location.directory, { recursive: true });
|
|
2062
|
+
return this.readTasksIfPresentLocked();
|
|
2063
|
+
}
|
|
2064
|
+
async readTasksIfPresentLocked() {
|
|
2065
|
+
if (!await fileExists(this.location.file)) return {
|
|
2066
|
+
schemaVersion: 1,
|
|
2067
|
+
revision: 0,
|
|
2068
|
+
teamId: this.teamId,
|
|
2069
|
+
tasks: []
|
|
2070
|
+
};
|
|
2071
|
+
try {
|
|
2072
|
+
return parseTasksState(await readJson(this.location.file), this.teamId, this.location.archiveDirectory);
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
if (error instanceof LiveTeamsError) throw error;
|
|
2075
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.file}`, { cause: error });
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
async readAttemptsIfPresentLocked() {
|
|
2079
|
+
if (!await fileExists(this.location.attemptsFile)) return {
|
|
2080
|
+
schemaVersion: 1,
|
|
2081
|
+
revision: 0,
|
|
2082
|
+
teamId: this.teamId,
|
|
2083
|
+
attempts: []
|
|
2084
|
+
};
|
|
2085
|
+
try {
|
|
2086
|
+
return parseAttemptsState(await readJson(this.location.attemptsFile), this.teamId);
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
if (error instanceof LiveTeamsError) throw error;
|
|
2089
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.attemptsFile}`, { cause: error });
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
async readSubmissionsIfPresentLocked() {
|
|
2093
|
+
if (!await fileExists(this.location.submissionsFile)) return {
|
|
2094
|
+
schemaVersion: 1,
|
|
2095
|
+
revision: 0,
|
|
2096
|
+
teamId: this.teamId,
|
|
2097
|
+
submissions: []
|
|
2098
|
+
};
|
|
2099
|
+
try {
|
|
2100
|
+
return parseSubmissionsState(await readJson(this.location.submissionsFile), this.teamId);
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
if (error instanceof LiveTeamsError) throw error;
|
|
2103
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.submissionsFile}`, { cause: error });
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
async readReviewsIfPresentLocked() {
|
|
2107
|
+
if (!await fileExists(this.location.reviewsFile)) return {
|
|
2108
|
+
schemaVersion: 1,
|
|
2109
|
+
revision: 0,
|
|
2110
|
+
teamId: this.teamId,
|
|
2111
|
+
rounds: []
|
|
2112
|
+
};
|
|
2113
|
+
try {
|
|
2114
|
+
return parseReviewsState(await readJson(this.location.reviewsFile), this.teamId);
|
|
2115
|
+
} catch (error) {
|
|
2116
|
+
if (error instanceof LiveTeamsError) throw error;
|
|
2117
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.reviewsFile}`, { cause: error });
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
async readTeamLocked() {
|
|
2121
|
+
let value;
|
|
2122
|
+
try {
|
|
2123
|
+
value = await readJson(this.location.teamFile);
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read team state: ${this.location.teamFile}`, { cause: error });
|
|
2126
|
+
}
|
|
2127
|
+
const team = objectRecord(value);
|
|
2128
|
+
if (team === void 0 || team.teamId !== this.teamId || !Array.isArray(team.members)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid team state");
|
|
2129
|
+
return team;
|
|
2130
|
+
}
|
|
2131
|
+
validateCreate(request, team, existing) {
|
|
2132
|
+
return this.validateContract({
|
|
2133
|
+
title: request.title,
|
|
2134
|
+
kind: request.kind,
|
|
2135
|
+
assignee: request.assignee,
|
|
2136
|
+
route: request.route,
|
|
2137
|
+
humanContact: request.humanContact ?? "none",
|
|
2138
|
+
acceptance: request.acceptance,
|
|
2139
|
+
checks: request.checks,
|
|
2140
|
+
why: request.why,
|
|
2141
|
+
body: request.body,
|
|
2142
|
+
roundLimit: request.roundLimit ?? 5,
|
|
2143
|
+
dependsOn: request.dependsOn,
|
|
2144
|
+
paths: request.paths
|
|
2145
|
+
}, team, existing, void 0);
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* The one place the contract rules live, shared by creation (B19) and editing
|
|
2149
|
+
* (B27): a second copy of them is how the writer and the reader start to
|
|
2150
|
+
* disagree about what a valid contract is.
|
|
2151
|
+
*/
|
|
2152
|
+
validateContract(input, team, existing, selfId) {
|
|
2153
|
+
const request = input;
|
|
2154
|
+
if (!nonEmpty(request.title)) fail("title must be non-empty");
|
|
2155
|
+
const title = request.title.trim();
|
|
2156
|
+
if (chars(title) > MAX_TITLE) fail(`title must be at most ${MAX_TITLE} characters`);
|
|
2157
|
+
if (!TASK_KINDS.includes(request.kind)) fail(`unsupported task kind: ${String(request.kind)}`);
|
|
2158
|
+
if (request.assignee !== void 0) {
|
|
2159
|
+
if (typeof request.assignee !== "string" || memberById(team, request.assignee) === void 0) fail(`assignee is not a current team member: ${String(request.assignee)}`);
|
|
2160
|
+
}
|
|
2161
|
+
const route = request.route === void 0 ? void 0 : validateRoute(request.route, team, request.assignee);
|
|
2162
|
+
const humanContact = request.humanContact ?? "none";
|
|
2163
|
+
if (humanContact !== "none" && humanContact !== "expected" && humanContact !== "required") fail("humanContact must be none, expected, or required");
|
|
2164
|
+
if (!Array.isArray(request.acceptance)) fail("acceptance must be an array");
|
|
2165
|
+
const acceptance = request.acceptance.map((entry) => typeof entry === "string" ? entry.trim() : "");
|
|
2166
|
+
const status = request.assignee !== void 0 && route !== void 0 ? "ready" : "draft";
|
|
2167
|
+
if (!acceptance.every((entry) => entry.length > 0) && status === "ready") fail("acceptance criteria must be non-empty strings");
|
|
2168
|
+
if (status === "ready" && acceptance.length === 0) fail("ready tasks require non-empty acceptance");
|
|
2169
|
+
const roundLimit = request.roundLimit ?? 5;
|
|
2170
|
+
if (!Number.isInteger(roundLimit) || roundLimit < 1 || roundLimit > 20) fail("roundLimit must be an integer from 1 to 20");
|
|
2171
|
+
if (request.body !== void 0 && (typeof request.body !== "string" || chars(request.body) > MAX_BODY)) fail(`body must be at most ${MAX_BODY} characters`);
|
|
2172
|
+
if (request.why !== void 0 && typeof request.why !== "string") fail("why must be a string");
|
|
2173
|
+
if (request.checks !== void 0 && (!Array.isArray(request.checks) || !request.checks.every((entry) => typeof entry === "string"))) fail("checks must be an array of strings");
|
|
2174
|
+
const checks = request.checks === void 0 ? [] : request.checks.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
2175
|
+
const why = request.why === void 0 ? void 0 : request.why.trim();
|
|
2176
|
+
const body = request.body === void 0 ? void 0 : request.body;
|
|
2177
|
+
if (status === "ready" && (route === void 0 || acceptance.length === 0)) fail("ready tasks require route and acceptance");
|
|
2178
|
+
const dependsOn = validateDependencies(request.dependsOn, selfId, existing);
|
|
2179
|
+
let paths;
|
|
2180
|
+
if (request.paths !== void 0 && request.paths !== null) {
|
|
2181
|
+
paths = normalizeScope(request.paths);
|
|
2182
|
+
if (paths === void 0) fail(`paths must be 1 to 32 unique workspace-relative globs without ".." or an absolute path`);
|
|
2183
|
+
}
|
|
2184
|
+
return {
|
|
2185
|
+
title,
|
|
2186
|
+
kind: request.kind,
|
|
2187
|
+
...request.assignee === void 0 ? {} : { assignee: request.assignee },
|
|
2188
|
+
...route === void 0 ? {} : { route },
|
|
2189
|
+
humanContact,
|
|
2190
|
+
acceptance,
|
|
2191
|
+
checks,
|
|
2192
|
+
...why === void 0 ? {} : { why },
|
|
2193
|
+
...body === void 0 ? {} : { body },
|
|
2194
|
+
roundLimit,
|
|
2195
|
+
...dependsOn === void 0 ? {} : { dependsOn },
|
|
2196
|
+
...paths === void 0 ? {} : { paths },
|
|
2197
|
+
status
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
function createTaskStore(options) {
|
|
2202
|
+
return new TaskStore(options);
|
|
2203
|
+
}
|
|
2204
|
+
//#endregion
|
|
2205
|
+
export { TaskStore, assignmentRouteText, blockedBy, createTaskStore, deriveRouteContacts, renderDocument, routeAllowsContact, routeText, taskDocumentPath };
|