@nextclaw/x-harness 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/main.js +23 -11
- package/dist/api.js +53 -1
- package/dist/browser-dom-capabilities.d.ts +4 -0
- package/dist/browser-dom-capabilities.js +110 -0
- package/dist/browser-dom-dependencies.d.ts +16 -0
- package/dist/browser-dom-editing.d.ts +148 -0
- package/dist/browser-dom-editing.js +386 -0
- package/dist/browser-dom-navigation.d.ts +9 -0
- package/dist/browser-dom-navigation.js +165 -0
- package/dist/browser-dom-network.d.ts +1 -0
- package/dist/browser-dom-network.js +128 -0
- package/dist/browser-dom-submission.d.ts +2 -0
- package/dist/browser-dom-submission.js +82 -0
- package/dist/browser-dom.d.ts +1 -1
- package/dist/browser-dom.js +51 -802
- package/dist/browser-native.js +32 -28
- package/dist/browser.js +6 -2
- package/dist/creator.js +1 -1
- package/dist/growth-batch.d.ts +5 -0
- package/dist/growth-batch.js +168 -0
- package/dist/growth-constants.d.ts +5 -0
- package/dist/growth-constants.js +13 -0
- package/dist/growth-dependencies.d.ts +33 -0
- package/dist/growth-execution.d.ts +11 -0
- package/dist/growth-execution.js +315 -0
- package/dist/growth-parallel.d.ts +4 -0
- package/dist/growth-parallel.js +301 -0
- package/dist/growth-plan.d.ts +4 -0
- package/dist/growth-plan.js +207 -0
- package/dist/growth-policy.d.ts +9 -0
- package/dist/growth-policy.js +181 -0
- package/dist/growth-publishing.d.ts +5 -0
- package/dist/growth-publishing.js +240 -0
- package/dist/growth-reading.d.ts +4 -0
- package/dist/growth-reading.js +135 -0
- package/dist/growth-recovery.d.ts +6 -0
- package/dist/growth-recovery.js +158 -0
- package/dist/growth-state.d.ts +8 -0
- package/dist/growth-state.js +183 -0
- package/dist/growth-tasks.d.ts +17 -0
- package/dist/growth-tasks.js +101 -0
- package/dist/growth.d.ts +1 -0
- package/dist/growth.js +122 -1902
- package/dist/platform-capabilities.d.ts +4 -0
- package/dist/platform-capabilities.js +19 -0
- package/dist/platform-collections.d.ts +7 -0
- package/dist/platform-collections.js +223 -0
- package/dist/platform-dependencies.d.ts +43 -0
- package/dist/platform-diagnostics.d.ts +9 -0
- package/dist/platform-diagnostics.js +51 -0
- package/dist/platform-observation.d.ts +8 -0
- package/dist/platform-observation.js +75 -0
- package/dist/platform-rules.d.ts +17 -0
- package/dist/platform-rules.js +41 -0
- package/dist/platform-targets.d.ts +7 -0
- package/dist/platform-targets.js +129 -0
- package/dist/platform-writing.d.ts +8 -0
- package/dist/platform-writing.js +171 -0
- package/dist/platform.d.ts +1 -1
- package/dist/platform.js +56 -677
- package/dist/readers-actions.d.ts +2 -0
- package/dist/readers-actions.js +66 -0
- package/dist/readers-ax-host.d.ts +2 -0
- package/dist/readers-ax-host.js +125 -0
- package/dist/readers-ax-parser.d.ts +1 -0
- package/dist/readers-ax-parser.js +69 -0
- package/dist/readers-ax-program.d.ts +1 -0
- package/dist/readers-ax-tree.d.ts +19 -0
- package/dist/readers-ax-tree.js +110 -0
- package/dist/readers-ax-views.d.ts +87 -0
- package/dist/readers-ax-views.js +247 -0
- package/dist/readers-cli.d.ts +6 -0
- package/dist/readers-composer.d.ts +2 -0
- package/dist/readers-composer.js +131 -0
- package/dist/readers-navigation.d.ts +2 -0
- package/dist/readers-navigation.js +66 -0
- package/dist/readers-posts.d.ts +2 -0
- package/dist/readers-posts.js +141 -0
- package/dist/readers-profile.d.ts +3 -0
- package/dist/readers-profile.js +157 -0
- package/dist/readers.d.ts +9 -20
- package/dist/results.js +1 -1
- package/dist/round-state.js +2 -2
- package/dist/types.d.ts +10 -0
- package/generated/observation.mjs +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +17 -36
- package/skill/references/api.md +7 -34
- package/skill/references/reads.md +23 -0
- package/skill/references/recovery.md +25 -0
- package/skill/references/setup.md +23 -0
- package/skill/references/writes.md +51 -0
- package/dist/readers.js +0 -1045
package/dist/browser-native.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
|
-
import { createAXHarness
|
|
2
|
-
import {
|
|
1
|
+
import { createAXHarness } from "./readers-ax-host.js";
|
|
2
|
+
import { readAX } from "./readers-ax-parser.js";
|
|
3
|
+
import { xURL } from "./platform-rules.js";
|
|
3
4
|
import { serialized, missingMethods, plain, valueOf, } from "./browser-common.js";
|
|
4
5
|
export function createNativeXBrowser(app) {
|
|
6
|
+
async function navigateNativeTarget(command) {
|
|
7
|
+
xURL(command.url);
|
|
8
|
+
if (!command.evidenceRef)
|
|
9
|
+
throw new Error("导航URL缺原观察证据");
|
|
10
|
+
await low.observe({ mode: "navigation" }).then((raw) => {
|
|
11
|
+
current = { raw };
|
|
12
|
+
});
|
|
13
|
+
let index = one((e) => /^文本栏 .*地址和搜索栏/.test(e.label));
|
|
14
|
+
await low
|
|
15
|
+
.act({
|
|
16
|
+
index,
|
|
17
|
+
operation: "fill",
|
|
18
|
+
value: command.url,
|
|
19
|
+
read: { mode: "navigation" },
|
|
20
|
+
})
|
|
21
|
+
.then((raw) => {
|
|
22
|
+
current = { raw };
|
|
23
|
+
});
|
|
24
|
+
index = one((e) => /^文本栏 .*地址和搜索栏/.test(e.label));
|
|
25
|
+
await low.act({
|
|
26
|
+
index,
|
|
27
|
+
operation: "pressKey",
|
|
28
|
+
value: "Return",
|
|
29
|
+
read: { mode: "navigation" },
|
|
30
|
+
});
|
|
31
|
+
current = null;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
5
34
|
let latestAX;
|
|
6
35
|
const delegated = {};
|
|
7
36
|
for (const name of [
|
|
@@ -124,32 +153,7 @@ export function createNativeXBrowser(app) {
|
|
|
124
153
|
observe,
|
|
125
154
|
async act(command) {
|
|
126
155
|
if (command.operation === "openObserved") {
|
|
127
|
-
|
|
128
|
-
if (!command.evidenceRef)
|
|
129
|
-
throw new Error("导航URL缺原观察证据");
|
|
130
|
-
await low.observe({ mode: "navigation" }).then((raw) => {
|
|
131
|
-
current = { raw };
|
|
132
|
-
});
|
|
133
|
-
let index = one((e) => /^文本栏 .*地址和搜索栏/.test(e.label));
|
|
134
|
-
await low
|
|
135
|
-
.act({
|
|
136
|
-
index,
|
|
137
|
-
operation: "fill",
|
|
138
|
-
value: command.url,
|
|
139
|
-
read: { mode: "navigation" },
|
|
140
|
-
})
|
|
141
|
-
.then((raw) => {
|
|
142
|
-
current = { raw };
|
|
143
|
-
});
|
|
144
|
-
index = one((e) => /^文本栏 .*地址和搜索栏/.test(e.label));
|
|
145
|
-
await low.act({
|
|
146
|
-
index,
|
|
147
|
-
operation: "pressKey",
|
|
148
|
-
value: "Return",
|
|
149
|
-
read: { mode: "navigation" },
|
|
150
|
-
});
|
|
151
|
-
current = null;
|
|
152
|
-
return;
|
|
156
|
+
return navigateNativeTarget(command);
|
|
153
157
|
}
|
|
154
158
|
if (command.operation === "refreshPerson") {
|
|
155
159
|
xURL(command.url);
|
package/dist/browser.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { readProfile
|
|
1
|
+
import { readProfile } from "./readers-profile.js";
|
|
2
|
+
import { readPosts } from "./readers-posts.js";
|
|
3
|
+
import { readComposer, readMediaEditor } from "./readers-composer.js";
|
|
4
|
+
import { readNavigation, readMenu } from "./readers-navigation.js";
|
|
2
5
|
export { createDOMXBrowser, readXNetworkDiagnostics } from "./browser-dom.js";
|
|
3
6
|
export { createNativeXBrowser } from "./browser-native.js";
|
|
4
7
|
// Read-only supplement: account identity, composer context and exact tweet bodies.
|
|
@@ -106,7 +109,8 @@ export function readXSurface({ view, posts = [], targetId, replyTargetId, } = {}
|
|
|
106
109
|
const read = (node) => {
|
|
107
110
|
if (node.nodeType === 3)
|
|
108
111
|
return node.textContent ?? "";
|
|
109
|
-
if (node.nodeType === 1 &&
|
|
112
|
+
if (node.nodeType === 1 &&
|
|
113
|
+
node.tagName === "A") {
|
|
110
114
|
const href = node.getAttribute("href") ?? "";
|
|
111
115
|
if (/^\/\w{1,15}\/status\/\d+$/.test(href))
|
|
112
116
|
return `https://x.com${href}`;
|
package/dist/creator.js
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BoundaryRecord } from "./boundary.js";
|
|
2
|
+
import type { GrowthDependencies } from "./growth-dependencies.js";
|
|
3
|
+
export declare function createFollowBatch(dependencies: Pick<GrowthDependencies, "prepareActionsForCall" | "store" | "scope" | "stamp" | "clock" | "evidenceExists" | "executor" | "parallelRound" | "round">): {
|
|
4
|
+
followBatch: ({ taskId, handles, preparedActions }?: any) => Promise<BoundaryRecord>;
|
|
5
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { followWindows, same } from "./growth-constants.js";
|
|
2
|
+
export function createFollowBatch(dependencies) {
|
|
3
|
+
const { prepareActionsForCall, store, scope, stamp, clock, evidenceExists, executor, parallelRound, round, } = dependencies;
|
|
4
|
+
return {
|
|
5
|
+
followBatch: async ({ taskId, handles, preparedActions } = {}) => {
|
|
6
|
+
if (!Array.isArray(handles) ||
|
|
7
|
+
handles.length < 5 ||
|
|
8
|
+
handles.length > 10 ||
|
|
9
|
+
handles.some((h) => !/^[A-Za-z0-9_]{1,15}$/.test(h)) ||
|
|
10
|
+
new Set(handles.map((h) => h.toLowerCase())).size !==
|
|
11
|
+
handles.length)
|
|
12
|
+
throw new Error("正常关注子批需5~10位不同的已准备对象");
|
|
13
|
+
if (preparedActions?.some((a) => a.kind !== "follow" ||
|
|
14
|
+
!handles.some((h) => same(h, a.handle))))
|
|
15
|
+
throw new Error("batch-action-target-mismatch");
|
|
16
|
+
await prepareActionsForCall(taskId, preparedActions);
|
|
17
|
+
let batchId, resumedBatch = false;
|
|
18
|
+
const original = await store.transaction(async (snapshot) => {
|
|
19
|
+
if (!scope(snapshot.state).includes("follow"))
|
|
20
|
+
throw new Error("kind-outside-round-scope");
|
|
21
|
+
if (snapshot.state.batchId !== taskId ||
|
|
22
|
+
handles.some((h) => !snapshot.state.actions?.some((a) => a.kind === "follow" && same(a.handle, h))))
|
|
23
|
+
throw new Error("子批名单必须归原任务队列");
|
|
24
|
+
const next = structuredClone(snapshot.state);
|
|
25
|
+
next.followBatches ??= [];
|
|
26
|
+
const previous = next.followBatches.findLast((b) => b.status !== "SETTLED" &&
|
|
27
|
+
b.handles.length === handles.length &&
|
|
28
|
+
b.handles.every((h, i) => same(h, handles[i])));
|
|
29
|
+
resumedBatch = Boolean(previous);
|
|
30
|
+
batchId = previous?.id ?? `${taskId}-FOLLOW-${stamp()}`;
|
|
31
|
+
if (previous)
|
|
32
|
+
Object.assign(previous, {
|
|
33
|
+
status: "RUNNING",
|
|
34
|
+
lastResumedAt: stamp(),
|
|
35
|
+
});
|
|
36
|
+
else
|
|
37
|
+
next.followBatches.push({
|
|
38
|
+
id: batchId,
|
|
39
|
+
handles,
|
|
40
|
+
planned: handles.length,
|
|
41
|
+
startedAt: stamp(),
|
|
42
|
+
status: "RUNNING",
|
|
43
|
+
});
|
|
44
|
+
next.updatedAt = stamp();
|
|
45
|
+
return next;
|
|
46
|
+
});
|
|
47
|
+
const preparedCount = original.state.actions.filter((a) => a.kind === "follow" &&
|
|
48
|
+
a.status === "prepared" &&
|
|
49
|
+
handles.some((h) => same(h, a.handle))).length;
|
|
50
|
+
let eligibleAt = clock();
|
|
51
|
+
const previousGroup = original.state.execution?.followGroup;
|
|
52
|
+
const lastAttempt = (original.state.attempts ?? [])
|
|
53
|
+
.filter((a) => a.kind === "follow")
|
|
54
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
|
|
55
|
+
.at(-1);
|
|
56
|
+
const lastAction = original.state.actions.find((a) => a.id === lastAttempt?.actionId);
|
|
57
|
+
const sameBatchRecovery = resumedBatch &&
|
|
58
|
+
lastAction?.status === "verified" &&
|
|
59
|
+
evidenceExists(lastAction.resultEvidence) &&
|
|
60
|
+
handles.some((h) => same(h, lastAction.handle));
|
|
61
|
+
if (preparedCount && previousGroup?.attempts && !sameBatchRecovery)
|
|
62
|
+
eligibleAt = Math.max(eligibleAt, Date.parse(previousGroup.lastAt) + 180000);
|
|
63
|
+
for (const [duration, limit] of followWindows) {
|
|
64
|
+
const recent = (original.state.attempts ?? [])
|
|
65
|
+
.filter((a) => a.kind === "follow" && Date.parse(a.at) > clock() - duration)
|
|
66
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
67
|
+
if (preparedCount > limit - recent.length)
|
|
68
|
+
eligibleAt = Math.max(eligibleAt, Date.parse(recent[recent.length - limit + preparedCount - 1].at) +
|
|
69
|
+
duration);
|
|
70
|
+
}
|
|
71
|
+
if (eligibleAt > clock()) {
|
|
72
|
+
const resumeAt = new Date(eligibleAt).toISOString();
|
|
73
|
+
await store.transaction((snapshot) => {
|
|
74
|
+
const next = structuredClone(snapshot.state);
|
|
75
|
+
Object.assign(next.followBatches.find((b) => b.id === batchId), { status: "WAITING", remaining: preparedCount, resumeAt });
|
|
76
|
+
next.updatedAt = stamp();
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
taskId,
|
|
81
|
+
batchId,
|
|
82
|
+
planned: handles.length,
|
|
83
|
+
attempted: 0,
|
|
84
|
+
verifiedThisCall: 0,
|
|
85
|
+
unknownInBatch: original.state.actions.filter((a) => handles.some((h) => same(h, a.handle)) &&
|
|
86
|
+
["unknown", "intent"].includes(a.status)).length,
|
|
87
|
+
remaining: preparedCount,
|
|
88
|
+
mayEnd: false,
|
|
89
|
+
decision: [
|
|
90
|
+
{
|
|
91
|
+
kind: "follow",
|
|
92
|
+
status: "waiting",
|
|
93
|
+
reason: "normal-batch-capacity-or-cooldown",
|
|
94
|
+
resumeAt,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
evidenceRef: store.path,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
let result;
|
|
101
|
+
const exceptional = [];
|
|
102
|
+
for (const handle of handles) {
|
|
103
|
+
// A resumed batch submits only its remaining prepared members. Recovery
|
|
104
|
+
// stays in the read-only API; a frozen member must not disturb that path.
|
|
105
|
+
if (!original.state.actions.some((a) => a.kind === "follow" &&
|
|
106
|
+
same(a.handle, handle) &&
|
|
107
|
+
a.status === "prepared"))
|
|
108
|
+
continue;
|
|
109
|
+
result = await (executor ? parallelRound : round)({
|
|
110
|
+
taskId,
|
|
111
|
+
requestedKind: "follow",
|
|
112
|
+
selector: (a) => a.kind === "follow" && same(a.handle, handle),
|
|
113
|
+
});
|
|
114
|
+
const relevant = (result.decision ?? []).filter((d) => d.kind === "follow" ||
|
|
115
|
+
["harness-boundary-error", "original-ledger-conflict"].includes(d.reason) ||
|
|
116
|
+
original.state.actions.some((a) => a.id === d.actionId && a.kind === "follow"));
|
|
117
|
+
exceptional.push(...relevant);
|
|
118
|
+
// A new unconfirmed submission is a diagnostic boundary, not permission
|
|
119
|
+
// to consume the rest of the batch on the same failing write path.
|
|
120
|
+
if (relevant.length)
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
const after = await store.load();
|
|
124
|
+
const summary = summarizeFollowBatch(taskId, batchId, handles, after, original, result, exceptional, store.path);
|
|
125
|
+
await store.transaction((snapshot) => {
|
|
126
|
+
const next = structuredClone(snapshot.state), batch = next.followBatches.find((b) => b.id === batchId);
|
|
127
|
+
Object.assign(batch, {
|
|
128
|
+
attempted: (batch.attempted ?? 0) + summary.attempted,
|
|
129
|
+
verifiedTotal: (batch.verifiedTotal ?? 0) + summary.verifiedThisCall,
|
|
130
|
+
verifiedThisCall: summary.verifiedThisCall,
|
|
131
|
+
unknown: summary.unknownInBatch,
|
|
132
|
+
remaining: summary.remaining,
|
|
133
|
+
finishedAt: stamp(),
|
|
134
|
+
status: summary.remaining || summary.unknownInBatch ? "PARTIAL" : "SETTLED",
|
|
135
|
+
});
|
|
136
|
+
next.updatedAt = stamp();
|
|
137
|
+
return next;
|
|
138
|
+
});
|
|
139
|
+
return summary;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function summarizeFollowBatch(taskId, batchId, handles, after, original, result, exceptional, evidenceRef) {
|
|
144
|
+
return {
|
|
145
|
+
taskId,
|
|
146
|
+
batchId,
|
|
147
|
+
planned: handles.length,
|
|
148
|
+
attempted: (after.state.attempts ?? [])
|
|
149
|
+
.slice(original.state.attempts?.length ?? 0)
|
|
150
|
+
.filter((a) => a.kind === "follow" &&
|
|
151
|
+
handles.some((h) => same(after.state.actions.find((x) => x.id === a.actionId)?.handle, h))).length,
|
|
152
|
+
verifiedThisCall: after.state.actions.filter((a) => a.kind === "follow" &&
|
|
153
|
+
a.status === "verified" &&
|
|
154
|
+
handles.some((h) => same(h, a.handle)) &&
|
|
155
|
+
original.state.actions.find((old) => old.id === a.id)?.status !==
|
|
156
|
+
"verified").length,
|
|
157
|
+
unknownInBatch: after.state.actions.filter((a) => a.kind === "follow" &&
|
|
158
|
+
handles.some((h) => same(a.handle, h)) &&
|
|
159
|
+
["unknown", "intent"].includes(a.status)).length,
|
|
160
|
+
remaining: after.state.actions.filter((a) => a.kind === "follow" &&
|
|
161
|
+
handles.some((h) => same(a.handle, h)) &&
|
|
162
|
+
a.status === "prepared").length,
|
|
163
|
+
actual: result?.actual,
|
|
164
|
+
decision: exceptional,
|
|
165
|
+
mayEnd: result?.mayEnd ?? false,
|
|
166
|
+
evidenceRef,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const kinds = ["follow", "original", "reply"];
|
|
2
|
+
export const followWindows = [
|
|
3
|
+
[600000, 20],
|
|
4
|
+
[3600000, 100],
|
|
5
|
+
];
|
|
6
|
+
export const operations = {
|
|
7
|
+
follow: "submitFollow",
|
|
8
|
+
original: "submitPost",
|
|
9
|
+
reply: "submitReply",
|
|
10
|
+
};
|
|
11
|
+
export const same = (a, b) => typeof a === "string" &&
|
|
12
|
+
typeof b === "string" &&
|
|
13
|
+
a.toLowerCase() === b.toLowerCase();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BoundaryRecord } from "./boundary.js";
|
|
2
|
+
import type { RoundStore } from "./store.js";
|
|
3
|
+
/** Explicit instance dependencies; each owner accepts only its declared subset. */
|
|
4
|
+
export interface GrowthDependencies {
|
|
5
|
+
platform: BoundaryRecord;
|
|
6
|
+
store: RoundStore;
|
|
7
|
+
clock: () => number;
|
|
8
|
+
executor: BoundaryRecord | undefined;
|
|
9
|
+
stamp: () => string;
|
|
10
|
+
evidenceExists: (path: string) => boolean;
|
|
11
|
+
runtimeId: string;
|
|
12
|
+
session: {
|
|
13
|
+
claim: any;
|
|
14
|
+
running: boolean;
|
|
15
|
+
};
|
|
16
|
+
authorizedWindow: (state: any, kind: string) => boolean;
|
|
17
|
+
scope: (state: any) => any;
|
|
18
|
+
project: (state: any) => any;
|
|
19
|
+
load: (taskId: any) => Promise<any>;
|
|
20
|
+
save: (snapshot: any, state: any) => Promise<any>;
|
|
21
|
+
result: (snapshot: any, needs?: any) => any;
|
|
22
|
+
capability: (operation: any, input?: any) => any;
|
|
23
|
+
waitBudget: (state: any) => any;
|
|
24
|
+
guardInput: (state: any, action: any, independentExecution?: any) => any;
|
|
25
|
+
settleProof: (snapshot: any, action: any, observation: any, { preserveStatus }?: any) => Promise<any>;
|
|
26
|
+
execute: (snapshot: any, action: any) => Promise<any>;
|
|
27
|
+
parallelRound: ({ taskId, decision, selector, requestedKind, maxActions, }?: any) => Promise<any>;
|
|
28
|
+
round: ({ taskId, decision, selector, requestedKind }?: any) => Promise<any>;
|
|
29
|
+
publishIndependent: ({ taskId, draftPath, preparedAction, independentExecution, }: any) => Promise<any>;
|
|
30
|
+
releaseSession: (taskId?: any) => Promise<any>;
|
|
31
|
+
readTask: (operation: any, { mode, ...input }?: any) => Promise<any>;
|
|
32
|
+
prepareActionsForCall: (taskId: string, actions?: any[]) => Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BoundaryRecord } from "./boundary.js";
|
|
2
|
+
import type { RoundSnapshot } from "./store.js";
|
|
3
|
+
interface ExecutionStep {
|
|
4
|
+
snapshot: RoundSnapshot;
|
|
5
|
+
need: BoundaryRecord | null;
|
|
6
|
+
}
|
|
7
|
+
import type { GrowthDependencies } from "./growth-dependencies.js";
|
|
8
|
+
export declare function createGrowthExecution(dependencies: Pick<GrowthDependencies, "capability" | "platform" | "settleProof" | "clock" | "save" | "guardInput" | "waitBudget" | "authorizedWindow" | "stamp" | "store" | "executor" | "session">): {
|
|
9
|
+
execute: (snapshot: any, action: any) => Promise<ExecutionStep>;
|
|
10
|
+
};
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { operations, same } from "./growth-constants.js";
|
|
2
|
+
export function createGrowthExecution(dependencies) {
|
|
3
|
+
const { capability, platform, settleProof, clock, save, guardInput, waitBudget, authorizedWindow, stamp, store, executor, session, } = dependencies;
|
|
4
|
+
async function checkExecutionIdentity(snapshot, action) {
|
|
5
|
+
const state = snapshot.state;
|
|
6
|
+
let own;
|
|
7
|
+
try {
|
|
8
|
+
own = await platform.readAccount();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return {
|
|
12
|
+
snapshot,
|
|
13
|
+
need: {
|
|
14
|
+
status: "needsDecision",
|
|
15
|
+
actionId: action.id,
|
|
16
|
+
reason: "account-read-failed",
|
|
17
|
+
error: error.message.slice(0, 240),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (own.needsStateCheck ||
|
|
22
|
+
own.alerts?.length ||
|
|
23
|
+
!same(own.accountHandle, state.accountHandle))
|
|
24
|
+
return {
|
|
25
|
+
snapshot,
|
|
26
|
+
need: {
|
|
27
|
+
status: "needsDecision",
|
|
28
|
+
actionId: action.id,
|
|
29
|
+
reason: "fresh-account-unconfirmed",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
async function admitAction(snapshot, action) {
|
|
35
|
+
const state = snapshot.state;
|
|
36
|
+
const item = state.items.find((i) => i.kind === action.kind);
|
|
37
|
+
if (!item.actionable)
|
|
38
|
+
return {
|
|
39
|
+
snapshot,
|
|
40
|
+
need: {
|
|
41
|
+
status: "needsDecision",
|
|
42
|
+
actionId: action.id,
|
|
43
|
+
reason: item.reason ?? "original-kind-not-actionable",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const inputError = guardInput(state, action);
|
|
47
|
+
if (inputError)
|
|
48
|
+
return {
|
|
49
|
+
snapshot,
|
|
50
|
+
need: {
|
|
51
|
+
status: "needsDecision",
|
|
52
|
+
actionId: action.id,
|
|
53
|
+
reason: inputError,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const identity = await checkExecutionIdentity(snapshot, action);
|
|
57
|
+
if (identity)
|
|
58
|
+
return identity;
|
|
59
|
+
if (action.kind === "follow") {
|
|
60
|
+
const budget = waitBudget(state);
|
|
61
|
+
if (budget)
|
|
62
|
+
return { snapshot, need: { ...budget, actionId: action.id } };
|
|
63
|
+
let value;
|
|
64
|
+
try {
|
|
65
|
+
value = await platform.readPerson(action);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
snapshot,
|
|
70
|
+
need: {
|
|
71
|
+
status: "needsDecision",
|
|
72
|
+
actionId: action.id,
|
|
73
|
+
reason: "person-read-failed",
|
|
74
|
+
error: error.message.slice(0, 240),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (value.needsStateCheck ||
|
|
79
|
+
value.alerts?.length ||
|
|
80
|
+
value.relationship === "unknown" ||
|
|
81
|
+
!same(value.accountHandle, state.accountHandle))
|
|
82
|
+
return {
|
|
83
|
+
snapshot,
|
|
84
|
+
need: {
|
|
85
|
+
status: "needsDecision",
|
|
86
|
+
actionId: action.id,
|
|
87
|
+
reason: "fresh-person-unconfirmed",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
if (value.blue === false)
|
|
91
|
+
return {
|
|
92
|
+
snapshot,
|
|
93
|
+
need: {
|
|
94
|
+
status: "needsDecision",
|
|
95
|
+
actionId: action.id,
|
|
96
|
+
reason: "fresh-blue-qualification-contradicts-review",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
if (value.relationship === "following") {
|
|
100
|
+
const next = structuredClone(state);
|
|
101
|
+
next.actions.find((a) => a.id === action.id).status =
|
|
102
|
+
"preexisting";
|
|
103
|
+
return { snapshot: await save(snapshot, next), need: null };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
108
|
+
timeZone: "Asia/Shanghai",
|
|
109
|
+
hour: "2-digit",
|
|
110
|
+
hourCycle: "h23",
|
|
111
|
+
}).format(clock()));
|
|
112
|
+
if ((hour < 7 || hour >= (action.kind === "reply" ? 24 : 22)) &&
|
|
113
|
+
!authorizedWindow(state, action.kind))
|
|
114
|
+
return {
|
|
115
|
+
snapshot,
|
|
116
|
+
need: {
|
|
117
|
+
status: "needsDecision",
|
|
118
|
+
actionId: action.id,
|
|
119
|
+
reason: "outside-contract-write-window",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let beforeIds = [];
|
|
124
|
+
if (action.kind !== "follow") {
|
|
125
|
+
let baseline;
|
|
126
|
+
try {
|
|
127
|
+
baseline = await platform.findSubmission({
|
|
128
|
+
...action,
|
|
129
|
+
intentAt: stamp(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
snapshot,
|
|
135
|
+
need: {
|
|
136
|
+
status: "needsDecision",
|
|
137
|
+
actionId: action.id,
|
|
138
|
+
reason: "publication-baseline-read-failed",
|
|
139
|
+
error: error.message.slice(0, 240),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (baseline.needsStateCheck || baseline.alerts?.length)
|
|
144
|
+
return {
|
|
145
|
+
snapshot,
|
|
146
|
+
need: {
|
|
147
|
+
status: "needsDecision",
|
|
148
|
+
actionId: action.id,
|
|
149
|
+
reason: "publication-baseline-unconfirmed",
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
beforeIds = (baseline.posts ?? []).map((p) => p.id).filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
return { beforeIds };
|
|
155
|
+
}
|
|
156
|
+
async function execute(snapshot, action) {
|
|
157
|
+
const recovering = ["intent", "unknown"].includes(action.status);
|
|
158
|
+
const caps = capability(recovering ? "read" : operations[action.kind], action);
|
|
159
|
+
if (!caps.covered)
|
|
160
|
+
return {
|
|
161
|
+
snapshot,
|
|
162
|
+
need: { status: "needsCapability", actionId: action.id, ...caps },
|
|
163
|
+
};
|
|
164
|
+
// Recovery is read-only even outside the write window. Never retry an intent.
|
|
165
|
+
if (recovering) {
|
|
166
|
+
let observation;
|
|
167
|
+
try {
|
|
168
|
+
observation = await platform.findSubmission(action);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
observation = {
|
|
172
|
+
verified: false,
|
|
173
|
+
missing: ["dedup-read-failed"],
|
|
174
|
+
error: error.message.slice(0, 240),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
snapshot = await settleProof(snapshot, action, observation);
|
|
178
|
+
return {
|
|
179
|
+
snapshot,
|
|
180
|
+
need: observation.verified
|
|
181
|
+
? null
|
|
182
|
+
: {
|
|
183
|
+
status: "needsDecision",
|
|
184
|
+
actionId: action.id,
|
|
185
|
+
reason: "submission-unknown-do-not-resubmit",
|
|
186
|
+
accountAlert: Boolean(observation.alerts?.length),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (snapshot.state.items
|
|
191
|
+
.find((i) => i.kind === action.kind)
|
|
192
|
+
.reason?.startsWith("harness:") &&
|
|
193
|
+
Date.parse(snapshot.state.items.find((i) => i.kind === action.kind).resumeAt) <= clock()) {
|
|
194
|
+
const next = structuredClone(snapshot.state), pending = next.items.find((i) => i.kind === action.kind);
|
|
195
|
+
pending.actionable = true;
|
|
196
|
+
delete pending.reason;
|
|
197
|
+
delete pending.resumeAt;
|
|
198
|
+
delete pending.resumeWhen;
|
|
199
|
+
delete pending.blockEvidence;
|
|
200
|
+
next.status = "RUNNING";
|
|
201
|
+
snapshot = await save(snapshot, next);
|
|
202
|
+
}
|
|
203
|
+
const admission = await admitAction(snapshot, action);
|
|
204
|
+
if ("snapshot" in admission)
|
|
205
|
+
return admission;
|
|
206
|
+
const { beforeIds } = admission;
|
|
207
|
+
const state = snapshot.state;
|
|
208
|
+
// Persist intent BEFORE any externally visible submission. Even a failed attempt consumes budget.
|
|
209
|
+
const next = structuredClone(state);
|
|
210
|
+
const entry = next.actions.find((a) => a.id === action.id);
|
|
211
|
+
entry.status = "intent";
|
|
212
|
+
entry.intentAt = stamp();
|
|
213
|
+
entry.beforeIds = beforeIds;
|
|
214
|
+
next.items.find((i) => i.kind === action.kind).unknown += 1;
|
|
215
|
+
next.attempts ??= [];
|
|
216
|
+
next.attempts.push({
|
|
217
|
+
actionId: action.id,
|
|
218
|
+
kind: action.kind,
|
|
219
|
+
at: entry.intentAt,
|
|
220
|
+
});
|
|
221
|
+
if (action.kind === "follow") {
|
|
222
|
+
next.execution ??= {};
|
|
223
|
+
const old = next.execution.followGroup;
|
|
224
|
+
next.execution.followGroup = {
|
|
225
|
+
attempts: old?.attempts < 10 && clock() < Date.parse(old.lastAt) + 180000
|
|
226
|
+
? old.attempts + 1
|
|
227
|
+
: 1,
|
|
228
|
+
lastAt: entry.intentAt,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
snapshot = await save(snapshot, next);
|
|
232
|
+
let observation, submissionMayHaveStarted = false;
|
|
233
|
+
try {
|
|
234
|
+
await platform[operations[action.kind]]({
|
|
235
|
+
...entry,
|
|
236
|
+
beforeSubmit: async () => {
|
|
237
|
+
const latest = await store.load();
|
|
238
|
+
if (executor) {
|
|
239
|
+
const current = latest.state.actions.find((a) => a.id === entry.id);
|
|
240
|
+
if (latest.state.batchId !== snapshot.state.batchId ||
|
|
241
|
+
current?.claim?.token !== session.claim.token ||
|
|
242
|
+
JSON.stringify(current) !==
|
|
243
|
+
JSON.stringify(snapshot.state.actions.find((a) => a.id === entry.id)))
|
|
244
|
+
throw new Error("提交前本动作或claim变化");
|
|
245
|
+
}
|
|
246
|
+
else if (latest.revision !== snapshot.revision)
|
|
247
|
+
throw new Error("提交前原台账变化,保留intent并重核执行权");
|
|
248
|
+
const inputError = guardInput(latest.state, latest.state.actions.find((a) => a.id === entry.id));
|
|
249
|
+
if (inputError)
|
|
250
|
+
throw new Error(inputError);
|
|
251
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
252
|
+
timeZone: "Asia/Shanghai",
|
|
253
|
+
hour: "2-digit",
|
|
254
|
+
hourCycle: "h23",
|
|
255
|
+
}).format(clock()));
|
|
256
|
+
if ((hour < 7 || hour >= (action.kind === "reply" ? 24 : 22)) &&
|
|
257
|
+
!authorizedWindow(latest.state, action.kind))
|
|
258
|
+
throw new Error("实际提交时已越过运营窗口");
|
|
259
|
+
if (action.kind === "follow") {
|
|
260
|
+
const check = structuredClone(latest.state);
|
|
261
|
+
check.attempts = check.attempts.filter((a) => a.actionId !== entry.id);
|
|
262
|
+
if (check.execution.followGroup)
|
|
263
|
+
check.execution.followGroup.attempts -= 1;
|
|
264
|
+
if (waitBudget(check))
|
|
265
|
+
throw new Error("实际提交前关注预算不再可用");
|
|
266
|
+
}
|
|
267
|
+
submissionMayHaveStarted = true;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
observation = await platform.findSubmission(entry);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (!submissionMayHaveStarted) {
|
|
274
|
+
const proof = await store.recordProof({
|
|
275
|
+
capturedAt: stamp(),
|
|
276
|
+
submissionStarted: false,
|
|
277
|
+
error: error.message.slice(0, 240),
|
|
278
|
+
});
|
|
279
|
+
const unsubmitted = structuredClone(snapshot.state), target = unsubmitted.actions.find((a) => a.id === entry.id);
|
|
280
|
+
target.status = "prepared";
|
|
281
|
+
target.preparationEvidence = proof;
|
|
282
|
+
unsubmitted.items.find((i) => i.kind === action.kind).unknown -= 1;
|
|
283
|
+
snapshot = await save(snapshot, unsubmitted);
|
|
284
|
+
return {
|
|
285
|
+
snapshot,
|
|
286
|
+
need: {
|
|
287
|
+
status: "needsDecision",
|
|
288
|
+
actionId: action.id,
|
|
289
|
+
reason: "pre-submit-preparation-failed",
|
|
290
|
+
evidenceRef: proof,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// A transport error does not distinguish a rejected write from a successful write.
|
|
295
|
+
observation = {
|
|
296
|
+
verified: false,
|
|
297
|
+
missing: ["submission-or-verification-failed"],
|
|
298
|
+
error: error.message.slice(0, 240),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
snapshot = await settleProof(snapshot, entry, observation);
|
|
302
|
+
return {
|
|
303
|
+
snapshot,
|
|
304
|
+
need: observation.verified
|
|
305
|
+
? null
|
|
306
|
+
: {
|
|
307
|
+
status: "needsDecision",
|
|
308
|
+
actionId: action.id,
|
|
309
|
+
reason: "submission-unknown-do-not-resubmit",
|
|
310
|
+
accountAlert: Boolean(observation.alerts?.length),
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { execute };
|
|
315
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GrowthDependencies } from "./growth-dependencies.js";
|
|
2
|
+
export declare function createParallelRound(dependencies: Pick<GrowthDependencies, "load" | "scope" | "result" | "store" | "executor" | "runtimeId" | "stamp" | "evidenceExists" | "execute" | "clock" | "releaseSession" | "session">): {
|
|
3
|
+
parallelRound: ({ taskId, decision, selector, requestedKind, maxActions, }?: any) => Promise<any>;
|
|
4
|
+
};
|