devcontour-harness 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +42 -2
- package/docs/compatibility-versions.md +56 -0
- package/docs/compatibility.md +2 -2
- package/docs/connectors.md +323 -0
- package/docs/contracts.md +33 -5
- package/docs/integrations/hindsight.md +148 -0
- package/docs/quickstart.md +1 -1
- package/docs/release-notes/0.1.0-alpha.6.md +238 -0
- package/docs/release-notes/0.1.0-alpha.7.md +71 -0
- package/docs/review-probes.md +199 -0
- package/docs/review-protocol.md +251 -0
- package/docs/toolchains.md +167 -0
- package/docs/turbo-starter.md +269 -0
- package/docs/upgrade.md +77 -24
- package/examples/README.md +1 -0
- package/examples/toolchains/README.md +32 -0
- package/examples/toolchains/go/bad/label/label.go +9 -0
- package/examples/toolchains/go/good/label/label.go +9 -0
- package/examples/toolchains/go/policy.json +79 -0
- package/examples/toolchains/go/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/go/repo/_gitignore +1 -0
- package/examples/toolchains/go/repo/go.mod +5 -0
- package/examples/toolchains/go/repo/go.sum +2 -0
- package/examples/toolchains/go/repo/label/label.go +9 -0
- package/examples/toolchains/go/repo/label/label_test.go +27 -0
- package/examples/toolchains/go/repo/label/version.go +9 -0
- package/examples/toolchains/go/task.json +25 -0
- package/examples/toolchains/mixed-go/bad/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/bad/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/good/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/good/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/policy.json +126 -0
- package/examples/toolchains/mixed-go/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/mixed-go/repo/_gitignore +6 -0
- package/examples/toolchains/mixed-go/repo/backend/go.mod +3 -0
- package/examples/toolchains/mixed-go/repo/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/repo/backend/label/label_test.go +30 -0
- package/examples/toolchains/mixed-go/repo/contract/check.mjs +17 -0
- package/examples/toolchains/mixed-go/repo/contract/label.json +9 -0
- package/examples/toolchains/mixed-go/repo/frontend/package.json +15 -0
- package/examples/toolchains/mixed-go/repo/frontend/pnpm-lock.yaml +22 -0
- package/examples/toolchains/mixed-go/repo/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/repo/frontend/test/label.test.js +11 -0
- package/examples/toolchains/mixed-go/task.json +25 -0
- package/examples/toolchains/mixed-python/bad/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/bad/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/good/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/good/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/policy.json +121 -0
- package/examples/toolchains/mixed-python/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/mixed-python/repo/_gitignore +6 -0
- package/examples/toolchains/mixed-python/repo/backend/.python-version +1 -0
- package/examples/toolchains/mixed-python/repo/backend/pyproject.toml +19 -0
- package/examples/toolchains/mixed-python/repo/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/repo/backend/tests/test_label.py +11 -0
- package/examples/toolchains/mixed-python/repo/backend/uv.lock +108 -0
- package/examples/toolchains/mixed-python/repo/contract/check.mjs +17 -0
- package/examples/toolchains/mixed-python/repo/contract/label.json +9 -0
- package/examples/toolchains/mixed-python/repo/frontend/package.json +15 -0
- package/examples/toolchains/mixed-python/repo/frontend/pnpm-lock.yaml +22 -0
- package/examples/toolchains/mixed-python/repo/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/repo/frontend/test/label.test.js +11 -0
- package/examples/toolchains/mixed-python/task.json +25 -0
- package/examples/toolchains/npm/bad/src/label.js +4 -0
- package/examples/toolchains/npm/good/src/label.js +4 -0
- package/examples/toolchains/npm/policy.json +82 -0
- package/examples/toolchains/npm/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/npm/repo/_gitignore +4 -0
- package/examples/toolchains/npm/repo/package-lock.json +22 -0
- package/examples/toolchains/npm/repo/package.json +13 -0
- package/examples/toolchains/npm/repo/src/label.js +4 -0
- package/examples/toolchains/npm/repo/test/label.test.js +13 -0
- package/examples/toolchains/npm/task.json +25 -0
- package/examples/toolchains/pnpm/bad/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/good/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/policy.json +85 -0
- package/examples/toolchains/pnpm/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/pnpm/repo/_gitignore +4 -0
- package/examples/toolchains/pnpm/repo/package.json +10 -0
- package/examples/toolchains/pnpm/repo/packages/checker/check.mjs +5 -0
- package/examples/toolchains/pnpm/repo/packages/checker/package.json +9 -0
- package/examples/toolchains/pnpm/repo/packages/label/package.json +14 -0
- package/examples/toolchains/pnpm/repo/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/repo/packages/label/test/label.test.js +13 -0
- package/examples/toolchains/pnpm/repo/pnpm-lock.yaml +29 -0
- package/examples/toolchains/pnpm/repo/pnpm-workspace.yaml +2 -0
- package/examples/toolchains/pnpm/task.json +25 -0
- package/examples/toolchains/python/bad/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/good/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/policy.json +80 -0
- package/examples/toolchains/python/repo/.python-version +1 -0
- package/examples/toolchains/python/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/python/repo/_gitignore +5 -0
- package/examples/toolchains/python/repo/pyproject.toml +19 -0
- package/examples/toolchains/python/repo/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/repo/tests/test_label.py +13 -0
- package/examples/toolchains/python/repo/uv.lock +108 -0
- package/examples/toolchains/python/task.json +25 -0
- package/examples/toolchains/turbo/bad/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/good/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/policy.json +110 -0
- package/examples/toolchains/turbo/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/turbo/repo/_gitignore +4 -0
- package/examples/toolchains/turbo/repo/package.json +13 -0
- package/examples/toolchains/turbo/repo/packages/checker/check.mjs +5 -0
- package/examples/toolchains/turbo/repo/packages/checker/package.json +9 -0
- package/examples/toolchains/turbo/repo/packages/label/package.json +14 -0
- package/examples/toolchains/turbo/repo/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/repo/packages/label/test/label.test.js +13 -0
- package/examples/toolchains/turbo/repo/pnpm-lock.yaml +94 -0
- package/examples/toolchains/turbo/repo/pnpm-workspace.yaml +2 -0
- package/examples/toolchains/turbo/repo/turbo.json +7 -0
- package/examples/toolchains/turbo/task.json +25 -0
- package/examples/turbo-starter/README.md +16 -0
- package/examples/turbo-starter/changes/issued-at-consumer-only.patch +12 -0
- package/examples/turbo-starter/changes/issued-at.patch +130 -0
- package/examples/turbo-starter/cleanup.mjs +58 -0
- package/examples/turbo-starter/graph.json +32 -0
- package/examples/turbo-starter/harness/acceptance/db-isolation.test.mjs +97 -0
- package/examples/turbo-starter/harness/acceptance/env-probe.test.mjs +75 -0
- package/examples/turbo-starter/harness/acceptance/generation.test.mjs +54 -0
- package/examples/turbo-starter/harness/acceptance/issued-at.test.mjs +43 -0
- package/examples/turbo-starter/harness/acceptance/lifecycle-probe.test.mjs +47 -0
- package/examples/turbo-starter/harness/compose-bridge.mjs +593 -0
- package/examples/turbo-starter/harness/compose.acceptance.yml +23 -0
- package/examples/turbo-starter/harness/env/backend.env.template +7 -0
- package/examples/turbo-starter/harness/env/compose.env.template +6 -0
- package/examples/turbo-starter/policy.json +109 -0
- package/examples/turbo-starter/policy.lifecycle.json +114 -0
- package/examples/turbo-starter/setup.mjs +352 -0
- package/lib/agents/cli-adapter.js +10 -1
- package/lib/agents/inspect.d.ts +56 -2
- package/lib/agents/inspect.js +264 -14
- package/lib/agents/review-probe.d.ts +229 -7
- package/lib/agents/review-probe.js +830 -72
- package/lib/agents/schemas.d.ts +3 -1
- package/lib/agents/schemas.js +7 -3
- package/lib/cli.js +167 -7
- package/lib/contracts/cli.d.ts +820 -11
- package/lib/contracts/cli.js +243 -4
- package/lib/contracts/errors.d.ts +3 -1
- package/lib/contracts/errors.js +2 -0
- package/lib/contracts/events.d.ts +37 -5
- package/lib/contracts/events.js +9 -0
- package/lib/contracts/execution.d.ts +389 -15
- package/lib/contracts/execution.js +76 -4
- package/lib/contracts/failure.js +5 -0
- package/lib/contracts/index.d.ts +1 -0
- package/lib/contracts/index.js +1 -0
- package/lib/contracts/policy.d.ts +15 -0
- package/lib/contracts/policy.js +10 -0
- package/lib/contracts/ports.d.ts +50 -0
- package/lib/contracts/profiles.d.ts +14 -0
- package/lib/contracts/profiles.js +4 -0
- package/lib/contracts/published.d.ts +279 -8
- package/lib/contracts/result.d.ts +171 -0
- package/lib/contracts/result.js +39 -1
- package/lib/contracts/review.d.ts +231 -0
- package/lib/contracts/review.js +326 -2
- package/lib/contracts/toolchain.d.ts +96 -0
- package/lib/contracts/toolchain.js +89 -0
- package/lib/control/doctor.d.ts +16 -1
- package/lib/control/doctor.js +45 -3
- package/lib/control/harness.d.ts +52 -6
- package/lib/control/harness.js +28 -1
- package/lib/control/recovery.d.ts +3 -1
- package/lib/control/review.js +23 -11
- package/lib/engine/budget.d.ts +3 -3
- package/lib/engine/classify.js +1 -0
- package/lib/engine/engine.d.ts +67 -17
- package/lib/engine/prompt.d.ts +29 -1
- package/lib/engine/prompt.js +57 -0
- package/lib/engine/property-recovery.js +2 -0
- package/lib/engine/result.js +52 -0
- package/lib/engine/reuse.js +6 -1
- package/lib/engine/runner.d.ts +25 -0
- package/lib/engine/runner.js +500 -123
- package/lib/environment/lifecycle.js +6 -0
- package/lib/environment/live-calls.js +3 -2
- package/lib/environment/os-tools.d.ts +16 -0
- package/lib/environment/os-tools.js +35 -0
- package/lib/environment/process.d.ts +8 -0
- package/lib/environment/process.js +13 -6
- package/lib/integrations/connector/commands.d.ts +358 -0
- package/lib/integrations/connector/commands.js +428 -0
- package/lib/integrations/connector/config.d.ts +215 -0
- package/lib/integrations/connector/config.js +214 -0
- package/lib/integrations/connector/delivery.d.ts +107 -0
- package/lib/integrations/connector/delivery.js +591 -0
- package/lib/integrations/connector/fake.d.ts +291 -0
- package/lib/integrations/connector/fake.js +786 -0
- package/lib/integrations/connector/history.d.ts +124 -0
- package/lib/integrations/connector/history.js +497 -0
- package/lib/integrations/connector/index.d.ts +16 -0
- package/lib/integrations/connector/index.js +16 -0
- package/lib/integrations/connector/redact.d.ts +19 -0
- package/lib/integrations/connector/redact.js +91 -0
- package/lib/integrations/connector/registry.d.ts +15 -0
- package/lib/integrations/connector/registry.js +43 -0
- package/lib/integrations/connector/snapshot.d.ts +131 -0
- package/lib/integrations/connector/snapshot.js +422 -0
- package/lib/integrations/connector/source.d.ts +37 -0
- package/lib/integrations/connector/source.js +73 -0
- package/lib/integrations/connector/steps.d.ts +71 -0
- package/lib/integrations/connector/steps.js +188 -0
- package/lib/integrations/connector/store.d.ts +140 -0
- package/lib/integrations/connector/store.js +277 -0
- package/lib/integrations/connector/summary.d.ts +38 -0
- package/lib/integrations/connector/summary.js +59 -0
- package/lib/integrations/connector/testing-worker.d.ts +1 -0
- package/lib/integrations/connector/testing-worker.js +81 -0
- package/lib/integrations/connector/testing.d.ts +205 -0
- package/lib/integrations/connector/testing.js +1615 -0
- package/lib/integrations/connector/transport.d.ts +240 -0
- package/lib/integrations/connector/transport.js +28 -0
- package/lib/integrations/memory/context.d.ts +120 -0
- package/lib/integrations/memory/context.js +239 -0
- package/lib/integrations/memory/index.d.ts +8 -0
- package/lib/integrations/memory/index.js +8 -0
- package/lib/integrations/memory/provider.d.ts +79 -0
- package/lib/integrations/memory/provider.js +22 -0
- package/lib/integrations/memory/sink.d.ts +56 -0
- package/lib/integrations/memory/sink.js +116 -0
- package/lib/integrations/memory/testing.d.ts +44 -0
- package/lib/integrations/memory/testing.js +61 -0
- package/lib/integrations/relay-v1/index.d.ts +122 -0
- package/lib/integrations/relay-v1/index.js +860 -0
- package/lib/integrations/relay-v2/index.d.ts +145 -0
- package/lib/integrations/relay-v2/index.js +752 -0
- package/lib/integrations/relay-v2/process.d.ts +32 -0
- package/lib/integrations/relay-v2/process.js +112 -0
- package/lib/integrations/relay-v2/protocol.d.ts +172 -0
- package/lib/integrations/relay-v2/protocol.js +142 -0
- package/lib/profiles/resolve.d.ts +5 -1
- package/lib/profiles/resolve.js +31 -0
- package/lib/storage/document.d.ts +116 -16
- package/lib/toolchains/index.d.ts +9 -0
- package/lib/toolchains/index.js +9 -0
- package/lib/toolchains/inspect.d.ts +105 -0
- package/lib/toolchains/inspect.js +831 -0
- package/lib/toolchains/manifests.d.ts +67 -0
- package/lib/toolchains/manifests.js +238 -0
- package/lib/toolchains/probe.d.ts +28 -0
- package/lib/toolchains/probe.js +54 -0
- package/lib/toolchains/recipes.d.ts +33 -0
- package/lib/toolchains/recipes.js +51 -0
- package/lib/verification/acceptance.d.ts +16 -0
- package/lib/verification/acceptance.js +16 -0
- package/lib/verification/gates.d.ts +38 -0
- package/lib/verification/gates.js +108 -26
- package/package.json +22 -1
- package/profiles/go-gotestsum.json +21 -0
- package/profiles/npm-ci.json +22 -0
- package/profiles/pnpm-frozen.json +24 -0
- package/profiles/python-uv.json +26 -0
- package/schemas/execution-document.schema.json +345 -4
- package/schemas/execution-policy.schema.json +48 -0
- package/schemas/result-envelope.schema.json +213 -1
- package/schemas/run-event.schema.json +93 -0
- package/schemas/run-result.schema.json +207 -0
- package/schemas/stack-profile.schema.json +43 -0
- package/templates/controller-mode/AGENTS.controller.md +58 -0
- package/templates/controller-mode/README.md +44 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { HarnessError, digestOf, sha256, } from "../../contracts/index.js";
|
|
3
|
+
import { redactor } from "../../environment/redaction.js";
|
|
4
|
+
import { completionColumn, orderIdOf, parseIdentity, } from "./config.js";
|
|
5
|
+
import { maskError, redactingTransport } from "./redact.js";
|
|
6
|
+
import { matchCriterion, readFailure, semanticDigest } from "./snapshot.js";
|
|
7
|
+
import { StepConflict, StepRetry, StepUncertain, newStep, runStep, } from "./steps.js";
|
|
8
|
+
import { summaryText } from "./summary.js";
|
|
9
|
+
/**
|
|
10
|
+
* Получатель результата во внешнем хранилище задач с сохраняемым протоколом.
|
|
11
|
+
*
|
|
12
|
+
* Ключ операции — `hash(provider, projectId, workId, runId, resultDigest)`.
|
|
13
|
+
* Intent записывается в состояние коннектора **до** внешнего вызова; без него
|
|
14
|
+
* мутация не отправляется. Каждый шаг (комментарий с маркером, отметки
|
|
15
|
+
* критериев с CAS, перемещение с CAS, история) записывается до и после
|
|
16
|
+
* вызова; неизвестный исход разбирается read-back по маркеру или сравнением
|
|
17
|
+
* состояния, а не повтором. Неоднозначность — `uncertain` и явный
|
|
18
|
+
* `source-reconcile`.
|
|
19
|
+
*
|
|
20
|
+
* Владелец доставки один: lease с токеном, сроком и fencing в той же SQLite.
|
|
21
|
+
* Гарантия действует для процессов с общим каталогом состояния коннектора;
|
|
22
|
+
* независимые установки с разными каталогами вне её. Distributed
|
|
23
|
+
* exactly-once не заявляется: у получателя нет идемпотентности по requestId.
|
|
24
|
+
*/
|
|
25
|
+
export const operationKey = (provider, projectId, workId, runId, resultDigest) => sha256(JSON.stringify([provider, projectId, workId, runId, resultDigest]));
|
|
26
|
+
const evidenceFor = (result, m) => result.evidence.find((e) => e.gate === m.gate &&
|
|
27
|
+
e.phase === 'integration' &&
|
|
28
|
+
e.passed &&
|
|
29
|
+
!e.consumer &&
|
|
30
|
+
(m.evidence === 'gate' ||
|
|
31
|
+
(e.tests ?? []).some((t) => t.id === m.testId && t.status === 'passed')));
|
|
32
|
+
export class RelaySink {
|
|
33
|
+
options;
|
|
34
|
+
id;
|
|
35
|
+
owner;
|
|
36
|
+
redact;
|
|
37
|
+
t;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.id = options.connectorId;
|
|
41
|
+
this.owner = options.owner ?? `${hostname()}:${process.pid}`;
|
|
42
|
+
this.redact = options.redact ?? redactor([]);
|
|
43
|
+
this.t = redactingTransport(options.transport, this.redact);
|
|
44
|
+
}
|
|
45
|
+
get config() {
|
|
46
|
+
return this.options.config;
|
|
47
|
+
}
|
|
48
|
+
/** Задача и её mapping по результату; чужой provider/проект — отказ до записи. */
|
|
49
|
+
taskOf(result) {
|
|
50
|
+
const identity = parseIdentity(result.source.ref);
|
|
51
|
+
if (!identity ||
|
|
52
|
+
identity.provider !== this.config.provider ||
|
|
53
|
+
identity.projectId !== this.config.projectId ||
|
|
54
|
+
result.source.connector !== this.id)
|
|
55
|
+
throw new HarnessError('INVALID_INPUT', `Результат относится к другому источнику (${result.source.connector}:${result.source.ref}); этот коннектор — ${this.id}`);
|
|
56
|
+
return identity.taskId;
|
|
57
|
+
}
|
|
58
|
+
sameSnapshot(op, read) {
|
|
59
|
+
const mapping = this.mappingOf(read);
|
|
60
|
+
return mapping !== undefined && semanticDigest(this.config, mapping, read) === op.revision;
|
|
61
|
+
}
|
|
62
|
+
mappingOf(read) {
|
|
63
|
+
return this.config.tasks.find((m) => m.ref === read.task.id || m.ref === read.task.key);
|
|
64
|
+
}
|
|
65
|
+
keyOf(result) {
|
|
66
|
+
return operationKey(this.config.provider, this.config.projectId, result.workOrderId, result.runId, digestOf(result));
|
|
67
|
+
}
|
|
68
|
+
outcomeOf(op) {
|
|
69
|
+
if (op.status === 'delivered')
|
|
70
|
+
return { status: 'delivered', receipt: `${this.id}:${op.taskId}:${op.key.slice(0, 16)}` };
|
|
71
|
+
if (op.status === 'uncertain')
|
|
72
|
+
return { status: 'uncertain', operation: op.key, reason: op.reason ?? 'исход не доказан' };
|
|
73
|
+
return { status: 'conflict', currentRevision: op.currentRevision ?? 'unknown' };
|
|
74
|
+
}
|
|
75
|
+
async deliver(input) {
|
|
76
|
+
try {
|
|
77
|
+
return await this.deliverOnce(input.result);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// Ошибка доставки сохраняется в lastError и выводится CLI: только в маскированном виде.
|
|
81
|
+
throw maskError(error, this.redact);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async deliverOnce(result) {
|
|
85
|
+
const taskId = this.taskOf(result);
|
|
86
|
+
const key = this.keyOf(result);
|
|
87
|
+
const store = this.options.store;
|
|
88
|
+
const settled = (op) => op && (op.status === 'delivered' || op.status === 'uncertain' || op.status === 'conflict');
|
|
89
|
+
const prior = store.operation(key);
|
|
90
|
+
// Подтверждённая доставка, неизвестный исход и конфликт не повторяются автоматически.
|
|
91
|
+
if (settled(prior))
|
|
92
|
+
return this.outcomeOf(prior);
|
|
93
|
+
const { lease, holder } = store.acquire(key, this.owner, this.config.limits.leaseMs);
|
|
94
|
+
if (!lease)
|
|
95
|
+
throw new HarnessError('CONTROLLER_BUSY', `Доставку ${key.slice(0, 16)} ведёт другой владелец (${holder.owner}, до ${new Date(holder.expiresAt).toISOString()})`);
|
|
96
|
+
// Живой владелец продлевает lease и во время долгого вызова: перехват
|
|
97
|
+
// возможен только после остановки процесса (crash) и истечения срока.
|
|
98
|
+
// Продление — отдельная короткая транзакция, вне вызова транспорта.
|
|
99
|
+
const heartbeat = setInterval(() => {
|
|
100
|
+
try {
|
|
101
|
+
store.renew(key, lease.token, this.config.limits.leaseMs);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* Утраченное владение проверит следующая fenced-запись. */
|
|
105
|
+
}
|
|
106
|
+
}, Math.max(200, Math.floor(this.config.limits.leaseMs / 3)));
|
|
107
|
+
try {
|
|
108
|
+
// Intent — до любого внешнего вызова; отказ записи — исключение без мутации.
|
|
109
|
+
let op = store.putIntent({
|
|
110
|
+
key,
|
|
111
|
+
provider: this.config.provider,
|
|
112
|
+
projectId: this.config.projectId,
|
|
113
|
+
workId: result.workOrderId,
|
|
114
|
+
runId: result.runId,
|
|
115
|
+
taskId,
|
|
116
|
+
resultDigest: digestOf(result),
|
|
117
|
+
revision: result.source.revision,
|
|
118
|
+
outcome: result.outcome,
|
|
119
|
+
}, lease.token);
|
|
120
|
+
if (settled(op))
|
|
121
|
+
return this.outcomeOf(op);
|
|
122
|
+
if (!op.planned)
|
|
123
|
+
op = await this.plan(op, result, lease.token);
|
|
124
|
+
op = await this.execute(op, result, lease.token);
|
|
125
|
+
return this.outcomeOf(op);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
clearInterval(heartbeat);
|
|
129
|
+
store.release(key, lease.token);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Предварительная сверка постановки и eligibility, затем план шагов — до первой мутации. */
|
|
133
|
+
async plan(op, result, token) {
|
|
134
|
+
const store = this.options.store;
|
|
135
|
+
let read;
|
|
136
|
+
try {
|
|
137
|
+
read = await this.t.readTask(op.taskId);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const failure = readFailure(error, 'Задача ' + op.taskId);
|
|
141
|
+
if (failure.code === 'NOT_READY')
|
|
142
|
+
return store.updateOp(op.key, token, (o) => ({
|
|
143
|
+
...o,
|
|
144
|
+
planned: true,
|
|
145
|
+
status: 'conflict',
|
|
146
|
+
currentRevision: 'removed',
|
|
147
|
+
reason: failure.message,
|
|
148
|
+
}));
|
|
149
|
+
throw failure;
|
|
150
|
+
}
|
|
151
|
+
const mapping = this.mappingOf(read);
|
|
152
|
+
const digest = mapping ? semanticDigest(this.config, mapping, read) : 'unmapped';
|
|
153
|
+
const problem = digest !== op.revision
|
|
154
|
+
? `постановка изменилась после снимка (${op.revision.slice(0, 12)} → ${digest.slice(0, 12)}): результат относится к прежней редакции`
|
|
155
|
+
: read.task.column === 'cancelled'
|
|
156
|
+
? 'задача отменена'
|
|
157
|
+
: read.task.column === 'done'
|
|
158
|
+
? 'задача уже завершена другим участником'
|
|
159
|
+
: undefined;
|
|
160
|
+
const identity = `${this.config.provider}:${this.config.projectId}:${read.task.id}`;
|
|
161
|
+
const base = { casRevision: read.task.revision, taskKey: read.task.key, identity };
|
|
162
|
+
if (problem) {
|
|
163
|
+
// Текущая задача не закрывается старым результатом; допустим только явно исторический комментарий.
|
|
164
|
+
const steps = this.config.completion.historicalComment && result.outcome === 'accepted'
|
|
165
|
+
? [newStep(1, 'historical-comment', op.key, { ...base, historical: true })]
|
|
166
|
+
: [];
|
|
167
|
+
return store.updateOp(op.key, token, (o) => ({
|
|
168
|
+
...o,
|
|
169
|
+
planned: true,
|
|
170
|
+
steps,
|
|
171
|
+
status: steps.length ? 'in-progress' : 'conflict',
|
|
172
|
+
currentRevision: digest,
|
|
173
|
+
reason: problem,
|
|
174
|
+
receipt: { conflict: problem },
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
const criteria = this.criteriaPlan(result, read, mapping);
|
|
178
|
+
const steps = [newStep(1, 'comment', op.key, { ...base, criteria })];
|
|
179
|
+
for (const c of criteria.filter((x) => x.outcome === 'closed'))
|
|
180
|
+
steps.push(newStep(steps.length + 1, 'criterion', op.key, { criterionId: c.criterionId }));
|
|
181
|
+
const move = this.movePlan(result, read, criteria);
|
|
182
|
+
if (move.column)
|
|
183
|
+
steps.push(newStep(steps.length + 1, 'move', op.key, { column: move.column }));
|
|
184
|
+
if (this.options.history?.enabled)
|
|
185
|
+
steps.push(newStep(steps.length + 1, 'history', op.key, {}));
|
|
186
|
+
return store.updateOp(op.key, token, (o) => ({
|
|
187
|
+
...o,
|
|
188
|
+
planned: true,
|
|
189
|
+
steps,
|
|
190
|
+
status: 'in-progress',
|
|
191
|
+
receipt: { criteria, completion: move.reason, casRevision: read.task.revision },
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
/** Критерий закрывается только явным mapping на прошедший gate/testcase принятого результата. */
|
|
195
|
+
criteriaPlan(result, read, mapping) {
|
|
196
|
+
return read.task.criteria.map((c) => {
|
|
197
|
+
const rule = mapping.criteria.find((m) => matchCriterion([c], m).length === 1);
|
|
198
|
+
if (!rule)
|
|
199
|
+
return {
|
|
200
|
+
criterionId: c.id,
|
|
201
|
+
title: c.title,
|
|
202
|
+
outcome: c.completed ? 'already' : 'manual',
|
|
203
|
+
...(c.completed ? {} : { reason: 'нет mapping: ручной критерий' }),
|
|
204
|
+
};
|
|
205
|
+
const target = { gate: rule.gate, ...(rule.testId ? { testId: rule.testId } : {}) };
|
|
206
|
+
if (result.outcome !== 'accepted')
|
|
207
|
+
return {
|
|
208
|
+
criterionId: c.id,
|
|
209
|
+
title: c.title,
|
|
210
|
+
...target,
|
|
211
|
+
outcome: c.completed ? 'already' : 'unproven',
|
|
212
|
+
reason: 'результат не принят',
|
|
213
|
+
};
|
|
214
|
+
const evidence = evidenceFor(result, rule);
|
|
215
|
+
if (!evidence)
|
|
216
|
+
return {
|
|
217
|
+
criterionId: c.id,
|
|
218
|
+
title: c.title,
|
|
219
|
+
...target,
|
|
220
|
+
outcome: c.completed ? 'already' : 'unproven',
|
|
221
|
+
reason: rule.evidence === 'gate'
|
|
222
|
+
? 'gate не прошёл в integration'
|
|
223
|
+
: `testcase ${rule.testId} не прошёл`,
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
criterionId: c.id,
|
|
227
|
+
title: c.title,
|
|
228
|
+
...target,
|
|
229
|
+
outcome: c.completed ? 'already' : 'closed',
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
movePlan(result, read, criteria) {
|
|
234
|
+
if (result.outcome !== 'accepted')
|
|
235
|
+
return { reason: 'результат не принят: задача не перемещается' };
|
|
236
|
+
const target = completionColumn(this.config);
|
|
237
|
+
const blockers = [
|
|
238
|
+
...criteria
|
|
239
|
+
.filter((c) => c.outcome === 'manual' || c.outcome === 'unproven')
|
|
240
|
+
.map((c) => `критерий «${c.title}» (${c.outcome})`),
|
|
241
|
+
...read.children.filter((c) => !c.finished).map((c) => `подзадача ${c.key}`),
|
|
242
|
+
...read.dependencies.filter((d) => !d.finished).map((d) => `зависимость ${d.key}`),
|
|
243
|
+
];
|
|
244
|
+
if (this.config.completion.mode === 'review-only') {
|
|
245
|
+
if (target === 'done')
|
|
246
|
+
return { reason: 'review-only: done не выставляется' };
|
|
247
|
+
return read.task.column === target
|
|
248
|
+
? { reason: `review-only: задача уже в ${target}` }
|
|
249
|
+
: { column: target, reason: `review-only: задача оставлена на проверку (${target})` };
|
|
250
|
+
}
|
|
251
|
+
if (target === 'done' && blockers.length)
|
|
252
|
+
return { reason: 'задача не завершена: ' + blockers.join(', ') };
|
|
253
|
+
if (read.task.column === target)
|
|
254
|
+
return { reason: `задача уже в ${target}` };
|
|
255
|
+
return { column: target, reason: `задача перемещена в ${target}` };
|
|
256
|
+
}
|
|
257
|
+
/** Выполнить оставшиеся шаги по протоколу; итог — новое состояние операции. */
|
|
258
|
+
async execute(op, result, token) {
|
|
259
|
+
const store = this.options.store;
|
|
260
|
+
const leaseMs = this.config.limits.leaseMs;
|
|
261
|
+
try {
|
|
262
|
+
for (const planned of op.steps) {
|
|
263
|
+
store.renew(op.key, token, leaseMs);
|
|
264
|
+
const n = planned.n;
|
|
265
|
+
const io = {
|
|
266
|
+
load: () => store.operation(op.key).steps.find((s) => s.n === n),
|
|
267
|
+
save: (step) => void store.updateOp(op.key, token, (o) => ({
|
|
268
|
+
...o,
|
|
269
|
+
steps: o.steps.map((s) => (s.n === n ? step : s)),
|
|
270
|
+
}), 'step'),
|
|
271
|
+
};
|
|
272
|
+
const current = io.load();
|
|
273
|
+
if (current.status !== 'done' && current.status !== 'skipped')
|
|
274
|
+
await this.verifyBefore(store.operation(op.key), current, token);
|
|
275
|
+
await runStep(io, this.handlers(store.operation(op.key), planned, result));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
const status = error instanceof StepUncertain
|
|
280
|
+
? 'uncertain'
|
|
281
|
+
: error instanceof StepConflict
|
|
282
|
+
? 'conflict'
|
|
283
|
+
: error instanceof StepRetry
|
|
284
|
+
? 'failed'
|
|
285
|
+
: undefined;
|
|
286
|
+
if (!status)
|
|
287
|
+
throw error;
|
|
288
|
+
const after = store.updateOp(op.key, token, (o) => ({
|
|
289
|
+
...o,
|
|
290
|
+
status,
|
|
291
|
+
reason: error.message,
|
|
292
|
+
...(status === 'conflict'
|
|
293
|
+
? {
|
|
294
|
+
currentRevision: error.currentRevision ?? o.currentRevision ?? 'cas-conflict',
|
|
295
|
+
}
|
|
296
|
+
: {}),
|
|
297
|
+
}));
|
|
298
|
+
if (status === 'failed')
|
|
299
|
+
throw new HarnessError('DELIVERY_FAILED', 'Доставка не завершена: ' + after.reason);
|
|
300
|
+
return after;
|
|
301
|
+
}
|
|
302
|
+
const final = store.operation(op.key);
|
|
303
|
+
if (final.receipt?.conflict)
|
|
304
|
+
return store.updateOp(op.key, token, (o) => ({ ...o, status: 'conflict' }));
|
|
305
|
+
const comment = final.steps.find((s) => s.kind === 'comment');
|
|
306
|
+
const last = [...final.steps].reverse().find((s) => s.result?.revision !== undefined);
|
|
307
|
+
const receipt = {
|
|
308
|
+
...final.receipt,
|
|
309
|
+
operation: op.key,
|
|
310
|
+
connector: this.id,
|
|
311
|
+
taskId: op.taskId,
|
|
312
|
+
commentId: comment?.result?.commentId,
|
|
313
|
+
revision: last?.result?.revision ?? final.receipt?.casRevision,
|
|
314
|
+
history: final.steps.find((s) => s.kind === 'history')?.result,
|
|
315
|
+
deliveredAt: new Date().toISOString(),
|
|
316
|
+
};
|
|
317
|
+
if (result.outcome === 'accepted')
|
|
318
|
+
store.putReceipt({
|
|
319
|
+
issuer: this.id,
|
|
320
|
+
orderId: orderIdOf(this.config.provider, this.config.projectId, op.taskId),
|
|
321
|
+
taskId: op.taskId,
|
|
322
|
+
repositoryId: result.repositoryId,
|
|
323
|
+
resultSha: result.shas.result,
|
|
324
|
+
workId: result.workOrderId,
|
|
325
|
+
runId: result.runId,
|
|
326
|
+
operation: op.key,
|
|
327
|
+
}, token);
|
|
328
|
+
return store.updateOp(op.key, token, (o) => ({ ...o, status: 'delivered', receipt }));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Сверка перед каждым неподтверждённым шагом (и при продолжении после
|
|
332
|
+
* обрыва): постановка та же (semantic digest), задача не отменена и не
|
|
333
|
+
* закрыта другим, а для нового CAS-шага текущая ревизия равна ревизии,
|
|
334
|
+
* записанной после **нашего** предыдущего шага. Иначе — conflict: старый
|
|
335
|
+
* результат не применяется к изменённой задаче.
|
|
336
|
+
*/
|
|
337
|
+
async verifyBefore(op, step, token) {
|
|
338
|
+
if (step.kind === 'historical-comment' || step.kind === 'history')
|
|
339
|
+
return;
|
|
340
|
+
const conflict = (reason, currentRevision) => {
|
|
341
|
+
this.options.store.updateOp(op.key, token, (o) => ({
|
|
342
|
+
...o,
|
|
343
|
+
steps: o.steps.map((s) => s.n === step.n
|
|
344
|
+
? { ...s, status: 'conflict', reason, updatedAt: new Date().toISOString() }
|
|
345
|
+
: s),
|
|
346
|
+
}));
|
|
347
|
+
throw new StepConflict(step.n, reason, currentRevision);
|
|
348
|
+
};
|
|
349
|
+
let read;
|
|
350
|
+
try {
|
|
351
|
+
read = await this.t.readTask(op.taskId);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
const failure = readFailure(error, 'Задача ' + op.taskId);
|
|
355
|
+
if (failure.code === 'NOT_READY')
|
|
356
|
+
return conflict(failure.message, 'removed');
|
|
357
|
+
throw new StepRetry(step.n, 'сверка перед шагом не выполнена: ' + failure.message);
|
|
358
|
+
}
|
|
359
|
+
if (!this.sameSnapshot(op, read)) {
|
|
360
|
+
const mapping = this.mappingOf(read);
|
|
361
|
+
const digest = mapping ? semanticDigest(this.config, mapping, read) : 'unmapped';
|
|
362
|
+
return conflict(`постановка изменилась во время доставки (${op.revision.slice(0, 12)} → ${digest.slice(0, 12)}): результат относится к прежней редакции`, digest);
|
|
363
|
+
}
|
|
364
|
+
if (read.task.column === 'cancelled')
|
|
365
|
+
return conflict('задача отменена во время доставки');
|
|
366
|
+
const ourMove = op.steps.some((s) => s.kind === 'move' && s.status === 'done');
|
|
367
|
+
if (read.task.column === 'done' && !ourMove && step.kind !== 'move')
|
|
368
|
+
return conflict('задача завершена другим участником во время доставки');
|
|
369
|
+
if (step.status === 'planned' && (step.kind === 'criterion' || step.kind === 'move')) {
|
|
370
|
+
const expected = this.revisionBefore(op, step.n);
|
|
371
|
+
if (read.task.revision !== expected)
|
|
372
|
+
return conflict(`ревизия задачи ${expected} → ${read.task.revision} без нашей записи (чужое изменение): старая команда по новой ревизии не применяется`, `cas:${read.task.revision}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Последняя подтверждённая CAS-ревизия задачи перед шагом `n`. */
|
|
376
|
+
revisionBefore(op, n) {
|
|
377
|
+
// После явного recheck оператора отсчёт идёт от перечитанной ревизии.
|
|
378
|
+
const from = op.receipt?.rebasedFrom ?? 0;
|
|
379
|
+
const done = op.steps
|
|
380
|
+
.filter((s) => s.n < n && s.n >= from && s.status === 'done' && typeof s.result?.revision === 'number')
|
|
381
|
+
.at(-1);
|
|
382
|
+
return (done?.result?.revision ??
|
|
383
|
+
op.receipt?.casRevision ??
|
|
384
|
+
op.steps[0]?.input.casRevision);
|
|
385
|
+
}
|
|
386
|
+
handlers(op, step, result) {
|
|
387
|
+
const t = this.t;
|
|
388
|
+
const taskId = op.taskId;
|
|
389
|
+
switch (step.kind) {
|
|
390
|
+
case 'comment':
|
|
391
|
+
case 'historical-comment': {
|
|
392
|
+
const text = summaryText({
|
|
393
|
+
marker: step.marker,
|
|
394
|
+
result,
|
|
395
|
+
task: { key: step.input.taskKey, identity: step.input.identity },
|
|
396
|
+
snapshot: { revision: op.revision, casRevision: step.input.casRevision },
|
|
397
|
+
criteria: step.input.criteria ?? [],
|
|
398
|
+
completion: op.receipt?.completion ?? op.reason ?? '',
|
|
399
|
+
historical: step.kind === 'historical-comment',
|
|
400
|
+
redact: this.redact,
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
appendOnly: true,
|
|
404
|
+
send: async () => ({ commentId: (await t.addComment(taskId, text)).commentId }),
|
|
405
|
+
readBack: async () => {
|
|
406
|
+
const found = (await t.listComments(taskId)).filter((c) => c.body.includes(step.marker));
|
|
407
|
+
if (found.length === 1)
|
|
408
|
+
return { state: 'found', result: { commentId: found[0].id } };
|
|
409
|
+
return {
|
|
410
|
+
state: 'ambiguous',
|
|
411
|
+
reason: `комментарий с маркером найден ${found.length} раз (ожидался ровно один)`,
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
case 'criterion': {
|
|
417
|
+
const criterionId = step.input.criterionId;
|
|
418
|
+
return {
|
|
419
|
+
prepare: () => ({
|
|
420
|
+
ifRevision: this.revisionBefore(this.options.store.operation(op.key), step.n),
|
|
421
|
+
}),
|
|
422
|
+
send: async (input) => ({
|
|
423
|
+
revision: (await t.setCriterion(taskId, {
|
|
424
|
+
criterionId,
|
|
425
|
+
completed: true,
|
|
426
|
+
ifRevision: input.ifRevision,
|
|
427
|
+
})).revision,
|
|
428
|
+
}),
|
|
429
|
+
readBack: async (input) => {
|
|
430
|
+
const read = await t.readTask(taskId);
|
|
431
|
+
const c = read.task.criteria.find((x) => x.id === criterionId);
|
|
432
|
+
// Своя запись доказана, только если после неё ничего не менялось:
|
|
433
|
+
// ровно +1 к нашей ревизии и та же постановка.
|
|
434
|
+
if (c?.completed &&
|
|
435
|
+
read.task.revision === input.ifRevision + 1 &&
|
|
436
|
+
this.sameSnapshot(op, read))
|
|
437
|
+
return { state: 'found', result: { revision: read.task.revision } };
|
|
438
|
+
if (c?.completed)
|
|
439
|
+
return {
|
|
440
|
+
state: 'foreign',
|
|
441
|
+
reason: `критерий отмечен, но ревизия ${String(input.ifRevision)} → ${read.task.revision} или постановка изменились: чужое изменение после записи`,
|
|
442
|
+
};
|
|
443
|
+
if (read.task.revision === input.ifRevision)
|
|
444
|
+
return {
|
|
445
|
+
state: 'absent',
|
|
446
|
+
reason: 'ревизия задачи не изменилась, критерий не отмечен',
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
state: 'foreign',
|
|
450
|
+
reason: `ревизия задачи ${String(input.ifRevision)} → ${read.task.revision} без нашей отметки (чужое изменение)`,
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
case 'move': {
|
|
456
|
+
const column = step.input.column;
|
|
457
|
+
return {
|
|
458
|
+
prepare: () => ({
|
|
459
|
+
ifRevision: this.revisionBefore(this.options.store.operation(op.key), step.n),
|
|
460
|
+
}),
|
|
461
|
+
send: async (input) => ({
|
|
462
|
+
revision: (await t.move(taskId, { column, ifRevision: input.ifRevision }))
|
|
463
|
+
.revision,
|
|
464
|
+
}),
|
|
465
|
+
readBack: async (input) => {
|
|
466
|
+
const read = await t.readTask(taskId);
|
|
467
|
+
if (read.task.column === column &&
|
|
468
|
+
read.task.revision === input.ifRevision + 1 &&
|
|
469
|
+
this.sameSnapshot(op, read))
|
|
470
|
+
return { state: 'found', result: { revision: read.task.revision } };
|
|
471
|
+
if (read.task.column === column)
|
|
472
|
+
return {
|
|
473
|
+
state: 'foreign',
|
|
474
|
+
reason: `колонка ${column}, но ревизия ${String(input.ifRevision)} → ${read.task.revision} или постановка изменились: чужое изменение после записи`,
|
|
475
|
+
};
|
|
476
|
+
if (read.task.revision === input.ifRevision)
|
|
477
|
+
return { state: 'absent', reason: 'ревизия задачи не изменилась, колонка прежняя' };
|
|
478
|
+
return {
|
|
479
|
+
state: 'foreign',
|
|
480
|
+
reason: `ревизия задачи ${String(input.ifRevision)} → ${read.task.revision} без нашего перемещения (чужое изменение)`,
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
case 'history': {
|
|
486
|
+
const history = this.options.history;
|
|
487
|
+
return {
|
|
488
|
+
// Шаги истории — собственные записи с маркерами; здесь только их итог.
|
|
489
|
+
send: async () => history.closeResult(result, op.workId),
|
|
490
|
+
readBack: async () => ({
|
|
491
|
+
state: 'found',
|
|
492
|
+
result: await history.closeResult(result, op.workId),
|
|
493
|
+
}),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
default:
|
|
497
|
+
throw new Error('Неизвестный шаг доставки: ' + step.kind);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Разбор неизвестного исхода или конфликта операции. Сначала — факты
|
|
502
|
+
* получателя (read-back); решение оператора требуется только там, где
|
|
503
|
+
* факты неоднозначны. Сам reconcile мутаций не отправляет: продолжение —
|
|
504
|
+
* обычной доставкой с тем же ключом.
|
|
505
|
+
*/
|
|
506
|
+
async reconcile(key, decision) {
|
|
507
|
+
const store = this.options.store;
|
|
508
|
+
const before = store.operation(key);
|
|
509
|
+
if (!before)
|
|
510
|
+
throw new HarnessError('NOT_FOUND', 'Операция доставки не найдена: ' + key);
|
|
511
|
+
if (before.status !== 'uncertain' && before.status !== 'conflict')
|
|
512
|
+
return {
|
|
513
|
+
operation: key,
|
|
514
|
+
before: before.status,
|
|
515
|
+
after: before.status,
|
|
516
|
+
resolved: before.status === 'delivered',
|
|
517
|
+
facts: [],
|
|
518
|
+
};
|
|
519
|
+
const { lease, holder } = store.acquire(key, this.owner, this.config.limits.leaseMs);
|
|
520
|
+
if (!lease)
|
|
521
|
+
throw new HarnessError('CONTROLLER_BUSY', `Операцию ведёт другой владелец (${holder.owner})`);
|
|
522
|
+
const facts = [];
|
|
523
|
+
try {
|
|
524
|
+
const op = store.operation(key);
|
|
525
|
+
const step = op.steps.find((s) => s.status === 'uncertain' || s.status === 'conflict');
|
|
526
|
+
const setStep = (patch, status) => store.updateOp(key, lease.token, (o) => ({
|
|
527
|
+
...o,
|
|
528
|
+
status,
|
|
529
|
+
steps: o.steps.map((s) => s.n === step.n ? { ...s, ...patch, updatedAt: new Date().toISOString() } : s),
|
|
530
|
+
}));
|
|
531
|
+
if (op.status === 'conflict') {
|
|
532
|
+
if (decision?.kind !== 'recheck' || !step)
|
|
533
|
+
return {
|
|
534
|
+
operation: key,
|
|
535
|
+
before: op.status,
|
|
536
|
+
after: op.status,
|
|
537
|
+
resolved: false,
|
|
538
|
+
facts: [op.reason ?? 'конфликт постановки: результат относится к прежнему снимку'],
|
|
539
|
+
};
|
|
540
|
+
// Явная перепроверка: постановка та же и задача допускает доставку — шаг снова planned.
|
|
541
|
+
const read = await this.t.readTask(op.taskId);
|
|
542
|
+
const mapping = this.mappingOf(read);
|
|
543
|
+
const digest = mapping ? semanticDigest(this.config, mapping, read) : 'unmapped';
|
|
544
|
+
if (digest !== op.revision || read.task.column === 'cancelled') {
|
|
545
|
+
facts.push('постановка изменилась или задача отменена: конфликт остаётся');
|
|
546
|
+
return { operation: key, before: op.status, after: op.status, resolved: false, facts };
|
|
547
|
+
}
|
|
548
|
+
const next = setStep({ status: 'planned', reason: 'recheck оператора' }, 'in-progress');
|
|
549
|
+
store.updateOp(key, lease.token, (o) => ({
|
|
550
|
+
...o,
|
|
551
|
+
receipt: { ...o.receipt, casRevision: read.task.revision, rebasedFrom: step.n },
|
|
552
|
+
}));
|
|
553
|
+
facts.push(`постановка не изменилась; продолжение от ревизии ${read.task.revision}`);
|
|
554
|
+
return { operation: key, before: op.status, after: next.status, resolved: true, facts };
|
|
555
|
+
}
|
|
556
|
+
if (!step)
|
|
557
|
+
return { operation: key, before: op.status, after: op.status, resolved: false, facts };
|
|
558
|
+
// Факты получателя.
|
|
559
|
+
let markers = [];
|
|
560
|
+
if (step.kind === 'comment' || step.kind === 'historical-comment') {
|
|
561
|
+
markers = (await this.t.listComments(op.taskId)).filter((c) => c.body.includes(step.marker));
|
|
562
|
+
facts.push(`комментариев с маркером: ${markers.length}`);
|
|
563
|
+
if (markers.length === 1) {
|
|
564
|
+
const next = setStep({ status: 'done', result: { commentId: markers[0].id } }, 'in-progress');
|
|
565
|
+
return { operation: key, before: op.status, after: next.status, resolved: true, facts };
|
|
566
|
+
}
|
|
567
|
+
if (decision?.kind === 'resend' && markers.length === 0) {
|
|
568
|
+
const next = setStep({ status: 'planned', reason: 'resend оператора: записи нет' }, 'in-progress');
|
|
569
|
+
return { operation: key, before: op.status, after: next.status, resolved: true, facts };
|
|
570
|
+
}
|
|
571
|
+
if (decision?.kind === 'adopt' && markers.some((m) => m.id === decision.id)) {
|
|
572
|
+
const next = setStep({ status: 'done', result: { commentId: decision.id }, reason: 'adopt оператора' }, 'in-progress');
|
|
573
|
+
return { operation: key, before: op.status, after: next.status, resolved: true, facts };
|
|
574
|
+
}
|
|
575
|
+
facts.push(markers.length === 0
|
|
576
|
+
? 'записи нет, но отправка могла состояться: нужен --decision resend после проверки получателя'
|
|
577
|
+
: 'несколько записей: нужен --decision adopt:<id> (первая найденная не выбирается автоматически)');
|
|
578
|
+
return { operation: key, before: op.status, after: op.status, resolved: false, facts };
|
|
579
|
+
}
|
|
580
|
+
// CAS-шаги и история: состояние получателя; uncertain здесь — read-back был недоступен.
|
|
581
|
+
if (step.kind === 'history')
|
|
582
|
+
this.options.history?.resetUncertain(op.workId);
|
|
583
|
+
const next = setStep({ status: 'sending', reason: 'reconcile: повторный read-back' }, 'in-progress');
|
|
584
|
+
facts.push('шаг будет разобран read-back при продолжении доставки');
|
|
585
|
+
return { operation: key, before: op.status, after: next.status, resolved: true, facts };
|
|
586
|
+
}
|
|
587
|
+
finally {
|
|
588
|
+
store.release(key, lease.token);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|