@brainervirus/workit-core 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +6 -1
- package/src/core/auto-approval.ts +34 -14
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +886 -172
- package/src/core/external-action.ts +222 -64
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +65 -52
- package/src/core/policy-resolver.ts +13 -5
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +66 -6
- package/src/core/task-engine.ts +47 -2
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workspaces.ts +554 -46
- package/src/core.ts +3 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
|
@@ -588,6 +588,8 @@ export const decisionSchema = z
|
|
|
588
588
|
state: z.enum(["reserved", "consumed", "uncertain"]),
|
|
589
589
|
at: utc,
|
|
590
590
|
actionRef: refSchema,
|
|
591
|
+
/** Historical identifier only; never interpreted as native authority. */
|
|
592
|
+
historicalActionId: text.optional(),
|
|
591
593
|
})
|
|
592
594
|
.strict()
|
|
593
595
|
.nullable(),
|
|
@@ -809,7 +811,10 @@ export const taskSummarySchema = z
|
|
|
809
811
|
})
|
|
810
812
|
.strict();
|
|
811
813
|
export type TaskSummary = z.infer<typeof taskSummarySchema>;
|
|
812
|
-
export const taskListItemSchema = taskSummarySchema
|
|
814
|
+
export const taskListItemSchema = taskSummarySchema
|
|
815
|
+
.omit({ policy: true, requirements: true })
|
|
816
|
+
.extend({ source: provenanceSchema.pick({ host: true, kind: true }) })
|
|
817
|
+
.strict();
|
|
813
818
|
export type TaskListItem = z.infer<typeof taskListItemSchema>;
|
|
814
819
|
export const exportBundleSchema = z
|
|
815
820
|
.object({
|
|
@@ -844,6 +849,7 @@ const taskOperations = {
|
|
|
844
849
|
list: operation({
|
|
845
850
|
action: z.literal("list"),
|
|
846
851
|
status: z.enum(["open", "closed", "all"]).optional(),
|
|
852
|
+
query: z.string().trim().min(1).max(200).optional(),
|
|
847
853
|
limit: z.number().int().min(1).max(50).optional(),
|
|
848
854
|
}),
|
|
849
855
|
inspect: operation({
|
|
@@ -1134,8 +1140,61 @@ export const OPERATION_SCHEMA_DEPTH = 1;
|
|
|
1134
1140
|
/** Provider nesting limit every advertised Workit schema must stay within. */
|
|
1135
1141
|
export const OPERATION_SCHEMA_MAX_DEPTH = 10;
|
|
1136
1142
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1143
|
+
const schemaShapeDescription = (value: unknown, depth = 0): string => {
|
|
1144
|
+
if (!value || typeof value !== "object") return "value";
|
|
1145
|
+
const schema = value as Record<string, unknown>;
|
|
1146
|
+
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
|
|
1147
|
+
const options = (Array.isArray(schema.oneOf) ? schema.oneOf : schema.anyOf) as unknown[];
|
|
1148
|
+
return `(${options.map((option) => schemaShapeDescription(option, depth)).join("|")})`;
|
|
1149
|
+
}
|
|
1150
|
+
if (schema.const !== undefined) return `=${JSON.stringify(schema.const)}`;
|
|
1151
|
+
if (Array.isArray(schema.enum)) return `[${schema.enum.map(String).join("|")}]`;
|
|
1152
|
+
const type = Array.isArray(schema.type) ? schema.type.join("/") : schema.type;
|
|
1153
|
+
if (type === "array") return `arr<${schemaShapeDescription(schema.items, depth)}>`;
|
|
1154
|
+
if (type === "object" && schema.properties && typeof schema.properties === "object") {
|
|
1155
|
+
if (depth >= 2) return "obj";
|
|
1156
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
1157
|
+
const properties = schema.properties as Record<string, unknown>;
|
|
1158
|
+
const fields = Object.entries(properties).map(
|
|
1159
|
+
([name, child]) =>
|
|
1160
|
+
`${name}${required.has(name) ? "!" : ""}:${schemaShapeDescription(child, depth + 1)}`,
|
|
1161
|
+
);
|
|
1162
|
+
return `obj{${fields.join(",")}}`;
|
|
1163
|
+
}
|
|
1164
|
+
if (typeof type !== "string") return "?";
|
|
1165
|
+
return type
|
|
1166
|
+
.split("/")
|
|
1167
|
+
.map((part) => ({ string: "str", boolean: "bool", number: "num", null: "null" })[part] ?? part)
|
|
1168
|
+
.join("|");
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
export const canonicalFieldsDescription = (
|
|
1172
|
+
value: string[] | Record<string, unknown> | z.ZodType,
|
|
1173
|
+
): string => {
|
|
1174
|
+
if (Array.isArray(value))
|
|
1175
|
+
return `Canonical object fields: ${value.join(", ")}. Workit validates the complete nested value.`;
|
|
1176
|
+
const isZodType = (schema: Record<string, unknown> | z.ZodType): schema is z.ZodType =>
|
|
1177
|
+
"_zod" in schema && typeof schema._zod === "object";
|
|
1178
|
+
const schema = isZodType(value)
|
|
1179
|
+
? (z.toJSONSchema(value, { target: "draft-2020-12" }) as Record<string, unknown>)
|
|
1180
|
+
: value;
|
|
1181
|
+
if (!schema.properties || typeof schema.properties !== "object") {
|
|
1182
|
+
const variants = Array.isArray(schema.oneOf)
|
|
1183
|
+
? schema.oneOf
|
|
1184
|
+
: Array.isArray(schema.anyOf)
|
|
1185
|
+
? schema.anyOf
|
|
1186
|
+
: null;
|
|
1187
|
+
return variants
|
|
1188
|
+
? `Canonical shapes: ${variants.map((variant) => schemaShapeDescription(variant)).join(" | ")}. Full nested value is validated.`
|
|
1189
|
+
: "Canonical nested value; Workit validates the complete value.";
|
|
1190
|
+
}
|
|
1191
|
+
const properties = schema.properties as Record<string, unknown>;
|
|
1192
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
1193
|
+
const fields = Object.entries(properties).map(
|
|
1194
|
+
([name, child]) => `${name}${required.has(name) ? "!" : ""}:${schemaShapeDescription(child)}`,
|
|
1195
|
+
);
|
|
1196
|
+
return `Fields (! required): ${fields.join(", ")}. Full nested value is validated.`;
|
|
1197
|
+
};
|
|
1139
1198
|
|
|
1140
1199
|
/**
|
|
1141
1200
|
* Provider-safe projection of an operation JSON schema. Objects deeper than
|
|
@@ -1172,11 +1231,12 @@ export function boundedOperationJsonSchema(
|
|
|
1172
1231
|
if (typeof node !== "object" || node === null) return node;
|
|
1173
1232
|
const record = node as Record<string, unknown>;
|
|
1174
1233
|
if (record.properties && typeof record.properties === "object") {
|
|
1175
|
-
const
|
|
1234
|
+
const properties = record.properties as Record<string, unknown>;
|
|
1235
|
+
const fields = Object.keys(properties);
|
|
1176
1236
|
if (currentDepth >= maxDepth || fields.length === 0) {
|
|
1177
1237
|
const description = stringifiedObjects
|
|
1178
|
-
? `${canonicalFieldsDescription(
|
|
1179
|
-
: canonicalFieldsDescription(
|
|
1238
|
+
? `${canonicalFieldsDescription(record)} A JSON-encoded string is also accepted.`
|
|
1239
|
+
: canonicalFieldsDescription(record);
|
|
1180
1240
|
return tolerate({ type: "object", description });
|
|
1181
1241
|
}
|
|
1182
1242
|
return tolerate({
|
package/src/core/task-engine.ts
CHANGED
|
@@ -512,6 +512,21 @@ const portableTask = (task: TaskRecord): TaskRecord => {
|
|
|
512
512
|
};
|
|
513
513
|
for (const entry of clone.assessments)
|
|
514
514
|
demote(entry.data.facts, entry.data.signals, entry.data.consequences);
|
|
515
|
+
const sourceDecisions = new Map(task.decisions.map((entry) => [entry.id, entry]));
|
|
516
|
+
for (const entry of clone.decisions) {
|
|
517
|
+
const consumption = sourceDecisions.get(entry.id)?.data.consumption;
|
|
518
|
+
if (!consumption) continue;
|
|
519
|
+
entry.data.consumption = {
|
|
520
|
+
...consumption,
|
|
521
|
+
state: consumption.state === "reserved" ? "uncertain" : consumption.state,
|
|
522
|
+
actionRef: { kind: "record", collection: "decisions", id: entry.id },
|
|
523
|
+
...(consumption.historicalActionId
|
|
524
|
+
? { historicalActionId: consumption.historicalActionId }
|
|
525
|
+
: consumption.actionRef.kind === "host"
|
|
526
|
+
? { historicalActionId: consumption.actionRef.handle }
|
|
527
|
+
: {}),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
515
530
|
// Candidate metadata can contain environment-derived paths and digests. The destination
|
|
516
531
|
// must recapture its own candidate instead of receiving source checkout material.
|
|
517
532
|
clone.candidates = [];
|
|
@@ -649,7 +664,13 @@ const importedTask = (
|
|
|
649
664
|
taskId: "",
|
|
650
665
|
workspaceId: destinationWorkspaceId,
|
|
651
666
|
},
|
|
652
|
-
consumption:
|
|
667
|
+
consumption: data.consumption
|
|
668
|
+
? {
|
|
669
|
+
...data.consumption,
|
|
670
|
+
state: data.consumption.state === "reserved" ? "uncertain" : data.consumption.state,
|
|
671
|
+
actionRef: { kind: "record", collection: "decisions", id: entry.id },
|
|
672
|
+
}
|
|
673
|
+
: null,
|
|
653
674
|
},
|
|
654
675
|
};
|
|
655
676
|
}),
|
|
@@ -873,7 +894,7 @@ export class WorkitCore {
|
|
|
873
894
|
if (!parsed.ok) return parsed as Result<never>;
|
|
874
895
|
const input = parsed.data as any;
|
|
875
896
|
if ((this.context.workerId ?? null) !== null) {
|
|
876
|
-
if (input.action !== "inspect")
|
|
897
|
+
if (input.action !== "inspect" && input.action !== "list")
|
|
877
898
|
return failure("permission_denied", "helpers cannot control task lifecycle or scope");
|
|
878
899
|
}
|
|
879
900
|
switch (input.action) {
|
|
@@ -897,6 +918,7 @@ export class WorkitCore {
|
|
|
897
918
|
if (!listed.ok) return listed as Result<never>;
|
|
898
919
|
const status = input.status ?? "open";
|
|
899
920
|
const limit = input.limit ?? 20;
|
|
921
|
+
const query = input.query?.toLowerCase().split(/\s+/);
|
|
900
922
|
const selected = listed.data
|
|
901
923
|
.filter((task) =>
|
|
902
924
|
status === "all"
|
|
@@ -905,6 +927,25 @@ export class WorkitCore {
|
|
|
905
927
|
? task.status === "closed"
|
|
906
928
|
: task.status !== "closed",
|
|
907
929
|
)
|
|
930
|
+
.filter(
|
|
931
|
+
(task) =>
|
|
932
|
+
!query ||
|
|
933
|
+
query.every((term: string) =>
|
|
934
|
+
JSON.stringify({
|
|
935
|
+
id: task.id,
|
|
936
|
+
workspaceId: task.workspaceId,
|
|
937
|
+
origin: task.origin,
|
|
938
|
+
createdAt: task.createdAt,
|
|
939
|
+
updatedAt: task.updatedAt,
|
|
940
|
+
source: task.intent.provenance,
|
|
941
|
+
objective: task.intent.data.objective,
|
|
942
|
+
progress: task.progress,
|
|
943
|
+
decisions: task.decisions.map(({ data }) => data),
|
|
944
|
+
})
|
|
945
|
+
.toLowerCase()
|
|
946
|
+
.includes(term),
|
|
947
|
+
),
|
|
948
|
+
)
|
|
908
949
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
909
950
|
.slice(0, limit);
|
|
910
951
|
if (selected.length === 0) return success(null, null, []);
|
|
@@ -923,6 +964,10 @@ export class WorkitCore {
|
|
|
923
964
|
closure: task.closure,
|
|
924
965
|
progress: task.progress,
|
|
925
966
|
writer: task.status === "closed" ? null : workspace.data!.writer,
|
|
967
|
+
source: {
|
|
968
|
+
host: task.intent.provenance.host,
|
|
969
|
+
kind: task.intent.provenance.kind,
|
|
970
|
+
},
|
|
926
971
|
}));
|
|
927
972
|
return success(null, null, tasks);
|
|
928
973
|
}
|
package/src/core/task-store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import { hostname } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -76,6 +77,7 @@ export type MetadataLock = {
|
|
|
76
77
|
processStart: string | null;
|
|
77
78
|
host: string;
|
|
78
79
|
nonce: string;
|
|
80
|
+
externalAction?: true;
|
|
79
81
|
};
|
|
80
82
|
export type ProcessEvidence = {
|
|
81
83
|
state: "stopped" | "accounted_for";
|
|
@@ -100,6 +102,7 @@ const metadataLockSchema = z
|
|
|
100
102
|
processStart: z.string().nullable(),
|
|
101
103
|
host: z.string().min(1),
|
|
102
104
|
nonce: z.string().min(1),
|
|
105
|
+
externalAction: z.literal(true).optional(),
|
|
103
106
|
})
|
|
104
107
|
.strict();
|
|
105
108
|
export type RecoveryCandidate = {
|
|
@@ -170,7 +173,35 @@ const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
|
|
|
170
173
|
const parsed = metadataLockSchema.safeParse(left);
|
|
171
174
|
return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
|
|
172
175
|
};
|
|
176
|
+
export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
177
|
+
if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
|
|
178
|
+
const normalizedLeft = path.resolve(left);
|
|
179
|
+
const normalizedRight = path.resolve(right);
|
|
180
|
+
try {
|
|
181
|
+
const a = fs.statSync(normalizedLeft, { bigint: true });
|
|
182
|
+
const b = fs.statSync(normalizedRight, { bigint: true });
|
|
183
|
+
if (!a.isDirectory() || !b.isDirectory()) return false;
|
|
184
|
+
if (
|
|
185
|
+
process.platform === "win32"
|
|
186
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
187
|
+
: normalizedLeft === normalizedRight
|
|
188
|
+
)
|
|
189
|
+
return true;
|
|
190
|
+
const canonicalLeft = fs.realpathSync(normalizedLeft);
|
|
191
|
+
const canonicalRight = fs.realpathSync(normalizedRight);
|
|
192
|
+
if (
|
|
193
|
+
process.platform === "win32"
|
|
194
|
+
? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase()
|
|
195
|
+
: canonicalLeft === canonicalRight
|
|
196
|
+
)
|
|
197
|
+
return true;
|
|
198
|
+
return a.dev === b.dev && a.ino !== 0n && a.ino === b.ino;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
173
203
|
type LockSnapshot = { raw: string; data: MetadataLock };
|
|
204
|
+
const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
174
205
|
|
|
175
206
|
export class TaskStore {
|
|
176
207
|
readonly root: string;
|
|
@@ -230,7 +261,11 @@ export class TaskStore {
|
|
|
230
261
|
readWorkspace(): Result<WorkspaceRecord | null> {
|
|
231
262
|
const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
|
|
232
263
|
if (!item.exists) return success(null, null, null);
|
|
233
|
-
if (
|
|
264
|
+
if (
|
|
265
|
+
item.result.ok &&
|
|
266
|
+
item.result.data.root !== this.root &&
|
|
267
|
+
!sameDirectoryIdentity(item.result.data.root, this.root)
|
|
268
|
+
)
|
|
234
269
|
return failure("recovery_required", "workspace root binding is invalid", {
|
|
235
270
|
path: this.workspacePath,
|
|
236
271
|
});
|
|
@@ -355,6 +390,37 @@ export class TaskStore {
|
|
|
355
390
|
return this.withLock<TaskRecord>(() => {
|
|
356
391
|
const current = this.readWorkspace();
|
|
357
392
|
if (!current.ok) return current;
|
|
393
|
+
const origin = input.task.origin;
|
|
394
|
+
if (current.data && origin) {
|
|
395
|
+
if (origin.workspaceId === current.data.id) {
|
|
396
|
+
const source = this.readTask(origin.taskId);
|
|
397
|
+
if (source.ok) return success(source.data.revision, current.data.revision, source.data);
|
|
398
|
+
if (source.code !== "not_found") return source as Result<never>;
|
|
399
|
+
return failure("not_found", "source task is not present in this shared workspace");
|
|
400
|
+
}
|
|
401
|
+
const tasks = this.listTasks();
|
|
402
|
+
if (!tasks.ok) return tasks as Result<never>;
|
|
403
|
+
const prior = tasks.data.filter(
|
|
404
|
+
(task) =>
|
|
405
|
+
task.origin?.workspaceId === origin.workspaceId &&
|
|
406
|
+
task.origin?.taskId === origin.taskId,
|
|
407
|
+
);
|
|
408
|
+
if (prior.length > 1)
|
|
409
|
+
return failure(
|
|
410
|
+
"recovery_required",
|
|
411
|
+
`source task has multiple imported mappings: ${prior.map((task) => task.id).join(", ")}`,
|
|
412
|
+
);
|
|
413
|
+
if (prior.length === 1) {
|
|
414
|
+
const existing = prior[0]!;
|
|
415
|
+
if (existing.origin!.exportDigest !== origin.exportDigest)
|
|
416
|
+
return failure(
|
|
417
|
+
"revision_conflict",
|
|
418
|
+
"source task export changed; reconcile the mapped destination task before importing",
|
|
419
|
+
{ taskId: existing.id },
|
|
420
|
+
);
|
|
421
|
+
return success(existing.revision, current.data.revision, existing);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
358
424
|
if (
|
|
359
425
|
current.data
|
|
360
426
|
? input.expectedWorkspaceRevision !== current.data.revision
|
|
@@ -822,6 +888,90 @@ export class TaskStore {
|
|
|
822
888
|
};
|
|
823
889
|
}
|
|
824
890
|
|
|
891
|
+
private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
|
|
892
|
+
const options = this.metadataLockOptions();
|
|
893
|
+
return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Hold the workspace metadata lock across a managed effect in this checkout. */
|
|
897
|
+
async withExternalActionLock<T>(
|
|
898
|
+
operation: () => Promise<Result<T>>,
|
|
899
|
+
reentrant = false,
|
|
900
|
+
): Promise<Result<T>> {
|
|
901
|
+
const activeRoots = externalActionLockRoots.getStore();
|
|
902
|
+
if (
|
|
903
|
+
activeRoots &&
|
|
904
|
+
[...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
|
|
905
|
+
)
|
|
906
|
+
return operation();
|
|
907
|
+
try {
|
|
908
|
+
this.initializeMutationStorage();
|
|
909
|
+
} catch (error) {
|
|
910
|
+
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
911
|
+
path: this.workitDir,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
let handle: FileLockSyncHandle;
|
|
915
|
+
try {
|
|
916
|
+
handle = acquireFileLockSync(this.workspacePath, this.externalActionLockOptions());
|
|
917
|
+
} catch (error) {
|
|
918
|
+
return this.lockFailure(error);
|
|
919
|
+
}
|
|
920
|
+
let result: Result<T>;
|
|
921
|
+
try {
|
|
922
|
+
if (!handle.verifyStillHeld())
|
|
923
|
+
result = failure("recovery_required", "metadata lock was compromised", {
|
|
924
|
+
path: this.lockPath,
|
|
925
|
+
});
|
|
926
|
+
else {
|
|
927
|
+
try {
|
|
928
|
+
const run = () => operation();
|
|
929
|
+
result = await (reentrant
|
|
930
|
+
? externalActionLockRoots.run(new Set([...(activeRoots ?? []), this.root]), run)
|
|
931
|
+
: run());
|
|
932
|
+
} catch {
|
|
933
|
+
result = failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
934
|
+
outcome: "unknown",
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
if (!handle.verifyStillHeld())
|
|
939
|
+
result = failure("external_outcome_unknown", "metadata lock was compromised", {
|
|
940
|
+
outcome: "unknown",
|
|
941
|
+
path: this.lockPath,
|
|
942
|
+
});
|
|
943
|
+
} catch {
|
|
944
|
+
result = failure("external_outcome_unknown", "metadata lock could not be verified", {
|
|
945
|
+
outcome: "unknown",
|
|
946
|
+
path: this.lockPath,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
} catch {
|
|
951
|
+
result = failure("external_outcome_unknown", "external action lock operation failed", {
|
|
952
|
+
outcome: "unknown",
|
|
953
|
+
path: this.lockPath,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
handle.release();
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const released = this.lockFailure(error);
|
|
960
|
+
const releaseError = released.ok ? "metadata lock release failed" : released.error;
|
|
961
|
+
return result.ok
|
|
962
|
+
? failure(
|
|
963
|
+
"external_outcome_unknown",
|
|
964
|
+
"external action metadata lock release is uncertain",
|
|
965
|
+
{
|
|
966
|
+
outcome: "unknown",
|
|
967
|
+
path: this.lockPath,
|
|
968
|
+
},
|
|
969
|
+
)
|
|
970
|
+
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
971
|
+
}
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
|
|
825
975
|
private recoveryLockOptions(
|
|
826
976
|
observed: LockSnapshot | null,
|
|
827
977
|
evidence: ProcessEvidence,
|
|
@@ -912,6 +1062,14 @@ export class TaskStore {
|
|
|
912
1062
|
private lockFailure(error: unknown): Result<never> {
|
|
913
1063
|
const value = error as { code?: unknown; message?: unknown };
|
|
914
1064
|
const code = typeof value?.code === "string" ? value.code : "";
|
|
1065
|
+
if (code === "EEXIST" || code === "file_lock_timeout") {
|
|
1066
|
+
const lock = this.readLockSnapshot();
|
|
1067
|
+
if (lock.ok && lock.data?.data.externalAction)
|
|
1068
|
+
return failure("writer_conflict", "workspace is reserved by a managed external action", {
|
|
1069
|
+
outcome: "not_started",
|
|
1070
|
+
path: this.lockPath,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
915
1073
|
const recovery =
|
|
916
1074
|
code === "EEXIST" ||
|
|
917
1075
|
code === "file_lock_timeout" ||
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { hostingCliAvailable, parseGhIssue, pushRemote } from "./pr-create";
|
|
3
|
+
import { hostingApiHostMatches, vcsConfig } from "./vcs-config";
|
|
5
4
|
|
|
6
5
|
// Read-only issue fetches for context.read (GitHub + GitLab), mirroring the
|
|
7
|
-
// YouTrack triple shape.
|
|
8
|
-
// when installed (per-directory identity comes free); token files are the
|
|
9
|
-
// fallback and fail closed without one. No writes, no new secret files.
|
|
6
|
+
// YouTrack triple shape. Authenticated gh/glab serves the read in the target checkout.
|
|
10
7
|
|
|
11
8
|
export type TrackerIssueBody = {
|
|
12
9
|
id: string;
|
|
@@ -20,22 +17,6 @@ export type TrackerIssueFailure = {
|
|
|
20
17
|
kind: "creds" | "request" | "input";
|
|
21
18
|
};
|
|
22
19
|
|
|
23
|
-
export type TrackerRequest = (
|
|
24
|
-
url: string,
|
|
25
|
-
init: { method: string; headers: Record<string, string> },
|
|
26
|
-
) => Promise<{ status: number; stdout: string; stderr: string }>;
|
|
27
|
-
|
|
28
|
-
export const trackerRequest: TrackerRequest = async (url, init) => {
|
|
29
|
-
try {
|
|
30
|
-
const res = await fetch(url, { method: init.method, headers: init.headers });
|
|
31
|
-
const text = await res.text();
|
|
32
|
-
if (!res.ok) return { status: res.status, stdout: "", stderr: text.slice(0, 200) };
|
|
33
|
-
return { status: 0, stdout: text, stderr: "" };
|
|
34
|
-
} catch (err) {
|
|
35
|
-
return { status: 1, stdout: "", stderr: err instanceof Error ? err.message : "network error" };
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
|
|
39
20
|
/** Full repo path from an origin URL (scp-style, https, or bare); subgroups kept. */
|
|
40
21
|
export const repoPathFromRemote = (remote: string): string | null => {
|
|
41
22
|
let rest = (remote || "").trim().replace(/\/+$/, "");
|
|
@@ -57,17 +38,20 @@ export type TrackerCli = (
|
|
|
57
38
|
) => Promise<{ status: number; stdout: string; stderr: string }> | null;
|
|
58
39
|
|
|
59
40
|
export type TrackerDeps = {
|
|
60
|
-
creds?: (root: string) => TrackerCreds;
|
|
61
|
-
request?: TrackerRequest;
|
|
62
41
|
remote?: (root: string) => string | null;
|
|
63
42
|
cli?: TrackerCli;
|
|
43
|
+
host?: string;
|
|
64
44
|
};
|
|
65
45
|
|
|
66
46
|
/** Native CLI runner (gh/glab) when installed; null when absent. */
|
|
67
47
|
const defaultCli = (bin: string, available: boolean): TrackerCli | null => {
|
|
68
48
|
if (!available) return null;
|
|
69
49
|
return (args, root) => {
|
|
70
|
-
const out = spawnSync(bin, args, {
|
|
50
|
+
const out = spawnSync(bin, args, {
|
|
51
|
+
cwd: root,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
env: { ...process.env, PATH: process.env.PATH ?? "" },
|
|
54
|
+
});
|
|
71
55
|
return Promise.resolve({
|
|
72
56
|
status: out.status ?? 1,
|
|
73
57
|
stdout: out.stdout ?? "",
|
|
@@ -78,55 +62,34 @@ const defaultCli = (bin: string, available: boolean): TrackerCli | null => {
|
|
|
78
62
|
|
|
79
63
|
const tryCliTriple = async (
|
|
80
64
|
cli: TrackerCli | null | undefined,
|
|
65
|
+
name: string,
|
|
81
66
|
args: string[],
|
|
82
67
|
root: string,
|
|
83
68
|
id: string,
|
|
84
69
|
idKeys: string[],
|
|
85
70
|
bodyKeys: string[],
|
|
86
|
-
): Promise<TrackerIssueBody |
|
|
87
|
-
if (!cli) return
|
|
71
|
+
): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
|
|
72
|
+
if (!cli) return { error: `${name} CLI is unavailable`, kind: "creds" };
|
|
88
73
|
const run = cli(args, root);
|
|
89
|
-
if (!run) return
|
|
74
|
+
if (!run) return { error: `${name} CLI is unavailable`, kind: "creds" };
|
|
90
75
|
const out = await run;
|
|
91
|
-
if (out.status !== 0)
|
|
76
|
+
if (out.status !== 0)
|
|
77
|
+
return { error: `${name} CLI is not authenticated or the issue is unavailable`, kind: "creds" };
|
|
92
78
|
try {
|
|
93
|
-
|
|
79
|
+
const triple = parseTriple(
|
|
80
|
+
JSON.parse(out.stdout) as Record<string, unknown>,
|
|
81
|
+
id,
|
|
82
|
+
idKeys,
|
|
83
|
+
bodyKeys,
|
|
84
|
+
);
|
|
85
|
+
return triple
|
|
86
|
+
? { data: triple }
|
|
87
|
+
: { error: `unexpected ${name} issue response`, kind: "request" };
|
|
94
88
|
} catch {
|
|
95
|
-
return
|
|
89
|
+
return { error: `invalid ${name} issue response`, kind: "request" };
|
|
96
90
|
}
|
|
97
91
|
};
|
|
98
92
|
|
|
99
|
-
const originRemote = (root: string): string | null => {
|
|
100
|
-
const out = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" });
|
|
101
|
-
return out.status === 0 ? (out.stdout ?? "").trim() || null : null;
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
type TrackerCreds = { token: string; api: string } | { error: string };
|
|
105
|
-
|
|
106
|
-
const trackerCreds = (root: string, provider: "github" | "gitlab"): TrackerCreds => {
|
|
107
|
-
const cfg = vcsConfig("load", root) as Record<string, unknown>;
|
|
108
|
-
if (!cfg || cfg.provider !== provider || typeof cfg.tokenPath !== "string" || !cfg.tokenPath)
|
|
109
|
-
return { error: `unconfigured ${provider} provider` };
|
|
110
|
-
let token = "";
|
|
111
|
-
try {
|
|
112
|
-
token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
|
|
113
|
-
} catch {
|
|
114
|
-
return { error: `missing ${provider} token` };
|
|
115
|
-
}
|
|
116
|
-
if (!token) return { error: `empty ${provider} token file` };
|
|
117
|
-
if (provider === "gitlab") {
|
|
118
|
-
const gitlab = cfg.gitlab as Record<string, unknown> | undefined;
|
|
119
|
-
const apiUrl = String(gitlab?.apiUrl ?? "https://gitlab.com/api/v4").replace(/\/+$/, "");
|
|
120
|
-
return { token, api: apiUrl };
|
|
121
|
-
}
|
|
122
|
-
const github = cfg.github as Record<string, unknown> | undefined;
|
|
123
|
-
const host = String(github?.host ?? "github.com").toLowerCase();
|
|
124
|
-
return {
|
|
125
|
-
token,
|
|
126
|
-
api: host === "github.com" ? "https://api.github.com" : `https://${host}/api/v3`,
|
|
127
|
-
};
|
|
128
|
-
};
|
|
129
|
-
|
|
130
93
|
const parseTriple = (
|
|
131
94
|
parsed: Record<string, unknown>,
|
|
132
95
|
id: string,
|
|
@@ -148,7 +111,7 @@ const parseTriple = (
|
|
|
148
111
|
return { id: foundId, title, body: pick(bodyKeys), state };
|
|
149
112
|
};
|
|
150
113
|
|
|
151
|
-
/** Fetch a GitHub issue triple for context.read
|
|
114
|
+
/** Fetch a GitHub issue triple for context.read through the authenticated CLI. */
|
|
152
115
|
export const fetchGitHubIssueBody = async (
|
|
153
116
|
ref: string,
|
|
154
117
|
root: string,
|
|
@@ -156,46 +119,30 @@ export const fetchGitHubIssueBody = async (
|
|
|
156
119
|
): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
|
|
157
120
|
const id = parseGhIssue(ref);
|
|
158
121
|
if (!/^\d+$/.test(id)) return { error: `invalid GitHub issue ref "${ref}"`, kind: "input" };
|
|
159
|
-
const
|
|
122
|
+
const remote = (deps.remote ?? pushRemote)(root);
|
|
123
|
+
const repo = repoPathFromRemote(remote ?? "");
|
|
160
124
|
if (!repo) return { error: "no origin remote to resolve the repository", kind: "input" };
|
|
161
|
-
const
|
|
125
|
+
const cfg = vcsConfig("load", root);
|
|
126
|
+
const host = (
|
|
127
|
+
deps.host ??
|
|
128
|
+
(cfg.ok && cfg.provider === "github" ? cfg.github?.host : null) ??
|
|
129
|
+
"github.com"
|
|
130
|
+
).toLowerCase();
|
|
131
|
+
if (!remote || !hostingApiHostMatches(remote, host))
|
|
132
|
+
return { error: "hosting API host does not match the push destination", kind: "input" };
|
|
133
|
+
const repoArg = host === "github.com" ? repo : `${host}/${repo}`;
|
|
134
|
+
return tryCliTriple(
|
|
162
135
|
deps.cli ?? defaultCli("gh", hostingCliAvailable("github")),
|
|
163
|
-
|
|
136
|
+
"gh",
|
|
137
|
+
["issue", "view", id, "--repo", repoArg, "--json", "number,title,body,state"],
|
|
164
138
|
root,
|
|
165
139
|
id,
|
|
166
140
|
["number"],
|
|
167
141
|
["body"],
|
|
168
142
|
);
|
|
169
|
-
if (viaCli) return { data: viaCli };
|
|
170
|
-
const credentials = (deps.creds ?? ((r) => trackerCreds(r, "github")))(root);
|
|
171
|
-
if ("error" in credentials) return { ...credentials, kind: "creds" as const };
|
|
172
|
-
const out = await (deps.request ?? trackerRequest)(
|
|
173
|
-
`${credentials.api}/repos/${repo}/issues/${id}`,
|
|
174
|
-
{
|
|
175
|
-
method: "GET",
|
|
176
|
-
headers: {
|
|
177
|
-
Authorization: `Bearer ${credentials.token}`,
|
|
178
|
-
Accept: "application/vnd.github+json",
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
);
|
|
182
|
-
if (out.status !== 0)
|
|
183
|
-
return { error: out.stderr || "GitHub issue fetch failed", kind: "request" };
|
|
184
|
-
try {
|
|
185
|
-
const triple = parseTriple(
|
|
186
|
-
JSON.parse(out.stdout) as Record<string, unknown>,
|
|
187
|
-
id,
|
|
188
|
-
["number"],
|
|
189
|
-
["body"],
|
|
190
|
-
);
|
|
191
|
-
if (!triple) return { error: "unexpected GitHub issue shape", kind: "request" };
|
|
192
|
-
return { data: triple };
|
|
193
|
-
} catch {
|
|
194
|
-
return { error: "invalid GitHub issue response", kind: "request" };
|
|
195
|
-
}
|
|
196
143
|
};
|
|
197
144
|
|
|
198
|
-
/** Fetch a GitLab issue triple for context.read
|
|
145
|
+
/** Fetch a GitLab issue triple for context.read through the authenticated CLI. */
|
|
199
146
|
export const fetchGitLabIssueBody = async (
|
|
200
147
|
ref: string,
|
|
201
148
|
root: string,
|
|
@@ -203,35 +150,25 @@ export const fetchGitLabIssueBody = async (
|
|
|
203
150
|
): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
|
|
204
151
|
const id = parseGhIssue(ref);
|
|
205
152
|
if (!/^\d+$/.test(id)) return { error: `invalid GitLab issue ref "${ref}"`, kind: "input" };
|
|
206
|
-
const
|
|
153
|
+
const remote = (deps.remote ?? pushRemote)(root);
|
|
154
|
+
const repo = repoPathFromRemote(remote ?? "");
|
|
207
155
|
if (!repo) return { error: "no origin remote to resolve the project", kind: "input" };
|
|
208
|
-
const
|
|
156
|
+
const cfg = vcsConfig("load", root);
|
|
157
|
+
const host = (
|
|
158
|
+
deps.host ??
|
|
159
|
+
(cfg.ok && cfg.provider === "gitlab" ? cfg.gitlab?.host : null) ??
|
|
160
|
+
"gitlab.com"
|
|
161
|
+
).toLowerCase();
|
|
162
|
+
if (!remote || !hostingApiHostMatches(remote, host))
|
|
163
|
+
return { error: "hosting API host does not match the push destination", kind: "input" };
|
|
164
|
+
const repoArg = host === "gitlab.com" ? repo : `https://${host}/${repo}`;
|
|
165
|
+
return tryCliTriple(
|
|
209
166
|
deps.cli ?? defaultCli("glab", hostingCliAvailable("gitlab")),
|
|
210
|
-
|
|
167
|
+
"glab",
|
|
168
|
+
["issue", "view", id, "-R", repoArg, "-F", "json"],
|
|
211
169
|
root,
|
|
212
170
|
id,
|
|
213
171
|
["iid"],
|
|
214
172
|
["description"],
|
|
215
173
|
);
|
|
216
|
-
if (viaCli) return { data: viaCli };
|
|
217
|
-
const credentials = (deps.creds ?? ((r) => trackerCreds(r, "gitlab")))(root);
|
|
218
|
-
if ("error" in credentials) return { ...credentials, kind: "creds" as const };
|
|
219
|
-
const out = await (deps.request ?? trackerRequest)(
|
|
220
|
-
`${credentials.api}/projects/${encodeURIComponent(repo)}/issues/${id}`,
|
|
221
|
-
{ method: "GET", headers: { "PRIVATE-TOKEN": credentials.token } },
|
|
222
|
-
);
|
|
223
|
-
if (out.status !== 0)
|
|
224
|
-
return { error: out.stderr || "GitLab issue fetch failed", kind: "request" };
|
|
225
|
-
try {
|
|
226
|
-
const triple = parseTriple(
|
|
227
|
-
JSON.parse(out.stdout) as Record<string, unknown>,
|
|
228
|
-
id,
|
|
229
|
-
["iid"],
|
|
230
|
-
["description"],
|
|
231
|
-
);
|
|
232
|
-
if (!triple) return { error: "unexpected GitLab issue shape", kind: "request" };
|
|
233
|
-
return { data: triple };
|
|
234
|
-
} catch {
|
|
235
|
-
return { error: "invalid GitLab issue response", kind: "request" };
|
|
236
|
-
}
|
|
237
174
|
};
|