@brainervirus/workit-core 2.1.4 → 2.2.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/package.json +3 -2
- package/scripts/analyze-release-scope.ts +3 -3
- package/scripts/doctor-check.ts +1 -2
- package/scripts/sync-release-manifests.ts +4 -4
- package/src/core/authority.ts +8 -8
- package/src/core/auto-approval.ts +6 -6
- package/src/core/branch.ts +4 -4
- package/src/core/config-conversion.ts +1 -1
- package/src/core/config.ts +3 -3
- package/src/core/cutover.ts +6 -6
- package/src/core/doctor.ts +49 -3
- package/src/core/external-action-effects.ts +15 -15
- package/src/core/external-action.ts +16 -19
- package/src/core/logger.ts +2 -2
- package/src/core/methods.ts +5 -0
- package/src/core/policy-resolver.ts +9 -6
- package/src/core/registration.ts +3 -5
- package/src/core/repo-context.ts +1 -1
- package/src/core/setup.ts +2 -2
- package/src/core/skill-manifests.ts +1 -1
- package/src/core/store-lock.ts +301 -0
- package/src/core/support-matrix.ts +1 -1
- package/src/core/task-context.ts +3 -3
- package/src/core/task-contract.ts +129 -9
- package/src/core/task-engine.ts +159 -159
- package/src/core/task-evaluation.ts +3 -3
- package/src/core/task-store.ts +407 -89
- package/src/core/tracker-issues.ts +1 -1
- package/src/core/uninstall.ts +3 -3
- package/src/core/workers.ts +3 -3
- package/src/core/workspaces.ts +4 -6
- package/src/core/youtrack-tools.ts +1 -1
- package/src/core.ts +1 -1
- package/src/git/rev.ts +736 -0
- package/src/{core/session-context.ts → hooks/context.ts} +97 -10
- package/src/hooks/descriptor.ts +113 -0
- package/src/hooks/handle.ts +111 -0
- package/src/hooks/hosts/claude-code.ts +281 -0
- package/src/hooks/hosts/codex.ts +298 -0
- package/src/hooks/hosts/cursor.ts +321 -0
- package/src/hooks/hosts/fields.ts +55 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +80 -0
- package/src/hooks/index.ts +43 -0
- package/src/hooks/policy.ts +14 -0
- package/src/hooks/protocol.ts +89 -0
- package/src/hooks/run.ts +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainervirus/workit-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
|
|
6
6
|
"keywords": [
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"type": "module",
|
|
37
37
|
"main": "./src/core.ts",
|
|
38
38
|
"exports": {
|
|
39
|
+
"./hooks": "./src/hooks/index.ts",
|
|
39
40
|
"./src/*.ts": "./src/*.ts",
|
|
40
41
|
"./src/*": "./src/*.ts",
|
|
41
42
|
"./package.json": "./package.json"
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@openclaw/fs-safe": "0.8.1",
|
|
51
|
-
"zod": "4.5
|
|
52
|
+
"zod": "4.6.5"
|
|
52
53
|
},
|
|
53
54
|
"engines": {
|
|
54
55
|
"node": ">=24"
|
|
@@ -26,11 +26,11 @@ const g = (root: string, args: string[]): string =>
|
|
|
26
26
|
const SEMVER_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
27
27
|
|
|
28
28
|
export function latestTag(root = process.cwd()): string | null {
|
|
29
|
-
const
|
|
29
|
+
const tag = g(root, ["tag", "--list", "v*", "--sort=-v:refname"])
|
|
30
30
|
.split("\n")
|
|
31
31
|
.map((l) => l.trim())
|
|
32
|
-
.
|
|
33
|
-
return
|
|
32
|
+
.find((l) => SEMVER_TAG.test(l));
|
|
33
|
+
return tag ?? null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
type Level = "major" | "minor" | "patch";
|
package/scripts/doctor-check.ts
CHANGED
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
import { runDoctor } from "../src/core/doctor";
|
|
12
12
|
|
|
13
13
|
const hostArg = process.argv[2];
|
|
14
|
-
const host =
|
|
15
|
-
hostArg === "cursor" ? "cursor" : hostArg === "cli" ? "cli" : "opencode";
|
|
14
|
+
const host = hostArg === "cursor" ? "cursor" : hostArg === "cli" ? "cli" : "opencode";
|
|
16
15
|
const staleOnly = process.argv.includes("--stale");
|
|
17
16
|
const report = runDoctor({ host, installer: true });
|
|
18
17
|
|
|
@@ -31,7 +31,8 @@ export type ManifestSyncResult = { version: string; changed: string[] };
|
|
|
31
31
|
* are left byte-untouched and omitted from `changed`. Throws on a value that
|
|
32
32
|
* is not a plain release version (`latest`, branches, ranges).
|
|
33
33
|
*/
|
|
34
|
-
export function syncManifests(root: string, tagOrVersion: string): ManifestSyncResult {
|
|
34
|
+
export function syncManifests(root: string, tagOrVersion: string): ManifestSyncResult {
|
|
35
|
+
const match = /^v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/.exec(tagOrVersion.trim());
|
|
35
36
|
if (!match) throw new Error(`invalid version tag: ${JSON.stringify(tagOrVersion)}`);
|
|
36
37
|
const version = match[1];
|
|
37
38
|
const changed: string[] = [];
|
|
@@ -48,15 +49,14 @@ export function syncManifests(root: string, tagOrVersion: string): ManifestSyncR
|
|
|
48
49
|
|
|
49
50
|
/** The newest `v*` tag by descending semver refname order (empty repo throws). */
|
|
50
51
|
function latestReleaseTag(cwd?: string): string {
|
|
51
|
-
const
|
|
52
|
+
const tag = execFileSync("git", ["tag", "--list", "v*", "--sort=-v:refname"], {
|
|
52
53
|
cwd,
|
|
53
54
|
encoding: "utf8",
|
|
54
55
|
stdio: ["pipe", "pipe", "pipe"],
|
|
55
56
|
})
|
|
56
57
|
.split("\n")
|
|
57
58
|
.map((line) => line.trim())
|
|
58
|
-
.
|
|
59
|
-
const tag = out[0];
|
|
59
|
+
.find(Boolean);
|
|
60
60
|
if (!tag) throw new Error("no v* release tag found — run after the first semantic-release");
|
|
61
61
|
return tag;
|
|
62
62
|
}
|
package/src/core/authority.ts
CHANGED
|
@@ -641,7 +641,7 @@ const validateAction = (
|
|
|
641
641
|
if (input.binding && canonicalJson(input.binding) !== canonicalJson(decision.binding))
|
|
642
642
|
return failure("permission_denied", "action binding does not match the approved decision");
|
|
643
643
|
const content = verifyContentRefs(store, decision.binding);
|
|
644
|
-
if (!content.ok) return content
|
|
644
|
+
if (!content.ok) return content;
|
|
645
645
|
const requestedSteps = parseSteps(decision.binding.approvedContent);
|
|
646
646
|
if (requestedSteps === null)
|
|
647
647
|
return failure("invalid_input", "bounded action steps must be a unique non-empty array");
|
|
@@ -679,7 +679,7 @@ export function reserveAction(input: ReserveActionInput): Result<ActionReservati
|
|
|
679
679
|
input.expectedRevision,
|
|
680
680
|
(current, mutation) => {
|
|
681
681
|
const workspace = input.store.readWorkspace();
|
|
682
|
-
if (!workspace.ok) return workspace
|
|
682
|
+
if (!workspace.ok) return workspace;
|
|
683
683
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
684
684
|
if (workspace.data.revision !== input.expectedWorkspaceRevision)
|
|
685
685
|
return failure("revision_conflict", "workspace revision does not match", {
|
|
@@ -687,7 +687,7 @@ export function reserveAction(input: ReserveActionInput): Result<ActionReservati
|
|
|
687
687
|
actualWorkspaceRevision: workspace.data.revision,
|
|
688
688
|
});
|
|
689
689
|
const valid = validateAction(input.store, current, workspace.data.id, input, authority);
|
|
690
|
-
if (!valid.ok) return valid
|
|
690
|
+
if (!valid.ok) return valid;
|
|
691
691
|
const { entry, workflow } = valid.data;
|
|
692
692
|
const existing = entry.data.consumption;
|
|
693
693
|
if (existing?.state === "uncertain")
|
|
@@ -736,7 +736,7 @@ export function reserveAction(input: ReserveActionInput): Result<ActionReservati
|
|
|
736
736
|
},
|
|
737
737
|
input.native?.now,
|
|
738
738
|
);
|
|
739
|
-
if (!changed.ok) return changed
|
|
739
|
+
if (!changed.ok) return changed;
|
|
740
740
|
const workspace = input.store.readWorkspace();
|
|
741
741
|
if (!workspace.ok || !workspace.data)
|
|
742
742
|
return failure("recovery_required", "workspace disappeared after reservation");
|
|
@@ -793,7 +793,7 @@ export function settleAction(input: SettleActionInput): Result<Entry<Decision>>
|
|
|
793
793
|
input.taskRevision,
|
|
794
794
|
(current, mutation) => {
|
|
795
795
|
const workspace = input.store.readWorkspace();
|
|
796
|
-
if (!workspace.ok) return workspace
|
|
796
|
+
if (!workspace.ok) return workspace;
|
|
797
797
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
798
798
|
if (
|
|
799
799
|
!authorityMatches(authority, {
|
|
@@ -909,7 +909,7 @@ export function settleAction(input: SettleActionInput): Result<Entry<Decision>>
|
|
|
909
909
|
},
|
|
910
910
|
input.native?.now,
|
|
911
911
|
);
|
|
912
|
-
if (!changed.ok) return changed
|
|
912
|
+
if (!changed.ok) return changed;
|
|
913
913
|
const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
914
914
|
if (!entry) return failure("recovery_required", "settled decision disappeared");
|
|
915
915
|
if (outcomeResult) return outcomeResult;
|
|
@@ -943,7 +943,7 @@ export function reconcileAction(input: ReconcileActionInput): Result<Entry<Decis
|
|
|
943
943
|
input.taskRevision,
|
|
944
944
|
(current, mutation) => {
|
|
945
945
|
const workspace = input.store.readWorkspace();
|
|
946
|
-
if (!workspace.ok) return workspace
|
|
946
|
+
if (!workspace.ok) return workspace;
|
|
947
947
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
948
948
|
if (authority.workspaceId !== workspace.data.id)
|
|
949
949
|
return failure("permission_denied", "native reconciliation workspace does not match");
|
|
@@ -1022,7 +1022,7 @@ export function reconcileAction(input: ReconcileActionInput): Result<Entry<Decis
|
|
|
1022
1022
|
},
|
|
1023
1023
|
input.native?.now,
|
|
1024
1024
|
);
|
|
1025
|
-
if (!changed.ok) return changed
|
|
1025
|
+
if (!changed.ok) return changed;
|
|
1026
1026
|
const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
1027
1027
|
return entry
|
|
1028
1028
|
? success(changed.data.revision, input.workspaceRevision, entry)
|
|
@@ -172,7 +172,7 @@ export const verifyStandingApproval = (
|
|
|
172
172
|
if (task.status !== "active")
|
|
173
173
|
return failure("invalid_transition", "only active tasks can authorize actions");
|
|
174
174
|
const workspace = new TaskStore(root).readWorkspace();
|
|
175
|
-
if (!workspace.ok) return workspace
|
|
175
|
+
if (!workspace.ok) return workspace;
|
|
176
176
|
if (!workspace.data || !currentWriterOwnsTask(task, workspace.data, caller))
|
|
177
177
|
return failure("permission_denied", "standing approval requires the current writer session");
|
|
178
178
|
const standing = binding.standing;
|
|
@@ -200,8 +200,8 @@ export const verifyStandingApproval = (
|
|
|
200
200
|
)
|
|
201
201
|
return failure("permission_denied", "standing auto-approval is not live for this operation");
|
|
202
202
|
if (cls === "push") {
|
|
203
|
-
const
|
|
204
|
-
const provider =
|
|
203
|
+
const targetWorkspace = resolveWorkspaceFrom(target, configDir());
|
|
204
|
+
const provider = targetWorkspace?.vcs?.provider;
|
|
205
205
|
const account = resolved.account;
|
|
206
206
|
if (!provider || !account)
|
|
207
207
|
return failure(
|
|
@@ -217,7 +217,7 @@ export const verifyStandingApproval = (
|
|
|
217
217
|
session: { kind: "host", host: caller.host, handle: caller.actor },
|
|
218
218
|
workerId: null,
|
|
219
219
|
receipts: [standingReceiptFor(target, standing.workspace, cls)],
|
|
220
|
-
}
|
|
220
|
+
});
|
|
221
221
|
};
|
|
222
222
|
|
|
223
223
|
export type StandingAutoBinding = {
|
|
@@ -259,8 +259,8 @@ export const standingAutoApplies = (
|
|
|
259
259
|
const listed = store.listTasks();
|
|
260
260
|
const workspace = store.readWorkspace();
|
|
261
261
|
if (!listed.ok || !workspace.ok || !workspace.data) return null;
|
|
262
|
-
const task = listed.data.find((
|
|
263
|
-
currentWriterOwnsTask(
|
|
262
|
+
const task = listed.data.find((entry) =>
|
|
263
|
+
currentWriterOwnsTask(entry, workspace.data!, { host, actor }),
|
|
264
264
|
);
|
|
265
265
|
if (!task) return null;
|
|
266
266
|
const resolved = resolveAutoApproval(target);
|
package/src/core/branch.ts
CHANGED
|
@@ -132,8 +132,8 @@ export const branchPolicySnapshotFor = (workspaceRoot: string) => {
|
|
|
132
132
|
const source = resolved.provenance.branchPolicy;
|
|
133
133
|
const allowed = branchPolicy.allowed
|
|
134
134
|
.map((pattern) => `${pattern.source}/${pattern.flags}`)
|
|
135
|
-
.
|
|
136
|
-
const protectedRefs = [...branchPolicy.protected].map((name) => name.toLowerCase()).
|
|
135
|
+
.toSorted();
|
|
136
|
+
const protectedRefs = [...branchPolicy.protected].map((name) => name.toLowerCase()).toSorted();
|
|
137
137
|
const fingerprint = createHash("sha256")
|
|
138
138
|
.update(JSON.stringify({ allowed, protectedRefs, source }))
|
|
139
139
|
.digest("hex");
|
|
@@ -675,8 +675,8 @@ export const branchSetup = ({
|
|
|
675
675
|
targetExists = false;
|
|
676
676
|
}
|
|
677
677
|
if (!targetExists) {
|
|
678
|
-
const
|
|
679
|
-
if (!
|
|
678
|
+
const namePolicy = validateBranchNameFor(cwd, target);
|
|
679
|
+
if (!namePolicy.ok) return { error: namePolicy.error, phase: "preflight" };
|
|
680
680
|
}
|
|
681
681
|
if (!targetExists) {
|
|
682
682
|
const baseResolved = baseBranch(cwd);
|
|
@@ -70,7 +70,7 @@ export function previewConversion(input: ConversionInput): ConversionPreview {
|
|
|
70
70
|
allowed?: string[];
|
|
71
71
|
protected?: string[];
|
|
72
72
|
};
|
|
73
|
-
const preset =
|
|
73
|
+
const preset = bp.preset ?? "gitflow";
|
|
74
74
|
const merged = mergePreset(
|
|
75
75
|
preset,
|
|
76
76
|
{
|
package/src/core/config.ts
CHANGED
|
@@ -297,7 +297,7 @@ export const resolveBranchPolicy = (
|
|
|
297
297
|
integration: "pr" | "merge";
|
|
298
298
|
defaultTargetBranch: string;
|
|
299
299
|
} => {
|
|
300
|
-
const wp =
|
|
300
|
+
const wp = workspace?.branchPolicy ?? {};
|
|
301
301
|
// An invalid workspace preset (e.g. a typo) falls back to the global preset,
|
|
302
302
|
// preserving resolution order workspace > global > preset, instead of
|
|
303
303
|
// crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
|
|
@@ -345,12 +345,12 @@ export const resolveCommitPolicy = (
|
|
|
345
345
|
config: ToolkitConfig,
|
|
346
346
|
workspace?: { commitPolicy?: Record<string, any> } | null,
|
|
347
347
|
): { preset: CommitFlavorPreset; pattern?: string } => {
|
|
348
|
-
const wp =
|
|
348
|
+
const wp = workspace?.commitPolicy ?? {};
|
|
349
349
|
if (COMMIT_PRESETS.includes(String(wp.preset ?? ""))) {
|
|
350
350
|
return {
|
|
351
351
|
preset: wp.preset as CommitFlavorPreset,
|
|
352
352
|
...(typeof wp.pattern === "string" ? { pattern: wp.pattern } : {}),
|
|
353
353
|
};
|
|
354
354
|
}
|
|
355
|
-
return config.commitPolicy ??
|
|
355
|
+
return config.commitPolicy ?? { preset: "conventional" };
|
|
356
356
|
};
|
package/src/core/cutover.ts
CHANGED
|
@@ -217,8 +217,8 @@ const readDirNamesBounded = (
|
|
|
217
217
|
try {
|
|
218
218
|
for (;;) {
|
|
219
219
|
const entry = handle.readSync();
|
|
220
|
-
if (!entry) return { names: names.
|
|
221
|
-
if (names.length === limit) return { names: names.
|
|
220
|
+
if (!entry) return { names: names.toSorted(), truncated: false };
|
|
221
|
+
if (names.length === limit) return { names: names.toSorted(), truncated: true };
|
|
222
222
|
names.push(entry.name);
|
|
223
223
|
}
|
|
224
224
|
} finally {
|
|
@@ -596,8 +596,8 @@ const cutoverInventoryScopes = (
|
|
|
596
596
|
category: "project-state",
|
|
597
597
|
disposition: "preserve",
|
|
598
598
|
},
|
|
599
|
-
...legacyFlowInventoryTargets(paths.workspace, issues).map((
|
|
600
|
-
path,
|
|
599
|
+
...legacyFlowInventoryTargets(paths.workspace, issues).map((target) => ({
|
|
600
|
+
path: target,
|
|
601
601
|
category: "project-history" as const,
|
|
602
602
|
disposition: "preserve" as const,
|
|
603
603
|
})),
|
|
@@ -743,7 +743,7 @@ const buildCutoverInventory = (paths: ReturnType<typeof resolvePaths>): CutoverI
|
|
|
743
743
|
}
|
|
744
744
|
hash.update(`${entry.path}\0${entry.kind}\0${entry.bytes}\0${entry.digest ?? ""}\n`);
|
|
745
745
|
}
|
|
746
|
-
const issueList = [...issues].
|
|
746
|
+
const issueList = [...issues].toSorted();
|
|
747
747
|
for (const issue of issueList) hash.update(`!${issue}\n`);
|
|
748
748
|
return {
|
|
749
749
|
complete: issueList.length === 0,
|
|
@@ -936,7 +936,7 @@ const readBackupFiles = (
|
|
|
936
936
|
issues.push(`backup content failed verification: ${entry.path}`);
|
|
937
937
|
continue;
|
|
938
938
|
}
|
|
939
|
-
files.set(entry.path, { digest: digest
|
|
939
|
+
files.set(entry.path, { digest: digest, storagePath });
|
|
940
940
|
}
|
|
941
941
|
return { files, issues };
|
|
942
942
|
};
|
package/src/core/doctor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import os from "node:os";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { SUPPORT_MATRIX } from "./support-matrix";
|
|
22
|
+
import { inspectMetadataLock } from "./store-lock";
|
|
22
23
|
import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
|
|
23
24
|
import { EVENT } from "./boundary";
|
|
24
25
|
import { getDiagnosticLogger, isConfigObject } from "./config";
|
|
@@ -61,6 +62,7 @@ export type DoctorCheckId =
|
|
|
61
62
|
| "duplicate_registration"
|
|
62
63
|
| "malformed_config"
|
|
63
64
|
| "workspace_mismatch"
|
|
65
|
+
| "workspace_lock"
|
|
64
66
|
| "credential_metadata"
|
|
65
67
|
| "github_identity"
|
|
66
68
|
| "gitlab_identity"
|
|
@@ -110,6 +112,8 @@ export type DoctorOptions = {
|
|
|
110
112
|
/** Checkout containing packages/ (monorepo or share clone). */
|
|
111
113
|
dev?: string;
|
|
112
114
|
cwd?: string;
|
|
115
|
+
/** Workit store root for the lock check (default: WORKFLOW_WORKSPACE_ROOT, then cwd). */
|
|
116
|
+
workspaceRoot?: string;
|
|
113
117
|
opencodeConfig?: string;
|
|
114
118
|
/** OpenCode npm `@latest` package cache root (test seam). */
|
|
115
119
|
opencodePackageCacheDir?: string;
|
|
@@ -129,6 +133,7 @@ type Resolved = {
|
|
|
129
133
|
configDir: string;
|
|
130
134
|
stateDir: string;
|
|
131
135
|
cwd: string;
|
|
136
|
+
workspaceRoot: string;
|
|
132
137
|
dev: string | null;
|
|
133
138
|
opencodeConfig: string;
|
|
134
139
|
opencodePackageCacheDir: string;
|
|
@@ -179,6 +184,7 @@ const resolve = (options: DoctorOptions): Resolved => {
|
|
|
179
184
|
configDir,
|
|
180
185
|
stateDir,
|
|
181
186
|
cwd,
|
|
187
|
+
workspaceRoot: options.workspaceRoot ?? env.WORKFLOW_WORKSPACE_ROOT ?? cwd,
|
|
182
188
|
dev,
|
|
183
189
|
opencodeConfig:
|
|
184
190
|
options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
|
|
@@ -559,7 +565,8 @@ const registeredCursorLauncher = (res: Resolved): CursorLauncher | null | "inval
|
|
|
559
565
|
// no version abstraction.
|
|
560
566
|
const canonicalCursorHook = cursorHooksEntry("").command;
|
|
561
567
|
|
|
562
|
-
|
|
568
|
+
// Returns the registered hook command, null when absent, or "invalid".
|
|
569
|
+
const registeredCursorHook = (res: Resolved): string | null => {
|
|
563
570
|
const hooksFile = path.join(res.cursorPluginDir, "hooks", "hooks-cursor.json");
|
|
564
571
|
if (!existsSync(hooksFile)) return "invalid";
|
|
565
572
|
const config = readJson(hooksFile);
|
|
@@ -866,7 +873,7 @@ const checkOpenCodePackageCache = (res: Resolved): DoctorCheck & { registryProbe
|
|
|
866
873
|
detail: "no workit opencode pin — package cache not inspected",
|
|
867
874
|
};
|
|
868
875
|
}
|
|
869
|
-
const pin = entries[0]
|
|
876
|
+
const pin = entries[0];
|
|
870
877
|
if (pin.startsWith("file:") || pin.startsWith("git+file:")) {
|
|
871
878
|
return {
|
|
872
879
|
id: "stale_install",
|
|
@@ -1318,7 +1325,7 @@ const providerIdentityFinding = (
|
|
|
1318
1325
|
provider: "github" | "gitlab",
|
|
1319
1326
|
surfaces: IdentitySurface[],
|
|
1320
1327
|
): DoctorCheck => {
|
|
1321
|
-
const id
|
|
1328
|
+
const id: "github_identity" | "gitlab_identity" = `${provider}_identity`;
|
|
1322
1329
|
const label = provider === "github" ? "GitHub" : "GitLab";
|
|
1323
1330
|
const resolved = surfaces.filter(
|
|
1324
1331
|
(entry): entry is { surface: string; login: string } => typeof entry.login === "string",
|
|
@@ -1692,6 +1699,44 @@ const checkManagedContentConflict = (res: Resolved): DoctorCheck => {
|
|
|
1692
1699
|
};
|
|
1693
1700
|
};
|
|
1694
1701
|
|
|
1702
|
+
// The checkout's `.workit/metadata.lock`. Writes reclaim a stale lock by
|
|
1703
|
+
// themselves, so a stale lock is a warning with an explicit cleanup command.
|
|
1704
|
+
const BLOCKING_LOCK_WARN_MS = 30_000;
|
|
1705
|
+
const checkWorkspaceLock = (res: Resolved): DoctorCheck => {
|
|
1706
|
+
const lock = inspectMetadataLock(res.workspaceRoot);
|
|
1707
|
+
const fix = "workit doctor --fix-lock";
|
|
1708
|
+
if (lock.guard === "abandoned")
|
|
1709
|
+
return {
|
|
1710
|
+
id: "workspace_lock",
|
|
1711
|
+
status: "warn",
|
|
1712
|
+
detail: `abandoned lock reclaim guard at ${lock.path}.reclaim`,
|
|
1713
|
+
fix,
|
|
1714
|
+
};
|
|
1715
|
+
if (lock.state === "absent")
|
|
1716
|
+
return { id: "workspace_lock", status: "pass", detail: "no metadata lock held" };
|
|
1717
|
+
if (lock.state === "stale")
|
|
1718
|
+
return {
|
|
1719
|
+
id: "workspace_lock",
|
|
1720
|
+
status: "warn",
|
|
1721
|
+
detail: `stale metadata lock at ${lock.path}: ${lock.reason}`,
|
|
1722
|
+
fix,
|
|
1723
|
+
};
|
|
1724
|
+
// An unverifiable owner (other host, pid namespace, or an older Workit's
|
|
1725
|
+
// lock) that has blocked writes this long needs an explicit decision.
|
|
1726
|
+
if (lock.state === "unknown" && (lock.ageMs ?? 0) > BLOCKING_LOCK_WARN_MS)
|
|
1727
|
+
return {
|
|
1728
|
+
id: "workspace_lock",
|
|
1729
|
+
status: "warn",
|
|
1730
|
+
detail: `metadata lock at ${lock.path} has blocked writes for ${Math.round((lock.ageMs ?? 0) / 1000)}s and its owner cannot be verified: ${lock.reason}`,
|
|
1731
|
+
fix: "workit doctor --fix-lock --force --yes",
|
|
1732
|
+
};
|
|
1733
|
+
return {
|
|
1734
|
+
id: "workspace_lock",
|
|
1735
|
+
status: "pass",
|
|
1736
|
+
detail: `metadata lock ${lock.reason} (writes retry, then report busy)`,
|
|
1737
|
+
};
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1695
1740
|
const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
|
|
1696
1741
|
checkRuntime,
|
|
1697
1742
|
checkVersions,
|
|
@@ -1704,6 +1749,7 @@ const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
|
|
|
1704
1749
|
checkDuplicateRegistration,
|
|
1705
1750
|
checkMalformedConfig,
|
|
1706
1751
|
checkWorkspaceMismatch,
|
|
1752
|
+
checkWorkspaceLock,
|
|
1707
1753
|
checkCredentialMetadata,
|
|
1708
1754
|
checkGithubIdentity,
|
|
1709
1755
|
checkGitLabIdentity,
|
|
@@ -86,9 +86,9 @@ const stagedPaths = (root: string): string[] | null => {
|
|
|
86
86
|
const rename = /^(?:R|C)\d{3}$/.test(status);
|
|
87
87
|
const count = rename ? 2 : 1;
|
|
88
88
|
for (let offset = 0; offset < count; offset += 1) {
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
91
|
-
paths.push(
|
|
89
|
+
const filePath = tokens[index++];
|
|
90
|
+
if (!filePath) return null;
|
|
91
|
+
paths.push(filePath);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
return [...new Set(paths)];
|
|
@@ -258,8 +258,8 @@ export const assertLocalExternalActionWriter = (
|
|
|
258
258
|
const store = new TaskStore(root);
|
|
259
259
|
const listed = store.listTasks();
|
|
260
260
|
const workspace = store.readWorkspace();
|
|
261
|
-
if (!listed.ok) return listed
|
|
262
|
-
if (!workspace.ok) return workspace
|
|
261
|
+
if (!listed.ok) return listed;
|
|
262
|
+
if (!workspace.ok) return workspace;
|
|
263
263
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
264
264
|
if (
|
|
265
265
|
expectedWorkspaceRevision !== undefined &&
|
|
@@ -317,7 +317,7 @@ const branchCreationBaseline = (
|
|
|
317
317
|
};
|
|
318
318
|
const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
|
|
319
319
|
const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
|
|
320
|
-
const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ?
|
|
320
|
+
const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ? sha : null;
|
|
321
321
|
return {
|
|
322
322
|
base_branch: base,
|
|
323
323
|
target_exists: false,
|
|
@@ -717,7 +717,7 @@ const mergedBranch = (root: string, branch: string): Result<Record<string, strin
|
|
|
717
717
|
},
|
|
718
718
|
);
|
|
719
719
|
const live = remoteBranchTip(root, provider, auth.host, repo, branch);
|
|
720
|
-
if (!live.ok) return live
|
|
720
|
+
if (!live.ok) return live;
|
|
721
721
|
const tip = live.data;
|
|
722
722
|
if (!tip)
|
|
723
723
|
return failure("invalid_input", "remote branch is absent or could not be read", {
|
|
@@ -978,8 +978,8 @@ export const readYouTrackAction = async (
|
|
|
978
978
|
const creds = youTrackToken();
|
|
979
979
|
if ("error" in creds || creds.base !== baseUrl) return unknownHostingEvidence(operation);
|
|
980
980
|
const bounded = "&$top=100&$skip=0";
|
|
981
|
-
const read = async (
|
|
982
|
-
const result = await youTrackRequest(`${baseUrl}${
|
|
981
|
+
const read = async (endpoint: string): Promise<unknown[] | null> => {
|
|
982
|
+
const result = await youTrackRequest(`${baseUrl}${endpoint}${bounded}`, {
|
|
983
983
|
method: "GET",
|
|
984
984
|
token: creds.token,
|
|
985
985
|
});
|
|
@@ -1670,7 +1670,7 @@ const resolveExternalActionAtRoot = (
|
|
|
1670
1670
|
request,
|
|
1671
1671
|
descriptorPayload: { ...request.payload, resolved: checked.data },
|
|
1672
1672
|
})
|
|
1673
|
-
:
|
|
1673
|
+
: checked;
|
|
1674
1674
|
}
|
|
1675
1675
|
case "changelog.apply": {
|
|
1676
1676
|
const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
|
|
@@ -1707,7 +1707,7 @@ const resolveExternalActionAtRoot = (
|
|
|
1707
1707
|
return failure("invalid_input", "youtrack.time requires an explicit dateMs");
|
|
1708
1708
|
{
|
|
1709
1709
|
const available = youTrackWritePreflight();
|
|
1710
|
-
if (!available.ok) return available
|
|
1710
|
+
if (!available.ok) return available;
|
|
1711
1711
|
const baseUrl = youTrackBase();
|
|
1712
1712
|
const date = youTrackWorkDateMs(String(request.payload.dateMs));
|
|
1713
1713
|
if (!baseUrl)
|
|
@@ -1741,7 +1741,7 @@ const resolveExternalActionAtRoot = (
|
|
|
1741
1741
|
case "youtrack.update":
|
|
1742
1742
|
case "youtrack.meeting": {
|
|
1743
1743
|
const available = youTrackWritePreflight();
|
|
1744
|
-
if (!available.ok) return available
|
|
1744
|
+
if (!available.ok) return available;
|
|
1745
1745
|
const baseUrl = youTrackBase();
|
|
1746
1746
|
const date = youTrackWorkDateMs(
|
|
1747
1747
|
preservedDateMs === undefined ? "auto" : String(preservedDateMs),
|
|
@@ -1787,7 +1787,7 @@ const resolveExternalActionAtRoot = (
|
|
|
1787
1787
|
}
|
|
1788
1788
|
default:
|
|
1789
1789
|
return success(null, null, {
|
|
1790
|
-
request: request
|
|
1790
|
+
request: request,
|
|
1791
1791
|
descriptorPayload: (request as unknown as { payload: unknown }).payload,
|
|
1792
1792
|
});
|
|
1793
1793
|
}
|
|
@@ -1802,7 +1802,7 @@ export const resolveExternalActionRequest = (
|
|
|
1802
1802
|
preservedDateMs?: number,
|
|
1803
1803
|
): Result<ResolvedExternalAction> => {
|
|
1804
1804
|
const target = actionRoot(taskRoot, request);
|
|
1805
|
-
if (!target.ok) return target
|
|
1805
|
+
if (!target.ok) return target;
|
|
1806
1806
|
const bindTarget = bindsActionTarget(request.operation);
|
|
1807
1807
|
const normalized = bindTarget
|
|
1808
1808
|
? ({ ...request, payload: { ...request.payload, cwd: target.data } } as ExternalActionRequest)
|
|
@@ -1909,7 +1909,7 @@ export const executeConcreteExternalAction = async (
|
|
|
1909
1909
|
);
|
|
1910
1910
|
if (!sameDirectoryIdentity(root, coordinationRoot)) {
|
|
1911
1911
|
const targetWorkspace = new TaskStore(root).readWorkspace();
|
|
1912
|
-
if (!targetWorkspace.ok) return targetWorkspace
|
|
1912
|
+
if (!targetWorkspace.ok) return targetWorkspace;
|
|
1913
1913
|
const owner = targetWorkspace.data?.writer?.owner;
|
|
1914
1914
|
if (
|
|
1915
1915
|
owner &&
|
|
@@ -210,11 +210,10 @@ export const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
210
210
|
export type ExternalActionRequest = z.infer<typeof externalActionSchema>;
|
|
211
211
|
|
|
212
212
|
/** The complete external-action contract for hosts that register native tools. */
|
|
213
|
-
export const externalActionJsonSchema = (): z.core.JSONSchema.BaseSchema =>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}) as z.core.JSONSchema.BaseSchema;
|
|
213
|
+
export const externalActionJsonSchema = (): z.core.JSONSchema.BaseSchema => ({
|
|
214
|
+
type: "object",
|
|
215
|
+
...z.toJSONSchema(externalActionSchema, { target: "draft-2020-12" }),
|
|
216
|
+
});
|
|
218
217
|
|
|
219
218
|
export type ExternalActionOperation = ExternalActionRequest["operation"];
|
|
220
219
|
|
|
@@ -639,7 +638,7 @@ export const chainStepBinding = (
|
|
|
639
638
|
typeof descriptor.payload?.target_branch === "string"
|
|
640
639
|
? {
|
|
641
640
|
operation: "git.branch_setup",
|
|
642
|
-
target: descriptor.payload.target_branch
|
|
641
|
+
target: descriptor.payload.target_branch,
|
|
643
642
|
cwd: targetRoot,
|
|
644
643
|
}
|
|
645
644
|
: descriptor.operation === "hosting.pull_request"
|
|
@@ -947,7 +946,7 @@ export const createAuthorizedExternalActionRunner =
|
|
|
947
946
|
let latest: ExternalActionResult<T> | undefined;
|
|
948
947
|
for (const step of sequence) {
|
|
949
948
|
const binding = bind(operation);
|
|
950
|
-
if ("ok" in binding) return binding
|
|
949
|
+
if ("ok" in binding) return binding;
|
|
951
950
|
const targetRoot = externalActionTargetRoot(operation, coordinationRoot);
|
|
952
951
|
if (targetRoot === null)
|
|
953
952
|
return failure("capability_unavailable", "external action target could not be locked", {
|
|
@@ -960,7 +959,7 @@ export const createAuthorizedExternalActionRunner =
|
|
|
960
959
|
? (run) => new TaskStore(targetRoot).withExternalActionLock(run, true)
|
|
961
960
|
: undefined;
|
|
962
961
|
const result = await runAuthorizedExternalAction(
|
|
963
|
-
{ core, ...
|
|
962
|
+
{ core, ...binding, ...(step ? { step } : {}) },
|
|
964
963
|
(reservation) => effect(step, reservation),
|
|
965
964
|
withActionLock,
|
|
966
965
|
);
|
|
@@ -1039,12 +1038,10 @@ export const readNativeExternalActionObservation = (
|
|
|
1039
1038
|
actionRef: candidate.actionRef as Ref,
|
|
1040
1039
|
outcome: candidate.outcome as NativeExternalActionObservation["outcome"],
|
|
1041
1040
|
...(candidate.callId === undefined ? {} : { callId: candidate.callId }),
|
|
1042
|
-
...(candidate.taskRevision === undefined
|
|
1043
|
-
? {}
|
|
1044
|
-
: { taskRevision: candidate.taskRevision as Revision }),
|
|
1041
|
+
...(candidate.taskRevision === undefined ? {} : { taskRevision: candidate.taskRevision }),
|
|
1045
1042
|
...(candidate.workspaceRevision === undefined
|
|
1046
1043
|
? {}
|
|
1047
|
-
: { workspaceRevision: candidate.workspaceRevision
|
|
1044
|
+
: { workspaceRevision: candidate.workspaceRevision }),
|
|
1048
1045
|
};
|
|
1049
1046
|
};
|
|
1050
1047
|
|
|
@@ -1100,7 +1097,7 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
1100
1097
|
...(input.step ? { step: input.step } : {}),
|
|
1101
1098
|
observation: input.reserveObservation,
|
|
1102
1099
|
});
|
|
1103
|
-
if (!reserved.ok) return reserved
|
|
1100
|
+
if (!reserved.ok) return reserved;
|
|
1104
1101
|
|
|
1105
1102
|
const settle = (
|
|
1106
1103
|
outcome: "succeeded" | "not_started" | "unknown",
|
|
@@ -1149,7 +1146,7 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
1149
1146
|
effectValue = await effect(reserved.data);
|
|
1150
1147
|
} catch {
|
|
1151
1148
|
const settled = settleWithRefresh("unknown", settle("unknown"));
|
|
1152
|
-
if (!settled.ok) return settled
|
|
1149
|
+
if (!settled.ok) return settled;
|
|
1153
1150
|
return failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
1154
1151
|
operation: "external_action",
|
|
1155
1152
|
outcome: "unknown",
|
|
@@ -1161,7 +1158,7 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
1161
1158
|
"ok" in effectValue &&
|
|
1162
1159
|
typeof (effectValue as { ok?: unknown }).ok === "boolean"
|
|
1163
1160
|
) {
|
|
1164
|
-
const contract = effectValue
|
|
1161
|
+
const contract = effectValue;
|
|
1165
1162
|
if (!contract.ok) {
|
|
1166
1163
|
const preflight =
|
|
1167
1164
|
typeof contract.details === "object" &&
|
|
@@ -1169,13 +1166,13 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
1169
1166
|
(contract.details as Record<string, unknown>).outcome === "not_started";
|
|
1170
1167
|
const outcome = input.failureOutcome ?? (preflight ? "not_started" : "unknown");
|
|
1171
1168
|
const settled = settleWithRefresh(outcome, settle(outcome));
|
|
1172
|
-
if (!settled.ok) return settled
|
|
1169
|
+
if (!settled.ok) return settled;
|
|
1173
1170
|
return contract;
|
|
1174
1171
|
}
|
|
1175
1172
|
effectValue = contract.data;
|
|
1176
1173
|
}
|
|
1177
1174
|
const settled = settleWithRefresh("succeeded", settle("succeeded"));
|
|
1178
|
-
if (!settled.ok) return settled
|
|
1175
|
+
if (!settled.ok) return settled;
|
|
1179
1176
|
return {
|
|
1180
1177
|
ok: true,
|
|
1181
1178
|
schemaVersion: 1,
|
|
@@ -1194,14 +1191,14 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
1194
1191
|
outcome: "unknown",
|
|
1195
1192
|
});
|
|
1196
1193
|
const settled = settleWithRefresh("not_started", settle("not_started"));
|
|
1197
|
-
if (!settled.ok) return settled
|
|
1194
|
+
if (!settled.ok) return settled;
|
|
1198
1195
|
return failure("writer_conflict", "target checkout was unavailable before the effect", {
|
|
1199
1196
|
outcome: "not_started",
|
|
1200
1197
|
});
|
|
1201
1198
|
}
|
|
1202
1199
|
if (!effectStarted) {
|
|
1203
1200
|
const settled = settleWithRefresh("not_started", settle("not_started"));
|
|
1204
|
-
if (!settled.ok) return settled
|
|
1201
|
+
if (!settled.ok) return settled;
|
|
1205
1202
|
}
|
|
1206
1203
|
return result;
|
|
1207
1204
|
}
|
package/src/core/logger.ts
CHANGED
|
@@ -216,8 +216,8 @@ const pruneOldFiles = (dir: string): void => {
|
|
|
216
216
|
try {
|
|
217
217
|
const files = readdirSync(dir)
|
|
218
218
|
.filter((name) => DAY_FILE.test(name))
|
|
219
|
-
.
|
|
220
|
-
.
|
|
219
|
+
.toSorted()
|
|
220
|
+
.toReversed();
|
|
221
221
|
for (const file of files.slice(RETAINED_DAYS)) {
|
|
222
222
|
try {
|
|
223
223
|
unlinkSync(path.join(dir, file));
|
package/src/core/methods.ts
CHANGED
|
@@ -110,6 +110,11 @@ Start a record once for an explicit tracked objective; assess or reassess only
|
|
|
110
110
|
when policy selection or changed evidence/constraints requires it. Omitted
|
|
111
111
|
expectedRevision and expectedWorkspaceRevision use current values; explicit
|
|
112
112
|
values are still concurrency-checked, so never copy revisions between calls.
|
|
113
|
+
A busy result means another live Workit call holds the checkout lock: retry the
|
|
114
|
+
same call; it is not a recovery condition. A lock left by a dead process is
|
|
115
|
+
reclaimed on the next write, and \`workit doctor --fix-lock\` clears it on demand.
|
|
116
|
+
A revision_conflict on a call that omitted expectedRevision is contention too:
|
|
117
|
+
re-read the record and retry the call.
|
|
113
118
|
A solo edit does not need writer acquisition; use it when concurrent checkout
|
|
114
119
|
writers need coordination. Record only observed facts and checks. Evidence can
|
|
115
120
|
become stale when its bound candidate changes; reconcile findings against the
|