@vellumai/assistant 0.11.2-dev.202608060508.a7ab7f7 → 0.11.2-dev.202608060701.ede4331

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/ARCHITECTURE.md +54 -1
  2. package/openapi.yaml +162 -3
  3. package/package.json +1 -1
  4. package/src/__tests__/db-schedule-syntax-migration.test.ts +4 -1
  5. package/src/__tests__/emit-signal-routing-intent.test.ts +3 -4
  6. package/src/__tests__/mtime-cache.test.ts +65 -3
  7. package/src/__tests__/notification-schedule-notify-dedup.test.ts +1 -1
  8. package/src/__tests__/run-due-schedules.test.ts +174 -0
  9. package/src/__tests__/schedule-routes.test.ts +242 -0
  10. package/src/__tests__/schedule-store.test.ts +371 -0
  11. package/src/__tests__/schedule-tools.test.ts +103 -0
  12. package/src/acp/failure-error.ts +3 -5
  13. package/src/cli/commands/__tests__/cli-test-harness.ts +82 -0
  14. package/src/cli/commands/__tests__/plugins.test.ts +692 -0
  15. package/src/cli/commands/__tests__/schedules.test.ts +136 -37
  16. package/src/cli/commands/plugins.help.ts +25 -2
  17. package/src/cli/commands/plugins.ts +289 -10
  18. package/src/cli/commands/schedules.ts +12 -3
  19. package/src/cli/lib/__tests__/inspect-plugin.test.ts +1 -0
  20. package/src/cli/lib/__tests__/install-from-github.test.ts +79 -0
  21. package/src/cli/lib/__tests__/install-from-platform.test.ts +47 -1
  22. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +86 -7
  23. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +122 -0
  24. package/src/cli/lib/install-from-github.ts +61 -0
  25. package/src/cli/lib/install-from-platform.ts +13 -2
  26. package/src/cli/lib/plugin-surfaces.ts +124 -4
  27. package/src/cli/lib/upgrade-plugin.ts +18 -0
  28. package/src/config/feature-flag-registry.json +8 -0
  29. package/src/daemon/lifecycle.ts +11 -0
  30. package/src/notifications/__tests__/broadcaster.test.ts +37 -1
  31. package/src/notifications/__tests__/copy-composer.test.ts +165 -0
  32. package/src/notifications/__tests__/emit-signal-pipeline-failure.test.ts +205 -0
  33. package/src/notifications/broadcaster.ts +30 -10
  34. package/src/notifications/copy-composer.ts +69 -0
  35. package/src/notifications/emit-signal.ts +60 -5
  36. package/src/notifications/events-store.ts +10 -2
  37. package/src/notifications/notification-utils.ts +6 -5
  38. package/src/notifications/signal.ts +12 -0
  39. package/src/persistence/migrations/364-add-schedule-source-key.test.ts +120 -0
  40. package/src/persistence/migrations/364-add-schedule-source-key.ts +43 -0
  41. package/src/persistence/schema/infrastructure.ts +3 -0
  42. package/src/persistence/steps.ts +2 -0
  43. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +82 -0
  44. package/src/plugins/__tests__/source-fingerprint.test.ts +5 -1
  45. package/src/plugins/collect-source-versions.ts +4 -27
  46. package/src/plugins/external-plugin-loader.ts +17 -8
  47. package/src/plugins/installed-plugin-dirs.ts +101 -0
  48. package/src/plugins/mtime-cache.ts +34 -22
  49. package/src/runtime/http-server.ts +12 -0
  50. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +59 -1
  51. package/src/runtime/routes/__tests__/plugins-routes.test.ts +89 -1
  52. package/src/runtime/routes/conversation-management-routes.ts +10 -4
  53. package/src/runtime/routes/plugins-routes.ts +56 -1
  54. package/src/runtime/routes/schedule-routes.ts +65 -5
  55. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +614 -0
  56. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1066 -0
  57. package/src/schedule/plugin-schedule-declarations.ts +518 -0
  58. package/src/schedule/plugin-schedule-reconciler.ts +605 -0
  59. package/src/schedule/plugin-schedules-gate.ts +23 -0
  60. package/src/schedule/schedule-store.ts +469 -20
  61. package/src/schedule/scheduler.ts +38 -0
  62. package/src/skills/inline-command-runner.ts +2 -7
  63. package/src/tools/schedule/delete.ts +13 -1
  64. package/src/tools/schedule/list.ts +12 -2
  65. package/src/tools/schedule/update.ts +46 -27
  66. package/src/util/ansi.ts +44 -0
  67. package/src/util/schedule-source-key.ts +23 -0
package/ARCHITECTURE.md CHANGED
@@ -838,7 +838,7 @@ graph LR
838
838
  JOBS["memory_jobs<br/>───────────────<br/>Async task queue<br/>Types: embed, extract,<br/>summarize, backfill, cleanup<br/>Status: pending → running →<br/>completed | failed"]
839
839
  ATT["attachments<br/>───────────────<br/>base64-encoded file data<br/>mime_type, size_bytes<br/>Linked to messages via<br/>message_attachments join"]
840
840
  REM["reminders<br/>───────────────<br/>One-time scheduled reminders<br/>label, message, fireAt<br/>mode: notify | execute<br/>status: pending → fired | cancelled<br/>routing_intent: single_channel |<br/>multi_channel | all_channels<br/>routing_hints_json (free-form)"]
841
- SCHED_JOBS["cron_jobs (recurrence schedules)<br/>───────────────<br/>Recurring schedule definitions<br/>cron_expression: cron or RRULE string<br/>schedule_syntax: 'cron' | 'rrule'<br/>timezone, message, next_run_at<br/>enabled, retry_count<br/>Legacy alias: scheduleJobs"]
841
+ SCHED_JOBS["cron_jobs (recurrence schedules)<br/>───────────────<br/>Recurring schedule definitions<br/>cron_expression: cron or RRULE string<br/>schedule_syntax: 'cron' | 'rrule'<br/>timezone, message, next_run_at<br/>enabled, retry_count<br/>source_key: plugin-declared provenance<br/>definition_hash, user_enabled<br/>Legacy alias: scheduleJobs"]
842
842
  SCHED_RUNS["cron_runs (schedule runs)<br/>───────────────<br/>Execution history per schedule<br/>job_id (FK → cron_jobs)<br/>status: ok | error<br/>duration_ms, output, error<br/>Legacy alias: scheduleRuns"]
843
843
  TASKS["tasks<br/>───────────────<br/>Reusable prompt templates<br/>title, Handlebars template<br/>inputSchema, contextFlags<br/>requiredTools, status"]
844
844
  TASK_RUNS["task_runs<br/>───────────────<br/>Execution history per task<br/>taskId (FK → tasks)<br/>conversationId, status<br/>startedAt, finishedAt, error"]
@@ -863,6 +863,59 @@ graph LR
863
863
 
864
864
  ---
865
865
 
866
+ ## Plugin-Declared Schedules: Declaration → Reconciler → cron_jobs
867
+
868
+ Plugins contribute recurring schedules as a surface: directories under
869
+ `<pluginDir>/schedules/`, each a `<name>/` holding `config.json` plus
870
+ exactly one `index.md` (runs as `execute`) or `index.sh` (runs as `script`)
871
+ entrypoint. Ambiguity fails closed: a file sitting directly under
872
+ `schedules/`, zero/multiple/unsupported entrypoints, and malformed config
873
+ produce per-schedule `DeclarationError`s without affecting siblings
874
+ (`src/schedule/plugin-schedule-declarations.ts`).
875
+
876
+ A level-based reconciler (`src/schedule/plugin-schedule-reconciler.ts`)
877
+ converges declarations into ordinary `cron_jobs` rows keyed by the nullable
878
+ `source_key` column (`plugin:<plugin>/<name>`). Rows with `source_key IS
879
+ NULL` are imperative schedules the reconciler never touches. Triggers: a
880
+ startup pass in `daemon/lifecycle.ts` (after plugin init, before the
881
+ scheduler starts), the end of `reconcilePluginSourcesNow()` in
882
+ `plugins/mtime-cache.ts` (install/uninstall/upgrade and sentinel-driven
883
+ changes), the plugin enable/disable routes, and a 60s backstop sweep
884
+ registered with the HTTP server's background sweeps.
885
+
886
+ Reconcile lag never lets a disabled plugin run. The disable path writes a
887
+ `.disabled` sentinel that only a reconcile pass turns into disarmed rows, so
888
+ the scheduler re-reads the sentinel at fire time and records a skipped run
889
+ instead of executing a claimed row whose plugin is off. Run-now applies the
890
+ same boundary through `declarationExistsOnDisk`, which also covers a plugin
891
+ whose manifest no longer parses, a declaration directory that is gone, and a
892
+ plugin root or declaration directory resolving outside the tree it belongs to
893
+ (the same `isInsidePluginRoot` containment the loader applies before importing
894
+ a plugin).
895
+
896
+ A declaration that stops parsing keeps its execute row armed on the message
897
+ already stored in the row, but disarms its script rows: a script row fires its
898
+ entrypoint by absolute path, so leaving it armed would run the very `index.sh`
899
+ the parser rejected. Those rows re-arm on the pass after the declaration parses
900
+ again.
901
+
902
+ Ownership boundaries: the reconciler owns definition columns (expression,
903
+ timezone, message/script, retry policy, `definition_hash`); the execution
904
+ engine owns runtime columns (`next_run_at`, `status`, `last_*`,
905
+ `retry_count`) and its latches are never overridden; the user owns
906
+ `user_enabled`, a sticky override consulted when computing effective
907
+ `enabled`. Nothing ever writes to plugin files. Execution itself is
908
+ unchanged: declared rows fire through the same `claimDueSchedules` path as
909
+ imperative ones.
910
+
911
+ Consent and surfacing: CLI install/upgrade list declared schedules and
912
+ prompt before finalizing; unattended daemon-route installs are covered by
913
+ `schedule.declared` arrival notifications, with `schedule.definition_changed`
914
+ and `schedule.definition_error` covering upgrades and broken declarations
915
+ (events registered in `notifications/signal.ts`, deterministic templates in
916
+ `notifications/copy-composer.ts`). Settings routes, CLI, and web render
917
+ plugin-sourced rows read-only except the enabled toggle.
918
+
866
919
  ---
867
920
 
868
921
  ## Web Server — Connection Modes
package/openapi.yaml CHANGED
@@ -24245,14 +24245,43 @@ paths:
24245
24245
  description:
24246
24246
  Registered tool names from `tools/<name>.{ts,js}` (filenames derived to tool names, e.g. `create-issue` →
24247
24247
  `create_issue`).
24248
+ schedules:
24249
+ type: array
24250
+ items:
24251
+ type: object
24252
+ properties:
24253
+ name:
24254
+ type: string
24255
+ description: "Schedule name: the declaration directory's name."
24256
+ cadence:
24257
+ type: string
24258
+ description: Raw schedule `expression` string from the declaration's config.
24259
+ mode:
24260
+ type: string
24261
+ enum:
24262
+ - execute
24263
+ - script
24264
+ description: "`execute` for a markdown prompt entrypoint, `script` for `index.sh`."
24265
+ required:
24266
+ - name
24267
+ - cadence
24268
+ - mode
24269
+ additionalProperties: false
24270
+ description:
24271
+ Schedules declared under `schedules/`, each a `<name>/` directory with `config.json` plus one entrypoint.
24272
+ Display surface only; files directly under `schedules/` and unsupported declarations are
24273
+ omitted.
24248
24274
  required:
24249
24275
  - skills
24250
24276
  - hooks
24251
24277
  - tools
24278
+ - schedules
24252
24279
  additionalProperties: false
24253
24280
  description: Surfaces the installed copy contributes, read from its on-disk tree.
24254
24281
  - type: "null"
24255
- description: Surfaces the installed copy contributes (skills, hooks, tools); null when the plugin is not installed.
24282
+ description:
24283
+ Surfaces the installed copy contributes (skills, hooks, tools, schedules); null when the plugin is not
24284
+ installed.
24256
24285
  required:
24257
24286
  - name
24258
24287
  - installed
@@ -25598,6 +25627,20 @@ paths:
25598
25627
  anyOf:
25599
25628
  - type: string
25600
25629
  - type: "null"
25630
+ sourceKey:
25631
+ anyOf:
25632
+ - type: string
25633
+ - type: "null"
25634
+ description:
25635
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
25636
+ schedules
25637
+ userEnabled:
25638
+ anyOf:
25639
+ - type: boolean
25640
+ - type: "null"
25641
+ description:
25642
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
25643
+ applies. Always null for user-created schedules.
25601
25644
  isOneShot:
25602
25645
  type: boolean
25603
25646
  isDeferred:
@@ -25632,6 +25675,8 @@ paths:
25632
25675
  - reuseConversation
25633
25676
  - wakeConversationId
25634
25677
  - workflowName
25678
+ - sourceKey
25679
+ - userEnabled
25635
25680
  - isOneShot
25636
25681
  - isDeferred
25637
25682
  additionalProperties: false
@@ -25815,6 +25860,20 @@ paths:
25815
25860
  anyOf:
25816
25861
  - type: string
25817
25862
  - type: "null"
25863
+ sourceKey:
25864
+ anyOf:
25865
+ - type: string
25866
+ - type: "null"
25867
+ description:
25868
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
25869
+ schedules
25870
+ userEnabled:
25871
+ anyOf:
25872
+ - type: boolean
25873
+ - type: "null"
25874
+ description:
25875
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
25876
+ applies. Always null for user-created schedules.
25818
25877
  isOneShot:
25819
25878
  type: boolean
25820
25879
  isDeferred:
@@ -25849,6 +25908,8 @@ paths:
25849
25908
  - reuseConversation
25850
25909
  - wakeConversationId
25851
25910
  - workflowName
25911
+ - sourceKey
25912
+ - userEnabled
25852
25913
  - isOneShot
25853
25914
  - isDeferred
25854
25915
  additionalProperties: false
@@ -25984,6 +26045,20 @@ paths:
25984
26045
  anyOf:
25985
26046
  - type: string
25986
26047
  - type: "null"
26048
+ sourceKey:
26049
+ anyOf:
26050
+ - type: string
26051
+ - type: "null"
26052
+ description:
26053
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
26054
+ schedules
26055
+ userEnabled:
26056
+ anyOf:
26057
+ - type: boolean
26058
+ - type: "null"
26059
+ description:
26060
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
26061
+ applies. Always null for user-created schedules.
25987
26062
  isOneShot:
25988
26063
  type: boolean
25989
26064
  isDeferred:
@@ -26018,6 +26093,8 @@ paths:
26018
26093
  - reuseConversation
26019
26094
  - wakeConversationId
26020
26095
  - workflowName
26096
+ - sourceKey
26097
+ - userEnabled
26021
26098
  - isOneShot
26022
26099
  - isDeferred
26023
26100
  additionalProperties: false
@@ -26150,6 +26227,20 @@ paths:
26150
26227
  anyOf:
26151
26228
  - type: string
26152
26229
  - type: "null"
26230
+ sourceKey:
26231
+ anyOf:
26232
+ - type: string
26233
+ - type: "null"
26234
+ description:
26235
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
26236
+ schedules
26237
+ userEnabled:
26238
+ anyOf:
26239
+ - type: boolean
26240
+ - type: "null"
26241
+ description:
26242
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
26243
+ applies. Always null for user-created schedules.
26153
26244
  isOneShot:
26154
26245
  type: boolean
26155
26246
  isDeferred:
@@ -26184,6 +26275,8 @@ paths:
26184
26275
  - reuseConversation
26185
26276
  - wakeConversationId
26186
26277
  - workflowName
26278
+ - sourceKey
26279
+ - userEnabled
26187
26280
  - isOneShot
26188
26281
  - isDeferred
26189
26282
  additionalProperties: false
@@ -26376,6 +26469,20 @@ paths:
26376
26469
  anyOf:
26377
26470
  - type: string
26378
26471
  - type: "null"
26472
+ sourceKey:
26473
+ anyOf:
26474
+ - type: string
26475
+ - type: "null"
26476
+ description:
26477
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
26478
+ schedules
26479
+ userEnabled:
26480
+ anyOf:
26481
+ - type: boolean
26482
+ - type: "null"
26483
+ description:
26484
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
26485
+ applies. Always null for user-created schedules.
26379
26486
  isOneShot:
26380
26487
  type: boolean
26381
26488
  isDeferred:
@@ -26410,6 +26517,8 @@ paths:
26410
26517
  - reuseConversation
26411
26518
  - wakeConversationId
26412
26519
  - workflowName
26520
+ - sourceKey
26521
+ - userEnabled
26413
26522
  - isOneShot
26414
26523
  - isDeferred
26415
26524
  additionalProperties: false
@@ -26545,6 +26654,20 @@ paths:
26545
26654
  anyOf:
26546
26655
  - type: string
26547
26656
  - type: "null"
26657
+ sourceKey:
26658
+ anyOf:
26659
+ - type: string
26660
+ - type: "null"
26661
+ description:
26662
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
26663
+ schedules
26664
+ userEnabled:
26665
+ anyOf:
26666
+ - type: boolean
26667
+ - type: "null"
26668
+ description:
26669
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
26670
+ applies. Always null for user-created schedules.
26548
26671
  isOneShot:
26549
26672
  type: boolean
26550
26673
  isDeferred:
@@ -26579,6 +26702,8 @@ paths:
26579
26702
  - reuseConversation
26580
26703
  - wakeConversationId
26581
26704
  - workflowName
26705
+ - sourceKey
26706
+ - userEnabled
26582
26707
  - isOneShot
26583
26708
  - isDeferred
26584
26709
  additionalProperties: false
@@ -26590,7 +26715,9 @@ paths:
26590
26715
  post:
26591
26716
  operationId: schedules_by_id_run_post
26592
26717
  summary: Run schedule now
26593
- description: Trigger an immediate execution of a schedule.
26718
+ description:
26719
+ Trigger an immediate execution of a schedule. A plugin-sourced schedule is rejected with a 400 when its
26720
+ plugin is disabled or no longer declares it.
26594
26721
  tags:
26595
26722
  - schedules
26596
26723
  parameters:
@@ -26714,6 +26841,20 @@ paths:
26714
26841
  anyOf:
26715
26842
  - type: string
26716
26843
  - type: "null"
26844
+ sourceKey:
26845
+ anyOf:
26846
+ - type: string
26847
+ - type: "null"
26848
+ description:
26849
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
26850
+ schedules
26851
+ userEnabled:
26852
+ anyOf:
26853
+ - type: boolean
26854
+ - type: "null"
26855
+ description:
26856
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
26857
+ applies. Always null for user-created schedules.
26717
26858
  isOneShot:
26718
26859
  type: boolean
26719
26860
  isDeferred:
@@ -26748,6 +26889,8 @@ paths:
26748
26889
  - reuseConversation
26749
26890
  - wakeConversationId
26750
26891
  - workflowName
26892
+ - sourceKey
26893
+ - userEnabled
26751
26894
  - isOneShot
26752
26895
  - isDeferred
26753
26896
  additionalProperties: false
@@ -26884,7 +27027,7 @@ paths:
26884
27027
  post:
26885
27028
  operationId: schedules_by_id_toggle_post
26886
27029
  summary: Toggle schedule
26887
- description: Enable or disable a schedule.
27030
+ description: Enable or disable a schedule. On a plugin-managed schedule this records the user's override (userEnabled).
26888
27031
  tags:
26889
27032
  - schedules
26890
27033
  parameters:
@@ -27020,6 +27163,20 @@ paths:
27020
27163
  anyOf:
27021
27164
  - type: string
27022
27165
  - type: "null"
27166
+ sourceKey:
27167
+ anyOf:
27168
+ - type: string
27169
+ - type: "null"
27170
+ description:
27171
+ Plugin declaration this schedule mirrors (plugin:<pluginName>/<scheduleName>); null for user-created
27172
+ schedules
27173
+ userEnabled:
27174
+ anyOf:
27175
+ - type: boolean
27176
+ - type: "null"
27177
+ description:
27178
+ User enable/disable override on a plugin-sourced schedule; null when the declaration's own enabled value
27179
+ applies. Always null for user-created schedules.
27023
27180
  isOneShot:
27024
27181
  type: boolean
27025
27182
  isDeferred:
@@ -27054,6 +27211,8 @@ paths:
27054
27211
  - reuseConversation
27055
27212
  - wakeConversationId
27056
27213
  - workflowName
27214
+ - sourceKey
27215
+ - userEnabled
27057
27216
  - isOneShot
27058
27217
  - isDeferred
27059
27218
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608060508.a7ab7f7",
3
+ "version": "0.11.2-dev.202608060701.ede4331",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,7 +58,10 @@ describe("schedule_syntax column migration", () => {
58
58
  capabilities_json TEXT,
59
59
  description TEXT NOT NULL DEFAULT '',
60
60
  created_at INTEGER NOT NULL,
61
- updated_at INTEGER NOT NULL
61
+ updated_at INTEGER NOT NULL,
62
+ source_key TEXT,
63
+ definition_hash TEXT,
64
+ user_enabled INTEGER
62
65
  )
63
66
  `);
64
67
 
@@ -5,7 +5,7 @@ const enforceRoutingIntentMock = mock();
5
5
  const updateDecisionMock = mock();
6
6
  const runDeterministicChecksMock = mock();
7
7
  const createEventMock = mock();
8
- const updateEventDedupeKeyMock = mock();
8
+ const setEventDedupeKeyMock = mock();
9
9
  const dispatchDecisionMock = mock();
10
10
  const isPlatformClientConfiguredMock = mock();
11
11
 
@@ -77,8 +77,7 @@ mock.module("../notifications/deterministic-checks.js", () => ({
77
77
 
78
78
  mock.module("../notifications/events-store.js", () => ({
79
79
  createEvent: (...args: unknown[]) => createEventMock(...args),
80
- updateEventDedupeKey: (...args: unknown[]) =>
81
- updateEventDedupeKeyMock(...args),
80
+ setEventDedupeKey: (...args: unknown[]) => setEventDedupeKeyMock(...args),
82
81
  }));
83
82
 
84
83
  mock.module("../notifications/runtime-dispatch.js", () => ({
@@ -104,7 +103,7 @@ beforeEach(() => {
104
103
  updateDecisionMock.mockReset();
105
104
  runDeterministicChecksMock.mockReset();
106
105
  createEventMock.mockReset();
107
- updateEventDedupeKeyMock.mockReset();
106
+ setEventDedupeKeyMock.mockReset();
108
107
  dispatchDecisionMock.mockReset();
109
108
  isPlatformClientConfiguredMock.mockReset();
110
109
 
@@ -14,6 +14,7 @@ import {
14
14
  mkdirSync,
15
15
  readFileSync,
16
16
  rmSync,
17
+ symlinkSync,
17
18
  utimesSync,
18
19
  writeFileSync,
19
20
  } from "node:fs";
@@ -507,17 +508,17 @@ describe("workspace hooks (<workspace>/hooks/)", () => {
507
508
  writePackageJson(dir, { ...SIMPLE_PKG, name: "ordering-plugin" });
508
509
  writeHook(
509
510
  dir,
510
- "pre-model-call",
511
+ "conversation-deleted",
511
512
  `export default () => ({ tag: "plugin" });`,
512
513
  );
513
514
  writeWorkspaceHook(
514
- "pre-model-call",
515
+ "conversation-deleted",
515
516
  `export default () => ({ tag: "workspace" });`,
516
517
  );
517
518
 
518
519
  await populateCacheAtBoot();
519
520
 
520
- const hooks = await getUserHooksFor("pre-model-call");
521
+ const hooks = await getUserHooksFor("conversation-deleted");
521
522
  expect(hooks).toHaveLength(2);
522
523
  const results = hooks.map((fn) =>
523
524
  (fn as unknown as () => { tag: string })(),
@@ -1004,4 +1005,65 @@ describe("plugin root validation (ATL-983)", () => {
1004
1005
  // The evil plugin's init hook must NOT have run.
1005
1006
  expect(existsSync(evilMarker)).toBe(false);
1006
1007
  });
1008
+
1009
+ test("a symlinked plugin root escaping the plugins dir is not activated at boot", async () => {
1010
+ // A real plugin outside the plugins directory, linked in under a name the
1011
+ // boot scan reads. Hook resolution follows the link, so discovery is the
1012
+ // only thing between boot and code from anywhere on disk.
1013
+ const escapee = join(ROOT, "outside", "escapee");
1014
+ mkdirSync(escapee, { recursive: true });
1015
+ writePackageJson(escapee, { ...SIMPLE_PKG, name: "escapee" });
1016
+ const escapeeMarker = join(ROOT, "escapee-init.log");
1017
+ rmSync(escapeeMarker, { force: true });
1018
+ writeMarkerHook(escapee, "init", escapeeMarker, "init");
1019
+ writeHook(
1020
+ escapee,
1021
+ "user-prompt-submit",
1022
+ `export default () => ({ tag: "escapee" });`,
1023
+ );
1024
+ symlinkSync(escapee, join(PLUGINS_DIR, "escapee"));
1025
+
1026
+ // A contained plugin in the same scan, so a boot that discovered nothing
1027
+ // at all cannot pass this test.
1028
+ const keeper = freshPluginDir("keeper");
1029
+ writePackageJson(keeper, { ...SIMPLE_PKG, name: "keeper" });
1030
+ writeHook(
1031
+ keeper,
1032
+ "user-prompt-submit",
1033
+ `export default () => ({ tag: "keeper" });`,
1034
+ );
1035
+
1036
+ await populateCacheAtBoot();
1037
+
1038
+ expect(
1039
+ (await getUserHooksFor("user-prompt-submit")).map(
1040
+ (fn) => (fn as unknown as () => { tag: string })().tag,
1041
+ ),
1042
+ ).toEqual(["keeper"]);
1043
+ expect(existsSync(escapeeMarker)).toBe(false);
1044
+ });
1045
+
1046
+ test("a symlinked plugin root staying inside the plugins dir is activated at boot", async () => {
1047
+ // A symlinked plugin root is supported (see `plugins/source-fingerprint.ts`)
1048
+ // as long as it resolves inside the plugins directory. The link target sits
1049
+ // in a dot-prefixed store directory, which carries no package.json and is
1050
+ // therefore not an install of its own.
1051
+ const target = join(PLUGINS_DIR, ".store", "alias");
1052
+ mkdirSync(target, { recursive: true });
1053
+ writePackageJson(target, { ...SIMPLE_PKG, name: "alias" });
1054
+ writeHook(
1055
+ target,
1056
+ "user-prompt-submit",
1057
+ `export default () => ({ tag: "alias" });`,
1058
+ );
1059
+ symlinkSync(target, join(PLUGINS_DIR, "alias"));
1060
+
1061
+ await populateCacheAtBoot();
1062
+
1063
+ expect(
1064
+ (await getUserHooksFor("user-prompt-submit")).map(
1065
+ (fn) => (fn as unknown as () => { tag: string })().tag,
1066
+ ),
1067
+ ).toEqual(["alias"]);
1068
+ });
1007
1069
  });
@@ -3,7 +3,7 @@
3
3
  * deduplicated against prior firings of the same schedule.
4
4
  *
5
5
  * The scheduler supplies a unique per-firing dedupeKey
6
- * (`schedule:notify:<id>:<timestamp>`) so `updateEventDedupeKey` is never
6
+ * (`schedule:notify:<id>:<timestamp>`) so `setEventDedupeKey` is never
7
7
  * called for schedule signals and `checkDedupe` never finds a matching
8
8
  * row when the LLM decision engine generates a stable key like
9
9
  * `schedule:notify:<id>`.