jg-plans 0.1.2 → 0.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/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/api/README.md +74 -11
- package/cli/README.md +23 -5
- package/dist/cli/arguments.js +18 -3
- package/dist/cli/commands.js +76 -17
- package/dist/cli/types.d.ts +3 -1
- package/dist/documents/fingerprints.d.ts +19 -0
- package/dist/documents/fingerprints.js +56 -0
- package/dist/documents/index.d.ts +2 -1
- package/dist/documents/index.js +1 -0
- package/dist/documents/milestones.js +44 -33
- package/dist/documents/plan.js +33 -68
- package/dist/documents/types.d.ts +22 -0
- package/dist/documents/utilities.d.ts +16 -2
- package/dist/documents/utilities.js +105 -4
- package/dist/edits/index.d.ts +2 -0
- package/dist/edits/index.js +2 -1
- package/dist/edits/preparations.d.ts +30 -0
- package/dist/edits/preparations.js +159 -0
- package/dist/edits/types.d.ts +14 -0
- package/dist/edits/utilities.d.ts +1 -0
- package/dist/edits/utilities.js +9 -2
- package/dist/errors/constants.d.ts +3 -1
- package/dist/errors/constants.js +4 -0
- package/dist/files/documents.d.ts +46 -0
- package/dist/files/documents.js +176 -0
- package/dist/files/index.d.ts +3 -1
- package/dist/files/index.js +2 -0
- package/dist/files/locks.d.ts +16 -0
- package/dist/files/locks.js +127 -0
- package/dist/files/mutations.d.ts +7 -3
- package/dist/files/mutations.js +32 -83
- package/dist/files/types.d.ts +19 -5
- package/dist/files/utilities.d.ts +4 -4
- package/dist/files/utilities.js +24 -7
- package/dist/index.d.ts +10 -8
- package/dist/index.js +5 -4
- package/dist/protocol/index.d.ts +4 -4
- package/dist/protocol/index.js +2 -2
- package/dist/protocol/types.d.ts +63 -0
- package/dist/protocol/types.js +53 -1
- package/docs/getting-started.md +2 -2
- package/docs/integrations.md +14 -36
- package/errors/README.md +6 -4
- package/examples/library.mjs +34 -2
- package/package.json +4 -3
- package/protocol/0.1/README.md +8 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hashValue } from 'jg-crypto';
|
|
3
|
+
import { parseMilestones, parsePlan } from "../documents/index.js";
|
|
4
|
+
import { planErrors } from "../errors/index.js";
|
|
5
|
+
import { PlanDocumentWriteSchema, PlanUpdateSchema, } from "../protocol/index.js";
|
|
6
|
+
import { applyEdits, decodeSource, encodeSource, lineEnding, milestoneEdits, planEdits, } from "./utilities.js";
|
|
7
|
+
/** Package-private expectation guard, also used on original read bytes by filesystem adapters. */
|
|
8
|
+
export const assertSourceExpectation = (source, expected) => {
|
|
9
|
+
if (expected === undefined)
|
|
10
|
+
return;
|
|
11
|
+
const actual = source === null ? null : hashValue(typeof source === 'string' ? encodeSource(source) : source);
|
|
12
|
+
if (actual !== expected)
|
|
13
|
+
throw planErrors.create('sourceChanged');
|
|
14
|
+
};
|
|
15
|
+
const prepared = (before, source, diagnostics, assignedMilestoneIds = []) => ({
|
|
16
|
+
source,
|
|
17
|
+
changed: before !== source,
|
|
18
|
+
sha256: hashValue(encodeSource(source)),
|
|
19
|
+
diagnostics,
|
|
20
|
+
assignedMilestoneIds,
|
|
21
|
+
});
|
|
22
|
+
const validateSet = (observation, set) => {
|
|
23
|
+
if (observation.diagnostics.some((item) => item.code === 'INVALID_YAML' || item.code === 'UNTERMINATED_FRONTMATTER'))
|
|
24
|
+
throw planErrors.create('unsafeEdit', { data: { reason: 'invalid-serialized-frontmatter' } });
|
|
25
|
+
for (const [key, value] of Object.entries(set))
|
|
26
|
+
if (!Object.is(observation.metadata[key], value))
|
|
27
|
+
throw planErrors.create('unsafeEdit', {
|
|
28
|
+
data: { reason: 'unsupported-serialized-value', field: key },
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Prepare one guarded metadata/marker patch entirely in memory, without policy updates or I/O.
|
|
33
|
+
* @param source Original selected document text.
|
|
34
|
+
* @param patch Explicit narrow patch and optional observed exact-byte SHA-256.
|
|
35
|
+
* @returns Prepared source, digest, diagnostics, and whether bytes would change.
|
|
36
|
+
* @throws
|
|
37
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
38
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
39
|
+
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
40
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
41
|
+
*/
|
|
42
|
+
export const preparePlanUpdate = (source, patch) => {
|
|
43
|
+
const validated = PlanUpdateSchema.safeParse(patch);
|
|
44
|
+
if (typeof source !== 'string' || !validated.success)
|
|
45
|
+
throw planErrors.create('invalidInput');
|
|
46
|
+
const request = validated.data;
|
|
47
|
+
const bytes = encodeSource(source);
|
|
48
|
+
assertSourceExpectation(source, request.expectedSha256);
|
|
49
|
+
const edits = request.document === 'plan' ? planEdits(source, request) : milestoneEdits(source, request);
|
|
50
|
+
const after = decodeSource(applyEdits(bytes, source, edits));
|
|
51
|
+
const observation = request.document === 'plan' ? parsePlan(after) : parseMilestones(after);
|
|
52
|
+
if (request.document === 'plan')
|
|
53
|
+
validateSet(observation, request.set);
|
|
54
|
+
return prepared(source, after, observation.diagnostics);
|
|
55
|
+
};
|
|
56
|
+
const assignMilestoneIds = (source) => {
|
|
57
|
+
const observation = parseMilestones(source);
|
|
58
|
+
if (observation.frontmatter && !observation.frontmatter.closed)
|
|
59
|
+
throw planErrors.create('unsafeEdit', { data: { reason: 'unterminated-frontmatter' } });
|
|
60
|
+
const ambiguous = new Set([
|
|
61
|
+
'INVALID_MILESTONE_ID',
|
|
62
|
+
'DUPLICATE_ID_MARKER',
|
|
63
|
+
'DUPLICATE_ID',
|
|
64
|
+
'DUPLICATE_ORDINAL',
|
|
65
|
+
'INVALID_ORDINAL',
|
|
66
|
+
]);
|
|
67
|
+
if (observation.diagnostics.some((item) => ambiguous.has(item.code)))
|
|
68
|
+
throw planErrors.create('unsafeEdit', { data: { reason: 'ambiguous-milestone-identities' } });
|
|
69
|
+
const ids = new Set(observation.milestones.flatMap((entry) => (entry.id ? [entry.id.toLowerCase()] : [])));
|
|
70
|
+
const assigned = [];
|
|
71
|
+
const changes = [];
|
|
72
|
+
for (const entry of observation.milestones) {
|
|
73
|
+
if (entry.id !== null)
|
|
74
|
+
continue;
|
|
75
|
+
const status = entry.statusMarkers[0];
|
|
76
|
+
if (!status ||
|
|
77
|
+
!status.value ||
|
|
78
|
+
entry.statusMarkers.length !== 1 ||
|
|
79
|
+
source.slice(entry.headingEnd, status.lineStart).trim())
|
|
80
|
+
throw planErrors.create('unsafeEdit', { data: { reason: 'noncanonical-id-insertion' } });
|
|
81
|
+
let id;
|
|
82
|
+
do {
|
|
83
|
+
id = randomUUID();
|
|
84
|
+
} while (ids.has(id.toLowerCase()));
|
|
85
|
+
ids.add(id.toLowerCase());
|
|
86
|
+
assigned.push({ ordinal: entry.ordinal, id });
|
|
87
|
+
changes.push({ select: { ordinal: entry.ordinal }, set: { id } });
|
|
88
|
+
}
|
|
89
|
+
if (!changes.length)
|
|
90
|
+
return { source, assigned };
|
|
91
|
+
const after = decodeSource(applyEdits(encodeSource(source), source, milestoneEdits(source, { document: 'milestones', changes })));
|
|
92
|
+
return { source: after, assigned };
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Prepare guarded body/metadata replacement or explicit milestone source/ID assignment in memory.
|
|
96
|
+
* A null source is accepted only for explicit absent milestone creation. No timestamps, revisions,
|
|
97
|
+
* identities, or completion are inferred; missing IDs are assigned only when requested.
|
|
98
|
+
* @param source Original selected document text, or null for absent milestones.
|
|
99
|
+
* @param input Explicit document replacement and required source expectation.
|
|
100
|
+
* @returns The exact source to persist once, its digest, diagnostics, and generated IDs.
|
|
101
|
+
* @throws
|
|
102
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
103
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
104
|
+
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
105
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
106
|
+
*/
|
|
107
|
+
export const preparePlanDocumentWrite = (source, input) => {
|
|
108
|
+
const validated = PlanDocumentWriteSchema.safeParse(input);
|
|
109
|
+
if ((source !== null && typeof source !== 'string') || !validated.success)
|
|
110
|
+
throw planErrors.create('invalidInput');
|
|
111
|
+
const request = validated.data;
|
|
112
|
+
if (request.document === 'plan' && source === null)
|
|
113
|
+
throw planErrors.create('invalidInput');
|
|
114
|
+
if (source !== null)
|
|
115
|
+
encodeSource(source);
|
|
116
|
+
assertSourceExpectation(source, request.expectedSha256);
|
|
117
|
+
if (request.document === 'milestones') {
|
|
118
|
+
encodeSource(request.source);
|
|
119
|
+
const assigned = request.assignMissingIds
|
|
120
|
+
? assignMilestoneIds(request.source)
|
|
121
|
+
: { source: request.source, assigned: [] };
|
|
122
|
+
return prepared(source, assigned.source, parseMilestones(assigned.source).diagnostics, assigned.assigned);
|
|
123
|
+
}
|
|
124
|
+
const before = source;
|
|
125
|
+
const observation = parsePlan(before);
|
|
126
|
+
if (observation.frontmatter && !observation.frontmatter.closed)
|
|
127
|
+
throw planErrors.create('unsafeEdit', { data: { reason: 'unterminated-frontmatter' } });
|
|
128
|
+
encodeSource(request.body);
|
|
129
|
+
const bom = before.startsWith('\uFEFF') ? 1 : 0;
|
|
130
|
+
const bodyStart = observation.frontmatter?.end ?? bom;
|
|
131
|
+
// A metadata-free reader includes the encoding marker in body; never duplicate that marker.
|
|
132
|
+
const body = !observation.frontmatter && bom && request.body.startsWith('\uFEFF')
|
|
133
|
+
? request.body.slice(1)
|
|
134
|
+
: request.body;
|
|
135
|
+
const eol = lineEnding(before);
|
|
136
|
+
const edits = request.set ? planEdits(before, { document: 'plan', set: request.set }) : [];
|
|
137
|
+
const insertion = !observation.frontmatter
|
|
138
|
+
? edits.findIndex((edit) => edit.start === bodyStart && edit.end === bodyStart)
|
|
139
|
+
: -1;
|
|
140
|
+
let replacement = body;
|
|
141
|
+
if (insertion >= 0) {
|
|
142
|
+
const prefix = edits.splice(insertion, 1)[0].replacement;
|
|
143
|
+
replacement = prefix + (body && !/[\r\n]$/.test(prefix) ? eol : '') + body;
|
|
144
|
+
}
|
|
145
|
+
else if (observation.frontmatter &&
|
|
146
|
+
body &&
|
|
147
|
+
bodyStart === before.length &&
|
|
148
|
+
!/[\r\n]$/.test(before)) {
|
|
149
|
+
replacement = eol + body;
|
|
150
|
+
}
|
|
151
|
+
edits.push({ start: bodyStart, end: before.length, replacement });
|
|
152
|
+
const after = decodeSource(applyEdits(encodeSource(before), before, edits));
|
|
153
|
+
const result = parsePlan(after);
|
|
154
|
+
if (!observation.frontmatter && !request.set && result.frontmatter)
|
|
155
|
+
throw planErrors.create('unsafeEdit', { data: { reason: 'body-would-create-frontmatter' } });
|
|
156
|
+
if (request.set)
|
|
157
|
+
validateSet(result, request.set);
|
|
158
|
+
return prepared(before, after, result.diagnostics);
|
|
159
|
+
};
|
package/dist/edits/types.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
import type { IDiagnostic } from '../protocol/index.ts';
|
|
1
2
|
/** Internal UTF-16 source spans, converted to original byte offsets before replacement. */
|
|
2
3
|
export type ITextEdit = {
|
|
3
4
|
start: number;
|
|
4
5
|
end: number;
|
|
5
6
|
replacement: string;
|
|
6
7
|
};
|
|
8
|
+
/** IDs generated only by an explicit missing-ID assignment request. */
|
|
9
|
+
export type IAssignedMilestoneId = {
|
|
10
|
+
ordinal: number;
|
|
11
|
+
id: string;
|
|
12
|
+
};
|
|
13
|
+
/** In-memory preparation performs no filesystem operation or implicit policy update. */
|
|
14
|
+
export type IPreparedPlanEdit = {
|
|
15
|
+
source: string;
|
|
16
|
+
changed: boolean;
|
|
17
|
+
sha256: string;
|
|
18
|
+
diagnostics: IDiagnostic[];
|
|
19
|
+
assignedMilestoneIds: IAssignedMilestoneId[];
|
|
20
|
+
};
|
|
@@ -8,6 +8,7 @@ export declare const decodeSource: (bytes: Uint8Array) => string;
|
|
|
8
8
|
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
9
9
|
*/
|
|
10
10
|
export declare const encodeSource: (source: string) => Buffer;
|
|
11
|
+
export declare const lineEnding: (source: string) => string;
|
|
11
12
|
/**
|
|
12
13
|
* Prepare supported metadata edits without changing unrelated YAML or Markdown.
|
|
13
14
|
* @throws
|
package/dist/edits/utilities.js
CHANGED
|
@@ -2,7 +2,14 @@ import { isMap, isScalar, parseDocument, Scalar, stringify } from 'yaml';
|
|
|
2
2
|
import { parseMilestones, parsePlan, sourceLines } from "../documents/index.js";
|
|
3
3
|
import { planErrors } from "../errors/index.js";
|
|
4
4
|
/** Decode strictly while retaining an existing BOM character. */
|
|
5
|
-
export const decodeSource = (bytes) =>
|
|
5
|
+
export const decodeSource = (bytes) => {
|
|
6
|
+
try {
|
|
7
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw planErrors.create('unsupportedEncoding');
|
|
11
|
+
}
|
|
12
|
+
};
|
|
6
13
|
/**
|
|
7
14
|
* Reject unpaired surrogates rather than letting UTF-8 encoding replace them.
|
|
8
15
|
* @throws
|
|
@@ -13,7 +20,7 @@ export const encodeSource = (source) => {
|
|
|
13
20
|
throw planErrors.create('unsupportedEncoding');
|
|
14
21
|
return Buffer.from(source, 'utf8');
|
|
15
22
|
};
|
|
16
|
-
const lineEnding = (source) => /\r\n|\n|\r/.exec(source)?.[0] ?? '\n';
|
|
23
|
+
export const lineEnding = (source) => /\r\n|\n|\r/.exec(source)?.[0] ?? '\n';
|
|
17
24
|
const scalarText = (value, style) => {
|
|
18
25
|
if (typeof value === 'number')
|
|
19
26
|
return String(value);
|
|
@@ -7,5 +7,7 @@ export declare const ERRORS: {
|
|
|
7
7
|
readonly DESTINATION_EXISTS: "JG_PLANS_DESTINATION_EXISTS";
|
|
8
8
|
readonly SOURCE_CHANGED: "JG_PLANS_SOURCE_CHANGED";
|
|
9
9
|
readonly WRITE_FAILED: "JG_PLANS_WRITE_FAILED";
|
|
10
|
+
readonly LOCK_BUSY: "JG_PLANS_LOCK_BUSY";
|
|
11
|
+
readonly LOCK_FAILED: "JG_PLANS_LOCK_FAILED";
|
|
10
12
|
};
|
|
11
|
-
export declare const planErrors: import("jg-exception").IErrorCatalog<"invalidInput" | "readFailed" | "unsupportedEncoding" | "unsafeEdit" | "destinationExists" | "sourceChanged" | "writeFailed">;
|
|
13
|
+
export declare const planErrors: import("jg-exception").IErrorCatalog<"invalidInput" | "readFailed" | "unsupportedEncoding" | "unsafeEdit" | "destinationExists" | "sourceChanged" | "writeFailed" | "lockBusy" | "lockFailed">;
|
package/dist/errors/constants.js
CHANGED
|
@@ -8,6 +8,8 @@ export const ERRORS = {
|
|
|
8
8
|
DESTINATION_EXISTS: 'JG_PLANS_DESTINATION_EXISTS',
|
|
9
9
|
SOURCE_CHANGED: 'JG_PLANS_SOURCE_CHANGED',
|
|
10
10
|
WRITE_FAILED: 'JG_PLANS_WRITE_FAILED',
|
|
11
|
+
LOCK_BUSY: 'JG_PLANS_LOCK_BUSY',
|
|
12
|
+
LOCK_FAILED: 'JG_PLANS_LOCK_FAILED',
|
|
11
13
|
};
|
|
12
14
|
// Underlying errors may contain user paths or input snippets. Stable public messages
|
|
13
15
|
// omit that text; causes retain diagnostics, and safe details are explicit data.
|
|
@@ -31,4 +33,6 @@ export const planErrors = defineErrorCatalog({
|
|
|
31
33
|
message: 'The document changed before replacement',
|
|
32
34
|
},
|
|
33
35
|
writeFailed: { code: ERRORS.WRITE_FAILED, message: 'The plan document could not be written' },
|
|
36
|
+
lockBusy: { code: ERRORS.LOCK_BUSY, message: 'The plan document is locked' },
|
|
37
|
+
lockFailed: { code: ERRORS.LOCK_FAILED, message: 'The plan document lock operation failed' },
|
|
34
38
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type IPreparedPlanEdit } from '../edits/index.ts';
|
|
2
|
+
import { type IPlanDocumentWrite, type IReadOptions, type IRemoveMilestones } from '../protocol/index.ts';
|
|
3
|
+
import type { IPlanDocumentWriteResult, IRemoveMilestonesResult } from './types.ts';
|
|
4
|
+
export type IOwnedFile = {
|
|
5
|
+
path: string;
|
|
6
|
+
dev: bigint;
|
|
7
|
+
ino: bigint;
|
|
8
|
+
bytes: Buffer;
|
|
9
|
+
};
|
|
10
|
+
/** Exclusive creation shared with createPlan; ownership is recorded before any write. */
|
|
11
|
+
export declare const writeExclusive: (root: string, path: string, bytes: Buffer, owned: IOwnedFile[]) => Promise<void>;
|
|
12
|
+
/** Package-private validation shared by all existing-directory mutations. */
|
|
13
|
+
export declare const validateMutationOptions: (directory: string, options: IReadOptions) => void;
|
|
14
|
+
/** Read exact bytes and guard them before decoding or preparing any new content. */
|
|
15
|
+
export declare const readMutationSource: (directory: string, document: "plan" | "milestones", options: IReadOptions, expected: string | null | undefined) => Promise<Buffer | null>;
|
|
16
|
+
/** Persist one prepared result once; null expectations use exclusive creation. */
|
|
17
|
+
export declare const persistPrepared: (directory: string, document: "plan" | "milestones", before: Buffer | null, prepared: IPreparedPlanEdit) => Promise<IPlanDocumentWriteResult>;
|
|
18
|
+
/**
|
|
19
|
+
* Guard and replace a plan body/core metadata or complete milestone document under its shared lock.
|
|
20
|
+
* Only the selected document is read; unrelated malformed documents do not block an operation.
|
|
21
|
+
* Expected null means absent milestones, never overwriting an existing file. No policy is inferred.
|
|
22
|
+
* @throws
|
|
23
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
24
|
+
* - JG_PLANS_READ_FAILED: The selected document could not be read
|
|
25
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
26
|
+
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
27
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
28
|
+
* - JG_PLANS_WRITE_FAILED: The plan document could not be written
|
|
29
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
30
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
31
|
+
*/
|
|
32
|
+
export declare const writePlanDocument: (directory: string, input: IPlanDocumentWrite, options?: IReadOptions) => Promise<IPlanDocumentWriteResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Remove only milestones.md with an explicit exact-byte expectation under its shared lock.
|
|
35
|
+
* Missing or changed files fail. No plan, directory, or history is removed. A final check detects
|
|
36
|
+
* observed external edits; nonparticipating writers still have a check-to-unlink race.
|
|
37
|
+
* @throws
|
|
38
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
39
|
+
* - JG_PLANS_READ_FAILED: The selected document could not be read
|
|
40
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
41
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
42
|
+
* - JG_PLANS_WRITE_FAILED: The plan document could not be written
|
|
43
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
44
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
45
|
+
*/
|
|
46
|
+
export declare const removeMilestones: (directory: string, input: IRemoveMilestones, options?: IReadOptions) => Promise<IRemoveMilestonesResult>;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { lstat, open, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getErrorCode } from 'jg-exception';
|
|
4
|
+
import { assertNoLinksBelow, readBoundedFile, writeFileAtomicIfUnchanged } from 'jg-fs';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { decodeSource, encodeSource, preparePlanDocumentWrite, } from "../edits/index.js";
|
|
7
|
+
import { assertSourceExpectation } from "../edits/preparations.js";
|
|
8
|
+
import { planErrors } from "../errors/index.js";
|
|
9
|
+
import { DEFAULT_MAX_BYTES, PlanDocumentWriteSchema, ReadOptionsSchema, RemoveMilestonesSchema, } from "../protocol/index.js";
|
|
10
|
+
import { withPlanDocumentLocks } from "./locks.js";
|
|
11
|
+
/** Exclusive creation shared with createPlan; ownership is recorded before any write. */
|
|
12
|
+
export const writeExclusive = async (root, path, bytes, owned) => {
|
|
13
|
+
await assertNoLinksBelow(root, path);
|
|
14
|
+
const handle = await open(path, 'wx');
|
|
15
|
+
try {
|
|
16
|
+
const stat = await handle.stat({ bigint: true });
|
|
17
|
+
owned.push({ path, dev: stat.dev, ino: stat.ino, bytes });
|
|
18
|
+
await handle.writeFile(bytes);
|
|
19
|
+
await handle.sync();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await handle.close();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** Package-private validation shared by all existing-directory mutations. */
|
|
26
|
+
export const validateMutationOptions = (directory, options) => {
|
|
27
|
+
if (!z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.refine((value) => !value.includes('\0'))
|
|
31
|
+
.safeParse(directory).success ||
|
|
32
|
+
!ReadOptionsSchema.safeParse(options).success)
|
|
33
|
+
throw planErrors.create('invalidInput');
|
|
34
|
+
};
|
|
35
|
+
/** Read exact bytes and guard them before decoding or preparing any new content. */
|
|
36
|
+
export const readMutationSource = async (directory, document, options, expected) => {
|
|
37
|
+
const path = join(directory, `${document}.md`);
|
|
38
|
+
let before;
|
|
39
|
+
try {
|
|
40
|
+
await assertNoLinksBelow(directory, path);
|
|
41
|
+
before = await readBoundedFile(path, options.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
const reason = getErrorCode(cause);
|
|
45
|
+
if (expected !== undefined && (reason === 'NON_EXISTENT_FILE' || reason === 'ENOENT'))
|
|
46
|
+
before = null;
|
|
47
|
+
else
|
|
48
|
+
throw planErrors.create('readFailed', { cause, ...(reason ? { data: { reason } } : {}) });
|
|
49
|
+
}
|
|
50
|
+
assertSourceExpectation(before, expected);
|
|
51
|
+
return before;
|
|
52
|
+
};
|
|
53
|
+
/** Persist one prepared result once; null expectations use exclusive creation. */
|
|
54
|
+
export const persistPrepared = async (directory, document, before, prepared) => {
|
|
55
|
+
if (prepared.changed) {
|
|
56
|
+
try {
|
|
57
|
+
const path = join(directory, `${document}.md`);
|
|
58
|
+
await assertNoLinksBelow(directory, path);
|
|
59
|
+
if (before === null) {
|
|
60
|
+
const owned = [];
|
|
61
|
+
try {
|
|
62
|
+
await writeExclusive(directory, path, encodeSource(prepared.source), owned);
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
const cleanupFailedPaths = [];
|
|
66
|
+
for (const file of owned) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = await lstat(file.path, { bigint: true });
|
|
69
|
+
const bytes = await readBoundedFile(file.path, Math.max(1, file.bytes.length));
|
|
70
|
+
if (!stat.isFile() ||
|
|
71
|
+
stat.dev !== file.dev ||
|
|
72
|
+
stat.ino !== file.ino ||
|
|
73
|
+
!bytes.equals(file.bytes.subarray(0, bytes.length)))
|
|
74
|
+
cleanupFailedPaths.push(file.path);
|
|
75
|
+
else
|
|
76
|
+
await unlink(file.path);
|
|
77
|
+
}
|
|
78
|
+
catch (cleanupCause) {
|
|
79
|
+
if (getErrorCode(cleanupCause) !== 'ENOENT' &&
|
|
80
|
+
getErrorCode(cleanupCause) !== 'NON_EXISTENT_FILE')
|
|
81
|
+
cleanupFailedPaths.push(file.path);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (getErrorCode(cause) === 'EEXIST')
|
|
85
|
+
throw planErrors.create('sourceChanged', { cause });
|
|
86
|
+
throw planErrors.create('writeFailed', {
|
|
87
|
+
cause,
|
|
88
|
+
...(cleanupFailedPaths.length ? { data: { cleanupFailedPaths } } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
await writeFileAtomicIfUnchanged(path, encodeSource(prepared.source), before);
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
if (getErrorCode(cause)?.startsWith('JG_PLANS_'))
|
|
97
|
+
throw cause;
|
|
98
|
+
if (getErrorCode(cause) === 'FILE_CHANGED')
|
|
99
|
+
throw planErrors.create('sourceChanged', { cause });
|
|
100
|
+
throw planErrors.create('writeFailed', { cause });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
directory,
|
|
105
|
+
document,
|
|
106
|
+
changed: prepared.changed,
|
|
107
|
+
sha256: prepared.sha256,
|
|
108
|
+
diagnostics: prepared.diagnostics,
|
|
109
|
+
assignedMilestoneIds: prepared.assignedMilestoneIds,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Guard and replace a plan body/core metadata or complete milestone document under its shared lock.
|
|
114
|
+
* Only the selected document is read; unrelated malformed documents do not block an operation.
|
|
115
|
+
* Expected null means absent milestones, never overwriting an existing file. No policy is inferred.
|
|
116
|
+
* @throws
|
|
117
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
118
|
+
* - JG_PLANS_READ_FAILED: The selected document could not be read
|
|
119
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
120
|
+
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
121
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
122
|
+
* - JG_PLANS_WRITE_FAILED: The plan document could not be written
|
|
123
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
124
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
125
|
+
*/
|
|
126
|
+
export const writePlanDocument = async (directory, input, options = {}) => {
|
|
127
|
+
validateMutationOptions(directory, options);
|
|
128
|
+
const parsed = PlanDocumentWriteSchema.safeParse(input);
|
|
129
|
+
if (!parsed.success)
|
|
130
|
+
throw planErrors.create('invalidInput');
|
|
131
|
+
const request = parsed.data;
|
|
132
|
+
return withPlanDocumentLocks(directory, [request.document], async (selected) => {
|
|
133
|
+
const before = await readMutationSource(selected, request.document, options, request.expectedSha256);
|
|
134
|
+
const prepared = preparePlanDocumentWrite(before === null ? null : decodeSource(before), request);
|
|
135
|
+
return persistPrepared(selected, request.document, before, prepared);
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Remove only milestones.md with an explicit exact-byte expectation under its shared lock.
|
|
140
|
+
* Missing or changed files fail. No plan, directory, or history is removed. A final check detects
|
|
141
|
+
* observed external edits; nonparticipating writers still have a check-to-unlink race.
|
|
142
|
+
* @throws
|
|
143
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
144
|
+
* - JG_PLANS_READ_FAILED: The selected document could not be read
|
|
145
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
146
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
147
|
+
* - JG_PLANS_WRITE_FAILED: The plan document could not be written
|
|
148
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
149
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
150
|
+
*/
|
|
151
|
+
export const removeMilestones = async (directory, input, options = {}) => {
|
|
152
|
+
validateMutationOptions(directory, options);
|
|
153
|
+
const parsed = RemoveMilestonesSchema.safeParse(input);
|
|
154
|
+
if (!parsed.success)
|
|
155
|
+
throw planErrors.create('invalidInput');
|
|
156
|
+
return withPlanDocumentLocks(directory, ['milestones'], async (selected) => {
|
|
157
|
+
const before = await readMutationSource(selected, 'milestones', options, parsed.data.expectedSha256);
|
|
158
|
+
decodeSource(before);
|
|
159
|
+
await readMutationSource(selected, 'milestones', options, parsed.data.expectedSha256);
|
|
160
|
+
try {
|
|
161
|
+
await unlink(join(selected, 'milestones.md'));
|
|
162
|
+
}
|
|
163
|
+
catch (cause) {
|
|
164
|
+
throw planErrors.create(getErrorCode(cause) === 'ENOENT' ? 'sourceChanged' : 'writeFailed', {
|
|
165
|
+
cause,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
directory: selected,
|
|
170
|
+
document: 'milestones',
|
|
171
|
+
changed: true,
|
|
172
|
+
sha256: null,
|
|
173
|
+
diagnostics: [],
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
};
|
package/dist/files/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { inspectPlan } from './utilities.ts';
|
|
2
2
|
export { createPlan, updatePlan } from './mutations.ts';
|
|
3
|
+
export { writePlanDocument, removeMilestones } from './documents.ts';
|
|
4
|
+
export { withPlanDocumentLocks } from './locks.ts';
|
|
3
5
|
export type { IDocumentRead, IPlanInspection } from './types.ts';
|
|
4
|
-
export type { IPlanCreation, IPlanUpdateResult } from './types.ts';
|
|
6
|
+
export type { IPlanCreation, IPlanUpdateResult, IPlanDocumentWriteResult, IRemoveMilestonesResult, } from './types.ts';
|
package/dist/files/index.js
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize participating local-filesystem writers using canonical document lock directories.
|
|
3
|
+
* Acquire plan before milestones and release in reverse order. Busy/dead owners fail immediately;
|
|
4
|
+
* recovery is an explicit operator decision. Use preparation and your own persistence inside the
|
|
5
|
+
* callback; nesting a filesystem mutation here would attempt to acquire the same lock again.
|
|
6
|
+
* @param directory Existing selected directory; aliases share its canonical lock namespace.
|
|
7
|
+
* @param documents Nonempty unique selection of plan and/or milestones, in any input order.
|
|
8
|
+
* @param operation One bounded operation, receiving the canonical directory. Never hold a lock across agent turns.
|
|
9
|
+
* @returns The callback's result after every lock is released.
|
|
10
|
+
* @throws
|
|
11
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
12
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
13
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
14
|
+
* - Callback errors propagate after successful lock release.
|
|
15
|
+
*/
|
|
16
|
+
export declare const withPlanDocumentLocks: <T>(directory: string, documents: readonly ("plan" | "milestones")[], operation: (directory: string) => T | Promise<T>) => Promise<T>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { getErrorCode } from 'jg-exception';
|
|
4
|
+
import { acquireLock } from 'jg-fs';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { planErrors } from "../errors/index.js";
|
|
7
|
+
const DocumentsSchema = z
|
|
8
|
+
.array(z.enum(['plan', 'milestones']))
|
|
9
|
+
.min(1)
|
|
10
|
+
.refine((values) => new Set(values).size === values.length);
|
|
11
|
+
const DirectorySchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.refine((value) => !value.includes('\0'));
|
|
15
|
+
const BusyReasonSchema = z.object({
|
|
16
|
+
data: z.object({
|
|
17
|
+
reason: z.enum([
|
|
18
|
+
'held',
|
|
19
|
+
'owner-gone',
|
|
20
|
+
'other-host',
|
|
21
|
+
'ownerless',
|
|
22
|
+
'recovery-in-progress',
|
|
23
|
+
'unexpected-entries',
|
|
24
|
+
]),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
const reason = (cause) => {
|
|
28
|
+
const busy = BusyReasonSchema.safeParse(cause);
|
|
29
|
+
return busy.success ? busy.data.data.reason : (getErrorCode(cause) ?? 'unknown');
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Serialize participating local-filesystem writers using canonical document lock directories.
|
|
33
|
+
* Acquire plan before milestones and release in reverse order. Busy/dead owners fail immediately;
|
|
34
|
+
* recovery is an explicit operator decision. Use preparation and your own persistence inside the
|
|
35
|
+
* callback; nesting a filesystem mutation here would attempt to acquire the same lock again.
|
|
36
|
+
* @param directory Existing selected directory; aliases share its canonical lock namespace.
|
|
37
|
+
* @param documents Nonempty unique selection of plan and/or milestones, in any input order.
|
|
38
|
+
* @param operation One bounded operation, receiving the canonical directory. Never hold a lock across agent turns.
|
|
39
|
+
* @returns The callback's result after every lock is released.
|
|
40
|
+
* @throws
|
|
41
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
42
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
43
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
44
|
+
* - Callback errors propagate after successful lock release.
|
|
45
|
+
*/
|
|
46
|
+
export const withPlanDocumentLocks = async (directory, documents, operation) => {
|
|
47
|
+
const selected = DocumentsSchema.safeParse(documents);
|
|
48
|
+
if (!DirectorySchema.safeParse(directory).success ||
|
|
49
|
+
!selected.success ||
|
|
50
|
+
typeof operation !== 'function')
|
|
51
|
+
throw planErrors.create('invalidInput');
|
|
52
|
+
let canonical;
|
|
53
|
+
try {
|
|
54
|
+
canonical = await realpath(resolve(directory));
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
throw planErrors.create('lockFailed', {
|
|
58
|
+
cause,
|
|
59
|
+
data: {
|
|
60
|
+
phase: 'canonicalize',
|
|
61
|
+
reason: reason(cause),
|
|
62
|
+
mutationOutcome: 'not-applied',
|
|
63
|
+
operationCompleted: false,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const held = [];
|
|
68
|
+
let started = false;
|
|
69
|
+
let completed = false;
|
|
70
|
+
let failed = false;
|
|
71
|
+
let failure;
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
for (const document of ['plan', 'milestones']) {
|
|
75
|
+
if (!selected.data.includes(document))
|
|
76
|
+
continue;
|
|
77
|
+
try {
|
|
78
|
+
held.push({
|
|
79
|
+
document,
|
|
80
|
+
lock: await acquireLock(join(canonical, `.${document}.md.jg-plans.lock`)),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
throw planErrors.create(getErrorCode(cause) === 'LOCK_BUSY' ? 'lockBusy' : 'lockFailed', {
|
|
85
|
+
cause,
|
|
86
|
+
data: {
|
|
87
|
+
document,
|
|
88
|
+
phase: 'acquire',
|
|
89
|
+
reason: reason(cause),
|
|
90
|
+
mutationOutcome: 'not-applied',
|
|
91
|
+
operationCompleted: false,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
started = true;
|
|
97
|
+
result = await operation(canonical);
|
|
98
|
+
completed = true;
|
|
99
|
+
}
|
|
100
|
+
catch (cause) {
|
|
101
|
+
failed = true;
|
|
102
|
+
failure = cause;
|
|
103
|
+
}
|
|
104
|
+
const releases = [];
|
|
105
|
+
for (const entry of held.reverse()) {
|
|
106
|
+
try {
|
|
107
|
+
await entry.lock.release();
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
releases.push({ document: entry.document, cause });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (releases.length)
|
|
114
|
+
throw planErrors.create('lockFailed', {
|
|
115
|
+
cause: new AggregateError([...(failed ? [failure] : []), ...releases.map((entry) => entry.cause)], 'The document operation or lock release failed'),
|
|
116
|
+
data: {
|
|
117
|
+
phase: 'release',
|
|
118
|
+
documents: releases.map((entry) => entry.document),
|
|
119
|
+
reason: reason(releases[0].cause),
|
|
120
|
+
operationCompleted: completed,
|
|
121
|
+
mutationOutcome: started ? 'uncertain' : 'not-applied',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (failed)
|
|
125
|
+
throw failure;
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
@@ -9,13 +9,15 @@ import type { IPlanCreation, IPlanUpdateResult } from './types.ts';
|
|
|
9
9
|
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
10
10
|
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
11
11
|
* - JG_PLANS_DESTINATION_EXISTS: The plan destination already exists
|
|
12
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
13
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
12
14
|
* - JG_PLANS_WRITE_FAILED: The plan document could not be written
|
|
13
15
|
*/
|
|
14
16
|
export declare const createPlan: (options: ICreatePlanOptions) => Promise<IPlanCreation>;
|
|
15
17
|
/**
|
|
16
|
-
* Apply
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* Apply an explicit narrow patch under the shared selected-document lock.
|
|
19
|
+
* Guard optional observed bytes before preparation; unchanged output is never replaced.
|
|
20
|
+
* Nonparticipating editors retain a final comparison-to-rename race.
|
|
19
21
|
* @throws
|
|
20
22
|
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
21
23
|
* - JG_PLANS_READ_FAILED: The selected document could not be read
|
|
@@ -23,5 +25,7 @@ export declare const createPlan: (options: ICreatePlanOptions) => Promise<IPlanC
|
|
|
23
25
|
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
24
26
|
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
25
27
|
* - JG_PLANS_WRITE_FAILED: The plan document could not be written
|
|
28
|
+
* - JG_PLANS_LOCK_BUSY: The plan document is locked
|
|
29
|
+
* - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
|
|
26
30
|
*/
|
|
27
31
|
export declare const updatePlan: (directory: string, patch: IPlanUpdate, options?: IReadOptions) => Promise<IPlanUpdateResult>;
|