@danypops/papyrus 0.50.2 → 0.51.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.
- package/package.json +1 -1
- package/src/cli/playbooks-command.ts +111 -1
- package/src/handlers/playbooks.ts +67 -4
- package/src/handlers/registry.ts +1 -0
- package/src/modules/playbooks.ts +51 -7
- package/src/playbook/playbook-service.ts +78 -2
- package/src/playbook/workflow-execution.ts +38 -10
- package/src/service.ts +8 -1
package/package.json
CHANGED
|
@@ -53,6 +53,7 @@ const createCommand = buildCommand({
|
|
|
53
53
|
extraJson?: Record<string, unknown>;
|
|
54
54
|
argumentsJson?: unknown[] | Record<string, unknown>;
|
|
55
55
|
projectRoot?: string;
|
|
56
|
+
projectsJson?: string[];
|
|
56
57
|
},
|
|
57
58
|
) {
|
|
58
59
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("playbooks.create", {
|
|
@@ -65,6 +66,7 @@ const createCommand = buildCommand({
|
|
|
65
66
|
extra: flags.extraJson,
|
|
66
67
|
arguments: flags.argumentsJson,
|
|
67
68
|
project_root: flags.projectRoot,
|
|
69
|
+
projects: flags.projectsJson,
|
|
68
70
|
});
|
|
69
71
|
render.call(this, artifact, `Created playbook: ${artifactLabel(artifact)}`);
|
|
70
72
|
},
|
|
@@ -91,18 +93,29 @@ const createCommand = buildCommand({
|
|
|
91
93
|
optional: true,
|
|
92
94
|
},
|
|
93
95
|
projectRoot: { brief: "Project scope", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
96
|
+
projectsJson: {
|
|
97
|
+
brief: "JSON string array of project id/name/alias/root references, taking precedence over --project-root",
|
|
98
|
+
kind: "parsed",
|
|
99
|
+
parse: parseStringArray,
|
|
100
|
+
placeholder: "json",
|
|
101
|
+
optional: true,
|
|
102
|
+
},
|
|
94
103
|
},
|
|
95
104
|
},
|
|
96
105
|
docs: { brief: "Create a Playbook" },
|
|
97
106
|
});
|
|
98
107
|
|
|
99
108
|
const listCommand = buildCommand({
|
|
100
|
-
func: async function (
|
|
109
|
+
func: async function (
|
|
110
|
+
this: PlaybooksContext,
|
|
111
|
+
flags: { status?: string; text?: string; limit?: number; projectRoot?: string; applicable?: boolean },
|
|
112
|
+
) {
|
|
101
113
|
const rows = await this.client.call<Record<string, unknown>, CliArtifact[]>("playbooks.list", {
|
|
102
114
|
status: flags.status,
|
|
103
115
|
text: flags.text,
|
|
104
116
|
limit: flags.limit,
|
|
105
117
|
project_root: flags.projectRoot,
|
|
118
|
+
applicable: flags.applicable,
|
|
106
119
|
});
|
|
107
120
|
render.call(this, rows, rows.length === 0 ? "No playbooks found." : rows.map((row) => artifactLabel(row)).join("\n"));
|
|
108
121
|
},
|
|
@@ -112,6 +125,11 @@ const listCommand = buildCommand({
|
|
|
112
125
|
text: { brief: "Substring match against title/body", kind: "parsed", parse: String, placeholder: "text", optional: true },
|
|
113
126
|
limit: { brief: "Maximum playbooks to return", kind: "parsed", parse: numberParser, optional: true },
|
|
114
127
|
projectRoot: { brief: "Project scope", kind: "parsed", parse: String, placeholder: "path", optional: true },
|
|
128
|
+
applicable: {
|
|
129
|
+
brief: "With --project-root: list global Playbooks plus Playbooks applicable to it, instead of exact membership",
|
|
130
|
+
kind: "boolean",
|
|
131
|
+
optional: true,
|
|
132
|
+
},
|
|
115
133
|
},
|
|
116
134
|
},
|
|
117
135
|
docs: { brief: "List Playbooks" },
|
|
@@ -213,6 +231,93 @@ const assignProjectCommand = buildCommand({
|
|
|
213
231
|
docs: { brief: "Reassign a Playbook's project scope, or unscope it" },
|
|
214
232
|
});
|
|
215
233
|
|
|
234
|
+
interface CliArtifactScope {
|
|
235
|
+
artifactId: string;
|
|
236
|
+
mode: string;
|
|
237
|
+
projectIds: string[];
|
|
238
|
+
source: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderScope(scope: CliArtifactScope): string {
|
|
242
|
+
return scope.mode === "global" ? "global (applies to every project)" : `projects: ${scope.projectIds.join(", ")}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const scopeCommand = buildCommand({
|
|
246
|
+
func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string) {
|
|
247
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.scope", { id });
|
|
248
|
+
render.call(this, scope, renderScope(scope));
|
|
249
|
+
},
|
|
250
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] } },
|
|
251
|
+
docs: { brief: "Show a Playbook's real project scope" },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const setGlobalCommand = buildCommand({
|
|
255
|
+
func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string) {
|
|
256
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.set_global", { id });
|
|
257
|
+
render.call(this, scope, renderScope(scope));
|
|
258
|
+
},
|
|
259
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] } },
|
|
260
|
+
docs: { brief: "Make a Playbook apply in every project" },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const addProjectCommand = buildCommand({
|
|
264
|
+
func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string, project: string) {
|
|
265
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.add_project", { id, project });
|
|
266
|
+
render.call(this, scope, renderScope(scope));
|
|
267
|
+
},
|
|
268
|
+
parameters: {
|
|
269
|
+
flags: {},
|
|
270
|
+
positional: {
|
|
271
|
+
kind: "tuple",
|
|
272
|
+
parameters: [
|
|
273
|
+
{ brief: "Playbook id", parse: String, placeholder: "id" },
|
|
274
|
+
{ brief: "Project id/name/alias/root to add", parse: String, placeholder: "project" },
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
docs: { brief: "Add one project to a Playbook's membership" },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const removeProjectCommand = buildCommand({
|
|
282
|
+
func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string, project: string) {
|
|
283
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.remove_project", { id, project });
|
|
284
|
+
render.call(this, scope, renderScope(scope));
|
|
285
|
+
},
|
|
286
|
+
parameters: {
|
|
287
|
+
flags: {},
|
|
288
|
+
positional: {
|
|
289
|
+
kind: "tuple",
|
|
290
|
+
parameters: [
|
|
291
|
+
{ brief: "Playbook id", parse: String, placeholder: "id" },
|
|
292
|
+
{ brief: "Project id/name/alias/root to remove", parse: String, placeholder: "project" },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
docs: { brief: "Remove one project from a Playbook's membership" },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const replaceProjectsCommand = buildCommand({
|
|
300
|
+
func: async function (this: PlaybooksContext, flags: { projectsJson: string[] }, id: string) {
|
|
301
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.replace_projects", {
|
|
302
|
+
id,
|
|
303
|
+
projects: flags.projectsJson,
|
|
304
|
+
});
|
|
305
|
+
render.call(this, scope, renderScope(scope));
|
|
306
|
+
},
|
|
307
|
+
parameters: {
|
|
308
|
+
flags: {
|
|
309
|
+
projectsJson: {
|
|
310
|
+
brief: "JSON string array of project id/name/alias/root references",
|
|
311
|
+
kind: "parsed",
|
|
312
|
+
parse: parseStringArray,
|
|
313
|
+
placeholder: "json",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] },
|
|
317
|
+
},
|
|
318
|
+
docs: { brief: "Replace a Playbook's entire project membership" },
|
|
319
|
+
});
|
|
320
|
+
|
|
216
321
|
const updateCommand = buildCommand({
|
|
217
322
|
func: async function (this: PlaybooksContext, flags: { title?: string; body?: string; labelsJson?: string[] }, id: string) {
|
|
218
323
|
if (flags.title === undefined && flags.body === undefined && flags.labelsJson === undefined)
|
|
@@ -275,6 +380,11 @@ const app = buildApplication(
|
|
|
275
380
|
enable: buildEnableDisableCommand("enable"),
|
|
276
381
|
disable: buildEnableDisableCommand("disable"),
|
|
277
382
|
"assign-project": assignProjectCommand,
|
|
383
|
+
scope: scopeCommand,
|
|
384
|
+
"set-global": setGlobalCommand,
|
|
385
|
+
"add-project": addProjectCommand,
|
|
386
|
+
"remove-project": removeProjectCommand,
|
|
387
|
+
"replace-projects": replaceProjectsCommand,
|
|
278
388
|
update: updateCommand,
|
|
279
389
|
contain: buildPairedIdCommand(
|
|
280
390
|
"playbooks.contain",
|
|
@@ -24,6 +24,7 @@ import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
|
24
24
|
import { playbooksOperations } from "../modules/playbooks.ts";
|
|
25
25
|
import type { PlaybookInvocationResult, PlaybookMissingArguments } from "../playbook/playbook-execution.ts";
|
|
26
26
|
import { listPlaybooks } from "../playbook/playbook-service.ts";
|
|
27
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
27
28
|
import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
|
|
28
29
|
import type { TaskEventStore } from "../stores/task-event-store.ts";
|
|
29
30
|
import type { TaskScopeStore } from "../stores/task-scope-store.ts";
|
|
@@ -56,6 +57,7 @@ export interface PlaybooksVehicleDeps {
|
|
|
56
57
|
artifactScopes: ArtifactScopeStore;
|
|
57
58
|
tasks: Tasks;
|
|
58
59
|
sessionIdentity: SessionIdentity;
|
|
60
|
+
projectRegistry: ProjectRegistryStore;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/** Unscoped resolution -- a Playbook is commonly cross-project (e.g. a lab-deploy playbook), matching the hand-rolled tool's own resolutionRequest choice. */
|
|
@@ -66,9 +68,12 @@ function resolvePlaybookId(artifacts: ArtifactStore, scopes: ArtifactScopeStore,
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, deps: PlaybooksVehicleDeps): void {
|
|
69
|
-
const { artifacts, events, scopes, artifactScopes, tasks, sessionIdentity } = deps;
|
|
71
|
+
const { artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, projectRegistry } = deps;
|
|
70
72
|
const moduleOperations = new Map(
|
|
71
|
-
playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }).map((op) => [
|
|
73
|
+
playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }).map((op) => [
|
|
74
|
+
op.name,
|
|
75
|
+
op,
|
|
76
|
+
]),
|
|
72
77
|
);
|
|
73
78
|
/**
|
|
74
79
|
* Every playbooks.* action funnels through here. invoke's own module handler re-runs
|
|
@@ -101,6 +106,7 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
|
|
|
101
106
|
extra: { type: "object" },
|
|
102
107
|
template_id: stringProp,
|
|
103
108
|
project_root: stringProp,
|
|
109
|
+
projects: { type: "array" },
|
|
104
110
|
actor: stringProp,
|
|
105
111
|
source: stringProp,
|
|
106
112
|
session_id: stringProp,
|
|
@@ -114,9 +120,9 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
|
|
|
114
120
|
|
|
115
121
|
define(
|
|
116
122
|
"list",
|
|
117
|
-
"Lists Playbooks matching an optional status/text filter
|
|
123
|
+
"Lists Playbooks matching an optional status/text filter. project_root alone scopes to EXACT membership in that project (audit semantics); project_root plus applicable:true instead lists every Playbook APPLICABLE to it (global Playbooks plus Playbooks whose membership includes it) -- what pi-papyrus's before_agent_start uses to inject only relevant Playbooks. Returns a lean summary (no body/steps) by default -- pass full: true for the complete artifact.",
|
|
118
124
|
"read",
|
|
119
|
-
{ status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, full: booleanProp },
|
|
125
|
+
{ status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, applicable: booleanProp, full: booleanProp },
|
|
120
126
|
[],
|
|
121
127
|
(input) => input,
|
|
122
128
|
);
|
|
@@ -200,6 +206,63 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
|
|
|
200
206
|
(input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
|
|
201
207
|
);
|
|
202
208
|
|
|
209
|
+
define(
|
|
210
|
+
"scope",
|
|
211
|
+
"Shows a Playbook's real project scope: global (applies everywhere) or the bounded set of registered projects it applies to.",
|
|
212
|
+
"read",
|
|
213
|
+
{ id: stringProp, name: stringProp },
|
|
214
|
+
[],
|
|
215
|
+
(input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
define(
|
|
219
|
+
"set_global",
|
|
220
|
+
"Makes a Playbook apply in every project, clearing any project membership. The only way to widen a project-bound Playbook back to global -- removing its last membership through remove_project is rejected instead.",
|
|
221
|
+
"local-write",
|
|
222
|
+
{ id: stringProp, name: stringProp },
|
|
223
|
+
[],
|
|
224
|
+
(input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
define(
|
|
228
|
+
"add_project",
|
|
229
|
+
"Adds one registered project (exact id, name, alias, or root) to a Playbook's membership, switching it from global to project-bound if it was global. Idempotent if the project is already a member.",
|
|
230
|
+
"local-write",
|
|
231
|
+
{
|
|
232
|
+
id: stringProp,
|
|
233
|
+
name: stringProp,
|
|
234
|
+
project: { ...stringProp, description: "Exact project id, name, alias, or registered root to add." },
|
|
235
|
+
},
|
|
236
|
+
["project"],
|
|
237
|
+
(input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
define(
|
|
241
|
+
"remove_project",
|
|
242
|
+
"Removes one registered project from a Playbook's membership. Rejected while it is the Playbook's only remaining membership -- call set_global first if the Playbook should stop being project-bound entirely.",
|
|
243
|
+
"local-write",
|
|
244
|
+
{
|
|
245
|
+
id: stringProp,
|
|
246
|
+
name: stringProp,
|
|
247
|
+
project: { ...stringProp, description: "Exact project id, name, alias, or registered root to remove." },
|
|
248
|
+
},
|
|
249
|
+
["project"],
|
|
250
|
+
(input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
define(
|
|
254
|
+
"replace_projects",
|
|
255
|
+
"Replaces a Playbook's entire project membership with exactly this bounded, non-empty list of registered project references (id/name/alias/root). Use set_global instead to clear scoping entirely.",
|
|
256
|
+
"local-write",
|
|
257
|
+
{
|
|
258
|
+
id: stringProp,
|
|
259
|
+
name: stringProp,
|
|
260
|
+
projects: { type: "array", description: "Non-empty list of exact project id/name/alias/root references." },
|
|
261
|
+
},
|
|
262
|
+
["projects"],
|
|
263
|
+
(input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
|
|
264
|
+
);
|
|
265
|
+
|
|
203
266
|
define(
|
|
204
267
|
"update",
|
|
205
268
|
"Changes a Playbook's title/body/labels (at least one required). Refused for a read-only external projection.",
|
package/src/handlers/registry.ts
CHANGED
|
@@ -59,6 +59,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
|
|
|
59
59
|
artifactScopes: deps.scopes,
|
|
60
60
|
tasks: deps.tasks,
|
|
61
61
|
sessionIdentity: deps.sessionIdentity,
|
|
62
|
+
projectRegistry: deps.projectRegistry,
|
|
62
63
|
});
|
|
63
64
|
registerTasksVehicleOperations(registry, { tasks: deps.tasks, artifacts: deps.artifacts, sessionIdentity: deps.sessionIdentity });
|
|
64
65
|
registerDiscussVehicleOperations(registry, deps.discussions, deps.artifacts);
|
package/src/modules/playbooks.ts
CHANGED
|
@@ -16,18 +16,24 @@ import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
|
16
16
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
17
17
|
import { invokePlaybook } from "../playbook/playbook-execution.ts";
|
|
18
18
|
import {
|
|
19
|
+
addPlaybookProject,
|
|
19
20
|
assignPlaybookProject,
|
|
20
21
|
containPlaybook,
|
|
21
22
|
createPlaybook,
|
|
22
23
|
dependPlaybook,
|
|
23
24
|
listPlaybooks,
|
|
24
25
|
playbookInvocation,
|
|
26
|
+
playbookScope,
|
|
27
|
+
removePlaybookProject,
|
|
28
|
+
replacePlaybookProjects,
|
|
29
|
+
setPlaybookGlobal,
|
|
25
30
|
showPlaybook,
|
|
26
31
|
transitionPlaybook,
|
|
27
32
|
uncontainPlaybook,
|
|
28
33
|
undependPlaybook,
|
|
29
34
|
updatePlaybook,
|
|
30
35
|
} from "../playbook/playbook-service.ts";
|
|
36
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
31
37
|
import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
|
|
32
38
|
import type { TaskEventStore } from "../stores/task-event-store.ts";
|
|
33
39
|
import type { TaskScopeStore } from "../stores/task-scope-store.ts";
|
|
@@ -47,12 +53,24 @@ const eventContextFor = (input: OperationInput, source: string) => {
|
|
|
47
53
|
return { ...context, source: context.source ?? source };
|
|
48
54
|
};
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
/**
|
|
57
|
+
* applicable=true switches project_root's meaning from exact-membership audit listing to
|
|
58
|
+
* applicable listing (global Playbooks plus Playbooks whose membership includes it) -- see
|
|
59
|
+
* ListFilter's own doc comment on projectRoot vs applicableToProjectRoot. Used by pi-papyrus's
|
|
60
|
+
* before_agent_start to inject only Playbooks applicable to ctx.cwd, not every active Playbook
|
|
61
|
+
* across every project.
|
|
62
|
+
*/
|
|
63
|
+
const artifactFilter = (input: OperationInput) => {
|
|
64
|
+
const projectRoot = optionalString(input, "project_root");
|
|
65
|
+
const applicable = optionalBoolean(input, "applicable") === true;
|
|
66
|
+
if (applicable && projectRoot === undefined) throw new Error("applicable requires project_root");
|
|
67
|
+
return {
|
|
68
|
+
status: optionalString(input, "status"),
|
|
69
|
+
text: optionalString(input, "text"),
|
|
70
|
+
limit: optionalNumber(input, "limit"),
|
|
71
|
+
...(applicable ? { applicableToProjectRoot: projectRoot } : { projectRoot }),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
56
74
|
|
|
57
75
|
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
58
76
|
export const PLAYBOOKS_OPERATION_NAMES = [
|
|
@@ -64,6 +82,11 @@ export const PLAYBOOKS_OPERATION_NAMES = [
|
|
|
64
82
|
"playbooks.enable",
|
|
65
83
|
"playbooks.disable",
|
|
66
84
|
"playbooks.assign_project",
|
|
85
|
+
"playbooks.scope",
|
|
86
|
+
"playbooks.set_global",
|
|
87
|
+
"playbooks.add_project",
|
|
88
|
+
"playbooks.remove_project",
|
|
89
|
+
"playbooks.replace_projects",
|
|
67
90
|
"playbooks.update",
|
|
68
91
|
"playbooks.contain",
|
|
69
92
|
"playbooks.uncontain",
|
|
@@ -81,6 +104,7 @@ export interface PlaybooksModuleDeps {
|
|
|
81
104
|
tasks: Tasks;
|
|
82
105
|
/** Guards the same session_secret check tasks.focus's own operation enforces (guardFocusMutation in modules/tasks.ts) -- invoke's internal tasks.focus() call goes straight through the Tasks class, bypassing that operation wrapper entirely, so the check must be applied here instead of silently skipped. */
|
|
83
106
|
sessionIdentity: SessionIdentity;
|
|
107
|
+
registry: ProjectRegistryStore;
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
export function playbooksOperations({
|
|
@@ -90,6 +114,7 @@ export function playbooksOperations({
|
|
|
90
114
|
artifactScopes,
|
|
91
115
|
tasks,
|
|
92
116
|
sessionIdentity,
|
|
117
|
+
registry,
|
|
93
118
|
}: PlaybooksModuleDeps): OperationDefinition[] {
|
|
94
119
|
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
95
120
|
name,
|
|
@@ -113,8 +138,10 @@ export function playbooksOperations({
|
|
|
113
138
|
extra: input.extra as Record<string, unknown> | undefined,
|
|
114
139
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
115
140
|
projectRoot: optionalString(input, "project_root"),
|
|
141
|
+
projectReferences: input.projects as string[] | undefined,
|
|
116
142
|
},
|
|
117
143
|
eventContext(input),
|
|
144
|
+
registry,
|
|
118
145
|
),
|
|
119
146
|
),
|
|
120
147
|
define("playbooks.list", (input: OperationInput) => {
|
|
@@ -133,7 +160,13 @@ export function playbooksOperations({
|
|
|
133
160
|
runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
|
|
134
161
|
arguments: input.arguments as Record<string, unknown> | undefined,
|
|
135
162
|
},
|
|
136
|
-
{
|
|
163
|
+
{
|
|
164
|
+
events,
|
|
165
|
+
scopes,
|
|
166
|
+
artifactScopes,
|
|
167
|
+
projectRoot: optionalString(input, "project_root"),
|
|
168
|
+
context: eventContextFor(input, "playbook-run"),
|
|
169
|
+
},
|
|
137
170
|
);
|
|
138
171
|
if ("missingArguments" in result) return result;
|
|
139
172
|
const focusContext = eventContextFor(input, "playbook-run");
|
|
@@ -150,6 +183,17 @@ export function playbooksOperations({
|
|
|
150
183
|
define("playbooks.assign_project", (input: OperationInput) =>
|
|
151
184
|
assignPlaybookProject(artifacts, artifactScopes, string(input, "id"), optionalString(input, "project_root")),
|
|
152
185
|
),
|
|
186
|
+
define("playbooks.scope", (input: OperationInput) => playbookScope(artifacts, artifactScopes, string(input, "id"))),
|
|
187
|
+
define("playbooks.set_global", (input: OperationInput) => setPlaybookGlobal(artifacts, artifactScopes, string(input, "id"))),
|
|
188
|
+
define("playbooks.add_project", (input: OperationInput) =>
|
|
189
|
+
addPlaybookProject(artifacts, artifactScopes, registry, string(input, "id"), string(input, "project")),
|
|
190
|
+
),
|
|
191
|
+
define("playbooks.remove_project", (input: OperationInput) =>
|
|
192
|
+
removePlaybookProject(artifacts, artifactScopes, registry, string(input, "id"), string(input, "project")),
|
|
193
|
+
),
|
|
194
|
+
define("playbooks.replace_projects", (input: OperationInput) =>
|
|
195
|
+
replacePlaybookProjects(artifacts, artifactScopes, registry, string(input, "id"), (input.projects as string[] | undefined) ?? []),
|
|
196
|
+
),
|
|
153
197
|
define("playbooks.update", (input: OperationInput) =>
|
|
154
198
|
updatePlaybook(
|
|
155
199
|
artifacts,
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
26
26
|
import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
|
|
27
27
|
import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
|
|
28
|
-
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
28
|
+
import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
29
29
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
30
30
|
import {
|
|
31
|
+
ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT,
|
|
31
32
|
ARTIFACT_TITLE_MAX_LENGTH,
|
|
32
33
|
PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
|
|
33
34
|
PLAYBOOK_ARGUMENT_MAX_COUNT,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
type BlueprintInputType,
|
|
44
45
|
validateArgumentValue,
|
|
45
46
|
} from "../domain/blueprint-definition.ts";
|
|
47
|
+
import { resolveProjectReference } from "../domain/project-registry.ts";
|
|
46
48
|
import { normalizeProjectRoot } from "../domain/task-scope.ts";
|
|
47
49
|
import {
|
|
48
50
|
assertBodyBounds,
|
|
@@ -57,6 +59,7 @@ import {
|
|
|
57
59
|
type TransitionTable,
|
|
58
60
|
type UpdateContentInput,
|
|
59
61
|
} from "../domain-service-shared.ts";
|
|
62
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
60
63
|
|
|
61
64
|
export interface PlaybookArgument {
|
|
62
65
|
name: string;
|
|
@@ -220,6 +223,8 @@ export interface CreatePlaybookInput {
|
|
|
220
223
|
extra?: Record<string, unknown>;
|
|
221
224
|
templateId?: string;
|
|
222
225
|
projectRoot?: string;
|
|
226
|
+
/** Bounded exact registered project references (id/name/alias/root) -- fail-closed unlike projectRoot's auto-register-by-root legacy form. Takes precedence over projectRoot when both are given. */
|
|
227
|
+
projectReferences?: string[];
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
export type PlaybookTransition = "enable" | "disable";
|
|
@@ -235,7 +240,11 @@ export function createPlaybook(
|
|
|
235
240
|
scopes: ArtifactScopeStore,
|
|
236
241
|
input: CreatePlaybookInput,
|
|
237
242
|
context?: ArtifactEventContext,
|
|
243
|
+
registry?: ProjectRegistryStore,
|
|
238
244
|
): Artifact {
|
|
245
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0 && registry === undefined) {
|
|
246
|
+
throw new Error("projectReferences requires a project registry");
|
|
247
|
+
}
|
|
239
248
|
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
240
249
|
const declaredArguments = validatePlaybookArguments(input.arguments);
|
|
241
250
|
const declaredSteps = validatePlaybookSteps(input.steps);
|
|
@@ -258,7 +267,15 @@ export function createPlaybook(
|
|
|
258
267
|
},
|
|
259
268
|
context,
|
|
260
269
|
);
|
|
261
|
-
|
|
270
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0) {
|
|
271
|
+
if (input.projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
272
|
+
throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
|
|
273
|
+
}
|
|
274
|
+
const ids = input.projectReferences.map((reference) => resolveProjectReference(registry!, reference).id);
|
|
275
|
+
scopes.replaceProjects(playbook.id, ids, "explicit");
|
|
276
|
+
} else {
|
|
277
|
+
scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
278
|
+
}
|
|
262
279
|
return playbook;
|
|
263
280
|
}
|
|
264
281
|
|
|
@@ -275,6 +292,65 @@ export function assignPlaybookProject(
|
|
|
275
292
|
return assignArtifactProject(artifacts, scopes, id, "playbook", projectRoot);
|
|
276
293
|
}
|
|
277
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The multi-project scope surface playbooks.assign_project cannot express -- mirrors
|
|
297
|
+
* docs.ts's own docScope/setDocGlobal/addDocProject/removeDocProject/replaceDocProjects (which
|
|
298
|
+
* itself mirrors rules.ts's original). id is resolved through requireKind so these reject the
|
|
299
|
+
* same way against a non-Playbook or unknown id as every other playbooks.* mutation; the
|
|
300
|
+
* project REFERENCE (name/alias/root) is resolved through the shared registry's
|
|
301
|
+
* resolveProjectReference, so an unknown or ambiguous project fails closed with bounded
|
|
302
|
+
* candidates rather than silently creating a new registration.
|
|
303
|
+
*/
|
|
304
|
+
export function playbookScope(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
305
|
+
requireKind(artifacts, id, "playbook");
|
|
306
|
+
return scopes.scope(id);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function setPlaybookGlobal(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
310
|
+
requireKind(artifacts, id, "playbook");
|
|
311
|
+
return scopes.setGlobal(id, "explicit");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function replacePlaybookProjects(
|
|
315
|
+
artifacts: ArtifactStore,
|
|
316
|
+
scopes: ArtifactScopeStore,
|
|
317
|
+
registry: ProjectRegistryStore,
|
|
318
|
+
id: string,
|
|
319
|
+
projectReferences: readonly string[],
|
|
320
|
+
): ArtifactScope {
|
|
321
|
+
requireKind(artifacts, id, "playbook");
|
|
322
|
+
if (projectReferences.length === 0) throw new Error("projectReferences must be non-empty; use playbooks.set_global to clear scoping");
|
|
323
|
+
if (projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
324
|
+
throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
|
|
325
|
+
}
|
|
326
|
+
const ids = projectReferences.map((reference) => resolveProjectReference(registry, reference).id);
|
|
327
|
+
return scopes.replaceProjects(id, ids, "explicit");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function addPlaybookProject(
|
|
331
|
+
artifacts: ArtifactStore,
|
|
332
|
+
scopes: ArtifactScopeStore,
|
|
333
|
+
registry: ProjectRegistryStore,
|
|
334
|
+
id: string,
|
|
335
|
+
projectReference: string,
|
|
336
|
+
): ArtifactScope {
|
|
337
|
+
requireKind(artifacts, id, "playbook");
|
|
338
|
+
const project = resolveProjectReference(registry, projectReference);
|
|
339
|
+
return scopes.addProject(id, project.id, "explicit");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function removePlaybookProject(
|
|
343
|
+
artifacts: ArtifactStore,
|
|
344
|
+
scopes: ArtifactScopeStore,
|
|
345
|
+
registry: ProjectRegistryStore,
|
|
346
|
+
id: string,
|
|
347
|
+
projectReference: string,
|
|
348
|
+
): ArtifactScope {
|
|
349
|
+
requireKind(artifacts, id, "playbook");
|
|
350
|
+
const project = resolveProjectReference(registry, projectReference);
|
|
351
|
+
return scopes.removeProject(id, project.id);
|
|
352
|
+
}
|
|
353
|
+
|
|
278
354
|
export function showPlaybook(artifacts: ArtifactStore, id: string): Artifact {
|
|
279
355
|
requireKind(artifacts, id, "playbook");
|
|
280
356
|
return artifacts.get(id, { tree: true })!;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
3
|
+
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
3
4
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
4
5
|
import { requireAtomicArtifactStore } from "../artifact/atomic-artifact-store.ts";
|
|
5
6
|
import {
|
|
@@ -150,8 +151,28 @@ function executionGraph(tasks: Artifact[], definition: BlueprintDefinition, ids:
|
|
|
150
151
|
return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
154
|
+
/**
|
|
155
|
+
* projectRoot is optional -- skills.run always supplies one (workflow-definition runs are always
|
|
156
|
+
* project-scoped today), while a Playbook invocation may legitimately be ad hoc/cross-project
|
|
157
|
+
* (e.g. a lab-deploy playbook not tied to any one repo), landing its tasks in the same
|
|
158
|
+
* "unscoped" bucket Tasks.create already supports for a caller that omits projectRoot entirely.
|
|
159
|
+
*
|
|
160
|
+
* artifactScopes is optional for the same reason: a caller with no Doc/Rule project-scoping
|
|
161
|
+
* concept at all (a bare workflow-definition run with no ArtifactScopeStore wired) still works
|
|
162
|
+
* unchanged, generated Docs/Rules simply staying unscoped/global like every artifact created
|
|
163
|
+
* before project scoping existed. When supplied, a generated Doc/Rule inherits the SAME
|
|
164
|
+
* destination projectRoot a generated Task already does via `scopes` -- the real fix for a
|
|
165
|
+
* previously-confirmed gap: generated Docs/Rules bypassed ArtifactScopeStore entirely, so a
|
|
166
|
+
* playbook invoked with a destination project still injected its own generated Rules into every
|
|
167
|
+
* other project's context.
|
|
168
|
+
*/
|
|
169
|
+
export type WorkflowRunHistory = {
|
|
170
|
+
events: TaskEventStore;
|
|
171
|
+
scopes: TaskScopeStore;
|
|
172
|
+
artifactScopes?: ArtifactScopeStore;
|
|
173
|
+
projectRoot?: string;
|
|
174
|
+
context?: TaskEventContext;
|
|
175
|
+
};
|
|
155
176
|
|
|
156
177
|
/**
|
|
157
178
|
* Public entry point: wraps one complete pipeline run (including every nested sub-pipeline
|
|
@@ -307,8 +328,8 @@ export function materializeWorkflowDefinition(
|
|
|
307
328
|
throw new TaskExecutionBoundExceededError(`workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
|
|
308
329
|
}
|
|
309
330
|
|
|
310
|
-
const docs = rendered.blueprints.docs.map((blueprint) =>
|
|
311
|
-
artifacts.create({
|
|
331
|
+
const docs = rendered.blueprints.docs.map((blueprint) => {
|
|
332
|
+
const doc = artifacts.create({
|
|
312
333
|
id: ids.get(blueprint.ref),
|
|
313
334
|
kind: "doc",
|
|
314
335
|
title: blueprint.title,
|
|
@@ -316,10 +337,12 @@ export function materializeWorkflowDefinition(
|
|
|
316
337
|
subtype: blueprint.subtype,
|
|
317
338
|
labels: withRunLabel(blueprint.labels, labelPrefix, runId),
|
|
318
339
|
extra: { ...(blueprint.extra ?? {}), [extraKey]: { id: runId, ownerId, ref: blueprint.ref } },
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
340
|
+
});
|
|
341
|
+
history?.artifactScopes?.assign(doc.id, projectRoot, projectRoot ? "cwd" : "unscoped");
|
|
342
|
+
return doc;
|
|
343
|
+
});
|
|
344
|
+
const rules = rendered.blueprints.rules.map((blueprint) => {
|
|
345
|
+
const rule = artifacts.create({
|
|
323
346
|
id: ids.get(blueprint.ref),
|
|
324
347
|
kind: "rule",
|
|
325
348
|
title: blueprint.title,
|
|
@@ -331,10 +354,15 @@ export function materializeWorkflowDefinition(
|
|
|
331
354
|
...(blueprint.action ? { action: blueprint.action } : {}),
|
|
332
355
|
...(blueprint.severity ? { severity: blueprint.severity } : {}),
|
|
333
356
|
[extraKey]: { id: runId, ownerId, ref: blueprint.ref },
|
|
357
|
+
// extra.scope is run-gating only (listInjectableRules' passesRunScope) -- an
|
|
358
|
+
// independent AND-condition alongside project-membership (appliesToProjectRoot,
|
|
359
|
+
// assigned just below), never a replacement for it.
|
|
334
360
|
scope: { type: labelPrefix, runId, taskIds },
|
|
335
361
|
},
|
|
336
|
-
})
|
|
337
|
-
|
|
362
|
+
});
|
|
363
|
+
history?.artifactScopes?.assign(rule.id, projectRoot, projectRoot ? "cwd" : "unscoped");
|
|
364
|
+
return rule;
|
|
365
|
+
});
|
|
338
366
|
const tasks = rendered.blueprints.tasks.map((blueprint) => {
|
|
339
367
|
const task = artifacts.create({
|
|
340
368
|
id: ids.get(blueprint.ref),
|
package/src/service.ts
CHANGED
|
@@ -441,6 +441,11 @@ function handlers(
|
|
|
441
441
|
"playbooks.enable": forwardToModule("playbooks.enable"),
|
|
442
442
|
"playbooks.disable": forwardToModule("playbooks.disable"),
|
|
443
443
|
"playbooks.assign_project": forwardToModule("playbooks.assign_project"),
|
|
444
|
+
"playbooks.scope": forwardToModule("playbooks.scope"),
|
|
445
|
+
"playbooks.set_global": forwardToModule("playbooks.set_global"),
|
|
446
|
+
"playbooks.add_project": forwardToModule("playbooks.add_project"),
|
|
447
|
+
"playbooks.remove_project": forwardToModule("playbooks.remove_project"),
|
|
448
|
+
"playbooks.replace_projects": forwardToModule("playbooks.replace_projects"),
|
|
444
449
|
"playbooks.update": forwardToModule("playbooks.update"),
|
|
445
450
|
"playbooks.contain": forwardToModule("playbooks.contain"),
|
|
446
451
|
"playbooks.uncontain": forwardToModule("playbooks.uncontain"),
|
|
@@ -505,7 +510,9 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
505
510
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
506
511
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry));
|
|
507
512
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
|
|
508
|
-
moduleRegistry.registerAll(
|
|
513
|
+
moduleRegistry.registerAll(
|
|
514
|
+
playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }),
|
|
515
|
+
);
|
|
509
516
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|
|
510
517
|
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
|
|
511
518
|
const state = (): SchemaState => {
|