@tomflow/proflow-task-orchestration 0.1.11 → 0.1.13
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 +14 -0
- package/dist/deployment/adapter.d.ts +3 -3
- package/dist/deployment/descriptor.d.ts +1 -1
- package/dist/deployment/descriptor.js +1 -1
- package/dist/src/contracts.js +8 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.js +3 -2
- package/dist/src/phase4-continuation-services.d.ts +127 -0
- package/dist/src/phase4-continuation-services.js +296 -0
- package/dist/src/phase4-contracts.d.ts +5 -0
- package/dist/src/phase4-contracts.js +193 -0
- package/dist/src/phase4-model.d.ts +122 -0
- package/dist/src/phase4-model.js +1 -0
- package/dist/src/phase4-services.d.ts +118 -0
- package/dist/src/phase4-services.js +809 -0
- package/dist/src/product-document-path.d.ts +2 -0
- package/dist/src/product-document-path.js +24 -0
- package/dist/src/services.js +27 -2
- package/package.json +3 -3
- package/proflow.module.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @tomflow/proflow-task-orchestration
|
|
2
2
|
|
|
3
|
+
## 0.1.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- test(release): align current task and role contracts
|
|
8
|
+
|
|
9
|
+
## 0.1.12
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Prune empty Task-level document recovery directories after recovery journals converge so durable recovery leaves no orphan filesystem residue.
|
|
14
|
+
|
|
15
|
+
- Harden Task-scoped Worker identity and generation semantics so reopened or invalidated executions cannot reuse stale Worker/conversation ownership or a previously consumed run generation.
|
|
16
|
+
|
|
3
17
|
## 0.1.11
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
|
@@ -4,7 +4,7 @@ declare const success: () => {
|
|
|
4
4
|
readonly ok: true;
|
|
5
5
|
readonly status: "SUCCEEDED";
|
|
6
6
|
readonly moduleRef: "task-orchestration";
|
|
7
|
-
readonly moduleVersion: "0.1.
|
|
7
|
+
readonly moduleVersion: "0.1.13";
|
|
8
8
|
};
|
|
9
9
|
observedEffects: string[];
|
|
10
10
|
};
|
|
@@ -17,7 +17,7 @@ export declare const behaviorAdapter: {
|
|
|
17
17
|
ok: true;
|
|
18
18
|
status: "SUCCEEDED";
|
|
19
19
|
moduleRef: "task-orchestration";
|
|
20
|
-
moduleVersion: "0.1.
|
|
20
|
+
moduleVersion: "0.1.13";
|
|
21
21
|
data: {
|
|
22
22
|
setupStatus: "READY";
|
|
23
23
|
runtimeStatus: "NOT_APPLICABLE";
|
|
@@ -32,7 +32,7 @@ export declare const behaviorAdapter: {
|
|
|
32
32
|
ok: true;
|
|
33
33
|
status: "SUCCEEDED";
|
|
34
34
|
moduleRef: "task-orchestration";
|
|
35
|
-
moduleVersion: "0.1.
|
|
35
|
+
moduleVersion: "0.1.13";
|
|
36
36
|
data: {
|
|
37
37
|
docs: string;
|
|
38
38
|
};
|
|
@@ -3,7 +3,7 @@ export declare const descriptor: {
|
|
|
3
3
|
readonly contractVersion: "1.0.0";
|
|
4
4
|
readonly moduleRef: "task-orchestration";
|
|
5
5
|
readonly packageName: "@tomflow/proflow-task-orchestration";
|
|
6
|
-
readonly moduleVersion: "0.1.
|
|
6
|
+
readonly moduleVersion: "0.1.13";
|
|
7
7
|
readonly kind: "library";
|
|
8
8
|
readonly templateVersion: "1.0.0";
|
|
9
9
|
readonly platformCompatibility: ">=1.0.0 <2.0.0";
|
|
@@ -3,7 +3,7 @@ export const descriptor = {
|
|
|
3
3
|
contractVersion: "1.0.0",
|
|
4
4
|
moduleRef: "task-orchestration",
|
|
5
5
|
packageName: "@tomflow/proflow-task-orchestration",
|
|
6
|
-
moduleVersion: "0.1.
|
|
6
|
+
moduleVersion: "0.1.13",
|
|
7
7
|
kind: "library",
|
|
8
8
|
templateVersion: "1.0.0",
|
|
9
9
|
platformCompatibility: ">=1.0.0 <2.0.0",
|
package/dist/src/contracts.js
CHANGED
|
@@ -98,6 +98,14 @@ const schemas = {
|
|
|
98
98
|
path: ["sequenceNo"],
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
+
input.roleBindings.forEach((binding, index) => {
|
|
102
|
+
if (binding.workerRef !== null || binding.conversationLocator !== null)
|
|
103
|
+
context.addIssue({
|
|
104
|
+
code: "custom",
|
|
105
|
+
message: "createTask roleBindings must start with workerRef and conversationLocator unset",
|
|
106
|
+
path: ["roleBindings", index],
|
|
107
|
+
});
|
|
108
|
+
});
|
|
101
109
|
const declaredAgentPackageRefs = new Set(input.roleBindings.map((binding) => binding.agentPackageRef));
|
|
102
110
|
if (declaredAgentPackageRefs.size !== requiredTaskAgentPackageRefs.length ||
|
|
103
111
|
requiredTaskAgentPackageRefs.some((agentPackageRef) => !declaredAgentPackageRefs.has(agentPackageRef))) {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { type PublicOperationName, publicOperationNames, validatePublicInput, } from "./contracts.ts";
|
|
1
|
+
export { type PublicOperationName, publicOperationNames, validatePublicInput, } from "./phase4-contracts.ts";
|
|
2
2
|
export * from "./model.ts";
|
|
3
|
-
export
|
|
3
|
+
export * from "./phase4-model.ts";
|
|
4
|
+
export { createTaskServices, type DocumentResult, type NodeResult, type TaskSummary, type TaskView, } from "./phase4-continuation-services.ts";
|
|
4
5
|
export { assertNodeTransition, assertTaskGroupTransition, assertTaskTransition, } from "./transitions.ts";
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { publicOperationNames, validatePublicInput, } from "./contracts.js";
|
|
1
|
+
export { publicOperationNames, validatePublicInput, } from "./phase4-contracts.js";
|
|
2
2
|
export * from "./model.js";
|
|
3
|
-
export
|
|
3
|
+
export * from "./phase4-model.js";
|
|
4
|
+
export { createTaskServices, } from "./phase4-continuation-services.js";
|
|
4
5
|
export { assertNodeTransition, assertTaskGroupTransition, assertTaskTransition, } from "./transitions.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { CampaignAuthorization, ProductCampaign, ProductDocument, ProductIntent } from "./phase4-model.ts";
|
|
2
|
+
import type { Task, TaskResult } from "./model.ts";
|
|
3
|
+
import { createTaskServices as createPhase4TaskServices, type DocumentResult, type NodeResult, type TaskSummary, type TaskView } from "./phase4-services.ts";
|
|
4
|
+
export type { DocumentResult, NodeResult, TaskSummary, TaskView } from "./phase4-services.ts";
|
|
5
|
+
type ServiceOptions = Parameters<typeof createPhase4TaskServices>[0];
|
|
6
|
+
type AuthorizationProjection = Pick<CampaignAuthorization, "authorizationRef" | "version">;
|
|
7
|
+
type ContinuationProjection = {
|
|
8
|
+
campaign: ProductCampaign;
|
|
9
|
+
intent: ProductIntent | null;
|
|
10
|
+
task: {
|
|
11
|
+
taskId: string;
|
|
12
|
+
status: Task["status"];
|
|
13
|
+
version: number;
|
|
14
|
+
} | null;
|
|
15
|
+
authorization: AuthorizationProjection | null;
|
|
16
|
+
requirementRevision: number | null;
|
|
17
|
+
nextAction: "NONE" | "PROVISION" | "TRANSFER_REQUIREMENT" | "CONSIDER_START" | "PRODUCT_REVIEW_REQUIRED";
|
|
18
|
+
};
|
|
19
|
+
type ProductDocumentRead = ProductDocument & {
|
|
20
|
+
content: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function createTaskServices(options: ServiceOptions): {
|
|
23
|
+
documents: {
|
|
24
|
+
putTaskDocument: (raw: unknown) => TaskResult<DocumentResult>;
|
|
25
|
+
reconcileDocumentIndex: (input: {
|
|
26
|
+
taskId: string;
|
|
27
|
+
actorRef: string;
|
|
28
|
+
}) => TaskResult<{
|
|
29
|
+
reconciled: number;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
commands: {
|
|
33
|
+
createTaskGroup: (raw: unknown) => TaskResult<import("./model.ts").TaskGroup>;
|
|
34
|
+
startTaskGroup: (raw: unknown) => TaskResult<import("./model.ts").TaskGroup & {
|
|
35
|
+
firstEligibleTaskId: string | null;
|
|
36
|
+
}>;
|
|
37
|
+
createTask: (raw: unknown) => TaskResult<import("./services.ts").CreateTaskResult>;
|
|
38
|
+
bindTaskWorker: (raw: unknown) => TaskResult<import("./services.ts").BindTaskWorkerResult>;
|
|
39
|
+
pauseTask: (raw: unknown) => TaskResult<Task>;
|
|
40
|
+
resumeTask: (raw: unknown) => TaskResult<Task>;
|
|
41
|
+
terminateTask: (raw: unknown) => TaskResult<Task>;
|
|
42
|
+
startNode: (raw: unknown) => TaskResult<NodeResult>;
|
|
43
|
+
completeNode: (raw: unknown) => TaskResult<import("./services.ts").CompleteNodeResult>;
|
|
44
|
+
waitNode: (raw: unknown) => TaskResult<Task>;
|
|
45
|
+
failNode: (raw: unknown) => TaskResult<Task>;
|
|
46
|
+
reopenNode: (raw: unknown) => TaskResult<NodeResult & {
|
|
47
|
+
currentNodeId: string;
|
|
48
|
+
}>;
|
|
49
|
+
acknowledgeMessage: (raw: unknown) => TaskResult<import("./model.ts").TaskMessage>;
|
|
50
|
+
createCampaignAuthorization: (raw: unknown) => TaskResult<{
|
|
51
|
+
campaign: ProductCampaign;
|
|
52
|
+
authorization: CampaignAuthorization;
|
|
53
|
+
}>;
|
|
54
|
+
revokeCampaignAuthorization: (raw: unknown) => TaskResult<CampaignAuthorization>;
|
|
55
|
+
submitProductTaskIntent: (raw: unknown) => TaskResult<{
|
|
56
|
+
intentRef: string;
|
|
57
|
+
campaignRef: string | null;
|
|
58
|
+
taskId: string;
|
|
59
|
+
status: Task["status"];
|
|
60
|
+
version: number;
|
|
61
|
+
roleBindings: Array<{
|
|
62
|
+
agentPackageRef: string;
|
|
63
|
+
roleRef: string;
|
|
64
|
+
workerRef: null;
|
|
65
|
+
conversationLocator: null;
|
|
66
|
+
}>;
|
|
67
|
+
}>;
|
|
68
|
+
transferProductIntentToTask: (raw: unknown) => TaskResult<TaskView>;
|
|
69
|
+
recordProductGapReview: (raw: unknown) => TaskResult<import("./phase4-model.ts").ProductGapReview>;
|
|
70
|
+
recordProductGoalSatisfied: (raw: unknown) => TaskResult<ProductCampaign>;
|
|
71
|
+
startTask: (raw: unknown) => TaskResult<TaskView>;
|
|
72
|
+
putProductDocument: (raw: unknown) => TaskResult<ProductDocumentRead>;
|
|
73
|
+
};
|
|
74
|
+
queries: {
|
|
75
|
+
getTaskGroup: (raw: unknown) => TaskResult<import("./model.ts").TaskGroup & {
|
|
76
|
+
currentTaskId: string | null;
|
|
77
|
+
tasks: Task[];
|
|
78
|
+
}>;
|
|
79
|
+
listTasks: (raw: unknown) => TaskResult<{
|
|
80
|
+
tasks: TaskSummary[];
|
|
81
|
+
}>;
|
|
82
|
+
getTask: (raw: unknown) => TaskResult<TaskView>;
|
|
83
|
+
getTaskDriveProjection: (raw: unknown) => TaskResult<{
|
|
84
|
+
taskId: string;
|
|
85
|
+
taskStatus: Task["status"];
|
|
86
|
+
taskVersion: number;
|
|
87
|
+
terminal: boolean;
|
|
88
|
+
currentNode: {
|
|
89
|
+
nodeId: string;
|
|
90
|
+
status: import("./model.ts").NodeStatus;
|
|
91
|
+
version: number;
|
|
92
|
+
runNo: number;
|
|
93
|
+
requiredAgentPackageRef: string;
|
|
94
|
+
} | null;
|
|
95
|
+
roleBinding: {
|
|
96
|
+
agentPackageRef: string;
|
|
97
|
+
roleRef: string;
|
|
98
|
+
workerRef: string | null;
|
|
99
|
+
conversationLocator: string | null;
|
|
100
|
+
} | null;
|
|
101
|
+
canDrive: boolean;
|
|
102
|
+
blockedReason: string | null;
|
|
103
|
+
resumeSignalRef: string | null;
|
|
104
|
+
}>;
|
|
105
|
+
getNodeContext: (raw: unknown) => TaskResult<{
|
|
106
|
+
task: Pick<Task, "taskId" | "title" | "objective" | "status" | "version">;
|
|
107
|
+
node: Pick<import("./model.ts").TaskNode, "nodeId" | "title" | "objective" | "status" | "version" | "runNo" | "requiredAgentPackageRef" | "workerRef" | "inputDocuments" | "outputDocuments">;
|
|
108
|
+
documents: Array<{
|
|
109
|
+
documentType: string;
|
|
110
|
+
path: string;
|
|
111
|
+
contentHash: string;
|
|
112
|
+
sizeBytes: number;
|
|
113
|
+
}>;
|
|
114
|
+
}>;
|
|
115
|
+
listPendingMessages: (raw: unknown) => TaskResult<{
|
|
116
|
+
messages: import("./model.ts").TaskMessage[];
|
|
117
|
+
}>;
|
|
118
|
+
listTaskEvents: (raw: unknown) => TaskResult<{
|
|
119
|
+
events: import("./model.ts").TaskEvent[];
|
|
120
|
+
}>;
|
|
121
|
+
getTaskDocument: (raw: unknown) => TaskResult<DocumentResult>;
|
|
122
|
+
getProductCampaign: (raw: unknown) => TaskResult<ProductCampaign>;
|
|
123
|
+
getCampaignAuthorization: (raw: unknown) => TaskResult<CampaignAuthorization>;
|
|
124
|
+
getProductDocument: (raw: unknown) => TaskResult<ProductDocumentRead>;
|
|
125
|
+
getProductContinuationProjection: (raw: unknown) => TaskResult<ContinuationProjection>;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { productDocumentAbsolute as safeProductDocumentAbsolute } from "./product-document-path.js";
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
import { validatePublicInput } from "./phase4-contracts.js";
|
|
7
|
+
import { createTaskServices as createPhase4TaskServices, } from "./phase4-services.js";
|
|
8
|
+
class ProjectionError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
retryable;
|
|
11
|
+
constructor(code, retryable = false) {
|
|
12
|
+
super(code);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.retryable = retryable;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const success = (data) => ({
|
|
18
|
+
contract: "task-orchestration",
|
|
19
|
+
contractVersion: "1.0.0",
|
|
20
|
+
ok: true,
|
|
21
|
+
data,
|
|
22
|
+
});
|
|
23
|
+
const failure = (error) => ({
|
|
24
|
+
contract: "task-orchestration",
|
|
25
|
+
contractVersion: "1.0.0",
|
|
26
|
+
ok: false,
|
|
27
|
+
error: {
|
|
28
|
+
code: error instanceof ProjectionError
|
|
29
|
+
? error.code
|
|
30
|
+
: error instanceof ZodError
|
|
31
|
+
? "INVALID_REQUEST"
|
|
32
|
+
: "INTERNAL_ERROR",
|
|
33
|
+
message: error instanceof Error ? error.message : "unexpected error",
|
|
34
|
+
retryable: error instanceof ProjectionError ? error.retryable : false,
|
|
35
|
+
correlationId: `corr-${randomUUID()}`,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
function ownerStore(store) {
|
|
39
|
+
const candidate = store;
|
|
40
|
+
if (typeof candidate.phase4Read !== "function" ||
|
|
41
|
+
typeof candidate.phase4Transaction !== "function")
|
|
42
|
+
throw new ProjectionError("PHASE4_OWNER_STORE_REQUIRED");
|
|
43
|
+
return candidate;
|
|
44
|
+
}
|
|
45
|
+
function canonical(value) {
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.map(canonical);
|
|
48
|
+
if (value !== null && typeof value === "object")
|
|
49
|
+
return Object.fromEntries(Object.entries(value)
|
|
50
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
51
|
+
.map(([key, item]) => [key, canonical(item)]));
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function requestHash(value) {
|
|
55
|
+
return `sha256:${createHash("sha256")
|
|
56
|
+
.update(JSON.stringify(canonical(value)))
|
|
57
|
+
.digest("hex")}`;
|
|
58
|
+
}
|
|
59
|
+
function contentHash(value) {
|
|
60
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
61
|
+
}
|
|
62
|
+
function productDocumentPath(documentRef) {
|
|
63
|
+
return `product-requirements/${createHash("sha256")
|
|
64
|
+
.update(documentRef)
|
|
65
|
+
.digest("hex")
|
|
66
|
+
.slice(0, 24)}.md`;
|
|
67
|
+
}
|
|
68
|
+
function productDocumentAbsolute(workspaceRoot, filePath) {
|
|
69
|
+
try {
|
|
70
|
+
return safeProductDocumentAbsolute(workspaceRoot, filePath);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_PATH_INVALID");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function atomicWrite(path, content) {
|
|
77
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
78
|
+
const temporary = `${path}.tmp-${randomUUID()}`;
|
|
79
|
+
try {
|
|
80
|
+
writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
81
|
+
renameSync(temporary, path);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
rmSync(temporary, { force: true });
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function readProductDocumentContent(workspaceRoot, document) {
|
|
89
|
+
const absolute = productDocumentAbsolute(workspaceRoot, document.filePath);
|
|
90
|
+
if (!existsSync(absolute))
|
|
91
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_NOT_FOUND");
|
|
92
|
+
const content = readFileSync(absolute, "utf8");
|
|
93
|
+
if (contentHash(content) !== document.contentHash)
|
|
94
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_INDEX_MISMATCH");
|
|
95
|
+
return content;
|
|
96
|
+
}
|
|
97
|
+
export function createTaskServices(options) {
|
|
98
|
+
const base = createPhase4TaskServices(options);
|
|
99
|
+
const store = ownerStore(options.store);
|
|
100
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
101
|
+
const putProductDocument = (raw) => {
|
|
102
|
+
let input;
|
|
103
|
+
try {
|
|
104
|
+
input = validatePublicInput("putProductDocument", raw);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return failure(error);
|
|
108
|
+
}
|
|
109
|
+
let writtenPath;
|
|
110
|
+
let previousContent;
|
|
111
|
+
let previousExisted = false;
|
|
112
|
+
try {
|
|
113
|
+
return store.phase4Transaction((tx) => {
|
|
114
|
+
const fingerprint = requestHash(input);
|
|
115
|
+
const prior = tx.idempotency.get(input.idempotencyKey);
|
|
116
|
+
if (prior) {
|
|
117
|
+
if (prior.operation !== "putProductDocument" ||
|
|
118
|
+
prior.requestHash !== fingerprint)
|
|
119
|
+
throw new ProjectionError("IDEMPOTENCY_CONFLICT");
|
|
120
|
+
return JSON.parse(prior.responseJson);
|
|
121
|
+
}
|
|
122
|
+
if (input.actorRef !== `product-discussion:${input.discussionRef}`)
|
|
123
|
+
throw new ProjectionError("PRODUCT_DISCUSSION_MISMATCH");
|
|
124
|
+
const campaign = tx.productCampaigns.get(input.campaignRef);
|
|
125
|
+
if (!campaign)
|
|
126
|
+
throw new ProjectionError("PRODUCT_CAMPAIGN_NOT_FOUND");
|
|
127
|
+
if (campaign.status !== "OPEN")
|
|
128
|
+
throw new ProjectionError("PRODUCT_CAMPAIGN_CLOSED");
|
|
129
|
+
if (campaign.discussionRef !== input.discussionRef)
|
|
130
|
+
throw new ProjectionError("PRODUCT_DISCUSSION_MISMATCH");
|
|
131
|
+
const current = tx.productDocuments.get(input.documentRef);
|
|
132
|
+
if (input.expectedRevision === 0) {
|
|
133
|
+
if (current)
|
|
134
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_REVISION_CONFLICT", true);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
if (!current)
|
|
138
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_NOT_FOUND");
|
|
139
|
+
if (current.intentRef !== null)
|
|
140
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_ALREADY_BOUND");
|
|
141
|
+
if (current.campaignRef !== input.campaignRef ||
|
|
142
|
+
current.documentType !== input.documentType)
|
|
143
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_SCOPE_MISMATCH");
|
|
144
|
+
if (current.revision !== input.expectedRevision)
|
|
145
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_REVISION_CONFLICT", true);
|
|
146
|
+
readProductDocumentContent(options.workspaceRoot, current);
|
|
147
|
+
}
|
|
148
|
+
const filePath = current?.filePath ?? productDocumentPath(input.documentRef);
|
|
149
|
+
const absolute = productDocumentAbsolute(options.workspaceRoot, filePath);
|
|
150
|
+
previousExisted = existsSync(absolute);
|
|
151
|
+
previousContent = previousExisted ? readFileSync(absolute, "utf8") : undefined;
|
|
152
|
+
if (!current &&
|
|
153
|
+
previousExisted &&
|
|
154
|
+
previousContent !== undefined &&
|
|
155
|
+
contentHash(previousContent) !== contentHash(input.content))
|
|
156
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_ORPHAN_CONFLICT");
|
|
157
|
+
atomicWrite(absolute, input.content);
|
|
158
|
+
writtenPath = absolute;
|
|
159
|
+
const document = {
|
|
160
|
+
documentRef: input.documentRef,
|
|
161
|
+
campaignRef: input.campaignRef,
|
|
162
|
+
intentRef: null,
|
|
163
|
+
documentType: input.documentType,
|
|
164
|
+
filePath,
|
|
165
|
+
contentHash: contentHash(input.content),
|
|
166
|
+
revision: input.expectedRevision + 1,
|
|
167
|
+
updatedByRef: input.actorRef,
|
|
168
|
+
updatedAt: now(),
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
if (current)
|
|
172
|
+
tx.productDocuments.update(document);
|
|
173
|
+
else
|
|
174
|
+
tx.productDocuments.insert(document);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof Error &&
|
|
178
|
+
error.message === "PRODUCT_DOCUMENT_REVISION_CONFLICT")
|
|
179
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_REVISION_CONFLICT", true);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const result = success({ ...document, content: input.content });
|
|
183
|
+
tx.idempotency.insert({
|
|
184
|
+
idempotencyKey: input.idempotencyKey,
|
|
185
|
+
operation: "putProductDocument",
|
|
186
|
+
requestHash: fingerprint,
|
|
187
|
+
responseJson: JSON.stringify(result),
|
|
188
|
+
createdAt: now(),
|
|
189
|
+
});
|
|
190
|
+
return result;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (writtenPath) {
|
|
195
|
+
try {
|
|
196
|
+
if (previousExisted && previousContent !== undefined)
|
|
197
|
+
atomicWrite(writtenPath, previousContent);
|
|
198
|
+
else
|
|
199
|
+
rmSync(writtenPath, { force: true });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return failure(new ProjectionError("PRODUCT_DOCUMENT_RECOVERY_REQUIRED"));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return failure(error);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const getProductDocument = (raw) => {
|
|
209
|
+
try {
|
|
210
|
+
const input = validatePublicInput("getProductDocument", raw);
|
|
211
|
+
return success(store.phase4Read((tx) => {
|
|
212
|
+
const document = tx.productDocuments.get(input.documentRef);
|
|
213
|
+
if (!document)
|
|
214
|
+
throw new ProjectionError("PRODUCT_DOCUMENT_NOT_FOUND");
|
|
215
|
+
const campaign = tx.productCampaigns.get(document.campaignRef);
|
|
216
|
+
if (document.campaignRef !== input.campaignRef || campaign?.discussionRef !== input.discussionRef)
|
|
217
|
+
throw new ProjectionError("PRODUCT_DISCUSSION_MISMATCH");
|
|
218
|
+
return {
|
|
219
|
+
...document,
|
|
220
|
+
content: readProductDocumentContent(options.workspaceRoot, document),
|
|
221
|
+
};
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return failure(error);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const getProductContinuationProjection = (raw) => {
|
|
229
|
+
try {
|
|
230
|
+
const input = validatePublicInput("getProductContinuationProjection", raw);
|
|
231
|
+
const ownerFacts = store.phase4Read((tx) => {
|
|
232
|
+
const taskLink = input.taskId ? tx.taskProductLinks.get(input.taskId) : undefined;
|
|
233
|
+
if (input.taskId && !taskLink?.campaignRef)
|
|
234
|
+
throw new ProjectionError("PRODUCT_CAMPAIGN_NOT_FOUND");
|
|
235
|
+
if (input.campaignRef !== undefined &&
|
|
236
|
+
taskLink?.campaignRef !== undefined &&
|
|
237
|
+
taskLink.campaignRef !== null &&
|
|
238
|
+
taskLink.campaignRef !== input.campaignRef)
|
|
239
|
+
throw new ProjectionError("PRODUCT_CAMPAIGN_TASK_MISMATCH");
|
|
240
|
+
const campaignRef = input.campaignRef ?? taskLink?.campaignRef;
|
|
241
|
+
if (!campaignRef)
|
|
242
|
+
throw new ProjectionError("PRODUCT_CAMPAIGN_NOT_FOUND");
|
|
243
|
+
const campaign = tx.productCampaigns.get(campaignRef);
|
|
244
|
+
if (!campaign)
|
|
245
|
+
throw new ProjectionError("PRODUCT_CAMPAIGN_NOT_FOUND");
|
|
246
|
+
const authorization = campaign.status === "OPEN"
|
|
247
|
+
? tx.campaignAuthorizations
|
|
248
|
+
.listByCampaign(campaignRef)
|
|
249
|
+
.filter((candidate) => candidate.revokedAt === null &&
|
|
250
|
+
candidate.discussionRef === campaign.discussionRef &&
|
|
251
|
+
candidate.goalRevision === campaign.goalRevision)
|
|
252
|
+
.at(-1) ?? null
|
|
253
|
+
: null;
|
|
254
|
+
return { campaignRef, authorization };
|
|
255
|
+
});
|
|
256
|
+
const baseResult = base.queries.getProductContinuationProjection({
|
|
257
|
+
campaignRef: ownerFacts.campaignRef,
|
|
258
|
+
...(input.taskId ? { taskId: input.taskId } : {}),
|
|
259
|
+
});
|
|
260
|
+
if (!baseResult.ok)
|
|
261
|
+
return baseResult;
|
|
262
|
+
const requirementRevision = baseResult.data.intent
|
|
263
|
+
? store.phase4Read((tx) => tx.productDocuments
|
|
264
|
+
.listByIntent(baseResult.data.intent?.intentRef ?? "")
|
|
265
|
+
.filter((document) => document.documentType === "REQUIREMENT")
|
|
266
|
+
.sort((left, right) => left.revision - right.revision)
|
|
267
|
+
.at(-1)?.revision ?? null)
|
|
268
|
+
: null;
|
|
269
|
+
return success({
|
|
270
|
+
...baseResult.data,
|
|
271
|
+
authorization: ownerFacts.authorization
|
|
272
|
+
? {
|
|
273
|
+
authorizationRef: ownerFacts.authorization.authorizationRef,
|
|
274
|
+
version: ownerFacts.authorization.version,
|
|
275
|
+
}
|
|
276
|
+
: null,
|
|
277
|
+
requirementRevision,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return failure(error);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
commands: {
|
|
287
|
+
...base.commands,
|
|
288
|
+
putProductDocument,
|
|
289
|
+
},
|
|
290
|
+
queries: {
|
|
291
|
+
...base.queries,
|
|
292
|
+
getProductDocument,
|
|
293
|
+
getProductContinuationProjection,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const phase4OperationNames: readonly ["createCampaignAuthorization", "getProductCampaign", "getCampaignAuthorization", "revokeCampaignAuthorization", "putProductDocument", "getProductDocument", "submitProductTaskIntent", "transferProductIntentToTask", "recordProductGapReview", "recordProductGoalSatisfied", "getProductContinuationProjection"];
|
|
2
|
+
export type Phase4OperationName = (typeof phase4OperationNames)[number];
|
|
3
|
+
export declare const publicOperationNames: readonly ["createTaskGroup", "getTaskGroup", "startTaskGroup", "createTask", "listTasks", "getTask", "startTask", "pauseTask", "resumeTask", "terminateTask", "bindTaskWorker", "getTaskDriveProjection", "getNodeContext", "startNode", "completeNode", "waitNode", "failNode", "reopenNode", "putTaskDocument", "getTaskDocument", "listPendingMessages", "acknowledgeMessage", "listTaskEvents", "createCampaignAuthorization", "getProductCampaign", "getCampaignAuthorization", "revokeCampaignAuthorization", "putProductDocument", "getProductDocument", "submitProductTaskIntent", "transferProductIntentToTask", "recordProductGapReview", "recordProductGoalSatisfied", "getProductContinuationProjection"];
|
|
4
|
+
export type PublicOperationName = (typeof publicOperationNames)[number];
|
|
5
|
+
export declare function validatePublicInput(operation: PublicOperationName, input: unknown): unknown;
|