@zq-silk/yui 0.15.8 → 0.15.9
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/ARCHITECTURE.md +2 -0
- package/ARCHITECTURE.zh-CN.md +151 -0
- package/README.md +211 -14
- package/dist/artifacts/artifactCapability.js +74 -0
- package/dist/artifacts/artifactCommitLock.js +249 -0
- package/dist/artifacts/artifactPaths.js +151 -0
- package/dist/artifacts/gitArtifactRef.js +146 -0
- package/dist/artifacts/managedGit.js +332 -0
- package/dist/artifacts/taskArtifactRepository.js +277 -0
- package/dist/cli/commandCatalog.js +14 -10
- package/dist/cli.js +67 -0
- package/dist/commands/operatorCommands.js +33 -2
- package/dist/commands/taskActivationCommands.js +22 -0
- package/dist/commands/taskCommands.js +342 -79
- package/dist/context/runContextPack.js +28 -16
- package/dist/context/taskContext.js +26 -3
- package/dist/controller/controller.js +8 -2
- package/dist/kernel/builtinCapabilities.js +32 -24
- package/dist/message/message.js +56 -0
- package/dist/plugins/pluginService.js +11 -3
- package/dist/resources/projectResource.js +0 -48
- package/dist/resources/projectResourceService.js +3 -81
- package/dist/setup/setupCommand.js +3 -8
- package/dist/storage/migrations/artifactsToGit.js +338 -0
- package/dist/storage/migrations/submitIntent.js +126 -0
- package/dist/storage/sqliteSchema.js +37 -3
- package/dist/storage/sqliteStore.js +1 -21
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -1
- package/dist/task/taskActivation.js +26 -0
- package/dist/task/taskActivationService.js +85 -69
- package/dist/task/taskSubmission.js +236 -0
- package/dist/web/assets/client/app.js +3 -2
- package/dist/web/assets/client/taskSurface.js +96 -7
- package/dist/web/webServer.js +18 -3
- package/dist/web/webTaskSurface.js +6 -6
- package/dist/workItem/workItem.js +14 -10
- package/docs/agent-result-consumption.md +2 -0
- package/docs/agent-result-consumption.zh-CN.md +81 -0
- package/docs/agent-runtime-drivers.md +2 -0
- package/docs/agent-runtime-drivers.zh-CN.md +77 -0
- package/docs/architecture/README.md +44 -32
- package/docs/architecture/README.zh-CN.md +43 -0
- package/docs/architecture/capabilities-and-resources.md +118 -79
- package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
- package/docs/managed-turn-and-session-runtime.md +2 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
- package/docs/observability/README.md +2 -0
- package/docs/observability/README.zh-CN.md +71 -0
- package/docs/plugin-sdk.md +320 -217
- package/docs/plugin-sdk.zh-CN.md +293 -0
- package/docs/provider-runtime.md +2 -0
- package/docs/provider-runtime.zh-CN.md +132 -0
- package/docs/release-workflow.md +2 -0
- package/docs/release-workflow.zh-CN.md +237 -0
- package/docs/roles-and-configuration.md +2 -0
- package/docs/roles-and-configuration.zh-CN.md +96 -0
- package/docs/sqlite-control-plane-design.md +2 -0
- package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
- package/docs/task-dag-semantics.md +80 -57
- package/docs/task-dag-semantics.zh-CN.md +59 -0
- package/docs/task-delivery.md +2 -0
- package/docs/task-delivery.zh-CN.md +82 -0
- package/docs/task-local-identity.md +2 -0
- package/docs/task-local-identity.zh-CN.md +58 -0
- package/docs/testing/verification-levels.md +2 -0
- package/docs/testing/verification-levels.zh-CN.md +69 -0
- package/i18n/README.zh-CN.md +199 -10
- package/package.json +2 -1
- package/skills/yui-leader/SKILL.md +88 -331
- package/skills/yui-leader/references/execution.md +303 -0
- package/skills/yui-leader/references/planning.md +109 -0
- package/skills/yui-leader/references/task-plugins.md +8 -4
- package/skills/yui-operator/SKILL.md +16 -3
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { externalProgramConfigViolations, managedGitSync, managedGitSyncSucceeds, requireCommitId } from "../../artifacts/managedGit.js";
|
|
5
|
+
import { safePathSegment, safeRelativeArtifactPath, taskArtifactRepoPath, taskArtifactsRoot } from "../../artifacts/artifactPaths.js";
|
|
6
|
+
import { migrateSubmitIntent } from "./submitIntent.js";
|
|
7
|
+
/**
|
|
8
|
+
* Storage 18 -> 19: retire the DB-owned immutable Artifact table and move every
|
|
9
|
+
* historical Artifact into its Task's LOCAL Git artifact repository, then
|
|
10
|
+
* rewrite the Candidate/completion references that pointed at them so they pin
|
|
11
|
+
* an exact `commit + relativePath` instead of a mutable DB id.
|
|
12
|
+
*
|
|
13
|
+
* Two-class ownership (§3.1/§3.7): after v19 the DB is no longer an authority
|
|
14
|
+
* for file/directory artifacts — the per-Task repo at
|
|
15
|
+
* `<YUI_HOME>/task-artifacts/<task-id>/` is. This migration is the ONE-TIME
|
|
16
|
+
* bridge: it reads the soon-to-be-dropped `artifacts` table, reconstructs the
|
|
17
|
+
* bytes/descriptor as deterministic Git history, and leaves NO dual read/write
|
|
18
|
+
* surface behind (the table is dropped in the same transaction).
|
|
19
|
+
*
|
|
20
|
+
* Determinism & crash-safety. `migrateData` runs INSIDE the outer
|
|
21
|
+
* `db.transaction`, which better-sqlite3 requires to be synchronous, so this
|
|
22
|
+
* builds repositories with the synchronous managed-Git runner. Every commit
|
|
23
|
+
* (including the empty root) is pinned to a date derived purely from the
|
|
24
|
+
* Artifact data, and identity/message/tree are fixed, so rebuilding the same
|
|
25
|
+
* table produces byte-identical commit ids. The DB half is atomic (the upgrade
|
|
26
|
+
* orchestrator backs up `yui.db` and rolls back on any throw); the Git repos
|
|
27
|
+
* are the only non-transactional side effect, so each Task repo is built in a
|
|
28
|
+
* staging directory and atomically renamed into place, and a leftover from a
|
|
29
|
+
* previously rolled-back attempt is replaced by an identical rebuild.
|
|
30
|
+
*
|
|
31
|
+
* Boundaries. Managed Git is fully isolated (no remote, no hooks/filters, no
|
|
32
|
+
* network/file transport, no ambient config — see managedGit). This migration
|
|
33
|
+
* NEVER re-accesses a Job receipt tool and NEVER fetches a reference locator: it
|
|
34
|
+
* moves exactly the bytes/descriptor already frozen in the DB. Reference-kind
|
|
35
|
+
* Artifacts are preserved as history but are never a reference target (they were
|
|
36
|
+
* never fixed results). Completion strings that are not Artifact ids (e.g.
|
|
37
|
+
* `turn:<id>` or an explicit URL) are preserved verbatim.
|
|
38
|
+
*/
|
|
39
|
+
/** The single managed branch, mirroring taskArtifactRepository. */
|
|
40
|
+
const ARTIFACT_BRANCH = "main";
|
|
41
|
+
/** Staging root for in-progress repository builds (hidden; never a Task id). */
|
|
42
|
+
const MIGRATION_STAGING = ".yui-artifact-migration";
|
|
43
|
+
/** Deterministic key for the id->reference map (task id + Artifact id). */
|
|
44
|
+
function refKey(taskId, artifactId) {
|
|
45
|
+
return `${taskId}\u0000${artifactId}`;
|
|
46
|
+
}
|
|
47
|
+
/** Stable JSON: object keys sorted at every depth, so bytes (and thus commit ids) are deterministic. */
|
|
48
|
+
function stableJson(value) {
|
|
49
|
+
const canonical = (input) => {
|
|
50
|
+
if (Array.isArray(input))
|
|
51
|
+
return input.map(canonical);
|
|
52
|
+
if (input !== null && typeof input === "object") {
|
|
53
|
+
const source = input;
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const key of Object.keys(source).sort())
|
|
56
|
+
result[key] = canonical(source[key]);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
return input;
|
|
60
|
+
};
|
|
61
|
+
return `${JSON.stringify(canonical(value), null, 2)}\n`;
|
|
62
|
+
}
|
|
63
|
+
/** The metadata record written for every migrated Artifact (content lives beside it, if any). */
|
|
64
|
+
function artifactRecord(artifact) {
|
|
65
|
+
const { content: _content, ...rest } = artifact;
|
|
66
|
+
return { ...rest, migratedFrom: { store: "artifacts", storageVersion: 18 } };
|
|
67
|
+
}
|
|
68
|
+
export function migrateArtifactsToGit(db) {
|
|
69
|
+
const home = dirname(db.name);
|
|
70
|
+
// A pure in-memory database (tests) has no Home to host repositories; there
|
|
71
|
+
// are no rows to move in that case, so treat it as an empty migration.
|
|
72
|
+
const rows = db.prepare("SELECT task_id, id, payload FROM artifacts").all();
|
|
73
|
+
const migrated = new Map();
|
|
74
|
+
if (rows.length > 0) {
|
|
75
|
+
if (db.name === ":memory:" || db.name.length === 0) {
|
|
76
|
+
throw new Error("Cannot migrate Artifacts to Git without an on-disk Home.");
|
|
77
|
+
}
|
|
78
|
+
const byTask = new Map();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const artifact = JSON.parse(row.payload);
|
|
81
|
+
const list = byTask.get(row.task_id) ?? [];
|
|
82
|
+
list.push(artifact);
|
|
83
|
+
byTask.set(row.task_id, list);
|
|
84
|
+
}
|
|
85
|
+
// Deterministic Task order, then deterministic Artifact order within a Task.
|
|
86
|
+
for (const taskId of [...byTask.keys()].sort()) {
|
|
87
|
+
const artifacts = byTask.get(taskId).slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
88
|
+
buildTaskRepository(home, taskId, artifacts, migrated);
|
|
89
|
+
}
|
|
90
|
+
// Every Task repo has been atomically renamed out of staging; remove the now
|
|
91
|
+
// empty staging root so it never masquerades as an entry under task-artifacts.
|
|
92
|
+
rmSync(join(taskArtifactsRoot(home), MIGRATION_STAGING), { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
rewriteCandidateRefs(db, migrated);
|
|
95
|
+
rewriteCompletionRefs(db, migrated);
|
|
96
|
+
// No dual surface: the DB is no longer an authority for file artifacts.
|
|
97
|
+
db.exec("DROP TABLE artifacts");
|
|
98
|
+
// Requirement A's independent, deterministic, idempotent backfill runs LAST,
|
|
99
|
+
// inside this same outer transaction. It touches only events + id_sequences.
|
|
100
|
+
migrateSubmitIntent(db);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build one Task's artifact repository from its historical Artifacts and record
|
|
104
|
+
* a frozen reference for each. Built in staging, then atomically swapped in.
|
|
105
|
+
*/
|
|
106
|
+
function buildTaskRepository(home, taskId, artifacts, migrated) {
|
|
107
|
+
const root = taskArtifactsRoot(home);
|
|
108
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
109
|
+
const stagingRoot = join(root, MIGRATION_STAGING);
|
|
110
|
+
mkdirSync(stagingRoot, { recursive: true, mode: 0o700 });
|
|
111
|
+
const staging = join(stagingRoot, safePathSegment(taskId, "Task id"));
|
|
112
|
+
// Discard any partial staging from a previously interrupted attempt.
|
|
113
|
+
rmSync(staging, { recursive: true, force: true });
|
|
114
|
+
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
115
|
+
// Root commit date: the earliest Artifact timestamp, so the whole history is a
|
|
116
|
+
// pure function of the data (no wall-clock, reproducible commit ids).
|
|
117
|
+
const rootDate = artifacts.reduce((earliest, artifact) => (artifact.createdAt < earliest ? artifact.createdAt : earliest), artifacts[0].createdAt);
|
|
118
|
+
managedGitSync(staging, ["init", "-b", ARTIFACT_BRANCH]);
|
|
119
|
+
managedGitSync(staging, ["commit", "--allow-empty", "-m", "init artifact repo"], {
|
|
120
|
+
commitDates: { author: rootDate, committer: rootDate }
|
|
121
|
+
});
|
|
122
|
+
assertManagedBoundary(taskId, staging);
|
|
123
|
+
for (const artifact of artifacts) {
|
|
124
|
+
const ref = commitArtifact(staging, artifact);
|
|
125
|
+
migrated.set(refKey(taskId, artifact.id), ref);
|
|
126
|
+
}
|
|
127
|
+
const builtHead = requireCommitId(managedGitSync(staging, ["rev-parse", "HEAD^{commit}"]));
|
|
128
|
+
// Adopt the freshly built repository WITHOUT ever destroying unknown data.
|
|
129
|
+
const finalPath = taskArtifactRepoPath(home, taskId);
|
|
130
|
+
adoptBuiltRepository(taskId, staging, finalPath, builtHead);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Move the freshly built repository into place WITHOUT ever clearing an unknown
|
|
134
|
+
* existing directory (fail-closed; no auto-repair).
|
|
135
|
+
*
|
|
136
|
+
* - No existing dir: atomic rename — there is nothing to lose.
|
|
137
|
+
* - Existing dir that is PROVABLY this exact build: a remnant of a previously
|
|
138
|
+
* rolled-back attempt. Keep it and discard the redundant staging build. Proof
|
|
139
|
+
* is its HEAD commit id (a hash over the whole tree AND commit history,
|
|
140
|
+
* reproducible only by this deterministic builder from this exact data), plus
|
|
141
|
+
* a clean working tree and the managed boundary (no remote / no
|
|
142
|
+
* external-program config) — so a dir with extra or modified files is NOT a
|
|
143
|
+
* remnant even if HEAD coincides.
|
|
144
|
+
* - Anything else: STOP. Preserve the existing directory and the staging build
|
|
145
|
+
* untouched and throw, so the outer transaction rolls the DB back and an
|
|
146
|
+
* operator can inspect. Never overwrite, clear, or auto-repair.
|
|
147
|
+
*/
|
|
148
|
+
function adoptBuiltRepository(taskId, staging, finalPath, builtHead) {
|
|
149
|
+
if (!existsSync(finalPath)) {
|
|
150
|
+
renameSync(staging, finalPath);
|
|
151
|
+
assertManagedBoundary(taskId, finalPath);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (isProvenIdenticalRemnant(finalPath, builtHead)) {
|
|
155
|
+
// The existing repo already IS the intended, verified result; drop the build.
|
|
156
|
+
rmSync(staging, { recursive: true, force: true });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`Refusing to overwrite existing artifact directory for ${taskId} at ${finalPath}: it is not a ` +
|
|
160
|
+
`proven byte-identical remnant of this migration (different HEAD, working-tree changes, or an ` +
|
|
161
|
+
`external remote/config). No data was modified; resolve it manually and re-run the upgrade.`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* True ONLY if `finalPath` is a managed Git repo whose HEAD equals `builtHead`,
|
|
165
|
+
* whose working tree is clean, and which is within the managed boundary. Any
|
|
166
|
+
* failure (not a repo, differing HEAD, dirty tree, remote/external config) means
|
|
167
|
+
* "not a remnant", so the caller fails closed rather than deleting it. Read-only.
|
|
168
|
+
*
|
|
169
|
+
* ORDER MATTERS: the managed-boundary check (remote + config scan — reads that
|
|
170
|
+
* never run a filter) runs BEFORE `git status`. `git status` re-hashes a
|
|
171
|
+
* same-size modified file through a `clean` filter to decide if it really
|
|
172
|
+
* differs, so running status first on a tampered repo could execute an external
|
|
173
|
+
* program before we ever reject it. The boundary scan also rejects config
|
|
174
|
+
* INCLUSION entry points, so a filter hidden behind `include.*` is refused here
|
|
175
|
+
* too — before status can follow the include.
|
|
176
|
+
*/
|
|
177
|
+
function isProvenIdenticalRemnant(finalPath, builtHead) {
|
|
178
|
+
if (!managedGitSyncSucceeds(finalPath, ["rev-parse", "--is-inside-work-tree"]))
|
|
179
|
+
return false;
|
|
180
|
+
// Fail closed on any remote or external-program/inclusion config BEFORE the
|
|
181
|
+
// first working-tree inspection, so no `clean`/`smudge` filter can run.
|
|
182
|
+
if (!isWithinManagedBoundary(finalPath))
|
|
183
|
+
return false;
|
|
184
|
+
let head;
|
|
185
|
+
try {
|
|
186
|
+
head = requireCommitId(managedGitSync(finalPath, ["rev-parse", "HEAD^{commit}"]));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
// A matching HEAD proves the entire committed history; a clean tree proves
|
|
192
|
+
// nothing was added or modified on top of that history. Only now that the
|
|
193
|
+
// boundary is proven is it safe to let `git status` touch the working tree.
|
|
194
|
+
if (head !== builtHead)
|
|
195
|
+
return false;
|
|
196
|
+
if (managedGitSync(finalPath, ["status", "--porcelain=v1", "--untracked-files=all", "-z"]).length > 0) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/** Write one Artifact's files, commit exactly them, and return its frozen reference. */
|
|
202
|
+
function commitArtifact(repoPath, artifact) {
|
|
203
|
+
const idSegment = safePathSegment(artifact.id, "Artifact id");
|
|
204
|
+
const base = `migrated/${idSegment}`;
|
|
205
|
+
const recordPath = safeRelativeArtifactPath(`${base}/record.json`);
|
|
206
|
+
const files = [
|
|
207
|
+
{ relativePath: recordPath, bytes: Buffer.from(stableJson(artifactRecord(artifact)), "utf8") }
|
|
208
|
+
];
|
|
209
|
+
let target = recordPath;
|
|
210
|
+
let digest;
|
|
211
|
+
if ((artifact.kind === "content" || artifact.kind === "receipt") && typeof artifact.content === "string") {
|
|
212
|
+
const contentPath = safeRelativeArtifactPath(`${base}/content`);
|
|
213
|
+
const bytes = Buffer.from(artifact.content, "utf8");
|
|
214
|
+
files.push({ relativePath: contentPath, bytes });
|
|
215
|
+
target = contentPath;
|
|
216
|
+
// sha256 of the exact committed bytes; equals the old ref's content digest.
|
|
217
|
+
digest = createHash("sha256").update(bytes).digest("hex");
|
|
218
|
+
}
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
const absolute = join(repoPath, file.relativePath);
|
|
221
|
+
mkdirSync(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
222
|
+
writeFileSync(absolute, file.bytes, { mode: 0o600 });
|
|
223
|
+
}
|
|
224
|
+
const pathspecs = files.map((file) => file.relativePath);
|
|
225
|
+
managedGitSync(repoPath, ["add", "--", ...pathspecs]);
|
|
226
|
+
managedGitSync(repoPath, ["commit", "--only", "-m", `migrate artifact ${artifact.id}`, "--", ...pathspecs], {
|
|
227
|
+
commitDates: { author: artifact.createdAt, committer: artifact.createdAt }
|
|
228
|
+
});
|
|
229
|
+
const commit = requireCommitId(managedGitSync(repoPath, ["rev-parse", "HEAD^{commit}"]));
|
|
230
|
+
// A reference target is never a reference-kind Artifact (those were never
|
|
231
|
+
// fixed results), but such Artifacts are still preserved above as history.
|
|
232
|
+
return artifact.kind === "reference"
|
|
233
|
+
? { commit, relativePath: recordPath }
|
|
234
|
+
: { commit, relativePath: target, ...(digest === undefined ? {} : { digest }) };
|
|
235
|
+
}
|
|
236
|
+
/** Verify a freshly built repository is within the managed boundary; throw otherwise. */
|
|
237
|
+
function assertManagedBoundary(taskId, repoPath) {
|
|
238
|
+
const remotes = managedGitSync(repoPath, ["remote"]).split("\n").map((line) => line.trim()).filter(Boolean);
|
|
239
|
+
if (remotes.length > 0) {
|
|
240
|
+
throw new Error(`Migrated artifact repository for ${taskId} unexpectedly has a remote: ${remotes.join(", ")}.`);
|
|
241
|
+
}
|
|
242
|
+
const configViolations = externalProgramConfigViolations(managedGitSync(repoPath, ["config", "--local", "--list", "-z"]));
|
|
243
|
+
if (configViolations.length > 0) {
|
|
244
|
+
throw new Error(`Migrated artifact repository for ${taskId} has repo-local config that can run external ` +
|
|
245
|
+
`programs: ${configViolations.join(", ")}.`);
|
|
246
|
+
}
|
|
247
|
+
// A managed repo must respond to a trivial plumbing query; fail closed otherwise.
|
|
248
|
+
if (!managedGitSyncSucceeds(repoPath, ["rev-parse", "--is-inside-work-tree"])) {
|
|
249
|
+
throw new Error(`Migrated artifact repository for ${taskId} did not initialize correctly.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Read-only managed-boundary predicate (no remote, no external-program config). Never throws. */
|
|
253
|
+
function isWithinManagedBoundary(repoPath) {
|
|
254
|
+
const remotes = managedGitSync(repoPath, ["remote"]).split("\n").map((line) => line.trim()).filter(Boolean);
|
|
255
|
+
if (remotes.length > 0)
|
|
256
|
+
return false;
|
|
257
|
+
return externalProgramConfigViolations(managedGitSync(repoPath, ["config", "--local", "--list", "-z"])).length === 0;
|
|
258
|
+
}
|
|
259
|
+
/** An upgradable Candidate reference resolved to its exact commit and path. */
|
|
260
|
+
function upgradeCandidateRef(taskId, ref, migrated) {
|
|
261
|
+
if (ref === null || typeof ref !== "object")
|
|
262
|
+
return ref;
|
|
263
|
+
const source = ref;
|
|
264
|
+
// Already a commit-pinned Git reference (defensive; a fresh v18 row is never this).
|
|
265
|
+
if (typeof source.commit === "string" && typeof source.relativePath === "string")
|
|
266
|
+
return ref;
|
|
267
|
+
const refTaskId = typeof source.taskId === "string" ? source.taskId : taskId;
|
|
268
|
+
const artifactId = source.artifactId;
|
|
269
|
+
if (typeof artifactId !== "string") {
|
|
270
|
+
throw new Error("Candidate artifact reference has no artifact id to migrate.");
|
|
271
|
+
}
|
|
272
|
+
const found = migrated.get(refKey(refTaskId, artifactId));
|
|
273
|
+
if (found === undefined) {
|
|
274
|
+
throw new Error(`Candidate artifact reference has no migrated Artifact: ${refTaskId}/${artifactId}.`);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
taskId: refTaskId,
|
|
278
|
+
commit: found.commit,
|
|
279
|
+
relativePath: found.relativePath,
|
|
280
|
+
...(found.digest === undefined ? {} : { digest: found.digest })
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Rewrite Candidate artifactRefs in work_items payloads (and the defensive candidate table). */
|
|
284
|
+
function rewriteCandidateRefs(db, migrated) {
|
|
285
|
+
for (const row of db.prepare("SELECT task_id, work_item_id, payload FROM work_items").all()) {
|
|
286
|
+
const item = JSON.parse(row.payload);
|
|
287
|
+
if (!Array.isArray(item.candidates))
|
|
288
|
+
continue;
|
|
289
|
+
item.candidates = item.candidates.map((candidate) => {
|
|
290
|
+
if (candidate === null || typeof candidate !== "object")
|
|
291
|
+
return candidate;
|
|
292
|
+
const record = candidate;
|
|
293
|
+
if (!Array.isArray(record.artifactRefs))
|
|
294
|
+
return candidate;
|
|
295
|
+
return {
|
|
296
|
+
...record,
|
|
297
|
+
artifactRefs: record.artifactRefs.map((ref) => upgradeCandidateRef(row.task_id, ref, migrated))
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
const next = JSON.stringify(item);
|
|
301
|
+
if (next !== row.payload) {
|
|
302
|
+
db.prepare("UPDATE work_items SET payload = ? WHERE task_id = ? AND work_item_id = ?")
|
|
303
|
+
.run(next, row.task_id, row.work_item_id);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// The dedicated candidate table is not written by the current runtime, but a
|
|
307
|
+
// historical row must still migrate rather than be left with a stale shape.
|
|
308
|
+
for (const row of db.prepare("SELECT task_id, candidate_id, payload FROM work_item_candidates").all()) {
|
|
309
|
+
const candidate = JSON.parse(row.payload);
|
|
310
|
+
if (!Array.isArray(candidate.artifactRefs))
|
|
311
|
+
continue;
|
|
312
|
+
candidate.artifactRefs = candidate.artifactRefs.map((ref) => upgradeCandidateRef(row.task_id, ref, migrated));
|
|
313
|
+
const next = JSON.stringify(candidate);
|
|
314
|
+
if (next !== row.payload) {
|
|
315
|
+
db.prepare("UPDATE work_item_candidates SET payload = ? WHERE task_id = ? AND candidate_id = ?")
|
|
316
|
+
.run(next, row.task_id, row.candidate_id);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Rewrite completion artifact refs (string ids) into commit-pinned Git strings. */
|
|
321
|
+
function rewriteCompletionRefs(db, migrated) {
|
|
322
|
+
for (const row of db.prepare("SELECT task_id, payload FROM task_records").all()) {
|
|
323
|
+
const task = JSON.parse(row.payload);
|
|
324
|
+
if (!Array.isArray(task.completionArtifactRefs))
|
|
325
|
+
continue;
|
|
326
|
+
task.completionArtifactRefs = task.completionArtifactRefs.map((ref) => {
|
|
327
|
+
if (typeof ref !== "string" || ref.startsWith("git:"))
|
|
328
|
+
return ref;
|
|
329
|
+
const found = migrated.get(refKey(row.task_id, ref));
|
|
330
|
+
// Preserve non-Artifact completion strings (e.g. turn:<id> or a URL) verbatim.
|
|
331
|
+
return found === undefined ? ref : `git:${found.commit}:${found.relativePath}`;
|
|
332
|
+
});
|
|
333
|
+
const next = JSON.stringify(task);
|
|
334
|
+
if (next !== row.payload) {
|
|
335
|
+
db.prepare("UPDATE task_records SET payload = ? WHERE task_id = ?").run(next, row.task_id);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createTaskEvent } from "../../event/taskEvent.js";
|
|
2
|
+
/**
|
|
3
|
+
* Storage 18 -> 19 data step for task-32 Requirement A (submit intent & Draft
|
|
4
|
+
* auto-routing).
|
|
5
|
+
*
|
|
6
|
+
* This helper is INDEPENDENT, deterministic and idempotent. The unified 18 -> 19
|
|
7
|
+
* migration owned by the artifacts WorkItem REGISTERS it (as its `migrateData`)
|
|
8
|
+
* and bumps `CURRENT_STORAGE_VERSION`; this module neither registers a second
|
|
9
|
+
* migration nor changes any schema.
|
|
10
|
+
*
|
|
11
|
+
* Requirement A adds three optional fields inside `messages.payload`, all part of
|
|
12
|
+
* this same 18 -> 19 contract even though each is optional — "optional" governs a
|
|
13
|
+
* single record's shape, not the Home compatibility line, so the version
|
|
14
|
+
* transition is still declared here and carried by the unified migration:
|
|
15
|
+
*
|
|
16
|
+
* - `intent` (record | discuss | develop): an absent value already reads as
|
|
17
|
+
* `discuss` (never `develop`), so historical Messages need no rewrite.
|
|
18
|
+
* - `submissionKey`: the client-chosen idempotency key. Only ever set going
|
|
19
|
+
* forward; NO historical key is fabricated (task-32 §2.3, message review), so
|
|
20
|
+
* old Messages stay keyless and non-idempotent exactly as before.
|
|
21
|
+
* - `submissionReceipt`: the frozen disposition a keyed submission replays from.
|
|
22
|
+
* Written only alongside a new `submissionKey`, so it too never appears on
|
|
23
|
+
* historical data.
|
|
24
|
+
*
|
|
25
|
+
* Because none of the three needs a value backfilled onto old rows, the only data
|
|
26
|
+
* step this migration performs is the planning-entered reconstruction below.
|
|
27
|
+
*
|
|
28
|
+
* The one fact old data cannot express is "this Draft already entered planning".
|
|
29
|
+
* Before Requirement A a Leader-waking user/operator submission on a Draft WAS
|
|
30
|
+
* the planning discussion, but it left no `task.planning-entered` fact because
|
|
31
|
+
* that event type did not exist. After the upgrade `develop` auto-activates an
|
|
32
|
+
* *unplanned* Draft; without this step a Draft that was mid-discussion would be
|
|
33
|
+
* mis-read as unplanned and snatched into auto-activation (task-32 §2.2, §4).
|
|
34
|
+
*
|
|
35
|
+
* So for every Draft that received at least one legitimate historical planning
|
|
36
|
+
* submission this appends exactly one `task.planning-entered` event, which makes
|
|
37
|
+
* {@link draftHasEnteredPlanning} derive `true` and routes a later develop to
|
|
38
|
+
* manual activation. A qualifying submission is an *unaddressed* user/operator
|
|
39
|
+
* Message that was not save-only (`wakePolicy !== "none"`) — byte-for-byte the
|
|
40
|
+
* same shape the new discuss path routes through the shared submission service.
|
|
41
|
+
*
|
|
42
|
+
* This is deliberately the conservative direction: it can only ever make develop
|
|
43
|
+
* MORE cautious, never activate a Draft it should not. Save-only Messages (old
|
|
44
|
+
* `wakePolicy === "none"`, the shape `record` now names) are excluded, so a Draft
|
|
45
|
+
* that only ever recorded context stays legitimately unplanned and its first
|
|
46
|
+
* develop may still auto-activate. Non-Draft Tasks are skipped because
|
|
47
|
+
* {@link draftHasEnteredPlanning} answers only about Drafts, and Activation
|
|
48
|
+
* requests are left untouched — a legacy request's absent `origin` is the
|
|
49
|
+
* truthful marker that it stays behind explicit activation.
|
|
50
|
+
*/
|
|
51
|
+
export function migrateSubmitIntent(db) {
|
|
52
|
+
const drafts = db.prepare("SELECT task_id FROM tasks_catalog WHERE status = 'draft'").all();
|
|
53
|
+
if (drafts.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
const listMessages = db.prepare("SELECT payload FROM messages WHERE task_id = ? ORDER BY seq ASC");
|
|
56
|
+
const listEventRows = db.prepare("SELECT event_id, type FROM events WHERE task_id = ?");
|
|
57
|
+
const readHighWater = db.prepare("SELECT high_water FROM id_sequences WHERE task_id = ? AND kind = 'event'");
|
|
58
|
+
// Advance the per-Task event high-water to exactly the id we just consumed, so
|
|
59
|
+
// the runtime keeps allocating strictly above every migrated event (mirrors
|
|
60
|
+
// sqliteStore's id_sequences bookkeeping).
|
|
61
|
+
const upsertHighWater = db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, 'event', ?)
|
|
62
|
+
ON CONFLICT(task_id, kind) DO UPDATE SET high_water = ?`);
|
|
63
|
+
const insertEvent = db.prepare("INSERT INTO events (task_id, event_id, type, occurred_at, payload) VALUES (?, ?, ?, ?, ?)");
|
|
64
|
+
for (const { task_id: taskId } of drafts) {
|
|
65
|
+
const eventRows = listEventRows.all(taskId);
|
|
66
|
+
// Idempotent: a Draft that already carries the fact (from a prior run of this
|
|
67
|
+
// migration, or from a real runtime planning entry) is left untouched.
|
|
68
|
+
if (eventRows.some((row) => row.type === PLANNING_ENTERED_EVENT_TYPE))
|
|
69
|
+
continue;
|
|
70
|
+
const entry = firstPlanningSubmission(listMessages.all(taskId));
|
|
71
|
+
if (entry === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
// Allocate the next event id from the greater of the recorded high-water and
|
|
74
|
+
// the largest event id actually present, so a fixture or historical database
|
|
75
|
+
// whose id_sequences lags the events table can never produce a colliding id.
|
|
76
|
+
const highWater = readHighWater.get(taskId)?.high_water ?? 0;
|
|
77
|
+
const maxEventSeq = eventRows.reduce((max, row) => Math.max(max, eventSequence(row.event_id)), 0);
|
|
78
|
+
const seq = Math.max(highWater, maxEventSeq) + 1;
|
|
79
|
+
// Build through the shared constructor so a migrated event is byte-identical
|
|
80
|
+
// in shape to one the shared submission service writes at runtime, and dated
|
|
81
|
+
// to the historical submission rather than the wall-clock upgrade moment.
|
|
82
|
+
const event = createTaskEvent(`event-${seq}`, taskId, PLANNING_ENTERED_EVENT_TYPE, { messageId: entry.id, intent: "discuss", derivedBy: MIGRATION_PROVENANCE }, new Date(entry.createdAt));
|
|
83
|
+
insertEvent.run(taskId, event.id, event.type, event.createdAt, JSON.stringify(event));
|
|
84
|
+
upsertHighWater.run(taskId, seq, seq);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The never-compacted event type recording a Draft entered planning. Inlined as a
|
|
89
|
+
* literal rather than imported so this released migration stays frozen against a
|
|
90
|
+
* future runtime rename of the constant; `TASK_PLANNING_ENTERED_EVENT` is the
|
|
91
|
+
* runtime owner, and a tripwire test asserts the two never diverge.
|
|
92
|
+
*/
|
|
93
|
+
const PLANNING_ENTERED_EVENT_TYPE = "task.planning-entered";
|
|
94
|
+
/** Marks the event as reconstructed by this migration, for later audit. */
|
|
95
|
+
const MIGRATION_PROVENANCE = "submit-intent-migration";
|
|
96
|
+
/**
|
|
97
|
+
* The earliest Message on a Draft that represents a historical planning
|
|
98
|
+
* submission, or `undefined` if the Draft only ever recorded save-only context.
|
|
99
|
+
*
|
|
100
|
+
* A qualifying Message is an unaddressed (`recipient === undefined`) user or
|
|
101
|
+
* operator Message whose wake policy was not `none`. An absent wake policy
|
|
102
|
+
* qualifies: before Requirement A that was the default Leader-waking submission,
|
|
103
|
+
* exactly the shape an omitted intent normalizes to `discuss` today.
|
|
104
|
+
*/
|
|
105
|
+
function firstPlanningSubmission(rows) {
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const message = JSON.parse(row.payload);
|
|
108
|
+
if (message.kind !== "user" && message.kind !== "operator")
|
|
109
|
+
continue;
|
|
110
|
+
if (message.recipient !== undefined)
|
|
111
|
+
continue; // an addressed continuation, not a submission
|
|
112
|
+
if (message.wakePolicy === "none")
|
|
113
|
+
continue; // old save-only == record, never planning
|
|
114
|
+
if (typeof message.id !== "string")
|
|
115
|
+
continue;
|
|
116
|
+
if (typeof message.createdAt !== "string")
|
|
117
|
+
continue;
|
|
118
|
+
return { id: message.id, createdAt: message.createdAt };
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/** The numeric suffix of an `event-<n>` id, or 0 for any unexpected shape. */
|
|
123
|
+
function eventSequence(eventId) {
|
|
124
|
+
const match = /^event-(\d+)$/.exec(eventId);
|
|
125
|
+
return match === null ? 0 : Number.parseInt(match[1], 10);
|
|
126
|
+
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { REMOVE_RUNTIME_GENERATION_SQL, removeRuntimeGenerationRecords } from "./migrations/removeRuntimeGeneration.js";
|
|
25
25
|
import { migrateAgentRunContract } from "./migrations/agentRunContract.js";
|
|
26
|
+
import { migrateArtifactsToGit } from "./migrations/artifactsToGit.js";
|
|
26
27
|
import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "./storageVersions.js";
|
|
27
28
|
/** Telemetry retention bounds (§4.4). Open question 3 in §11; defaults from the design. */
|
|
28
29
|
export const TELEMETRY_KEEP_PER_RUN = 200;
|
|
@@ -1117,6 +1118,31 @@ UPDATE review_rounds SET payload = json_set(payload, '$.executionGroup.lanes', j
|
|
|
1117
1118
|
// now retain explicit replacement intent; OS owner records may identify
|
|
1118
1119
|
// the dedicated execution child, independently of a disposable Host.
|
|
1119
1120
|
sql: "SELECT 1; -- Replacement intent, independent retained native control evidence/process custody, and optional fixed TaskWake refs"
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
version: 19,
|
|
1124
|
+
name: "task-artifacts-local-git",
|
|
1125
|
+
introducedIn: "0.16.0",
|
|
1126
|
+
// Retire the DB-owned immutable Artifact store: file/directory artifacts now
|
|
1127
|
+
// live in a per-Task local Git repository, referenced by a self-certifying
|
|
1128
|
+
// `commit + relativePath`. The whole rewrite is payload work that must READ
|
|
1129
|
+
// the `artifacts` table before it is dropped, so it runs entirely in
|
|
1130
|
+
// `migrateData` (which executes after this `sql`) — the table is dropped
|
|
1131
|
+
// there, last, once its rows have been moved. Requirement A's independent,
|
|
1132
|
+
// deterministic submit-intent backfill (events + id_sequences only) is the
|
|
1133
|
+
// final step inside that same transaction. This `sql` is intentionally a
|
|
1134
|
+
// no-op: dropping the table here would destroy the rows before they move.
|
|
1135
|
+
//
|
|
1136
|
+
// The 18->19 contract also DECLARES three optional `messages.payload` fields
|
|
1137
|
+
// added by Requirement A — `intent` (record|discuss|develop), an optional
|
|
1138
|
+
// client idempotency `submissionKey`, and a frozen `submissionReceipt`
|
|
1139
|
+
// disposition. They add no column and no table and are only ever written
|
|
1140
|
+
// going forward (absent `intent` reads as discuss; absent key is keyless;
|
|
1141
|
+
// absent receipt is a pre-19 message), so NO historical row is rewritten and
|
|
1142
|
+
// no key or receipt is ever fabricated for old data — declaring them here
|
|
1143
|
+
// satisfies "optional still requires a migration declaration".
|
|
1144
|
+
sql: "SELECT 1; -- artifacts move to per-Task local Git; see migrateArtifactsToGit",
|
|
1145
|
+
migrateData: migrateArtifactsToGit
|
|
1120
1146
|
}
|
|
1121
1147
|
]);
|
|
1122
1148
|
for (let index = 0; index < MIGRATIONS.length; index += 1) {
|
|
@@ -1350,7 +1376,12 @@ export function migrateSqliteSchema(db, options) {
|
|
|
1350
1376
|
throw new SqliteSchemaMigrationError(`storage version ${applied.currentVersion} is older than the minimum supported `
|
|
1351
1377
|
+ `${MIN_SUPPORTED_STORAGE_VERSION}`, "admission");
|
|
1352
1378
|
}
|
|
1353
|
-
|
|
1379
|
+
// Production callers omit `throughVersion`, so the effective target is head
|
|
1380
|
+
// and behavior is unchanged; a partial target is honored only in apply mode.
|
|
1381
|
+
const effectiveTarget = options.mode === "apply" && options.throughVersion !== undefined
|
|
1382
|
+
? options.throughVersion
|
|
1383
|
+
: CURRENT_STORAGE_VERSION;
|
|
1384
|
+
const pending = MIGRATIONS.filter((migration) => !applied.versions.has(migration.version) && migration.version <= effectiveTarget);
|
|
1354
1385
|
if (!ledgerWasCreated && pending.length > 0 && options.mode === "validate") {
|
|
1355
1386
|
throw new SqliteSchemaMigrationError(`Storage version ${applied.currentVersion} requires an explicit upgrade to `
|
|
1356
1387
|
+ `${CURRENT_STORAGE_VERSION}`, "admission");
|
|
@@ -1364,7 +1395,11 @@ export function migrateSqliteSchema(db, options) {
|
|
|
1364
1395
|
VALUES (?, ?, ?, ?)`).run(migration.version, migration.name, appliedAt, checksum(migration.sql));
|
|
1365
1396
|
newlyApplied.push(migration.version);
|
|
1366
1397
|
}
|
|
1367
|
-
|
|
1398
|
+
// The table/index inventory describes the HEAD shape; only assert it once the
|
|
1399
|
+
// migration has actually advanced to head (a deliberate partial target stops
|
|
1400
|
+
// earlier and is validated when the real upgrade later completes it).
|
|
1401
|
+
if (effectiveTarget >= CURRENT_STORAGE_VERSION)
|
|
1402
|
+
validateSchemaObjects(db);
|
|
1368
1403
|
return newlyApplied;
|
|
1369
1404
|
};
|
|
1370
1405
|
const newlyApplied = options.mode === "apply" && !db.inTransaction
|
|
@@ -1376,7 +1411,6 @@ export function migrateSqliteSchema(db, options) {
|
|
|
1376
1411
|
export const SQLITE_SCHEMA_TABLES = [
|
|
1377
1412
|
"plugin_intents",
|
|
1378
1413
|
"plugin_validations",
|
|
1379
|
-
"artifacts",
|
|
1380
1414
|
"local_resources",
|
|
1381
1415
|
"environment_preparations",
|
|
1382
1416
|
"schema_migrations",
|
|
@@ -40,7 +40,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
40
40
|
import Database from "better-sqlite3";
|
|
41
41
|
import { validatePluginValidation } from "../plugins/pluginPackage.js";
|
|
42
42
|
import { validatePluginIntent, validatePluginIntentFailure } from "../plugins/pluginIntent.js";
|
|
43
|
-
import {
|
|
43
|
+
import { validateLocalResource, validateEnvironmentPreparation } from "../resources/projectResource.js";
|
|
44
44
|
import { validateConfiguredAgent } from "../agent/agent.js";
|
|
45
45
|
import { validateTaskBrief } from "../brief/taskBrief.js";
|
|
46
46
|
import { consumePendingBatch, mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
|
|
@@ -614,26 +614,6 @@ export class SqliteTaskStore {
|
|
|
614
614
|
});
|
|
615
615
|
}
|
|
616
616
|
// -- projects ---------------------------------------------------------------
|
|
617
|
-
saveArtifact(artifact) {
|
|
618
|
-
validateArtifact(artifact);
|
|
619
|
-
this.#mutate(() => {
|
|
620
|
-
const previous = this.getArtifact(artifact.taskId, artifact.id);
|
|
621
|
-
if (previous !== null) {
|
|
622
|
-
if (!isDeepStrictEqual(previous, artifact))
|
|
623
|
-
throw new StorageRecordError("Artifacts are immutable.");
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
this.#db.prepare("INSERT INTO artifacts (task_id, id, payload) VALUES (?, ?, ?)")
|
|
627
|
-
.run(artifact.taskId, artifact.id, this.#json(artifact));
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
getArtifact(taskId, artifactId) {
|
|
631
|
-
const artifact = this.#getPayload("artifacts", "task_id = ? AND id = ?", [taskId, artifactId]);
|
|
632
|
-
return artifact === null ? null : validateArtifact(artifact);
|
|
633
|
-
}
|
|
634
|
-
listArtifacts(taskId) {
|
|
635
|
-
return this.#listPayload("artifacts", "task_id = ?", [taskId]).map(validateArtifact);
|
|
636
|
-
}
|
|
637
617
|
savePluginValidation(validation) {
|
|
638
618
|
validatePluginValidation(validation);
|
|
639
619
|
this.#mutate(() => {
|
package/dist/storage/storeRpc.js
CHANGED
|
@@ -29,7 +29,7 @@ import { StorageCancelledError, StorageConflictError, StorageRecordError } from
|
|
|
29
29
|
*/
|
|
30
30
|
const READ_ONLY_STORE_METHODS = new Set([
|
|
31
31
|
"getPluginIntent", "listPluginIntents",
|
|
32
|
-
"getPluginValidation", "
|
|
32
|
+
"getPluginValidation", "getLocalResource", "listLocalResources",
|
|
33
33
|
"getEnvironmentPreparation", "listEnvironmentPreparations",
|
|
34
34
|
"rootDirectory",
|
|
35
35
|
"getConfig",
|
|
@@ -22,6 +22,7 @@ export const TASK_ACTIVATION_EVENT = Object.freeze({
|
|
|
22
22
|
adopted: "task.activation-adopted",
|
|
23
23
|
failed: "task.activation-failed"
|
|
24
24
|
});
|
|
25
|
+
export const TASK_ACTIVATION_ORIGINS = Object.freeze(["explicit", "submit-develop"]);
|
|
25
26
|
/**
|
|
26
27
|
* The digest that decides whether a repeated requestId is the same request.
|
|
27
28
|
*
|
|
@@ -63,6 +64,7 @@ export function createTaskActivationRequest(taskId, input, now) {
|
|
|
63
64
|
partialResultRefs: []
|
|
64
65
|
},
|
|
65
66
|
startMode: input.startMode,
|
|
67
|
+
...(input.origin === undefined ? {} : { origin: input.origin }),
|
|
66
68
|
...(input.afterPlanningRun === undefined
|
|
67
69
|
? {}
|
|
68
70
|
: { afterPlanningRun: input.afterPlanningRun }),
|
|
@@ -194,6 +196,26 @@ export function admitTaskActivationRequest(task, request, planningRunIsActive) {
|
|
|
194
196
|
}
|
|
195
197
|
return { disposition: "ready", request };
|
|
196
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether the Controller may continue (auto-adopt) a released request without an
|
|
201
|
+
* explicit activation boundary call (task-32 §2.4).
|
|
202
|
+
*
|
|
203
|
+
* A deferred request already holds activation authority and is released by its
|
|
204
|
+
* planning Turn ending, so it is adoptable regardless of origin. An immediate
|
|
205
|
+
* request is auto-continued only when it carries a recognised provable origin:
|
|
206
|
+
* an explicit activation action, or a develop submission the shared transaction
|
|
207
|
+
* accepted while the Task was unplanned. A request with no origin — every one
|
|
208
|
+
* migrated from before the field existed — is deliberately held back so an
|
|
209
|
+
* upgrade cannot turn a historical pending request into a silent activation; it
|
|
210
|
+
* still adopts through the explicit `yui task activate` boundary, which does not
|
|
211
|
+
* consult origin. This replaces the old actor heuristic that both dropped legal
|
|
212
|
+
* Operator/user immediate requests and could not distinguish provenance.
|
|
213
|
+
*/
|
|
214
|
+
export function activationRequestIsControllerAdoptable(request) {
|
|
215
|
+
if (request.startMode === "after-planning-turn")
|
|
216
|
+
return true;
|
|
217
|
+
return request.origin !== undefined;
|
|
218
|
+
}
|
|
197
219
|
/**
|
|
198
220
|
* The durable terminal outcome for a requestId, read from the activation event
|
|
199
221
|
* ledger.
|
|
@@ -236,6 +258,10 @@ export function validateTaskActivationRequest(request) {
|
|
|
236
258
|
if (!["immediate", "after-planning-turn"].includes(request.startMode)) {
|
|
237
259
|
throw new Error(`Activation start mode is invalid: ${String(request.startMode)}.`);
|
|
238
260
|
}
|
|
261
|
+
if (request.origin !== undefined
|
|
262
|
+
&& !TASK_ACTIVATION_ORIGINS.includes(request.origin)) {
|
|
263
|
+
throw new Error(`Activation origin is invalid: ${String(request.origin)}.`);
|
|
264
|
+
}
|
|
239
265
|
if ((request.startMode === "after-planning-turn")
|
|
240
266
|
!== (request.afterPlanningRun !== undefined)) {
|
|
241
267
|
throw new Error("Activation deferral must name exactly the planning Turn it waits for.");
|