@nextclaw/x-harness 0.1.0 → 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.
Files changed (113) hide show
  1. package/LICENSE +231 -6
  2. package/README.md +17 -3
  3. package/SOURCE.md +9 -0
  4. package/cli/main.js +232 -188
  5. package/dist/api.d.ts +56 -2
  6. package/dist/api.js +178 -75
  7. package/dist/boundary.js +1 -0
  8. package/dist/browser-common.d.ts +11 -0
  9. package/dist/browser-common.js +34 -18
  10. package/dist/browser-dom-capabilities.d.ts +9 -0
  11. package/dist/browser-dom-capabilities.js +131 -0
  12. package/dist/browser-dom-dependencies.d.ts +16 -0
  13. package/dist/browser-dom-dependencies.js +1 -0
  14. package/dist/browser-dom-editing.d.ts +166 -0
  15. package/dist/browser-dom-editing.js +418 -0
  16. package/dist/browser-dom-navigation.d.ts +18 -0
  17. package/dist/browser-dom-navigation.js +181 -0
  18. package/dist/browser-dom-network.d.ts +1 -0
  19. package/dist/browser-dom-network.js +128 -0
  20. package/dist/browser-dom-submission.d.ts +7 -0
  21. package/dist/browser-dom-submission.js +101 -0
  22. package/dist/browser-dom.d.ts +14 -2
  23. package/dist/browser-dom.js +120 -824
  24. package/dist/browser-native.d.ts +14 -1
  25. package/dist/browser-native.js +180 -143
  26. package/dist/browser.js +6 -2
  27. package/dist/creator-native.d.ts +3 -0
  28. package/dist/creator-native.js +113 -0
  29. package/dist/creator.js +1 -1
  30. package/dist/diagnostics.d.ts +11 -0
  31. package/dist/diagnostics.js +10 -0
  32. package/dist/growth-batch.d.ts +8 -0
  33. package/dist/growth-batch.js +190 -0
  34. package/dist/growth-constants.d.ts +5 -0
  35. package/dist/growth-constants.js +13 -0
  36. package/dist/growth-dependencies.d.ts +33 -0
  37. package/dist/growth-dependencies.js +1 -0
  38. package/dist/growth-execution.d.ts +17 -0
  39. package/dist/growth-execution.js +346 -0
  40. package/dist/growth-parallel.d.ts +8 -0
  41. package/dist/growth-parallel.js +332 -0
  42. package/dist/growth-plan.d.ts +9 -0
  43. package/dist/growth-plan.js +229 -0
  44. package/dist/growth-policy.d.ts +18 -0
  45. package/dist/growth-policy.js +208 -0
  46. package/dist/growth-publishing.d.ts +10 -0
  47. package/dist/growth-publishing.js +266 -0
  48. package/dist/growth-reading.d.ts +8 -0
  49. package/dist/growth-reading.js +157 -0
  50. package/dist/growth-recovery.d.ts +9 -0
  51. package/dist/growth-recovery.js +187 -0
  52. package/dist/growth-state.d.ts +16 -0
  53. package/dist/growth-state.js +220 -0
  54. package/dist/growth-tasks.d.ts +20 -0
  55. package/dist/growth-tasks.js +164 -0
  56. package/dist/growth.d.ts +26 -1
  57. package/dist/growth.js +211 -1908
  58. package/dist/platform-capabilities.d.ts +8 -0
  59. package/dist/platform-capabilities.js +29 -0
  60. package/dist/platform-collections.d.ts +14 -0
  61. package/dist/platform-collections.js +250 -0
  62. package/dist/platform-dependencies.d.ts +43 -0
  63. package/dist/platform-dependencies.js +1 -0
  64. package/dist/platform-diagnostics.d.ts +14 -0
  65. package/dist/platform-diagnostics.js +63 -0
  66. package/dist/platform-observation.d.ts +16 -0
  67. package/dist/platform-observation.js +96 -0
  68. package/dist/platform-rules.d.ts +17 -0
  69. package/dist/platform-rules.js +41 -0
  70. package/dist/platform-targets.d.ts +14 -0
  71. package/dist/platform-targets.js +154 -0
  72. package/dist/platform-writing.d.ts +16 -0
  73. package/dist/platform-writing.js +202 -0
  74. package/dist/platform.d.ts +23 -2
  75. package/dist/platform.js +125 -678
  76. package/dist/readers-actions.d.ts +2 -0
  77. package/dist/readers-actions.js +66 -0
  78. package/dist/readers-ax-host.d.ts +12 -0
  79. package/dist/readers-ax-host.js +134 -0
  80. package/dist/readers-ax-parser.d.ts +1 -0
  81. package/dist/readers-ax-parser.js +69 -0
  82. package/dist/readers-ax-program.d.ts +1 -0
  83. package/dist/readers-ax-program.js +5 -0
  84. package/dist/readers-ax-tree.d.ts +19 -0
  85. package/dist/readers-ax-tree.js +111 -0
  86. package/dist/readers-ax-views.d.ts +87 -0
  87. package/dist/readers-ax-views.js +247 -0
  88. package/dist/readers-cli.d.ts +6 -0
  89. package/dist/readers-cli.js +79 -0
  90. package/dist/readers-composer.d.ts +2 -0
  91. package/dist/readers-composer.js +131 -0
  92. package/dist/readers-navigation.d.ts +2 -0
  93. package/dist/readers-navigation.js +66 -0
  94. package/dist/readers-posts.d.ts +2 -0
  95. package/dist/readers-posts.js +141 -0
  96. package/dist/readers-profile.d.ts +3 -0
  97. package/dist/readers-profile.js +157 -0
  98. package/dist/readers.d.ts +9 -20
  99. package/dist/readers.js +9 -1045
  100. package/dist/results.js +1 -1
  101. package/dist/round-state.js +20 -3
  102. package/dist/store.d.ts +22 -4
  103. package/dist/store.js +173 -151
  104. package/dist/types.d.ts +10 -0
  105. package/dist/types.js +1 -0
  106. package/generated/observation.mjs +3 -2
  107. package/package.json +23 -9
  108. package/skill/SKILL.md +17 -36
  109. package/skill/references/api.md +7 -34
  110. package/skill/references/reads.md +23 -0
  111. package/skill/references/recovery.md +25 -0
  112. package/skill/references/setup.md +23 -0
  113. package/skill/references/writes.md +51 -0
package/dist/growth.js CHANGED
@@ -1,1918 +1,221 @@
1
- import { checkRound } from "./round-state.js";
2
- import { xURL } from "./platform.js";
3
1
  import { randomUUID } from "node:crypto";
4
- const kinds = ["follow", "original", "reply"];
5
- const followWindows = [
6
- [600000, 20],
7
- [3600000, 100],
8
- ];
9
- const operations = {
10
- follow: "submitFollow",
11
- original: "submitPost",
12
- reply: "submitReply",
13
- };
14
- const same = (a, b) => typeof a === "string" &&
15
- typeof b === "string" &&
16
- a.toLowerCase() === b.toLowerCase();
17
- // No UI implementation, hidden state, scheduling or LLM calls in this layer.
18
- export function createGrowthHarness({ platform, store, clock = () => Date.now(), executor, } = {}) {
19
- const stamp = () => new Date(clock()).toISOString();
20
- const evidenceExists = store.evidenceExists ?? (() => false);
21
- const runtimeId = randomUUID();
22
- let claim, running = false;
23
- const readCache = new Map();
24
- if (executor &&
25
- (!executor.id ||
26
- !executor.tabId ||
27
- !Array.isArray(executor.scopeKinds) ||
28
- !executor.scopeKinds.length ||
29
- new Set(executor.scopeKinds).size !== executor.scopeKinds.length ||
30
- executor.scopeKinds.some((k) => !kinds.includes(k)) ||
31
- !evidenceExists(executor.evidenceRef) ||
32
- typeof store.transaction !== "function"))
33
- throw new Error("并行执行者需ID、独立tab、有效职责及原合同证据与transaction");
34
- const scope = (state) => executor?.scopeKinds ?? state.scopeKinds ?? kinds;
35
- const project = (state) => executor
36
- ? {
37
- ...state,
38
- scopeKinds: executor.scopeKinds,
39
- scopeEvidenceRef: executor.evidenceRef,
40
- status: state.executions?.[executor.id]?.status ?? "RUNNING",
41
- stop: state.executions?.[executor.id]?.stop,
42
- }
43
- : state;
44
- async function load(taskId) {
45
- const snapshot = await store.load();
46
- if (snapshot.state.batchId !== taskId)
47
- throw new Error("任务ID不是原台账当前批次");
48
- // Expired WAITING is actionable, not a malformed ledger. Validate structure separately from stopping.
49
- const gate = checkRound({ ...project(snapshot.state), status: "RUNNING" }, { now: clock(), evidenceExists });
50
- if (!gate.valid)
51
- throw new Error(`原轮次标记无效:${gate.errors.join(";")}`);
52
- const actions = snapshot.state.actions ?? [];
53
- if (new Set(actions.map((a) => a.id)).size !== actions.length)
54
- throw new Error("原队列动作ID重复");
55
- for (const a of actions) {
56
- if (!a.id ||
57
- !kinds.includes(a.kind) ||
58
- ![
59
- "prepared",
60
- "intent",
61
- "unknown",
62
- "verified",
63
- "preexisting",
64
- "skipped",
65
- ].includes(a.status))
66
- throw new Error("原队列动作结构无效");
67
- }
68
- return snapshot;
69
- }
70
- async function save(snapshot, state) {
71
- state.updatedAt = stamp();
72
- if (!executor)
73
- return store.save(snapshot, state);
74
- // Batch metadata still uses a short withLock transaction; it never contains browser I/O.
75
- if (!claim)
76
- return store.save(snapshot, state);
77
- return store.transaction((latest) => {
78
- const next = structuredClone(latest.state), before = snapshot.state.actions.find((a) => a.id === claim.actionId);
79
- const entry = next.actions.find((a) => a.id === claim.actionId), after = state.actions.find((a) => a.id === claim.actionId);
80
- if (next.batchId !== snapshot.state.batchId ||
81
- entry?.claim?.token !== claim.token ||
82
- JSON.stringify({ ...entry, claim: undefined }) !==
83
- JSON.stringify({ ...before, claim: undefined }))
84
- throw new Error("action-claim-or-version-conflict");
85
- const oldItem = snapshot.state.items.find((i) => i.kind === entry.kind), newItem = state.items.find((i) => i.kind === entry.kind);
86
- const item = next.items.find((i) => i.kind === entry.kind);
87
- const reservation = before.status === "prepared" && after.status === "intent";
88
- if (reservation) {
89
- if (!item.actionable || item.verified + item.unknown >= item.planned)
90
- throw new Error("shared-target-capacity-exhausted");
91
- if (entry.kind === "follow" && waitBudget(next))
92
- throw new Error("shared-follow-budget-exhausted");
93
- }
94
- if (after.status === "verified" &&
95
- entry.kind !== "follow" &&
96
- next.actions.some((a) => a.id !== entry.id &&
97
- a.status === "verified" &&
98
- a.publicId === after.publicId))
99
- throw new Error("同一公开结果不能重复结算");
100
- Object.assign(entry, after, { claim: entry.claim });
101
- item.verified += newItem.verified - oldItem.verified;
102
- item.unknown += newItem.unknown - oldItem.unknown;
103
- if (item.unknown < 0 || item.verified + item.unknown > item.planned)
104
- throw new Error("并行结算越界");
105
- item.resultEvidence = [
106
- ...new Set([
107
- ...(item.resultEvidence ?? []),
108
- ...(newItem.resultEvidence ?? []),
109
- ]),
110
- ];
111
- for (const key of [
112
- "actionable",
113
- "reason",
114
- "resumeAt",
115
- "resumeWhen",
116
- "blockEvidence",
117
- ]) {
118
- if (JSON.stringify(oldItem[key]) !== JSON.stringify(newItem[key])) {
119
- if (key in newItem)
120
- item[key] = newItem[key];
121
- else
122
- delete item[key];
123
- }
124
- }
125
- if (reservation) {
126
- const attempt = state.attempts.at(-1);
127
- next.attempts ??= [];
128
- next.attempts.push(attempt);
129
- if (entry.kind === "follow") {
130
- next.execution ??= {};
131
- const old = next.execution.followGroup;
132
- next.execution.followGroup = {
133
- attempts: old?.attempts < 10 && clock() < Date.parse(old.lastAt) + 180000
134
- ? old.attempts + 1
135
- : 1,
136
- lastAt: attempt.at,
137
- };
138
- }
139
- }
140
- next.status = "RUNNING";
141
- next.updatedAt = stamp();
142
- return next;
2
+ import { kinds } from "./growth-constants.js";
3
+ import { createGrowthPolicy } from "./growth-policy.js";
4
+ import { createGrowthState } from "./growth-state.js";
5
+ import { createGrowthExecution } from "./growth-execution.js";
6
+ import { createParallelRound } from "./growth-parallel.js";
7
+ import { createPlannedRound } from "./growth-plan.js";
8
+ import { createIndependentPublishing } from "./growth-publishing.js";
9
+ import { createGrowthReading } from "./growth-reading.js";
10
+ import { createGrowthRecovery } from "./growth-recovery.js";
11
+ import { createFollowBatch } from "./growth-batch.js";
12
+ import { createGrowthTasks } from "./growth-tasks.js";
13
+ /** Synchronous composition only; owners retain one original ledger and settlement path. */
14
+ export class GrowthHarness {
15
+ #platform;
16
+ #store;
17
+ #clock;
18
+ #executor;
19
+ #session;
20
+ #evidenceExists;
21
+ #runtimeId;
22
+ #authorizedWindow;
23
+ #scope;
24
+ #project;
25
+ #capability;
26
+ #waitBudget;
27
+ #guardInput;
28
+ #load;
29
+ #save;
30
+ #result;
31
+ #settleProof;
32
+ #releaseSession;
33
+ #execute;
34
+ #parallelRound;
35
+ #round;
36
+ #publishIndependent;
37
+ #prepareActionsForCall;
38
+ #readTask;
39
+ #createGrowthRecoveryResult;
40
+ #createFollowBatchResult;
41
+ #createGrowthTasksResult;
42
+ verifyPublishedPost;
43
+ verifySubmission;
44
+ verifyFollow;
45
+ followBatch;
46
+ preflight;
47
+ readCreator;
48
+ inspectAccount;
49
+ readPost;
50
+ readPerson;
51
+ readBookmarks;
52
+ searchSources;
53
+ readFeedback;
54
+ runGrowthRound;
55
+ resumeGrowthRound;
56
+ inspectRound;
57
+ followOne;
58
+ publishPost;
59
+ replyToPost;
60
+ constructor({ platform, store, clock = () => Date.now(), executor, } = {}) {
61
+ this.#platform = platform;
62
+ this.#store = store;
63
+ this.#clock = clock;
64
+ this.#executor = executor;
65
+ this.#session = { claim: undefined, running: false };
66
+ this.#evidenceExists = this.#store.evidenceExists ?? (() => false);
67
+ this.#runtimeId = randomUUID();
68
+ if (this.#executor &&
69
+ (!this.#executor.id ||
70
+ !this.#executor.tabId ||
71
+ !Array.isArray(this.#executor.scopeKinds) ||
72
+ !this.#executor.scopeKinds.length ||
73
+ new Set(this.#executor.scopeKinds).size !==
74
+ this.#executor.scopeKinds.length ||
75
+ this.#executor.scopeKinds.some((k) => !kinds.includes(k)) ||
76
+ !this.#evidenceExists(this.#executor.evidenceRef) ||
77
+ typeof this.#store.transaction !== "function"))
78
+ throw new Error("并行执行者需ID、独立tab、有效职责及原合同证据与transaction");
79
+ const { authorizedWindow, scope, project, capability, waitBudget, guardInput, } = createGrowthPolicy({
80
+ executor: this.#executor,
81
+ clock: this.#clock,
82
+ evidenceExists: this.#evidenceExists,
83
+ platform: this.#platform,
84
+ store: this.#store,
143
85
  });
144
- }
145
- function result(snapshot, needs = []) {
146
- const gate = checkRound(project(snapshot.state), {
147
- now: clock(),
148
- evidenceExists,
86
+ this.#authorizedWindow = authorizedWindow;
87
+ this.#scope = scope;
88
+ this.#project = project;
89
+ this.#capability = capability;
90
+ this.#waitBudget = waitBudget;
91
+ this.#guardInput = guardInput;
92
+ const { load, save, result, settleProof, releaseSession } = createGrowthState({
93
+ store: this.#store,
94
+ project: this.#project,
95
+ clock: this.#clock,
96
+ evidenceExists: this.#evidenceExists,
97
+ stamp: this.stamp.bind(this),
98
+ executor: this.#executor,
99
+ waitBudget: this.#waitBudget,
100
+ platform: this.#platform,
101
+ runtimeId: this.#runtimeId,
102
+ session: this.#session,
149
103
  });
150
- return {
151
- status: gate.mayEnd
152
- ? gate.status === "COMPLETE"
153
- ? "complete"
154
- : "waiting"
155
- : needs.some((n) => n.status === "needsCapability")
156
- ? "needsCapability"
157
- : "needsDecision",
158
- taskId: snapshot.state.batchId,
159
- mayEnd: gate.mayEnd,
160
- scopeKinds: gate.scopeKinds,
161
- actual: Object.fromEntries(snapshot.state.items.map((i) => [
162
- i.kind,
163
- { verified: i.verified, unknown: i.unknown },
164
- ])),
165
- gaps: gate.pending,
166
- ...(needs.length ? { decision: needs } : {}),
167
- evidenceRef: snapshot.path ?? store.path,
168
- ...(gate.errors.length ? { settlementErrors: gate.errors } : {}),
169
- };
170
- }
171
- function capability(operation, input = {}) {
172
- const kind = input.kind ??
173
- {
174
- followOne: "follow",
175
- followBatch: "follow",
176
- submitFollow: "follow",
177
- publishPost: "original",
178
- submitPost: "original",
179
- replyToPost: "reply",
180
- submitReply: "reply",
181
- }[operation];
182
- const required = [
183
- "preflight",
184
- "readAccount",
185
- "findSubmission",
186
- "captureProof",
187
- ...(operation === "read" ? [] : [operations[kind]]),
188
- ];
189
- if (kind === "follow")
190
- required.push("readPerson");
191
- const missing = required.filter((name) => typeof platform?.[name] !== "function");
192
- const storage = ["load", "save", "withLock", "recordProof"].filter((name) => typeof store?.[name] !== "function");
193
- if (operation === "followBatch" && typeof store?.transaction !== "function")
194
- storage.push("transaction");
195
- if (kind === "original" &&
196
- operation !== "read" &&
197
- typeof store?.fingerprint !== "function")
198
- storage.push("fingerprint");
199
- const lower = typeof platform?.preflight === "function"
200
- ? platform.preflight({
201
- operation: operation === "read" ? "read" : operations[kind],
202
- input,
203
- })
204
- : { covered: false, missing: [] };
205
- return {
206
- covered: !missing.length && !storage.length && lower.covered,
207
- missing: [
208
- ...missing.map((n) => `platform.${n}`),
209
- ...storage.map((n) => `store.${n}`),
210
- ...(lower.missing ?? []),
211
- ],
212
- owner: missing.length
213
- ? "scripts/x-harness-platform.mjs"
214
- : storage.length
215
- ? "scripts/x-harness-store.mjs"
216
- : lower.owner,
217
- };
218
- }
219
- function waitBudget(state) {
220
- const now = clock();
221
- const hour = Number(new Intl.DateTimeFormat("en-GB", {
222
- timeZone: "Asia/Shanghai",
223
- hour: "2-digit",
224
- hourCycle: "h23",
225
- }).format(now));
226
- if (hour < 7 || hour >= 22)
227
- return {
228
- status: "needsDecision",
229
- reason: "outside-contract-write-window",
230
- };
231
- const coverage = state.attemptsCoverage;
232
- if (!coverage?.complete ||
233
- !Number.isFinite(Date.parse(coverage.since)) ||
234
- Date.parse(coverage.since) > now - 3600000 ||
235
- !evidenceExists(coverage.evidenceRef))
236
- return {
237
- status: "needsDecision",
238
- reason: "shared-attempt-history-incomplete",
239
- };
240
- const attempts = state.attempts ?? [];
241
- if (attempts.some((a) => !kinds.includes(a.kind) || !Number.isFinite(Date.parse(a.at))))
242
- return { status: "needsDecision", reason: "attempt-history-invalid" };
243
- const follows = attempts.filter((a) => a.kind === "follow");
244
- for (const [duration, limit] of followWindows) {
245
- const recent = follows
246
- .filter((a) => Date.parse(a.at) > now - duration)
247
- .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
248
- if (recent.length >= limit)
249
- return {
250
- status: "waiting",
251
- reason: "follow-rolling-budget",
252
- resumeAt: new Date(Date.parse(recent[recent.length - limit].at) + duration).toISOString(),
253
- };
254
- }
255
- const group = state.execution?.followGroup;
256
- if (group?.attempts >= 10 && now < Date.parse(group.lastAt) + 180000)
257
- return {
258
- status: "waiting",
259
- reason: "follow-group-cooldown",
260
- resumeAt: new Date(Date.parse(group.lastAt) + 180000).toISOString(),
261
- };
262
- return null;
263
- }
264
- function guardInput(state, action, independentExecution = undefined) {
265
- if (!independentExecution && !scope(state).includes(action.kind))
266
- return "kind-outside-round-scope";
267
- if (independentExecution) {
268
- if (action.kind !== "original" ||
269
- !independentExecution.tabId ||
270
- !evidenceExists(independentExecution.evidenceRef) ||
271
- !evidenceExists(independentExecution.scopeEvidenceRef))
272
- return "independent-execution-proof-missing";
273
- }
274
- else if (!executor &&
275
- (!state.execution?.exclusive || state.execution.owner !== "harness"))
276
- return "exclusive-account-execution-unconfirmed";
277
- if (!same(state.accountHandle, action.accountHandle))
278
- return "account-identity-mismatch";
279
- if (!evidenceExists(action.accountEvidenceRef) ||
280
- xURL(action.accountURL).pathname.toLowerCase() !==
281
- `/${state.accountHandle}`.toLowerCase())
282
- return "own-profile-proof-missing";
283
- if (!action.quality?.approved ||
284
- !evidenceExists(action.quality.evidenceRef))
285
- return "quality-review-required";
286
- if (action.kind === "follow") {
287
- if (!same(action.quality.handle, action.handle) ||
288
- action.quality.blue !== true ||
289
- action.quality.chinese !== true)
290
- return "follow-qualification-required";
291
- if (!evidenceExists(action.evidenceRef))
292
- return "person-observation-proof-missing";
293
- }
294
- else {
295
- if (!action.text || action.quality.approvedText !== action.text)
296
- return "exact-text-quality-review-required";
297
- if (action.kind === "original" &&
298
- (!action.draftPath || !evidenceExists(action.draftPath)))
299
- return "draft-source-missing";
300
- if (action.kind === "original" &&
301
- (!action.draftPath.endsWith(".post.md") ||
302
- action.quality.draftFingerprint !==
303
- store.fingerprint(action.draftPath)))
304
- return "draft-changed-since-quality-review";
305
- if (action.kind === "original" &&
306
- (!evidenceExists(action.composeEvidenceRef) ||
307
- xURL(action.composeURL).pathname !== "/compose/post"))
308
- return "observed-composer-proof-missing";
309
- if (action.kind === "reply" &&
310
- (!/^\d+$/.test(action.postId ?? "") ||
311
- !evidenceExists(action.evidenceRef)))
312
- return "reply-target-proof-missing";
313
- }
314
- return null;
315
- }
316
- async function settleProof(snapshot, action, observation, { preserveStatus = false } = {}) {
317
- const proof = await store.recordProof(await platform.captureProof({ observation }));
318
- const state = structuredClone(snapshot.state);
319
- const entry = state.actions.find((a) => a.id === action.id);
320
- const item = state.items.find((i) => i.kind === action.kind);
321
- if (observation.verified) {
322
- if (action.kind !== "follow" &&
323
- (!/^\d+$/.test(observation.publicId ?? "") ||
324
- state.actions.some((a) => a.id !== action.id &&
325
- a.status === "verified" &&
326
- a.publicId === observation.publicId)))
327
- throw new Error("同一公开结果不能重复结算");
328
- if (["intent", "unknown"].includes(entry.status))
329
- item.unknown -= 1;
330
- if (entry.status !== "verified")
331
- item.verified += 1;
332
- entry.status = "verified";
333
- entry.publicId = observation.publicId;
334
- entry.resultEvidence = proof;
335
- item.resultEvidence = [
336
- ...new Set([...(item.resultEvidence ?? []), proof]),
337
- ];
338
- }
339
- else {
340
- if (!["intent", "unknown"].includes(entry.status))
341
- item.unknown += 1;
342
- entry.status = "unknown";
343
- entry.resultEvidence = proof;
344
- }
345
- if (item.verified > item.planned || item.unknown < 0)
346
- throw new Error("结算越界:不覆盖原目标或重复计数");
347
- if (!preserveStatus)
348
- state.status = "RUNNING";
349
- return save(snapshot, state);
350
- }
351
- async function execute(snapshot, action) {
352
- const recovering = ["intent", "unknown"].includes(action.status);
353
- const caps = capability(recovering ? "read" : operations[action.kind], action);
354
- if (!caps.covered)
355
- return {
356
- snapshot,
357
- need: { status: "needsCapability", actionId: action.id, ...caps },
358
- };
359
- // Recovery is read-only even outside the write window. Never retry an intent.
360
- if (recovering) {
361
- let observation;
362
- try {
363
- observation = await platform.findSubmission(action);
364
- }
365
- catch (error) {
366
- observation = {
367
- verified: false,
368
- missing: ["dedup-read-failed"],
369
- error: error.message.slice(0, 240),
370
- };
371
- }
372
- snapshot = await settleProof(snapshot, action, observation);
373
- return {
374
- snapshot,
375
- need: observation.verified
376
- ? null
377
- : {
378
- status: "needsDecision",
379
- actionId: action.id,
380
- reason: "submission-unknown-do-not-resubmit",
381
- accountAlert: Boolean(observation.alerts?.length),
382
- },
383
- };
384
- }
385
- if (snapshot.state.items
386
- .find((i) => i.kind === action.kind)
387
- .reason?.startsWith("harness:") &&
388
- Date.parse(snapshot.state.items.find((i) => i.kind === action.kind).resumeAt) <= clock()) {
389
- const next = structuredClone(snapshot.state), pending = next.items.find((i) => i.kind === action.kind);
390
- pending.actionable = true;
391
- delete pending.reason;
392
- delete pending.resumeAt;
393
- delete pending.resumeWhen;
394
- delete pending.blockEvidence;
395
- next.status = "RUNNING";
396
- snapshot = await save(snapshot, next);
397
- }
398
- const state = snapshot.state;
399
- const item = state.items.find((i) => i.kind === action.kind);
400
- if (!item.actionable)
401
- return {
402
- snapshot,
403
- need: {
404
- status: "needsDecision",
405
- actionId: action.id,
406
- reason: item.reason ?? "original-kind-not-actionable",
407
- },
408
- };
409
- const inputError = guardInput(state, action);
410
- if (inputError)
411
- return {
412
- snapshot,
413
- need: {
414
- status: "needsDecision",
415
- actionId: action.id,
416
- reason: inputError,
417
- },
418
- };
419
- let own;
420
- try {
421
- own = await platform.readAccount();
422
- }
423
- catch (error) {
424
- return {
425
- snapshot,
426
- need: {
427
- status: "needsDecision",
428
- actionId: action.id,
429
- reason: "account-read-failed",
430
- error: error.message.slice(0, 240),
431
- },
432
- };
433
- }
434
- if (own.needsStateCheck ||
435
- own.alerts?.length ||
436
- !same(own.accountHandle, state.accountHandle))
437
- return {
438
- snapshot,
439
- need: {
440
- status: "needsDecision",
441
- actionId: action.id,
442
- reason: "fresh-account-unconfirmed",
443
- },
444
- };
445
- if (action.kind === "follow") {
446
- const budget = waitBudget(state);
447
- if (budget)
448
- return { snapshot, need: { ...budget, actionId: action.id } };
449
- let value;
450
- try {
451
- value = await platform.readPerson(action);
452
- }
453
- catch (error) {
454
- return {
455
- snapshot,
456
- need: {
457
- status: "needsDecision",
458
- actionId: action.id,
459
- reason: "person-read-failed",
460
- error: error.message.slice(0, 240),
461
- },
462
- };
463
- }
464
- if (value.needsStateCheck ||
465
- value.alerts?.length ||
466
- value.relationship === "unknown" ||
467
- !same(value.accountHandle, state.accountHandle))
468
- return {
469
- snapshot,
470
- need: {
471
- status: "needsDecision",
472
- actionId: action.id,
473
- reason: "fresh-person-unconfirmed",
474
- },
475
- };
476
- if (value.blue === false)
477
- return {
478
- snapshot,
479
- need: {
480
- status: "needsDecision",
481
- actionId: action.id,
482
- reason: "fresh-blue-qualification-contradicts-review",
483
- },
484
- };
485
- if (value.relationship === "following") {
486
- const next = structuredClone(state);
487
- next.actions.find((a) => a.id === action.id).status =
488
- "preexisting";
489
- return { snapshot: await save(snapshot, next), need: null };
490
- }
491
- }
492
- else {
493
- const hour = Number(new Intl.DateTimeFormat("en-GB", {
494
- timeZone: "Asia/Shanghai",
495
- hour: "2-digit",
496
- hourCycle: "h23",
497
- }).format(clock()));
498
- if (hour < 7 || hour >= 22)
499
- return {
500
- snapshot,
501
- need: {
502
- status: "needsDecision",
503
- actionId: action.id,
504
- reason: "outside-contract-write-window",
505
- },
506
- };
507
- }
508
- let beforeIds = [];
509
- if (action.kind !== "follow") {
510
- let baseline;
511
- try {
512
- baseline = await platform.findSubmission({
513
- ...action,
514
- intentAt: stamp(),
515
- });
516
- }
517
- catch (error) {
518
- return {
519
- snapshot,
520
- need: {
521
- status: "needsDecision",
522
- actionId: action.id,
523
- reason: "publication-baseline-read-failed",
524
- error: error.message.slice(0, 240),
525
- },
526
- };
527
- }
528
- if (baseline.needsStateCheck || baseline.alerts?.length)
529
- return {
530
- snapshot,
531
- need: {
532
- status: "needsDecision",
533
- actionId: action.id,
534
- reason: "publication-baseline-unconfirmed",
535
- },
536
- };
537
- beforeIds = (baseline.posts ?? []).map((p) => p.id).filter(Boolean);
538
- }
539
- // Persist intent BEFORE any externally visible submission. Even a failed attempt consumes budget.
540
- const next = structuredClone(state);
541
- const entry = next.actions.find((a) => a.id === action.id);
542
- entry.status = "intent";
543
- entry.intentAt = stamp();
544
- entry.beforeIds = beforeIds;
545
- next.items.find((i) => i.kind === action.kind).unknown += 1;
546
- next.attempts ??= [];
547
- next.attempts.push({
548
- actionId: action.id,
549
- kind: action.kind,
550
- at: entry.intentAt,
104
+ this.#load = load;
105
+ this.#save = save;
106
+ this.#result = result;
107
+ this.#settleProof = settleProof;
108
+ this.#releaseSession = releaseSession;
109
+ const { execute } = createGrowthExecution({
110
+ capability: this.#capability,
111
+ platform: this.#platform,
112
+ settleProof: this.#settleProof,
113
+ clock: this.#clock,
114
+ save: this.#save,
115
+ guardInput: this.#guardInput,
116
+ waitBudget: this.#waitBudget,
117
+ authorizedWindow: this.#authorizedWindow,
118
+ stamp: this.stamp.bind(this),
119
+ store: this.#store,
120
+ executor: this.#executor,
121
+ session: this.#session,
551
122
  });
552
- if (action.kind === "follow") {
553
- next.execution ??= {};
554
- const old = next.execution.followGroup;
555
- next.execution.followGroup = {
556
- attempts: old?.attempts < 10 && clock() < Date.parse(old.lastAt) + 180000
557
- ? old.attempts + 1
558
- : 1,
559
- lastAt: entry.intentAt,
560
- };
561
- }
562
- snapshot = await save(snapshot, next);
563
- let observation, submissionMayHaveStarted = false;
564
- try {
565
- await platform[operations[action.kind]]({
566
- ...entry,
567
- beforeSubmit: async () => {
568
- const latest = await store.load();
569
- if (executor) {
570
- const current = latest.state.actions.find((a) => a.id === entry.id);
571
- if (latest.state.batchId !== snapshot.state.batchId ||
572
- current?.claim?.token !== claim.token ||
573
- JSON.stringify(current) !==
574
- JSON.stringify(snapshot.state.actions.find((a) => a.id === entry.id)))
575
- throw new Error("提交前本动作或claim变化");
576
- }
577
- else if (latest.revision !== snapshot.revision)
578
- throw new Error("提交前原台账变化,保留intent并重核执行权");
579
- const inputError = guardInput(latest.state, latest.state.actions.find((a) => a.id === entry.id));
580
- if (inputError)
581
- throw new Error(inputError);
582
- const hour = Number(new Intl.DateTimeFormat("en-GB", {
583
- timeZone: "Asia/Shanghai",
584
- hour: "2-digit",
585
- hourCycle: "h23",
586
- }).format(clock()));
587
- if (hour < 7 || hour >= 22)
588
- throw new Error("实际提交时已越过运营窗口");
589
- if (action.kind === "follow") {
590
- const check = structuredClone(latest.state);
591
- check.attempts = check.attempts.filter((a) => a.actionId !== entry.id);
592
- if (check.execution.followGroup)
593
- check.execution.followGroup.attempts -= 1;
594
- if (waitBudget(check))
595
- throw new Error("实际提交前关注预算不再可用");
596
- }
597
- submissionMayHaveStarted = true;
598
- },
599
- });
600
- observation = await platform.findSubmission(entry);
601
- }
602
- catch (error) {
603
- if (!submissionMayHaveStarted) {
604
- const proof = await store.recordProof({
605
- capturedAt: stamp(),
606
- submissionStarted: false,
607
- error: error.message.slice(0, 240),
608
- });
609
- const unsubmitted = structuredClone(snapshot.state), target = unsubmitted.actions.find((a) => a.id === entry.id);
610
- target.status = "prepared";
611
- target.preparationEvidence = proof;
612
- unsubmitted.items.find((i) => i.kind === action.kind).unknown -= 1;
613
- snapshot = await save(snapshot, unsubmitted);
614
- return {
615
- snapshot,
616
- need: {
617
- status: "needsDecision",
618
- actionId: action.id,
619
- reason: "pre-submit-preparation-failed",
620
- evidenceRef: proof,
621
- },
622
- };
623
- }
624
- // A transport error does not distinguish a rejected write from a successful write.
625
- observation = {
626
- verified: false,
627
- missing: ["submission-or-verification-failed"],
628
- error: error.message.slice(0, 240),
629
- };
630
- }
631
- snapshot = await settleProof(snapshot, entry, observation);
632
- return {
633
- snapshot,
634
- need: observation.verified
635
- ? null
636
- : {
637
- status: "needsDecision",
638
- actionId: action.id,
639
- reason: "submission-unknown-do-not-resubmit",
640
- accountAlert: Boolean(observation.alerts?.length),
641
- },
642
- };
643
- }
644
- async function parallelRound({ taskId, decision, selector, requestedKind, maxActions = 10, } = {}) {
645
- if (running)
646
- return {
647
- status: "needsDecision",
648
- mayEnd: false,
649
- taskId,
650
- decision: [{ reason: "same-tab-operation-running" }],
651
- };
652
- running = true;
653
- let completed = 0;
654
- const needs = [];
655
- try {
656
- if (!Number.isInteger(maxActions) || maxActions < 1 || maxActions > 100)
657
- throw new Error("maxActions须为1–100");
658
- let snapshot = await load(taskId);
659
- const activeKinds = scope(snapshot.state);
660
- if (requestedKind && !activeKinds.includes(requestedKind))
661
- return {
662
- ...result(snapshot),
663
- status: "needsDecision",
664
- decision: [{ reason: "kind-outside-round-scope" }],
665
- };
666
- snapshot = await store.transaction((latest) => {
667
- if (latest.state.batchId !== taskId)
668
- throw new Error("original-task-changed");
669
- const next = structuredClone(latest.state);
670
- next.executions ??= {};
671
- if (Object.values(next.executions).some((e) => (e.tabId === executor.tabId || e.id === executor.id) &&
672
- e.runtimeId !== runtimeId &&
673
- (e.activity || e.actionId) &&
674
- store.ownerAlive(e.pid)))
675
- throw new Error("same-tab-or-executor-claimed");
676
- next.executions[executor.id] = {
677
- ...next.executions[executor.id],
678
- ...executor,
679
- runtimeId,
680
- pid: store.processId,
681
- activity: "round",
682
- status: "RUNNING",
683
- updatedAt: stamp(),
684
- };
685
- if (decision?.type === "prepareActions") {
686
- if (!Array.isArray(decision.actions) ||
687
- !same(next.accountHandle, decision.accountHandle) ||
688
- !evidenceExists(decision.ownershipEvidenceRef))
689
- throw new Error("需原账号核验和完整动作队列");
690
- next.actions ??= [];
691
- for (const action of decision.actions) {
692
- if (!action.id ||
693
- !activeKinds.includes(action.kind) ||
694
- action.status !== "prepared" ||
695
- next.actions.some((a) => a.id === action.id))
696
- throw new Error("原prepared动作ID无效");
697
- next.actions.push(structuredClone(action));
698
- }
699
- if (next.items.some((i) => next.actions.filter((a) => a.kind === i.kind && a.status === "prepared").length >
700
- i.planned - i.verified - i.unknown))
701
- throw new Error("队列超过原目标");
702
- if (decision.sharedAttempts) {
703
- if (!Array.isArray(decision.sharedAttempts))
704
- throw new Error("共享尝试须为数组");
705
- next.attempts = [
706
- ...(next.attempts ?? []),
707
- ...structuredClone(decision.sharedAttempts),
708
- ];
709
- }
710
- if (decision.attemptsCoverage) {
711
- if (!decision.attemptsCoverage.complete ||
712
- !evidenceExists(decision.attemptsCoverage.evidenceRef))
713
- throw new Error("共享历史缺证据");
714
- next.attemptsCoverage = structuredClone(decision.attemptsCoverage);
715
- }
716
- if (decision.nextScheduledCheckAt)
717
- next.executions[executor.id].nextScheduledCheckAt =
718
- decision.nextScheduledCheckAt;
719
- }
720
- else if (decision) {
721
- const action = next.actions?.find((a) => a.id === decision.actionId);
722
- if (!action ||
723
- !activeKinds.includes(action.kind) ||
724
- action.status !== "prepared" ||
725
- action.claim ||
726
- typeof decision.approved !== "boolean" ||
727
- !evidenceExists(decision.evidenceRef))
728
- throw new Error("审查只绑定职责内未领取的原prepared动作");
729
- if (!decision.approved)
730
- action.status = "skipped";
731
- else
732
- action.quality = {
733
- approved: true,
734
- evidenceRef: decision.evidenceRef,
735
- ...(action.kind === "follow"
736
- ? {
737
- handle: action.handle,
738
- blue: decision.blue === true,
739
- chinese: decision.chinese === true,
740
- }
741
- : {
742
- approvedText: decision.approvedText,
743
- ...(action.kind === "original"
744
- ? { draftFingerprint: decision.draftFingerprint }
745
- : {}),
746
- }),
747
- };
748
- }
749
- next.updatedAt = stamp();
750
- return next;
751
- });
752
- for (const kind of activeKinds) {
753
- const queue = (snapshot.state.actions ?? [])
754
- .filter((a) => a.kind === kind && (!selector || selector(a)))
755
- .sort((a, b) => Number(["intent", "unknown"].includes(b.status)) -
756
- Number(["intent", "unknown"].includes(a.status)));
757
- if (snapshot.state.items.find((i) => i.kind === kind).unknown >
758
- snapshot.state.actions.filter((a) => a.kind === kind && ["intent", "unknown"].includes(a.status)).length) {
759
- needs.push({
760
- status: "needsDecision",
761
- reason: "original-unmapped-unknown-needs-dedup",
762
- kind,
763
- });
764
- continue;
765
- }
766
- for (const original of queue) {
767
- if (completed >= maxActions)
768
- break;
769
- if (["verified", "preexisting", "skipped"].includes(original.status))
770
- continue;
771
- try {
772
- snapshot = await store.transaction((latest) => {
773
- const next = structuredClone(latest.state), action = next.actions.find((a) => a.id === original.id);
774
- if (next.batchId !== taskId)
775
- throw new Error("original-task-changed");
776
- if (["verified", "preexisting", "skipped"].includes(action.status))
777
- return null;
778
- const sameTarget = (other) => other.kind === action.kind &&
779
- (kind === "follow"
780
- ? same(other.handle, action.handle)
781
- : kind === "original"
782
- ? other.draftPath === action.draftPath
783
- : other.postId === action.postId &&
784
- other.text === action.text);
785
- if (next.actions.some((a) => a.id !== action.id &&
786
- sameTarget(a) &&
787
- (a.claim ||
788
- ["intent", "unknown", "verified"].includes(a.status))))
789
- throw new Error("same-target-already-claimed-or-submitted");
790
- if (action.claim && store.ownerAlive(action.claim.pid))
791
- throw new Error("same-action-claimed");
792
- if (Object.values(next.executions).some((e) => e.tabId === executor.tabId &&
793
- e.actionId &&
794
- e.runtimeId !== runtimeId &&
795
- store.ownerAlive(e.pid)))
796
- throw new Error("same-tab-claimed");
797
- claim = {
798
- actionId: action.id,
799
- token: randomUUID(),
800
- executorId: executor.id,
801
- runtimeId,
802
- pid: store.processId,
803
- acquiredAt: stamp(),
804
- };
805
- action.claim = claim;
806
- next.executions[executor.id].actionId = action.id;
807
- next.updatedAt = stamp();
808
- return next;
809
- });
810
- if (!claim)
811
- continue;
812
- const outcome = await execute(snapshot, snapshot.state.actions.find((a) => a.id === original.id));
813
- snapshot = outcome.snapshot;
814
- completed++;
815
- if (outcome.need) {
816
- needs.push(outcome.need);
817
- break;
818
- }
819
- }
820
- catch (error) {
821
- needs.push({
822
- status: "needsDecision",
823
- kind,
824
- actionId: original.id,
825
- reason: /claimed|same-target/.test(error.message)
826
- ? "action-or-tab-claimed"
827
- : /budget|capacity/.test(error.message)
828
- ? "shared-reservation-unavailable"
829
- : "harness-boundary-error",
830
- error: error.message.slice(0, 240),
831
- });
832
- break;
833
- }
834
- finally {
835
- if (claim) {
836
- const owned = claim;
837
- claim = null;
838
- snapshot = await store.transaction((latest) => {
839
- const next = structuredClone(latest.state), action = next.actions?.find((a) => a.id === owned.actionId);
840
- if (action?.claim?.token !== owned.token)
841
- return null;
842
- delete action.claim;
843
- if (next.executions[executor.id]?.runtimeId === runtimeId)
844
- delete next.executions[executor.id].actionId;
845
- next.updatedAt = stamp();
846
- return next;
847
- });
848
- }
849
- }
850
- }
851
- }
852
- snapshot = await store.transaction(async (latest) => {
853
- const next = structuredClone(latest.state), own = next.executions[executor.id];
854
- const pending = next.items.filter((i) => activeKinds.includes(i.kind) &&
855
- (i.verified < i.planned || i.unknown));
856
- own.status = pending.length ? "RUNNING" : "COMPLETE";
857
- const scheduled = own.nextScheduledCheckAt ??
858
- next.execution?.nextScheduledCheckAt ??
859
- next.stop?.nextScheduledCheckAt;
860
- const waits = pending.map((i) => needs.find((n) => n.status === "waiting" &&
861
- next.actions.find((a) => a.id === n.actionId)?.kind ===
862
- i.kind));
863
- if (pending.length &&
864
- pending.every((i) => !i.unknown &&
865
- next.actions.filter((a) => a.kind === i.kind && a.status === "prepared").length >=
866
- i.planned - i.verified) &&
867
- waits.every((w) => w && Date.parse(w.resumeAt) > clock()) &&
868
- Date.parse(scheduled) > clock()) {
869
- for (let i = 0; i < pending.length; i++) {
870
- const proof = await store.recordProof({
871
- observedAt: stamp(),
872
- ...waits[i],
873
- attempts: next.attempts,
874
- });
875
- Object.assign(pending[i], {
876
- actionable: false,
877
- reason: `harness:${waits[i].reason}`,
878
- resumeAt: waits[i].resumeAt,
879
- resumeWhen: `不早于${waits[i].resumeAt}重核`,
880
- blockEvidence: [proof],
881
- });
882
- }
883
- own.status = "WAITING";
884
- own.stop = {
885
- independentActionsHandled: true,
886
- nextScheduledCheckAt: scheduled,
887
- nextEligibleCheckAt: waits.map((w) => w.resumeAt).sort()[0],
888
- };
889
- }
890
- next.status = next.items.every((i) => i.verified === i.planned && !i.unknown)
891
- ? "COMPLETE"
892
- : "RUNNING";
893
- own.updatedAt = next.updatedAt = stamp();
894
- return next;
895
- });
896
- const out = result(snapshot, needs);
897
- if (!out.mayEnd && !needs.length && completed >= maxActions)
898
- return {
899
- ...out,
900
- status: "checkpoint",
901
- checkpoint: {
902
- taskId,
903
- executorId: executor.id,
904
- completed,
905
- resume: "resumeGrowthRound",
906
- },
907
- };
908
- if (!out.mayEnd && !needs.length)
909
- out.decision = [
910
- { reason: "prepared-queue-does-not-cover-original-targets" },
911
- ];
912
- return out;
913
- }
914
- catch (error) {
915
- return {
916
- status: "needsDecision",
917
- mayEnd: false,
918
- taskId,
919
- evidenceRef: store.path,
920
- decision: [
921
- {
922
- reason: "harness-boundary-error",
923
- error: error.message.slice(0, 240),
924
- },
925
- ],
926
- };
927
- }
928
- finally {
929
- try {
930
- await releaseSession(taskId);
931
- }
932
- finally {
933
- running = false;
934
- }
935
- }
936
- }
937
- async function round({ taskId, decision, selector, requestedKind, } = {}) {
938
- try {
939
- return await store.withLock(async () => {
940
- let snapshot = await load(taskId);
941
- const activeKinds = snapshot.state.scopeKinds ?? kinds;
942
- if (requestedKind && !activeKinds.includes(requestedKind))
943
- return {
944
- ...result(snapshot),
945
- status: "needsDecision",
946
- mayEnd: false,
947
- decision: [
948
- { kind: requestedKind, reason: "kind-outside-round-scope" },
949
- ],
950
- };
951
- if (decision?.type === "prepareActions") {
952
- if (!Array.isArray(decision.actions) ||
953
- !/^[A-Za-z0-9_]{1,15}$/.test(decision.accountHandle ?? "") ||
954
- !evidenceExists(decision.ownershipEvidenceRef) ||
955
- (snapshot.state.execution?.owner &&
956
- snapshot.state.execution.owner !== "harness"))
957
- throw new Error("需原账号执行权核验和完整动作队列;不接管已有执行者");
958
- if (snapshot.state.accountHandle &&
959
- !same(snapshot.state.accountHandle, decision.accountHandle))
960
- throw new Error("不能变更原账号");
961
- const next = structuredClone(snapshot.state);
962
- next.actions ??= [];
963
- for (const action of decision.actions) {
964
- if (!action.id ||
965
- !activeKinds.includes(action.kind) ||
966
- action.status !== "prepared" ||
967
- next.actions.some((a) => a.id === action.id))
968
- throw new Error("仅添加职责范围内唯一ID的prepared原动作");
969
- next.actions.push(structuredClone(action));
970
- }
971
- if (next.items.some((item) => next.actions.filter((a) => a.kind === item.kind && a.status === "prepared").length >
972
- item.planned - item.verified - item.unknown))
973
- throw new Error("prepared队列不能越过原批次未决目标");
974
- next.accountHandle = decision.accountHandle;
975
- next.execution = {
976
- ...next.execution,
977
- owner: "harness",
978
- exclusive: true,
979
- ownershipEvidenceRef: decision.ownershipEvidenceRef,
980
- ...(decision.nextScheduledCheckAt
981
- ? { nextScheduledCheckAt: decision.nextScheduledCheckAt }
982
- : {}),
983
- };
984
- if (decision.attemptsCoverage) {
985
- if (!decision.attemptsCoverage.complete ||
986
- !evidenceExists(decision.attemptsCoverage.evidenceRef))
987
- throw new Error("共享历史须完整取证;不能默认为零");
988
- next.attemptsCoverage = structuredClone(decision.attemptsCoverage);
989
- }
990
- if (decision.sharedAttempts) {
991
- if (!Array.isArray(decision.sharedAttempts))
992
- throw new Error("共享尝试记录必须为数组");
993
- next.attempts = [
994
- ...(next.attempts ?? []),
995
- ...structuredClone(decision.sharedAttempts),
996
- ];
997
- }
998
- snapshot = await save(snapshot, next);
999
- }
1000
- else if (decision) {
1001
- const action = snapshot.state.actions?.find((a) => a.id === decision.actionId);
1002
- if (action && !activeKinds.includes(action.kind))
1003
- throw new Error("kind-outside-round-scope");
1004
- if (!action ||
1005
- action.status !== "prepared" ||
1006
- typeof decision.approved !== "boolean" ||
1007
- !evidenceExists(decision.evidenceRef))
1008
- throw new Error("决策仅能绑定原prepared动作及真实审查证据");
1009
- const next = structuredClone(snapshot.state), target = next.actions.find((a) => a.id === action.id);
1010
- if (!decision.approved)
1011
- target.status = "skipped";
1012
- else
1013
- target.quality = {
1014
- approved: true,
1015
- evidenceRef: decision.evidenceRef,
1016
- ...(action.kind === "follow"
1017
- ? {
1018
- handle: action.handle,
1019
- blue: decision.blue === true,
1020
- chinese: decision.chinese === true,
1021
- }
1022
- : {
1023
- approvedText: decision.approvedText,
1024
- ...(action.kind === "original"
1025
- ? { draftFingerprint: decision.draftFingerprint }
1026
- : {}),
1027
- }),
1028
- };
1029
- snapshot = await save(snapshot, next);
1030
- }
1031
- const needs = [];
1032
- for (const kind of activeKinds) {
1033
- const queue = (snapshot.state.actions ?? [])
1034
- .filter((a) => a.kind === kind && (!selector || selector(a)))
1035
- .sort((a, b) => Number(["intent", "unknown"].includes(b.status)) -
1036
- Number(["intent", "unknown"].includes(a.status)));
1037
- // A selector narrows this invocation, not the original unresolved ledger.
1038
- const unresolved = (snapshot.state.actions ?? []).filter((a) => a.kind === kind && ["intent", "unknown"].includes(a.status)).length;
1039
- if (snapshot.state.items.find((i) => i.kind === kind).unknown >
1040
- unresolved) {
1041
- needs.push({
1042
- status: "needsDecision",
1043
- reason: "original-unmapped-unknown-needs-dedup",
1044
- kind,
1045
- });
1046
- continue;
1047
- }
1048
- for (const original of queue) {
1049
- const item = snapshot.state.items.find((i) => i.kind === kind);
1050
- const action = snapshot.state.actions.find((a) => a.id === original.id);
1051
- if (["verified", "preexisting", "skipped"].includes(action.status))
1052
- continue;
1053
- if (item.verified >= item.planned && action.status === "prepared")
1054
- continue;
1055
- const outcome = await execute(snapshot, action);
1056
- snapshot = outcome.snapshot;
1057
- if (outcome.need) {
1058
- needs.push(outcome.need);
1059
- break;
1060
- } // Stop only this kind; continue independent kinds.
1061
- }
1062
- }
1063
- if (snapshot.state.items
1064
- .filter((i) => activeKinds.includes(i.kind))
1065
- .every((i) => i.verified === i.planned && i.unknown === 0)) {
1066
- const next = structuredClone(snapshot.state);
1067
- next.status = "COMPLETE";
1068
- snapshot = await save(snapshot, next);
1069
- }
1070
- else {
1071
- const pending = snapshot.state.items.filter((i) => activeKinds.includes(i.kind) &&
1072
- (i.verified < i.planned || i.unknown > 0));
1073
- const waits = pending.map((item) => needs.find((n) => n.status === "waiting" &&
1074
- snapshot.state.actions.find((a) => a.id === n.actionId)
1075
- ?.kind === item.kind));
1076
- const scheduled = snapshot.state.execution?.nextScheduledCheckAt ??
1077
- snapshot.state.stop?.nextScheduledCheckAt;
1078
- const covered = pending.every((item) => item.unknown === 0 &&
1079
- (snapshot.state.actions ?? []).filter((a) => a.kind === item.kind && a.status === "prepared").length >=
1080
- item.planned - item.verified);
1081
- if (covered &&
1082
- waits.every((w) => w && Date.parse(w.resumeAt) > clock()) &&
1083
- Date.parse(scheduled) > clock()) {
1084
- const next = structuredClone(snapshot.state);
1085
- for (let i = 0; i < pending.length; i++) {
1086
- const item = next.items.find((a) => a.kind === pending[i].kind), wait = waits[i];
1087
- const proof = await store.recordProof({
1088
- observedAt: stamp(),
1089
- reason: wait.reason,
1090
- resumeAt: wait.resumeAt,
1091
- attempts: next.attempts,
1092
- });
1093
- Object.assign(item, {
1094
- actionable: false,
1095
- reason: `harness:${wait.reason}`,
1096
- resumeAt: wait.resumeAt,
1097
- resumeWhen: `不早于${wait.resumeAt}重核预算及fresh状态`,
1098
- blockEvidence: [proof],
1099
- });
1100
- }
1101
- next.status = "WAITING";
1102
- next.stop = {
1103
- independentActionsHandled: true,
1104
- nextScheduledCheckAt: scheduled,
1105
- nextEligibleCheckAt: waits.map((w) => w.resumeAt).sort()[0],
1106
- };
1107
- snapshot = await save(snapshot, next);
1108
- }
1109
- else if (!needs.length)
1110
- needs.push({
1111
- status: "needsDecision",
1112
- reason: "prepared-queue-does-not-cover-original-targets",
1113
- });
1114
- }
1115
- return result(snapshot, needs);
1116
- });
1117
- }
1118
- catch (error) {
1119
- return {
1120
- status: "needsDecision",
1121
- mayEnd: false,
1122
- taskId,
1123
- evidenceRef: store.path,
1124
- decision: [
1125
- {
1126
- reason: error.code === "CONFLICT"
1127
- ? "original-ledger-conflict"
1128
- : "harness-boundary-error",
1129
- error: error.message.slice(0, 240),
1130
- },
1131
- ],
1132
- };
1133
- }
1134
- }
1135
- // Independent tabs share only short ledger transactions, never a browser-operation lock.
1136
- async function publishIndependent({ taskId, draftPath, preparedAction, independentExecution, }) {
1137
- let action, token, submissionStarted = false;
1138
- const inWindow = () => {
1139
- const hour = Number(new Intl.DateTimeFormat("en-GB", {
1140
- timeZone: "Asia/Shanghai",
1141
- hour: "2-digit",
1142
- hourCycle: "h23",
1143
- }).format(clock()));
1144
- return hour >= 7 && hour < 22;
1145
- };
1146
- try {
1147
- const caps = capability("submitPost", preparedAction ?? {});
1148
- if (!caps.covered)
1149
- return { status: "needsCapability", ...caps };
1150
- if (!inWindow())
1151
- throw new Error("outside-contract-write-window");
1152
- await store.withLock(async () => {
1153
- const snapshot = await load(taskId), state = structuredClone(snapshot.state);
1154
- state.actions ??= [];
1155
- action = state.actions.find((a) => a.kind === "original" && a.draftPath === draftPath);
1156
- if (!action) {
1157
- if (!preparedAction?.id ||
1158
- preparedAction.kind !== "original" ||
1159
- preparedAction.status !== "prepared" ||
1160
- preparedAction.draftPath !== draftPath ||
1161
- state.actions.some((a) => a.id === preparedAction.id))
1162
- throw new Error("unique-prepared-original-required");
1163
- const error = guardInput(state, preparedAction, independentExecution);
1164
- if (error)
1165
- throw new Error(error);
1166
- action = structuredClone(preparedAction);
1167
- state.actions.push(action);
1168
- await save(snapshot, state);
1169
- }
1170
- if (action.status !== "prepared")
1171
- throw new Error("existing-submission-do-not-resubmit");
1172
- if (action.claim && store.ownerAlive(action.claim.pid))
1173
- throw new Error("same-action-claimed");
1174
- const error = guardInput(state, action, independentExecution);
1175
- if (error)
1176
- throw new Error(error);
1177
- });
1178
- const own = await platform.readAccount();
1179
- if (own.needsStateCheck ||
1180
- own.alerts?.length ||
1181
- !same(own.accountHandle, action.accountHandle))
1182
- throw new Error("fresh-account-unconfirmed");
1183
- const baseline = await platform.findSubmission({
1184
- ...action,
1185
- intentAt: stamp(),
1186
- });
1187
- if (baseline.needsStateCheck || baseline.alerts?.length)
1188
- throw new Error("publication-baseline-unconfirmed");
1189
- token = `${action.id}:${stamp()}:${Math.random()}`;
1190
- await store.withLock(async () => {
1191
- const snapshot = await load(taskId), state = structuredClone(snapshot.state);
1192
- const target = state.actions.find((a) => a.id === action.id), item = state.items.find((i) => i.kind === "original");
1193
- if (target?.status !== "prepared" ||
1194
- (target.claim && store.ownerAlive(target.claim.pid)))
1195
- throw new Error("action-already-claimed-do-not-resubmit");
1196
- const error = guardInput(state, target, independentExecution);
1197
- if (error)
1198
- throw new Error(error);
1199
- if (!inWindow() || item.verified + item.unknown >= item.planned)
1200
- throw new Error("window-or-original-capacity-unavailable");
1201
- Object.assign(target, {
1202
- status: "intent",
1203
- intentAt: stamp(),
1204
- beforeIds: (baseline.posts ?? [])
1205
- .map((p) => p.id)
1206
- .filter(Boolean),
1207
- claimToken: token,
1208
- independentExecution,
1209
- claim: {
1210
- actionId: target.id,
1211
- token,
1212
- pid: store.processId,
1213
- tabId: independentExecution.tabId,
1214
- acquiredAt: stamp(),
1215
- },
1216
- });
1217
- item.unknown += 1;
1218
- state.attempts ??= [];
1219
- state.attempts.push({
1220
- actionId: target.id,
1221
- kind: "original",
1222
- at: target.intentAt,
1223
- });
1224
- await save(snapshot, state);
1225
- action = structuredClone(target);
1226
- });
1227
- let observation;
1228
- try {
1229
- await platform.submitPost({
1230
- ...action,
1231
- beforeSubmit: async () => {
1232
- const latest = await load(taskId), target = latest.state.actions.find((a) => a.id === action.id);
1233
- if (target?.status !== "intent" ||
1234
- target.claimToken !== token ||
1235
- !inWindow())
1236
- throw new Error("action-claim-or-window-changed");
1237
- const error = guardInput(latest.state, target, independentExecution);
1238
- if (error)
1239
- throw new Error(error);
1240
- submissionStarted = true;
1241
- },
1242
- });
1243
- observation = await platform.findSubmission(action);
1244
- }
1245
- catch (error) {
1246
- if (!submissionStarted)
1247
- throw error;
1248
- observation = {
1249
- verified: false,
1250
- missing: ["submission-or-verification-failed"],
1251
- error: error.message.slice(0, 240),
1252
- };
1253
- }
1254
- return await store.withLock(async () => {
1255
- const snapshot = await load(taskId), target = snapshot.state.actions.find((a) => a.id === action.id);
1256
- if (target?.claimToken !== token || target.status !== "intent")
1257
- throw new Error("action-settlement-conflict");
1258
- await settleProof(snapshot, target, observation, {
1259
- preserveStatus: true,
1260
- });
1261
- return {
1262
- status: observation.verified ? "verified" : "unknown",
1263
- taskId,
1264
- actionId: action.id,
1265
- publicId: observation.publicId,
1266
- evidenceRef: store.path,
1267
- mayEnd: false,
1268
- };
1269
- });
1270
- }
1271
- catch (error) {
1272
- if (token && !submissionStarted)
1273
- await store.withLock(async () => {
1274
- const snapshot = await load(taskId), state = structuredClone(snapshot.state), target = state.actions.find((a) => a.id === action.id);
1275
- if (target?.claimToken !== token || target.status !== "intent")
1276
- return;
1277
- target.status = "prepared";
1278
- target.preparationEvidence = await store.recordProof({
1279
- observedAt: stamp(),
1280
- submissionStarted: false,
1281
- error: error.message.slice(0, 240),
1282
- });
1283
- state.items.find((i) => i.kind === "original").unknown -= 1;
1284
- await save(snapshot, state);
1285
- });
1286
- return {
1287
- status: "needsDecision",
1288
- taskId,
1289
- mayEnd: false,
1290
- decision: [
1291
- {
1292
- reason: error.code === "CONFLICT"
1293
- ? "original-ledger-conflict"
1294
- : error.message.slice(0, 240),
1295
- },
1296
- ],
1297
- };
1298
- }
1299
- finally {
1300
- if (token)
1301
- await store.transaction((snapshot) => {
1302
- if (snapshot.state.batchId !== taskId)
1303
- return null;
1304
- const next = structuredClone(snapshot.state), target = next.actions.find((a) => a.id === action.id);
1305
- if (target?.claim?.token !== token)
1306
- return null;
1307
- delete target.claim;
1308
- return next;
1309
- });
1310
- }
1311
- }
1312
- async function releaseSession(taskId = undefined) {
1313
- if (!executor)
1314
- return;
1315
- await store.transaction((latest) => {
1316
- const own = latest.state.executions?.[executor.id];
1317
- if ((taskId && latest.state.batchId !== taskId) ||
1318
- own?.runtimeId !== runtimeId ||
1319
- !own.activity)
1320
- return null;
1321
- const next = structuredClone(latest.state);
1322
- delete next.executions[executor.id].activity;
1323
- next.updatedAt = stamp();
1324
- return next;
123
+ this.#execute = execute;
124
+ const { parallelRound } = createParallelRound({
125
+ load: this.#load,
126
+ scope: this.#scope,
127
+ result: this.#result,
128
+ store: this.#store,
129
+ executor: this.#executor,
130
+ runtimeId: this.#runtimeId,
131
+ stamp: this.stamp.bind(this),
132
+ evidenceExists: this.#evidenceExists,
133
+ execute: this.#execute,
134
+ clock: this.#clock,
135
+ releaseSession: this.#releaseSession,
136
+ session: this.#session,
1325
137
  });
1326
- }
1327
- async function readTask(operation, { mode = "receipt", ...input } = {}) {
1328
- if (!["decision", "receipt", "delta"].includes(mode))
1329
- throw new Error("读取模式须为decision/receipt/delta");
1330
- if (running)
1331
- return {
1332
- status: "needsDecision",
1333
- complete: false,
1334
- reason: "same-tab-operation-running",
1335
- };
1336
- const caps = platform.preflight({
1337
- operation: ["readAccount", "readPerson", "readPost"].includes(operation)
1338
- ? "read"
1339
- : operation,
1340
- input,
138
+ this.#parallelRound = parallelRound;
139
+ const { round } = createPlannedRound({
140
+ store: this.#store,
141
+ load: this.#load,
142
+ result: this.#result,
143
+ evidenceExists: this.#evidenceExists,
144
+ save: this.#save,
145
+ execute: this.#execute,
146
+ clock: this.#clock,
147
+ stamp: this.stamp.bind(this),
148
+ });
149
+ this.#round = round;
150
+ const { publishIndependent, prepareActionsForCall } = createIndependentPublishing({
151
+ clock: this.#clock,
152
+ evidenceExists: this.#evidenceExists,
153
+ capability: this.#capability,
154
+ store: this.#store,
155
+ load: this.#load,
156
+ guardInput: this.#guardInput,
157
+ save: this.#save,
158
+ platform: this.#platform,
159
+ stamp: this.stamp.bind(this),
160
+ settleProof: this.#settleProof,
161
+ });
162
+ this.#publishIndependent = publishIndependent;
163
+ this.#prepareActionsForCall = prepareActionsForCall;
164
+ const { readTask } = createGrowthReading({
165
+ platform: this.#platform,
166
+ executor: this.#executor,
167
+ store: this.#store,
168
+ runtimeId: this.#runtimeId,
169
+ stamp: this.stamp.bind(this),
170
+ releaseSession: this.#releaseSession,
171
+ session: this.#session,
1341
172
  });
1342
- if (!caps.covered || typeof platform[operation] !== "function")
1343
- return {
1344
- status: "needsCapability",
1345
- ...caps,
1346
- missing: [
1347
- ...(caps.missing ?? []),
1348
- ...(typeof platform[operation] === "function"
1349
- ? []
1350
- : [`platform.${operation}`]),
1351
- ],
1352
- };
1353
- running = true;
1354
- try {
1355
- if (executor)
1356
- await store.transaction((latest) => {
1357
- const next = structuredClone(latest.state);
1358
- next.executions ??= {};
1359
- if (Object.values(next.executions).some((e) => (e.tabId === executor.tabId || e.id === executor.id) &&
1360
- e.runtimeId !== runtimeId &&
1361
- (e.activity || e.actionId) &&
1362
- store.ownerAlive(e.pid)))
1363
- throw new Error("same-tab-or-executor-claimed");
1364
- next.executions[executor.id] = {
1365
- ...next.executions[executor.id],
1366
- ...executor,
1367
- runtimeId,
1368
- pid: store.processId,
1369
- activity: operation,
1370
- status: next.executions[executor.id]?.status ?? "RUNNING",
1371
- updatedAt: stamp(),
1372
- };
1373
- next.updatedAt = stamp();
1374
- return next;
1375
- });
1376
- const value = await platform[operation](input);
1377
- const evidenceRef = await store.recordProof(await platform.captureProof({ observation: value }));
1378
- const status = value.status ??
1379
- (value.complete === false ||
1380
- value.needsStateCheck ||
1381
- value.alerts?.length
1382
- ? "needsDecision"
1383
- : "observed");
1384
- const receipt = {
1385
- status,
1386
- fresh: true,
1387
- evidenceRef,
1388
- complete: status === "observed" && (value.complete ?? !value.needsStateCheck),
1389
- ...((value.sources ?? value.feedback)
1390
- ? { ids: (value.sources ?? value.feedback).map((p) => p.id) }
1391
- : {}),
1392
- ...(operation === "readAccount"
1393
- ? {
1394
- accountHandle: value.accountHandle ?? null,
1395
- accountURL: value.accountURL ?? null,
1396
- }
1397
- : {}),
1398
- ...(value.missing?.length ? { missing: value.missing } : {}),
1399
- };
1400
- if (mode === "receipt")
1401
- return receipt;
1402
- const collection = value.sources
1403
- ? "sources"
1404
- : value.feedback
1405
- ? "feedback"
1406
- : null;
1407
- if (mode === "delta" && collection) {
1408
- const unchangedIds = [], changed = [];
1409
- for (const entry of value[collection]) {
1410
- const key = JSON.stringify([operation, input, entry.id]);
1411
- const normalized = JSON.stringify(entry, (name, val) => name === "observedAt" ? undefined : val);
1412
- if (readCache.get(key) === normalized)
1413
- unchangedIds.push(entry.id);
1414
- else
1415
- changed.push(entry);
1416
- readCache.set(key, normalized);
1417
- }
1418
- return { ...receipt, [collection]: changed, unchangedIds };
1419
- }
1420
- return mode === "delta" ? receipt : { ...value, ...receipt };
1421
- }
1422
- catch (error) {
1423
- const category = /connection|transport|interrupted|extension/i.test(error.message)
1424
- ? "browser-connection"
1425
- : "ui-not-confirmed";
1426
- const code = error.code === "CONFLICT"
1427
- ? "ledger-conflict"
1428
- : /same-tab-or-executor-claimed/.test(error.message)
1429
- ? "tab-busy"
1430
- : category === "browser-connection"
1431
- ? "connection-failed"
1432
- : "observation-unconfirmed";
1433
- const diagnostic = { operation, category, code };
1434
- const evidenceRef = await store.recordProof({
1435
- capturedAt: stamp(),
1436
- diagnostic: { ...diagnostic, error: error.message },
1437
- });
1438
- return {
1439
- status: "needsDecision",
1440
- fresh: false,
1441
- complete: false,
1442
- evidenceRef,
1443
- diagnostic: mode === "decision"
1444
- ? { ...diagnostic, error: error.message.slice(0, 240) }
1445
- : diagnostic,
1446
- };
1447
- }
1448
- finally {
1449
- try {
1450
- await releaseSession();
1451
- }
1452
- finally {
1453
- running = false;
1454
- }
1455
- }
173
+ this.#readTask = readTask;
174
+ this.composeTasks();
1456
175
  }
1457
- // Stable prepared actions are adopted in the original ledger, never in a second queue.
1458
- async function prepareActionsForCall(taskId, actions = []) {
1459
- if (!actions.length)
1460
- return;
1461
- await store.transaction(async (snapshot) => {
1462
- if (snapshot.state.batchId !== taskId)
1463
- throw new Error("original-task-id-required");
1464
- const next = structuredClone(snapshot.state);
1465
- next.actions ??= [];
1466
- const ids = new Set();
1467
- for (const action of actions) {
1468
- if (!action?.id ||
1469
- ids.has(action.id) ||
1470
- action.status !== "prepared" ||
1471
- !kinds.includes(action.kind))
1472
- throw new Error("unique-prepared-action-required");
1473
- ids.add(action.id);
1474
- const inputError = guardInput(next, action);
1475
- if (inputError)
1476
- throw new Error(inputError);
1477
- const existing = next.actions.find((a) => a.id === action.id);
1478
- if (existing) {
1479
- const fields = [
1480
- "kind",
1481
- "accountHandle",
1482
- "handle",
1483
- "postId",
1484
- "text",
1485
- "draftPath",
1486
- ];
1487
- if (fields.some((field) => existing[field] !== action[field]))
1488
- throw new Error("original-action-input-changed");
1489
- continue; // intent/unknown/verified stay untouched and follow their original recovery.
1490
- }
1491
- const duplicate = next.actions.some((a) => a.kind === action.kind &&
1492
- (action.kind === "follow"
1493
- ? same(a.handle, action.handle)
1494
- : action.kind === "original"
1495
- ? a.draftPath === action.draftPath
1496
- : a.postId === action.postId && a.text === action.text));
1497
- if (duplicate)
1498
- throw new Error("business-object-already-prepared");
1499
- const item = next.items.find((i) => i.kind === action.kind);
1500
- const queued = next.actions.filter((a) => a.kind === action.kind &&
1501
- !["skipped", "preexisting"].includes(a.status)).length;
1502
- if (!item || queued >= item.planned)
1503
- throw new Error("prepared-actions-exceed-original-target");
1504
- next.actions.push(structuredClone(action));
1505
- }
1506
- next.updatedAt = stamp();
1507
- return next;
176
+ composeTasks() {
177
+ this.#createGrowthRecoveryResult = createGrowthRecovery({
178
+ capability: this.#capability,
179
+ platform: this.#platform,
180
+ load: this.#load,
181
+ evidenceExists: this.#evidenceExists,
182
+ clock: this.#clock,
183
+ store: this.#store,
184
+ settleProof: this.#settleProof,
185
+ executor: this.#executor,
186
+ parallelRound: this.#parallelRound,
187
+ round: this.#round,
188
+ readTask: this.#readTask,
1508
189
  });
190
+ this.#createFollowBatchResult = createFollowBatch({
191
+ prepareActionsForCall: this.#prepareActionsForCall,
192
+ store: this.#store,
193
+ scope: this.#scope,
194
+ stamp: this.stamp.bind(this),
195
+ clock: this.#clock,
196
+ evidenceExists: this.#evidenceExists,
197
+ executor: this.#executor,
198
+ parallelRound: this.#parallelRound,
199
+ round: this.#round,
200
+ });
201
+ this.#createGrowthTasksResult = createGrowthTasks({
202
+ platform: this.#platform,
203
+ capability: this.#capability,
204
+ executor: this.#executor,
205
+ readTask: this.#readTask,
206
+ parallelRound: this.#parallelRound,
207
+ round: this.#round,
208
+ load: this.#load,
209
+ result: this.#result,
210
+ publishIndependent: this.#publishIndependent,
211
+ prepareActionsForCall: this.#prepareActionsForCall,
212
+ });
213
+ Object.assign(this, Object.assign({}, this.#createGrowthRecoveryResult, this.#createFollowBatchResult, this.#createGrowthTasksResult));
1509
214
  }
1510
- return {
1511
- async verifyPublishedPost({ taskId, draftPath, publicURL, evidenceRef, sourceLink, }) {
1512
- // Read-only recovery of a single submitted original; never opens a composer.
1513
- const caps = capability("read");
1514
- if (!caps.covered || typeof platform.readPost !== "function")
1515
- return {
1516
- status: "needsCapability",
1517
- missing: ["platform.readPost", ...caps.missing],
1518
- };
1519
- const initial = await load(taskId);
1520
- const action = initial.state.actions.find((a) => a.kind === "original" && a.draftPath === draftPath);
1521
- if (!action ||
1522
- !["intent", "unknown"].includes(action.status) ||
1523
- !evidenceExists(evidenceRef))
1524
- throw new Error("submitted-original-and-public-proof-required");
1525
- const url = xURL(publicURL), postId = url.pathname.match(/\/status\/(\d+)$/)?.[1];
1526
- if (!postId ||
1527
- url.pathname.toLowerCase() !==
1528
- `/${action.accountHandle}/status/${postId}`.toLowerCase())
1529
- throw new Error("public-account-mismatch");
1530
- const observation = await platform.readPost({
1531
- postId,
1532
- url: publicURL,
1533
- evidenceRef,
1534
- });
1535
- const trailing = action.text.match(/\n\n(https:\/\/\S+)$/);
1536
- const matches = (observation.posts ?? []).filter((p) => {
1537
- const exact = p.body === action.text;
1538
- const card = trailing &&
1539
- sourceLink &&
1540
- evidenceExists(sourceLink.evidenceRef) &&
1541
- sourceLink.resolvedURL === trailing[1] &&
1542
- p.links?.includes(sourceLink.href) &&
1543
- p.body === action.text.slice(0, -trailing[0].length);
1544
- let renderedSource = false;
1545
- if (trailing &&
1546
- sourceLink?.renderedText &&
1547
- evidenceExists(sourceLink.evidenceRef) &&
1548
- sourceLink.resolvedURL === trailing[1] &&
1549
- p.links?.includes(sourceLink.href)) {
1550
- try {
1551
- renderedSource =
1552
- new URL(sourceLink.renderedText.replace(/\s/g, "")).href ===
1553
- sourceLink.resolvedURL &&
1554
- p.body?.endsWith(sourceLink.renderedText) &&
1555
- p.body.slice(0, -sourceLink.renderedText.length).trimEnd() ===
1556
- action.text.slice(0, -trailing[0].length);
1557
- }
1558
- catch {
1559
- /* Invalid rendered URL cannot verify publication. */
1560
- }
1561
- }
1562
- return (p.id === postId &&
1563
- same(p.author, action.accountHandle) &&
1564
- (exact || card || renderedSource) &&
1565
- !p.needsExpansion &&
1566
- !(action.beforeIds ?? []).includes(p.id) &&
1567
- Date.parse(p.datetime) >= Date.parse(action.intentAt) &&
1568
- Date.parse(p.datetime) <= clock() + 5000 &&
1569
- (!action.assets?.length ||
1570
- action.assets.every((a) => p.images?.some((i) => i.alt === a.alt))));
1571
- });
1572
- Object.assign(observation, {
1573
- verified: !observation.needsStateCheck &&
1574
- !observation.alerts?.length &&
1575
- matches.length === 1,
1576
- publicId: matches.length === 1 ? postId : null,
1577
- sourceLink,
1578
- });
1579
- return store.withLock(async () => {
1580
- const latest = await load(taskId), target = latest.state.actions.find((a) => a.id === action.id);
1581
- if (!["intent", "unknown"].includes(target?.status) ||
1582
- target.intentAt !== action.intentAt ||
1583
- target.text !== action.text)
1584
- throw new Error("recovery-action-changed");
1585
- await settleProof(latest, target, observation, {
1586
- preserveStatus: true,
1587
- });
1588
- return {
1589
- status: observation.verified ? "verified" : "unknown",
1590
- publicId: observation.publicId,
1591
- taskId,
1592
- actionId: action.id,
1593
- mayEnd: false,
1594
- };
1595
- });
1596
- },
1597
- preflight: ({ operation, input = {} }) => {
1598
- if (["readBookmarks", "searchSources", "readFeedback"].includes(operation))
1599
- return platform.preflight({ operation, input });
1600
- if (["inspectRound", "verifySubmission"].includes(operation))
1601
- return capability("read", input);
1602
- if (["inspectAccount", "verifyFollow"].includes(operation)) {
1603
- const name = operation === "verifyFollow" ? "readPerson" : "readAccount";
1604
- if (typeof platform?.preflight !== "function" ||
1605
- typeof platform?.[name] !== "function")
1606
- return {
1607
- covered: false,
1608
- missing: [name],
1609
- owner: "scripts/x-harness-platform.mjs",
1610
- };
1611
- return platform.preflight({ operation: "read", input });
1612
- }
1613
- if (["runGrowthRound", "resumeGrowthRound"].includes(operation)) {
1614
- const scoped = executor?.scopeKinds ?? input.scopeKinds ?? kinds;
1615
- if (!Array.isArray(scoped) ||
1616
- !scoped.length ||
1617
- scoped.some((k) => !kinds.includes(k)))
1618
- return {
1619
- covered: false,
1620
- missing: ["scopeKinds"],
1621
- owner: "scripts/x-growth-harness.mjs",
1622
- };
1623
- const all = scoped.map((kind) => capability(operations[kind], { ...input, kind }));
1624
- return {
1625
- covered: all.every((c) => c.covered),
1626
- missing: [...new Set(all.flatMap((c) => c.missing))],
1627
- owners: [
1628
- ...new Set(all.filter((c) => !c.covered).map((c) => c.owner)),
1629
- ],
1630
- };
1631
- }
1632
- if (![
1633
- "read",
1634
- "followOne",
1635
- "followBatch",
1636
- "publishPost",
1637
- "replyToPost",
1638
- ...Object.values(operations),
1639
- ].includes(operation))
1640
- return {
1641
- covered: false,
1642
- missing: [`operation.${operation}`],
1643
- owner: "scripts/x-growth-harness.mjs",
1644
- };
1645
- return capability(operation, input);
1646
- },
1647
- readCreator: (args) => readTask("readCreator", args),
1648
- inspectAccount: (args) => readTask("readAccount", args),
1649
- readPost: (args) => readTask("readPost", { ...args, expand: true }),
1650
- readPerson: (args) => readTask("readPerson", args),
1651
- readBookmarks: (args) => readTask("readBookmarks", args),
1652
- searchSources: (args) => readTask("searchSources", args),
1653
- readFeedback: (args) => readTask("readFeedback", args),
1654
- verifySubmission: async ({ taskId, actionId }) => {
1655
- const snapshot = await load(taskId), action = snapshot.state.actions?.find((a) => a.id === actionId);
1656
- if (!action || !["intent", "unknown"].includes(action.status))
1657
- return {
1658
- status: "needsDecision",
1659
- reason: "verification-requires-original-unknown-or-intent",
1660
- mayEnd: false,
1661
- };
1662
- return (executor ? parallelRound : round)({
1663
- taskId,
1664
- requestedKind: action.kind,
1665
- selector: (a) => a.id === actionId,
1666
- });
1667
- },
1668
- verifyFollow: async (target) => {
1669
- const mode = target.mode ?? "receipt";
1670
- if (!["receipt", "decision"].includes(mode))
1671
- throw new Error("关注核验模式须为receipt/decision");
1672
- const caps = platform.preflight({ operation: "read" });
1673
- if (!caps.covered)
1674
- return {
1675
- handle: target.handle,
1676
- verified: false,
1677
- status: "needsCapability",
1678
- ...caps,
1679
- };
1680
- if (!target.url || !target.evidenceRef) {
1681
- const original = (await store.load()).state.actions?.find((a) => a.kind === "follow" && same(a.handle, target.handle));
1682
- if (!original)
1683
- return {
1684
- handle: target.handle,
1685
- verified: false,
1686
- status: "needsDecision",
1687
- reason: "original-person-observation-required",
1688
- };
1689
- target = original;
1690
- }
1691
- const value = await readTask("readPerson", {
1692
- ...target,
1693
- mode: "decision",
1694
- });
1695
- return {
1696
- handle: target.handle,
1697
- status: value.status,
1698
- fresh: value.fresh,
1699
- verified: value.status === "observed" &&
1700
- !value.needsStateCheck &&
1701
- !value.alerts?.length &&
1702
- value.relationship === "following" &&
1703
- (!target.accountHandle ||
1704
- same(value.accountHandle, target.accountHandle)),
1705
- ...(value.evidenceRef ? { evidenceRef: value.evidenceRef } : {}),
1706
- ...(value.missing?.length ? { missing: value.missing } : {}),
1707
- ...(value.diagnostic
1708
- ? {
1709
- diagnostic: {
1710
- operation: value.diagnostic.operation,
1711
- category: value.diagnostic.category,
1712
- code: value.diagnostic.code,
1713
- },
1714
- }
1715
- : {}),
1716
- ...(mode === "decision" ? { observation: value } : {}),
1717
- };
1718
- },
1719
- runGrowthRound: (args) => executor ? parallelRound(args) : round(args),
1720
- resumeGrowthRound: (args) => executor ? parallelRound(args) : round(args),
1721
- inspectRound: async ({ taskId }) => {
1722
- const snapshot = await load(taskId);
1723
- return result(snapshot);
1724
- },
1725
- followOne: ({ taskId, handle }) => (executor ? parallelRound : round)({
1726
- taskId,
1727
- requestedKind: "follow",
1728
- selector: (a) => a.kind === "follow" && same(a.handle, handle),
1729
- }),
1730
- followBatch: async ({ taskId, handles, preparedActions } = {}) => {
1731
- if (!Array.isArray(handles) ||
1732
- handles.length < 5 ||
1733
- handles.length > 10 ||
1734
- handles.some((h) => !/^[A-Za-z0-9_]{1,15}$/.test(h)) ||
1735
- new Set(handles.map((h) => h.toLowerCase())).size !==
1736
- handles.length)
1737
- throw new Error("正常关注子批需5~10位不同的已准备对象");
1738
- if (preparedActions?.some((a) => a.kind !== "follow" ||
1739
- !handles.some((h) => same(h, a.handle))))
1740
- throw new Error("batch-action-target-mismatch");
1741
- await prepareActionsForCall(taskId, preparedActions);
1742
- let batchId, resumedBatch = false;
1743
- const original = await store.transaction(async (snapshot) => {
1744
- if (!scope(snapshot.state).includes("follow"))
1745
- throw new Error("kind-outside-round-scope");
1746
- if (snapshot.state.batchId !== taskId ||
1747
- handles.some((h) => !snapshot.state.actions?.some((a) => a.kind === "follow" && same(a.handle, h))))
1748
- throw new Error("子批名单必须归原任务队列");
1749
- const next = structuredClone(snapshot.state);
1750
- next.followBatches ??= [];
1751
- const previous = next.followBatches.findLast((b) => b.status !== "SETTLED" &&
1752
- b.handles.length === handles.length &&
1753
- b.handles.every((h, i) => same(h, handles[i])));
1754
- resumedBatch = Boolean(previous);
1755
- batchId = previous?.id ?? `${taskId}-FOLLOW-${stamp()}`;
1756
- if (previous)
1757
- Object.assign(previous, {
1758
- status: "RUNNING",
1759
- lastResumedAt: stamp(),
1760
- });
1761
- else
1762
- next.followBatches.push({
1763
- id: batchId,
1764
- handles,
1765
- planned: handles.length,
1766
- startedAt: stamp(),
1767
- status: "RUNNING",
1768
- });
1769
- next.updatedAt = stamp();
1770
- return next;
1771
- });
1772
- const preparedCount = original.state.actions.filter((a) => a.kind === "follow" &&
1773
- a.status === "prepared" &&
1774
- handles.some((h) => same(h, a.handle))).length;
1775
- let eligibleAt = clock();
1776
- const previousGroup = original.state.execution?.followGroup;
1777
- const lastAttempt = (original.state.attempts ?? [])
1778
- .filter((a) => a.kind === "follow")
1779
- .sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
1780
- .at(-1);
1781
- const lastAction = original.state.actions.find((a) => a.id === lastAttempt?.actionId);
1782
- const sameBatchRecovery = resumedBatch &&
1783
- lastAction?.status === "verified" &&
1784
- evidenceExists(lastAction.resultEvidence) &&
1785
- handles.some((h) => same(h, lastAction.handle));
1786
- if (preparedCount && previousGroup?.attempts && !sameBatchRecovery)
1787
- eligibleAt = Math.max(eligibleAt, Date.parse(previousGroup.lastAt) + 180000);
1788
- for (const [duration, limit] of followWindows) {
1789
- const recent = (original.state.attempts ?? [])
1790
- .filter((a) => a.kind === "follow" && Date.parse(a.at) > clock() - duration)
1791
- .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
1792
- if (preparedCount > limit - recent.length)
1793
- eligibleAt = Math.max(eligibleAt, Date.parse(recent[recent.length - limit + preparedCount - 1].at) +
1794
- duration);
1795
- }
1796
- if (eligibleAt > clock()) {
1797
- const resumeAt = new Date(eligibleAt).toISOString();
1798
- await store.transaction((snapshot) => {
1799
- const next = structuredClone(snapshot.state);
1800
- Object.assign(next.followBatches.find((b) => b.id === batchId), { status: "WAITING", remaining: preparedCount, resumeAt });
1801
- next.updatedAt = stamp();
1802
- return next;
1803
- });
1804
- return {
1805
- taskId,
1806
- batchId,
1807
- planned: handles.length,
1808
- attempted: 0,
1809
- verifiedThisCall: 0,
1810
- unknownInBatch: original.state.actions.filter((a) => handles.some((h) => same(h, a.handle)) &&
1811
- ["unknown", "intent"].includes(a.status)).length,
1812
- remaining: preparedCount,
1813
- mayEnd: false,
1814
- decision: [
1815
- {
1816
- kind: "follow",
1817
- status: "waiting",
1818
- reason: "normal-batch-capacity-or-cooldown",
1819
- resumeAt,
1820
- },
1821
- ],
1822
- evidenceRef: store.path,
1823
- };
1824
- }
1825
- let result;
1826
- const exceptional = [];
1827
- for (const handle of handles) {
1828
- // A resumed batch submits only its remaining prepared members. Recovery
1829
- // stays in the read-only API; a frozen member must not disturb that path.
1830
- if (!original.state.actions.some((a) => a.kind === "follow" &&
1831
- same(a.handle, handle) &&
1832
- a.status === "prepared"))
1833
- continue;
1834
- result = await (executor ? parallelRound : round)({
1835
- taskId,
1836
- requestedKind: "follow",
1837
- selector: (a) => a.kind === "follow" && same(a.handle, handle),
1838
- });
1839
- const relevant = (result.decision ?? []).filter((d) => d.kind === "follow" ||
1840
- ["harness-boundary-error", "original-ledger-conflict"].includes(d.reason) ||
1841
- original.state.actions.some((a) => a.id === d.actionId && a.kind === "follow"));
1842
- exceptional.push(...relevant);
1843
- // A new unconfirmed submission is a diagnostic boundary, not permission
1844
- // to consume the rest of the batch on the same failing write path.
1845
- if (relevant.length)
1846
- break;
1847
- }
1848
- const after = await store.load();
1849
- const summary = {
1850
- taskId,
1851
- batchId,
1852
- planned: handles.length,
1853
- attempted: (after.state.attempts ?? [])
1854
- .slice(original.state.attempts?.length ?? 0)
1855
- .filter((a) => a.kind === "follow" &&
1856
- handles.some((h) => same(after.state.actions.find((x) => x.id === a.actionId)
1857
- ?.handle, h))).length,
1858
- verifiedThisCall: after.state.actions.filter((a) => a.kind === "follow" &&
1859
- a.status === "verified" &&
1860
- handles.some((h) => same(h, a.handle)) &&
1861
- original.state.actions.find((old) => old.id === a.id)
1862
- ?.status !== "verified").length,
1863
- unknownInBatch: after.state.actions.filter((a) => a.kind === "follow" &&
1864
- handles.some((h) => same(a.handle, h)) &&
1865
- ["unknown", "intent"].includes(a.status)).length,
1866
- remaining: after.state.actions.filter((a) => a.kind === "follow" &&
1867
- handles.some((h) => same(a.handle, h)) &&
1868
- a.status === "prepared").length,
1869
- actual: result?.actual,
1870
- decision: exceptional,
1871
- mayEnd: result?.mayEnd ?? false,
1872
- evidenceRef: store.path,
1873
- };
1874
- await store.transaction((snapshot) => {
1875
- const next = structuredClone(snapshot.state), batch = next.followBatches.find((b) => b.id === batchId);
1876
- Object.assign(batch, {
1877
- attempted: (batch.attempted ?? 0) + summary.attempted,
1878
- verifiedTotal: (batch.verifiedTotal ?? 0) + summary.verifiedThisCall,
1879
- verifiedThisCall: summary.verifiedThisCall,
1880
- unknown: summary.unknownInBatch,
1881
- remaining: summary.remaining,
1882
- finishedAt: stamp(),
1883
- status: summary.remaining || summary.unknownInBatch ? "PARTIAL" : "SETTLED",
1884
- });
1885
- next.updatedAt = stamp();
1886
- return next;
1887
- });
1888
- return summary;
1889
- },
1890
- publishPost: async (input) => {
1891
- if (!executor && input.independentExecution)
1892
- return publishIndependent(input);
1893
- if (input.preparedAction &&
1894
- (input.preparedAction.kind !== "original" ||
1895
- input.preparedAction.draftPath !== input.draftPath))
1896
- throw new Error("prepared-original-target-mismatch");
1897
- await prepareActionsForCall(input.taskId, input.preparedAction ? [input.preparedAction] : []);
1898
- return (executor ? parallelRound : round)({
1899
- taskId: input.taskId,
1900
- requestedKind: "original",
1901
- selector: (a) => a.kind === "original" && a.draftPath === input.draftPath,
1902
- });
1903
- },
1904
- replyToPost: async ({ taskId, postId, text, preparedAction }) => {
1905
- if (preparedAction &&
1906
- (preparedAction.kind !== "reply" ||
1907
- preparedAction.postId !== postId ||
1908
- preparedAction.text !== text))
1909
- throw new Error("prepared-reply-target-mismatch");
1910
- await prepareActionsForCall(taskId, preparedAction ? [preparedAction] : []);
1911
- return (executor ? parallelRound : round)({
1912
- taskId,
1913
- requestedKind: "reply",
1914
- selector: (a) => a.kind === "reply" && a.postId === postId && a.text === text,
1915
- });
1916
- },
1917
- };
215
+ stamp() {
216
+ return new Date(this.#clock()).toISOString();
217
+ }
218
+ }
219
+ export function createGrowthHarness(...args) {
220
+ return new GrowthHarness(...args);
1918
221
  }